ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

深入理解SQLite:轻量级数据库的核心原理与工程实践

深入理解SQLite:轻量级数据库的核心原理与工程实践 1. 先搞清楚SQLite到底解决了什么问题以及它的“轻量”为什么是护城河做本地开发这些年我见过太多项目在“要不要上数据库”这件事上来回纠结。后端说“这功能就一个客户端用上MySQL太浪费”前端说“那就存JSON文件吧简单”结果数据一多查询、排序、去重、事务全部要靠手写代码硬扛维护成本直线上升。这时候SQLite这个名字就会反复出现——它是目前部署量最大的数据库引擎没有之一手机、PC浏览器、路由器、车载系统、收银机里几乎都有它的影子。但很多人对它的认知停留在“一个轻量级数据库”具体轻在哪、适合干什么、不适合干什么其实并没有想清楚。我自己的定位很明确SQLite就是本地存储场景的首选关系型数据库。它不需要独立服务进程不需要端口配置不需要账号密码数据就是一个文件把这个文件拷走数据就跟着走了。对于个人工具、移动App、桌面应用、边缘计算设备这些不需要多人并发写入的场景SQLite在开发效率和查询能力上的综合性价比远高于MySQL之类的大型数据库也远高于自己用JSON、CSV硬写一套“伪数据库”。1.1 嵌入式数据库和C/S数据库不在一个赛道碰到SQLite最容易犯的错误是拿它去跟MySQL、PostgreSQL对比然后得出“高端数据库更强”的结论。这个结论单独看没错但放在本地存储这个上下文里基本没有意义。SQLite属于嵌入式数据库设计目标只有一个作为一个C语言函数库被宿主程序直接调用数据存储在普通磁盘文件里。MySQL、PostgreSQL则是客户端/服务器架构有独立的守护进程、网络端口、权限体系客户端通过TCP/IP协议连接服务器再发SQL。打个比方MySQL像一个专门的物业公司你想回家要给它打电话它在楼里有一整间办公室SQLite则像你家门上自带的指纹锁开锁的逻辑就在锁芯里不需要额外找物业。SQLite的所有SQL操作发生在你的程序进程内部没有网络往返没有后台守护进程所以它极轻、极快、极省资源。代价也很明显它不是一个可供多个进程同时写入的共享服务并发能力和在线服务能力被刻意砍掉了。把SQLite和CSV/JSON文件对比就更有意思了。用文件存数据看似“零依赖”但一旦涉及修改单条记录、跨文件关联查询、并发防冲突代码量会迅速失控。SQLite在保留“一个文件就是全部”的简单性之外把SQL查询、事务、索引、约束这些成熟关系型数据库能力都给了你。一句话它在“纯文件”和“大型数据库”之间取了一个对本地存储最友好的平衡点。对比项SQLiteMySQLJSON/CSV文件部署复杂度极低引入库即可高需要服务进程和配置极低但需自己写逻辑数据组织关系型表结构关系型表结构无结构自由存放查询能力SQL完整但有限制SQL完整需要手写遍历、过滤并发写入单写者多读通常可用强并发主从/集群基本无并发保障数据体积单文件多个数据文件按需求设计典型场景本地库、离线缓存在线业务系统简单配置、日志1.2 单文件、零配置、跨平台这三个词的真实含义“单文件”这个特性初看没什么实际用起来才知道多幸福。SQLite数据库对外就是一个独立的.db文件备份、迁移、传输都是复制粘贴的事。我经常在开发机上调试完直接把.db文件通过网盘发到测试机上再把路径换成测试环境数据就完整过去了。相比之下MySQL的物理备份要考虑数据目录、日志、权限表操作起来非常重。“零配置”也不是一句空话。很多数据库在装好之后还要调内存参数、字符集、连接池、用户权限SQLite引入库就能用。它默认配置已经足够覆盖90%的本地场景按需调整就是几个PRAGMA语句的事。“跨平台”则体现在两方面第一SQLite本身可在Windows、Linux、macOS、Android、iOS上编译运行第二它的数据库文件格式是跨架构通用的。你在x86的Windows机器上创建了一个.db文件直接扔给ARM架构的路由器一样能正常打开。这一点在物联网设备、安卓端、桌面端混合的开发场景里极其重要。1.3 本地存储场景中SQLite比JSON文件强在哪如果只存个把配置项JSON文件完全够用。一旦数据量上百条、需要按条件筛选、需要保证多个字段之间的数据完整性文件方案就开始露馅了。我举一个真实做过的小工具例子本地图片管理器要为每张图片记录路径、拍摄时间、标签、评分。用JSON存加载时要全部读进内存查询时手写filter和sort改一条记录就要重写整个文件而且多个进程同时读写时还得自己加锁。换成SQLite之后索引、WHERE、ORDER BY、事务全部现成改一条记录只需要UPDATE语句数据库自身保证原子性数据量上万条也毫无压力。更重要的是SQLite的SQL能力让很多复杂逻辑可以下沉到数据库层完成应用代码只需要拼SQL、读结果逻辑清晰很多。包括UNIQUE约束、CHECK约束、外键约束这些东西在JSON方案里全部要靠业务代码手工维护遗漏一个分支就是数据脏了。所以当数据开始有结构、有查询需求、有“必须不能丢”的完整性要求时SQLite就是比文件更合适的那一层。2. 单文件数据库的底层真相存储格式、类型系统与事务模型我一直觉得用SQLite不能只停留在“会用CRUD”的层面。很多诡异问题的根源都在底层实现上比如为什么删除数据后文件不缩小为什么某条脏数据能插入成功为什么并发一高就报database is locked。理解SQLite的存储格式、类型亲和性、锁机制以后这些问题基本都能自己推断出来。2.1 数据库文件里到底装了什么SQLite把整个数据库放在一个普通磁盘文件里文件头占用100字节存储了格式版本、页大小、编码方式等信息。文件剩余部分被划分成固定大小的页page默认一页4096字节页与页之间有B-tree索引组织。为什么要用页因为SQLite所有的磁盘读写都以页为单位类似于操作系统以块为单位读写硬盘这样可以减少随机IO次数。一张普通表的底层是一棵B树树节点正好是一页。表数据本身也放在这棵B树的叶子节点上。索引同样是一棵独立的B树叶子节点保存索引键值和对应的rowid。所以每增加一个索引数据库就会多出一棵树这也是索引会占空间的根本原因。值得留意的是SQLite文件格式官方给了一套公开的规范所有版本的SQLite都兼容老格式新版本也能打开旧版本创建的数据库文件。这意味着你完全可以把数据库文件当作一种稳定的数据交换格式。只要对方装了SQLite就能直接读你的文件不用导出再导入少了很多格式转换的麻烦。2.2 宽松的类型系统一个优点也是一个隐患熟悉MySQL的人第一次在SQLite里建表一定有过这样的疑惑为什么我在INTEGER列里插入一个文本字符串也没报错这是SQLite的设计哲学之一——动态类型。SQLite每个值本身带有类型标签存储类一共五种NULL、INTEGER、REAL、TEXT、BLOB。表里某个列声明成INTEGER并不像MySQL那样强制校验它只是表达一种“亲和性”表示这个列更倾向于存整数。举个例子有一列声明为INTEGER往里面插入字符串“abc”SQLite允许只是存成TEXT类型插入字符串“123”SQLite认为它长得像数字会尝试转成INTEGER再存插入浮点数会尝试转成INTEGER。这种宽松策略在快速开发时很省心不用提前把所有类型钉死但反过来如果靠它来保证数据质量早晚要出事。所以我的习惯是即便SQLite类型很宽松建表时依然要明确写清每个字段的声明类型并且用CHECK约束来兜底。比如金额字段可以加CHECK(amount 0)状态字段加CHECK(status IN (0, 1, 2))把业务规则的校验责任交给数据库而不是全压在业务代码上。另一个隐藏点SQLite的FOREIGN KEY外键约束默认是关闭的必须在每次连接后执行PRAGMA foreign_keys ON;否则建了外键也形同虚设这个坑几乎每个人都踩过。2.3 “database is locked”背后的锁机制与WAL模式SQLite是单写者数据库这意味着任意时刻只允许一个连接写数据写的时候会锁住整个数据库文件。它实现了多级锁读锁是共享的多个连接可以同时读写锁是排他的一个连接在写其他所有连接的读也会被阻塞直到写事务结束。这个锁粒度是“整个数据库”不是某一行某一页所以并发的自由度天然比MySQL低。初学阶段遇到database is locked第一反应基本是“程序出bug了”其实大部分是锁等待超时。默认情况下另一个连接持锁超过busy_timeout指定的时间默认是0也就是立即失败就会抛这个错。解决办法不是去调什么神秘参数而是想清楚自己的事务边界保持短事务、避免在网络请求或用户输入期间一直持锁、避免在多个线程里共用一个连接。WAL模式Write-Ahead Logging是解决读写冲突最有效的手段。开启WAL后写操作先把日志追加到独立的-wal文件不需要直接改动主库文件因此写操作不再阻塞读操作读操作也能读到已提交的最新数据。对本地App来说WAL几乎是必开项一条PRAGMA journal_modeWAL;就能让并发体验提升一个档次。但它也不是没有代价WAL模式下数据库目录里会出现.db-wal和.db-shm两个临时文件备份时必须连它们一起考虑或者让所有连接干净关闭后再拷主文件。3. 高频CRUD里最容易被忽视的细节自动序号、UPDATE行为与文件收缩很多开发者用SQLite的时间长了会觉得自己CRUD已经玩得很溜但真到写代码时还是会在几个细节上卡住。比如插入一条订单后怎么拿到自增idUPDATE一些行后怎么确认更新了几行DELETE后数据库文件为什么还是老大小。这些点单个拎出来不复杂但组合在一起体现的是对SQLite行为的理解深度。3.1 先建一张能说明问题的订单表为了方便后面展开我建一张贴近实际业务的订单表。它在后面的示例里会反复用到CREATE TABLE IF NOT EXISTS orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_no TEXT NOT NULL UNIQUE, customer_name TEXT NOT NULL, amount REAL NOT NULL DEFAULT 0, status INTEGER NOT NULL DEFAULT 0, create_time TEXT NOT NULL DEFAULT (datetime(now, localtime)) );id列采用INTEGER PRIMARY KEY AUTOINCREMENT这是SQLite里最常用的自增主键写法。有一点需要说明在SQLite里只要声明了INTEGER PRIMARY KEY这个列就会成为rowid的别名并不一定非要AUTOINCREMENT。不写AUTOINCREMENT时新插入行默认取“当前最大rowid1”作为主键但如果你删除了表中最大的那条记录新行可能复用这个被删除的id。加了AUTOINCREMENT之后SQLite会额外维护一张sqlite_sequence表保证id只增不减永不复用。代价是AUTOINCREMENT会多一次写sqlite_sequence表的操作虽然性能影响微乎其微但如果你根本不需要“永不复用”这个特性完全可以只用INTEGER PRIMARY KEY。很多ORM生成的表默认带AUTOINCREMENT其实是为了在主从复制或数据恢复时避免ID冲突本地场景大多数时候没必要。3.2 INSERT之后拿到自动序号的正确姿势插入数据之后立刻要拿到新记录的id这是最常见的场景。SQLite提供last_insert_rowid()函数它返回当前数据库连接中最近一次成功INSERT所产生的rowid。关键点它是连接级别的不是全局的更不是表级别的。如果你在另一个连接里调用拿到的可能是另一个连接的行为甚至为0。在Python的sqlite3模块里游标对象直接暴露了lastrowid属性import sqlite3 conn sqlite3.connect(demo.db) cur conn.cursor() cur.execute( INSERT INTO orders(order_no, customer_name, amount) VALUES (?, ?, ?), (SO20240415001, 张三, 199.00), ) print(新订单ID:, cur.lastrowid) conn.commit()在Android原生的SQLiteDatabase里更直接insert方法的返回值就是新行的rowidContentValues values new ContentValues(); values.put(order_no, SO20240415001); values.put(customer_name, 张三); values.put(amount, 199.00); long newId db.insert(orders, null, values);这里有几个容易出错的细节。第一多线程场景下不要跨线程共用一个连接去取值连接是线程不安全的应该每个线程都用独立的连接这样才能保证last_insert_rowid是自己这条链路上的值。第二如果你插入的是批量数据最后一次插入的rowid就是本批次最后一条的rowid要想拿到整批的id范围需要自己在应用层记下批量起始值再推算。第三使用ORM时ORM通常已经帮你封装好了但如果你执行的是原生SQL还是要手动调用上面的取id方法。3.3 UPDATE语句的典型写法与“忘写WHERE”的抢救方案UPDATE是日常高频操作写法本身不复杂UPDATE orders SET status 1 WHERE order_no SO20240415001;再复杂一点的场景是按条件批量更新比如把所有金额大于500的未支付订单标记为“待审核”状态UPDATE orders SET status 2 WHERE amount 500 AND status 0;有时候需要在一次UPDATE里根据不同条件设置不同值可以用CASE表达式UPDATE orders SET status CASE WHEN amount 100 THEN 0 WHEN amount 500 THEN 1 ELSE 2 END WHERE id 0;但最经典的坑还是那句UPDATE忘记写WHERE。一旦漏掉WHERE整张表的所有行都会被更新轻则数据异常重则直接摧毁整张表的数据。我在本地调试时发生过不止一次所幸SQLite支持事务回滚。我的抢救方案很朴素所有UPDATE操作都先包在事务里执行完先查一下受影响行数和关键字段确认无误再COMMIT否则直接ROLLBACK。还有一点容易被忽略想要知道UPDATE影响了多少行SQLite提供了sqlite3_changes()函数Python里对应cursor.rowcountAndroid里对应SQLiteDatabase的changeCount。这个值在判断“更新是否真的命中了目标行”时非常有用比如根据单号更新订单更新完发现rowcount是0说明这个单号根本不存在可以提前发现业务数据异常。3.4 DELETE掉的数据为什么不释放空间DELETE语句从逻辑上删掉了记录但数据库文件的大小往往纹丝不动。原因是SQLite删除数据后被释放的页会进入一个“空闲页链表”供后续INSERT复用但物理空间并不会主动还给操作系统。这就好比你在一个仓库里挪走了一些箱子货架空出来了但整栋仓库的建筑还在面积没有变小。想让文件真正变小需要执行VACUUM。这个命令会重建整个数据库文件把空闲页压缩掉相当于把仓库推倒重建只保留实际在用的货物。VACUUM的操作注意点有三条执行期间需要额外磁盘空间因为SQLite会创建一个临时文件。执行期间会持有排他锁其他读写全部阻塞不要在业务高峰期执行。频繁执行VACUUM反而会加剧文件碎片建议在批量清理数据之后执行一次即可。如果你希望数据库文件长期保持收缩习惯可以在建库早期执行PRAGMA auto_vacuum FULL;但这同样会带来一定的写放大而且必须在建表之前设置。权衡下来大部分本地场景我都不开auto_vacuum而是每次清理完数据后手动VACUUM一次简单可控。4. 让日常开发效率翻倍的工具sqlite3命令、DB Browser与Android可视化SQLite的上手成本低很大程度得益于工具链简单。我日常最多用的是三个东西终端里的sqlite3命令、DB Browser for SQLite图形界面、以及Android Studio内嵌的数据库查看工具。它们覆盖了脚本化、可视化和移动端调试三类场景。4.1 sqlite3命令行脚本化操作和排障的利器sqlite3命令行工具是SQLite官方自带的装上就有。它既可以交互式使用也可以直接跟在命令后面执行单条SQL非常适合脚本和快速排查场景。比如想直接看库里有哪几张表sqlite3 demo.db .tables想导出一条简单的查询结果用管道把SQL传过去sqlite3 demo.db SELECT order_no, amount FROM orders WHERE status 0;真正进入交互模式后有一批点命令dot command会经常用到我整理了一份常用的放在下面命令作用.open demo.db打开/创建数据库.databases查看当前连接的数据库文件.tables列出所有表名.schema orders查看指定表的建表语句.indexes orders查看表上的索引.headers on查询结果显示列名.mode column按列对齐表格输出.mode csv切换成CSV格式输出.output result.csv查询结果写入文件.import data.csv orders从CSV文件导入数据.dump导出完整建表语句和数据.backup backup.db在线备份数据库到另一个文件.quit退出.dump和.backup是文件备份里最常用的两个命令。区别在于.dump导出的是SQL文本需要重建库时用.backup生成的是SQLite底层页级别的备份文件更安全高效支持在线备份但要求目标库不存在或为空。4.2 DB Browser for SQLite适合什么都不想敲的场合DB Browser for SQLiteDB4S是我在桌面上最常用的图形化工具Windows、macOS、Linux都有。它最常用的几个功能查看表结构和索引左侧数据库结构面板能直接看每张表的字段、类型、约束。执行临时SQL写复杂SQL时先在DB4S里跑通了再贴回代码比在应用里反复跑日志高效。浏览和编辑数据双击单元格直接改值适合手工修正测试数据。导出/导入CSV做数据搬运、从Excel数据转成SQLite表非常方便。DB4S唯一不太强的是对一个库文件进行大规模并发操作的能力但这不是它的定位。我通常用它做“瞪眼排查”某个查询结果和预期不一致直接在图形界面里跑一遍看原始数据长什么样比在代码里加日志更快。4.3 Android Studio中查看和调试应用数据库移动端开发遇到数据库问题最麻烦的是看不到数据。过去要么用反射把.db文件从应用私有目录拷出来要么把设备root后再去/data/data目录下翻。现在Android Studio内置了App Inspection工具旧版本叫Database Inspector可以直接查看运行中App的数据库、执行SQL、观察实时变化。使用条件很宽松App以debug方式运行系统API等级26以上Android 8.0模拟器和真机都行。操作步骤在Android Studio里运行App。在底部菜单栏打开View → Tool Windows → App Inspection。切换到Database Inspector标签页。找到应用数据库下的表就能看到实时数据。还可以在Query框里执行任意SQL。这个工具最有用的一点是实时性。你在App里触发一次数据插入Database Inspector里立刻能看到新行出现对于调试“数据到底写没写进去”这类问题效率极高。如果想把设备里的数据库文件导出来做离线分析可以用adb命令。以包名为com.example.app、数据库文件名为app.db为例adb exec-out run-as com.example.app cat /data/data/com.example.app/databases/app.db app-backup.db这条命令对debug包通常有效它利用run-as进入应用私有目录读取文件再把内容重定向到本地。注意如果数据库开了WAL模式最好先确保App干净退出连同.db-wal文件一并导出否则可能拿到不完整的数据。5. 跨平台落地Android原生、uniapp与本地云存储架构中的SQLiteSQLite最舒服的舞台在端上Android、iOS、桌面客户端以及各类嵌入式设备。聊完工具之后我用几个实际技术栈来串一遍包括Android原生的接入方式、uniapp App端的调用方式以及一类比较典型的“本地库云端”架构。5.1 Android原生该用SQLiteOpenHelper、SQLiteDatabase还是RoomAndroid原生操作SQLite常见路数有三套直接用SQLiteOpenHelper管理数据库裸写SQLiteDatabase增删改查接入ORM框架比如官方推荐的Room。三套方案各有取舍我按项目规模来选。SQLiteOpenHelper配合SQLiteDatabase是最底层的用法灵活度高适合数据库操作不复杂、不想引入额外依赖的项目。典型流程是写一个类继承SQLiteOpenHelper在onCreate里建表onUpgrade里做迁移。初版可以写得很粗暴public class DBHelper extends SQLiteOpenHelper { public DBHelper(Context context) { super(context, app.db, null, 1); } Override public void onCreate(SQLiteDatabase db) { db.execSQL( CREATE TABLE orders( id INTEGER PRIMARY KEY AUTOINCREMENT, order_no TEXT NOT NULL UNIQUE, customer_name TEXT NOT NULL, amount REAL NOT NULL DEFAULT 0) ); } Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { db.execSQL(DROP TABLE IF EXISTS orders); onCreate(db); } }但正式项目里onUpgrade千万别写DROP TABLE这种操作否则用户升级App时数据全没了。正确做法是按oldVersion到newVersion逐版本执行ALTER TABLE迁移脚本或者至少先备份老表再重建。如果你的项目里数据库表比较多、查询逻辑复杂Room会是更好的选择。它通过注解定义Entity、Dao、Database在编译期生成大量模板代码还能把SQLite的cursor到对象的转换过程自动化。Room的底层其实还是SQLite所以本文讲的SQLite特性在Room里依然适用只是用法上被封装了。5.2 uniapp App端通过plus.sqlite管理本地库跨平台开发里uniapp是很常见的选择。注意uni-app本身提供了一些本地存储API比如uni.setStorage适合存小配置对象但如果你要存几百上千条结构化数据并做查询就应该用SQLite。在uni-app的App端可以通过plus.sqlite这一组HTML5 API操作本地数据库。它的用法非常直接。先打开数据库再执行建表和增删改查plus.sqlite.openDatabase({ name: demo, path: _doc/demo.db, success: function() { plus.sqlite.executeSql({ name: demo, sql: CREATE TABLE IF NOT EXISTS orders(id INTEGER PRIMARY KEY AUTOINCREMENT, order_no TEXT, amount REAL), success: function() { console.log(建表成功); }, fail: function(err) { console.log(建表失败, JSON.stringify(err)); } }); }, fail: function(err) { console.log(打开数据库失败, JSON.stringify(err)); } });查询用selectSql同executeSql类似只是sql传SELECT语句。注意plus.sqlite只支持App端H5和微信小程序里没有这套API。如果要在小程序里做本地结构化存储只能换方案比如用小程序自己的本地缓存自行封装或者接入云数据库的本地缓存能力。用plus.sqlite的时候我遇到最多的两个问题一个是路径写错path参数要按HTML5规范的相对路径来写例如“_doc/”表示应用私有文档目录另一个是API参数版本差异所以写代码前最好对照当前HBuilderX对应的官方文档别凭记忆硬写。5.3 从本地数据库到云端视频类应用的存储分层思路回到前面提到的一个热搜词“视频本地云存储架构”。这类场景的核心矛盾是原始视频文件很大云端负责内容存储和分发但端上又必须快速响应用户操作不能让所有读操作都依赖网络请求。实际上最常见的分层做法是云端存视频原片和元数据本地SQLite存播放进度、收藏状态、离线下载任务、推荐列表缓存这些高频访问的小数据。SQLite在这里的价值很明确它是本地离线优先架构的“状态中心”。App启动时先读本地库秒级渲染界面同时后台向云端拉取增量数据回来后更新SQLite并刷新界面。这样即使断网用户的播放记录、收藏列表也不会丢。数据规模可控查询需求明确并发量低——这就是SQLite最擅长的工作位置。数据备份与迁移在这个架构里也很重要。定期把SQLite文件备份到云端或者把关键表导出成JSON同步上去都是常见做法。SQLite的.backup命令可以生成一致性快照适合做定时备份.dump导出的SQL文本则适合做跨版本迁移。本地到云端、云端到本地两条通路都打通之后这个存储层就稳了。6. 性能与稳定性把SQLite用稳的实践经验这一部分是我自己踩坑最多的地方。SQLite平时很乖但一旦触发并发写冲突、文件损坏、查询性能退化定位起来还是要费一番功夫。下面几条经验值得在项目初期就纳入设计考虑。6.1 并发写冲突的排查链路与解决顺序遇到database is locked我的排查顺序是固定的先看有没有长事务。在同一个事务里执行了网络请求、大量循环等待相当于长时间握住写锁不放。解决办法是把事务拆短提交后再做耗时操作。再看是不是有多个连接同时写。SQLite同一时间只允许一个写者即使是不同表也一样。如果是多线程写入要么串行化要么用单个写线程所有写请求排队执行。开启WAL模式。它能让读写并行很多读写互相阻塞的问题在WAL下直接消失。设置合理的busy_timeout。默认是0改成3000到5000毫秒多数瞬时锁竞争就能自动等待而非立刻报错PRAGMA busy_timeout 5000;最后一条兜底原则SQLite不是为高并发写设计的。如果单机写入速度超过每秒几百甚至上千次或者有多个进程同时频繁写就该认真考虑换用其他数据库或引入消息队列做异步落库而不是继续压榨SQLite的锁机制。6.2 数据库损坏的预防、检测与恢复流程SQLite文件损坏大多数情况是掉电、进程被杀、或者数据库文件在同步过程中被复制了一半。很多人以为这个概率很低但实际在嵌入式设备和弱网环境下概率并不小尤其当WAL模式下来不及合并日志时把不完整的-wal文件一起拷走也会带来问题。预防为主我通常做两件事。一是把synchronous设为NORMAL这个级别在WAL模式下既能保证基本一致性性能也不会太差二是备份时不用简单的文件复制而是用.backup命令或VACUUM INTO生成一致性快照避免备份到“写到一半”的库。检测损坏用内置的完整性检查命令PRAGMA integrity_check;如果返回ok说明结构完整。返回其他信息比如malformed database schema、database disk image is malformed就需要抢救数据。我的恢复步骤是先把损坏文件备份一份再用sqlite3命令尝试导出可读部分sqlite3 damaged.db .dump recovery.sql这个命令会尽可能把能读的表和数据以SQL形式导出。导出完成后新建一个空库再导入recovery.sqlsqlite3 new.db recovery.sql能救回来多少算多少至少业务表的核心数据大概率能保住。说实话真到了这一步修复本来就是“尽人事”所以定期备份才是王道。6.3 索引怎么加才有效EXPLAIN QUERY PLAN的使用索引不是越多越好。本地场景数据量通常不大几万条以内全表扫描很多时候也就几十毫秒这时候加一堆索引反而拖慢写入速度。但当查询开始变慢时索引是性价比最高的解法。我加索引的判断依据很简单WHERE、JOIN、ORDER BY里高频出现的列才加。比如按customer_name查订单就可以加上CREATE INDEX idx_orders_customer ON orders(customer_name);加完要验证是否真的被用上用EXPLAIN QUERY PLAN看执行计划EXPLAIN QUERY PLAN SELECT * FROM orders WHERE customer_name 张三;执行计划里出现SCAN orders说明是全表扫描出现SEARCH orders USING INDEX idx_orders_customer说明索引生效了。这里有个常见误区LIKE %xxx%这种前导通配查询用不了普通索引只有LIKE xxx%前缀匹配才能命中。还有在列上做函数运算比如WHERE date(create_time) 2025-01-01同样会使索引失效应该直接比对原始列的区间范围。6.4 一套我常用的PRAGMA配置参考最后把我常用的PRAGMA配置整理成一张表本地App类项目可以直接参考。每个参数的含义写清楚方便按项目实际情况调整PRAGMA推荐值说明journal_modeWAL允许读写并发明显改善体验synchronousNORMALWAL模式下安全和性能的平衡点busy_timeout5000等待锁释放的时间单位毫秒foreign_keysON每次连接后必须显式开启才生效cache_size-2000以KB为单位的页缓存-2000表示2000KBtemp_storeMEMORY临时表/排序尽量放内存减少磁盘IOauto_vacuumNONE保持默认按需VACUUM更可控把这些写在一个连接初始化方法里每个新连接打开后执行一遍。这个习惯我坚持了很久尤其foreign_keys这条很多项目建了外键却没有开启它导致约束完全无效直到数据出问题才反应过来。最后分享一个我的个人习惯每次改动表结构之前先执行一次.backup把当前库完整备份到单独目录。SQLite的迁移不像大型数据库那样有成熟的前置校验机制多一份备份就少一分“升级后数据全乱”的焦虑。本地存储这件事简单是它的优势但正因为简单很多保障措施要靠使用者自己补上。
返回列表