sqlite3 这门数据库技术一直处于一个很有意思的位置有人把它当玩具有人把它当生产环境里的秘密武器而更多人是把它当成一个“会出现在面试题里的名词”背完就忘。我这些年和各类数据库打交道下来最直接的一个体会是你数据库玩得溜不溜真不一定取决于你会不会 MySQL反而得看你有没有真正搞懂 SQLite。举个很常见的例子。你随手打开手机里的任何一个 App里面那些消息记录、设置项、草稿箱、排行榜多半不是后端服务器下发的结果而是 App 本地用 SQLite 存的。很多桌面软件、嵌入式设备、物联网网关甚至一些中小型网站的冷数据存储后台都是这一个只有几百 KB 的库文件在扛。你很少听人吹嘘它但它几乎无处不在。这篇东西我不想写成一册教科书只想从一个实际干活的人视角把我这些年用 SQLite 的经验、踩过的坑、以及那些在官方文档里写得含糊但在实战里必须弄明白的细节一次性讲清楚。不管你现在是零基础还是已经写过一点CREATE TABLE只要你愿意花十几分钟把这篇文章看进去我可以保证你会对sqlite3有一个远超“会用”层面的理解。1. 先回答那个绕不开的问题SQLite 到底能干什么不能干什么1.1 它不是“MySQL 的简化版”而是另一条技术路线很多人一上手就犯一个观念错误把 SQLite 当成 MySQL 的阉割品觉得它就是一个“不支持并发、功能少、只能教学用”的小玩意。这个认知会害死人。因为 SQLite 从一开始走的就不是“服务器-客户端”那条路线它的定位是嵌入式关系型数据库。什么意思MySQL、PostgreSQL 这类数据库是“服务”你写代码连接它数据库进程帮你管理内存、磁盘、连接、权限多套系统可以同时连同一个库。而 SQLite 是“库”——它是一个直接链接进你程序里的 C 语言函数库。你调sqlite3_open()打开一个文件之后所有的读写操作都发生在你当前进程内部不占网络端口不需要账号密码也不需要单独部署。整个“数据库服务器”这个概念在 SQLite 里是不存在的。这个设计带来了几个非常独特的性质零配置下载一个sqlite3命令行工具或者你语言自带的驱动就能直接创建数据库文件。没有my.cnf没有postgresql.conf没有“初始化数据目录”这一说。可移植性整个数据库就是一个普通的二进制文件。你把它拷到 U 盘、发到另一台电脑、放进 Git 仓库它还是那个数据库。我甚至见过有人把 SQLite 文件直接塞进邮件附件里给对方演示数据的。进程内访问读写不需要走网络协议栈部分高频场景下它比“应用连 MySQL”还要快因为少了一整轮 IPC 和网络开销。正是这种“文件即数据库”的思路让 SQLite 成了移动端、桌面端、嵌入式设备里几乎唯一的选择。Andriod 和 iOS 的系统级 API 底层直接内置 SQLite主流浏览器里的 IndexedDB 底层也包含它的身影很多 Windows 桌面软件的本地业务数据库用的就是它。说它“一天被加载十亿次”都不夸张。1.2 什么场景千万别硬上它并发写场景要谨慎再好的技术也有边界。SQLite 最大的软肋就是并发写入。它的锁机制是数据库级锁不是行级锁。更准确地说它在默认的 rollback journal 模式下当一个连接拿到了写锁其他连接的写操作会直接报SQLITE_BUSY或database is locked。哪怕你只是更新一行数据整个数据库文件的写入也是串行的。这句话翻译成人话就是如果你的业务是“30 个用户同时往同一个库文件里写数据”SQLite 大概率会让你头痛。比如一个多人协作的后台管理系统每个操作都往库里写日志随便来点量就会把文件锁冲突的报错刷满。我自己见过一个比较典型的翻车案例有人把 SQLite 当 MySQL 用写了一个小论坛后端刚开始没什么人访问一切正常后面流量一大各种database is locked报错直接打满日志。原因很简单——网络论坛这种模型天然就是高频并发写。你再怎么优化 SQLite 的busy_timeout也解决不了它一次只能有一个写事务这个根本限制。所以我的经验是在选型之前先做一道简单的判断题数据是给单个用户本地 App、桌面软件、个人工具脚本用的SQLite 完美适配。数据是给多用户低并发读比如 Web 站点只读的配置数据、报表数据、静态内容用的SQLite 完全扛得住。数据是给多用户高频并发写用的请出门左转上 MySQL、PostgreSQL 这类真正的服务器型数据库。这道判断题想清楚了后面能省掉一大半的麻烦。1.3 拿它跑大数据量心里要有数关于“SQLite 能存多大”官方文档给的答案是单个数据库可以达到 281 TB。但这个数字纯粹是理论上限实际生产里如果真有人把一张表干到了 TB 级我得说他大概率是在给自己挖坑。我个人的经验分界点是这样的十万级到百万级行只要索引设计得合理SQLite 跑得又快又稳查询响应常在毫秒级。千万级行依然能用但已经能感受到写入变慢、备份变慢、部分复杂查询开始出现明显的等待时间。这时候需要配合PRAGMA调优、合理的分页策略才能压住阵脚。亿级行往上不建议继续把 SQLite 当主力存储了。不是说绝对不行而是你会发现自己花了太多精力在绕开它的限制上收益不划算。到了这个量级换个专业服务器数据库是更理智的选择。还有一个比“行数”更要命的概念数据库文件大小和单条记录的宽度。一张表即使只有几十万行如果其中某些字段是大量文本、JSON 长串、甚至 BLOB 二进制流文件体积会迅速膨胀。文件一大整个读写都要付出更高的 IO 成本性能就会明显下滑。2. 核心细节拆解存储、类型、事务和锁2.1 你写进去的数据到底是怎么躺在磁盘上的先花两分钟理解一下 SQLite 在磁盘上的物理布局因为很多后面遇到的奇怪问题根源都在这。SQLite 把整个数据库文件划分成一页一页的小块。默认页大小是4096 字节。所有数据不管是表还是索引最终都以B树的结构组织在这些页里。写数据的时候不是按“行”直接往文件里怼的而是先找到对应页把页加载进内存在页内的 B树节点上做插入或修改最终再把页落盘。这个机制带来的第一个实际影响是最小写入单位是“页”。哪怕你只改了一行里的一个字节SQLite 也可能要把含有那一行的整个 4KB 页读出来改完再写回去。所以存在这么一种情况你的数据明明很小但频繁写入文件碎片和写放大效应会让你感觉性能不对劲。这也是为什么批量插入永远比单条逐条插入快得多的原因之一——你可以在一个事务里把几百条插入攒着最后只落盘几个页。索引的存储也是独立的 B树。这就解释了另一个常见现象为什么一张表上索引建多了插入会变慢。因为每插入一行数据SQLite 除了要往表数据 B树里加一条还要往这个表所有索引的 B树里各加一条。索引多一条写入工作多一份。索引不是白给的是要拿写入性能换的。2.2 类型系统比你想象中更“随和”和 MySQL、Oracle 那种强类型、列类型一不匹配就直接报错或隐式转换一大堆行为的数据库不同SQLite 采用的是动态类型存储。官方文档里定义了五种存储类Storage ClassNULL空值。INTEGER有符号整数按实际大小存 1~8 字节。REAL浮点数8 字节。TEXT文本字符串按编码存储。BLOB二进制数据按原样存储。重点来了SQLite 允许你把任意类型的数据塞进任意类型的列里。你建表的时候写了一个INTEGER列但你要是INSERT一个字符串进去SQLite 不报错它照样接收。这就是所谓的“类型亲和性”Type Affinity机制。你可以理解为建表时声明的那一个类型只是一个“亲和倾向”SQLite 会尽量按声明的类型去处理但你不按它的来它也不抗议。这个特性的实战意义有两点对快速开发、原型验证、数据清洗这种场景极其友好。你从几十个乱七八糟的 CSV、API 返回体里抽数据写进 SQLite 的时候不用先花半天调字段类型硬插进去就行。副作用是你如果带着 MySQL 的严谨思维去写 SQLite很容易埋下脏数据的坑。比如某个整型字段里混进了 abc查出来一排序结果可能超出你的预期。建议是虽然它宽容你还是要自肃保持类型一致这能省掉后面一堆调试时间。另外经常有人问的一个细节SQLite 没有专门的“布尔类型”。你建表写BOOLEAN可以但它会被当成INTEGER亲和底层存的还是 0 和 1。查询的时候写WHERE flag true其实等价于WHERE flag 1。这没问题心里有数就行。还有一个让我踩过一次坑的点日期时间类型。SQLite 同样没有原生的DATETIME你用它存日期就是存字符串。好在 SQLite 内置了一整套时间函数date()、datetime()、julianday()这些都能直接处理YYYY-MM-DD HH:MM:SS这样的格式。它甚至能用datetime(now)拿当前时间。所以本质上是“用文本存时间用函数算时间”习惯之后比专门的日期类型还灵活。2.3 默认就有 rowid每张表其实都藏着一个主键这点是 SQLite 的老玩家才熟、新玩家容易懵的地方。SQLite 里大部分表除非你声明了WITHOUT ROWID在底层都维护一个隐形的整数主键叫做rowid。你可以通过rowid、_rowid_、oid三个名字之一去访问它。当你建表的时候声明了INTEGER PRIMARY KEY这个字段就会直接充当 rowid 的别名而不是另外再建一个独立隐藏列。这也是为什么INTEGER PRIMARY KEY的自增行为和其他数据库不太一样——它不是“用户自定义的普通自增列”而是直接绑定了行号。这里有一个实战中特别容易踩的坑如果你在建表时用了INTEGER PRIMARY KEY AUTOINCREMENTSQLite 会专门维护一个sqlite_sequence表来记录当前最大值保证自增值不重用。但如果你只用INTEGER PRIMARY KEY那么默认情况下新插入的行会取“当前最大 rowid 1”如果你删掉了最大的几行后面插入的数据可能会复用那些被删掉的 rowid。在绝大多数业务场景里我们根本不关心自增值是否复用所以能不用AUTOINCREMENT就不用省下那一次额外的系统表读写性能开销。2.4 事务、提交和锁最容易出乱子的区域SQLite 的事务处理方式和很多数据库的习惯不一样你如果拿 MySQL 那套“自动提交”的习惯来写 SQLite分分钟出现“明明插了数据进程一崩就全没了”的闹剧。默认情况下SQLite 处在autocommit 模式。每执行一条写语句INSERT、UPDATE、DELETE它自己开一个事务执行完自动提交。这没问题但它不是最高效的用法。更微妙的是编程语言驱动对“隐式事务”的处理。我用 Python 的sqlite3模块举个例子因为这是最常见的场景。Python 的sqlite3模块默认行为是只要遇到写语句就自动开启一个事务但不会自动提交。你必须显式调用conn.commit()事务才会真正落盘。而如果你忘了提交直接关连接或者程序退出这个事务会被回滚数据灰飞烟灭。我用这个词不是夸张是真的灰飞烟灭。很多人第一次用 Python 写 SQLite 时会写出这样的代码import sqlite3 conn sqlite3.connect(demo.db) cursor conn.cursor() cursor.execute(CREATE TABLE IF NOT EXISTS user (id INTEGER PRIMARY KEY, name TEXT)) cursor.execute(INSERT INTO user (name) VALUES (张三))然后发现数据库文件里user表建出来了但张三那行数据不见了。原因就是CREATE TABLE和INSERT都处在一个隐式事务里程序结束时没有commit()整个事务回滚表结构是CREATE TABLE IF NOT EXISTS的后续行为带来的错觉数据却全丢了。正确写法很清楚import sqlite3 conn sqlite3.connect(demo.db) try: cursor conn.cursor() cursor.execute(INSERT INTO user (name) VALUES (张三)) conn.commit() # 别忘了这一步 except Exception as e: conn.rollback() raise e finally: conn.close()再进一步如果你要在循环里插入几千条数据最忌讳的做法是每条都自动提交。正确姿势是一次性开事务全部插入完最后一次 commit。实测差距可以做到十倍以上原因我们前面说过了少了很多次不必要的页同步和锁竞争。SQLite 的锁状态机也值得了解一下虽然不用背全但至少要知道它在默认 journal 模式下五把锁的递进关系UNLOCKED→SHARED读→RESERVED预留写→PENDING即将升级→EXCLUSIVE独占写。读到SHARED锁的文件其他连接还能继续拿SHARED锁所以并发读没问题但一旦有连接想要拿RESERVED或EXCLUSIVE其他连接的读写就会受到不同程度阻塞。这就是“读多写少”场景SQLite还能勉强硬撑、但“写多读不少”场景会非常痛苦的根本原因。3. 实操环节从命令行到 Python一步步把它用熟3.1 那些你不可不知的 sqlite3 命令行点命令标题是数据库 sqlite3那我们必须从它的命令行工具讲起。装完 SQLite 之后你在终端里输sqlite3回车就会进入一个交互式 shell。这个 shell 里有一堆以点号开头的“点命令”它们不是 SQL但查起来比 SQL 还勤。我先列三个使用率最高的基础操作.databases列出当前连接打开的所有数据库文件路径。.tables列出当前库里所有表不含系统内部表。.schema 表名查看某张表的建表语句如果不带表名就输出整个库所有对象的建表语句。做交接、做迁移、没文档的时候这是救命命令。再往下几个是日常调试的好手.dump把整个数据库导出成一段接一段的 SQL 文本。你要把一个 SQLite 库迁到另一个库或者想交给别人审查数据变更用它最直观。.header on/.mode column这俩通常连着一起开。默认情况下查询结果输出得非常朴素不带列名开了.header on才有表头.mode column让列对齐观感舒服很多排查数据的时候尤其有用。.indexes 表名列出一张表上的所有索引。优化查询时你先看一眼它就知道索引有没有生效。.timer on打开命令耗时展示。SQLite shell 会显示每条查询的执行时间。我优化慢查询时第一步永远是开它。还有两个操作容易混淆必须单独拎出来说.backup 备份名.db和.dump 备份.sql都能做备份但原理完全不同。.backup走的是 SQLite 官方在线备份 API它会生成一份真正字节级一致的数据库拷贝推荐使用。而.dump导出的是 SQL 文本你得靠日后“重放 SQL”来还原库耗时更长也容易丢失某些微妙状态。所以凡是做物理备份、迁移文件优先选.backup。3.2 用 Python 写增删改查示例代码带参数Python 的sqlite3模块是标准库不需要 pip 安装任何第三方包。连接数据库就一行import sqlite3 conn sqlite3.connect(app.db)如果这个文件不存在SQLite 会顺手创建一个空文件。这个“连库即建库”的行为新手第一次遇到会觉得奇怪但用熟了会发现它香得很——不用事先建库代码逻辑简单了不止一星半点。接着建表conn.execute( CREATE TABLE IF NOT EXISTS user ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT UNIQUE, age INTEGER ) ) conn.commit()注意这里我加了IF NOT EXISTS。日常脚本重复执行也不会因为表已存在而报错这是非常省心的习惯。然后查数据cursor conn.execute(SELECT id, name, age FROM user WHERE age ?, (18,)) for row in cursor: print(row)看到那个?了吗这是 SQLite 的占位符参数通过第二个元组传进去。我见过太多人用字符串拼接方式写 SQL# 绝对不要这样做 cursor.execute(fSELECT ... WHERE name {name})这样写有两个问题。第一是安全性用户传入的值如果带了引号或恶意 SQL 片段分分钟把你的查询改得面目全非形成 SQL 注入。第二是性能每执行一条带变量的 SQLSQLite 都得重新解析一遍文本用参数化查询则可以复用预编译好的语句效率更好。把参数化查询当成铁律来执行不管做什么数据库都适用。插入操作类似但注意用完记得 commitconn.execute(INSERT INTO user (name, email, age) VALUES (?, ?, ?), (李四, lisiexample.com, 22)) conn.commit()批量插入推荐使用executemany配合一个事务性能提升非常明显users [ (u1, u1example.com, 20), (u2, u2example.com, 21), (u3, u3example.com, 22), ] conn.executemany(INSERT INTO user (name, email, age) VALUES (?, ?, ?), users) conn.commit()3.3 索引优化先学看 EXPLAIN QUERY PLAN很多人说 SQLite 查询慢先别急着怪数据库先看有没有走索引。SQLite 提供了EXPLAIN QUERY PLAN这个命令用来输出查询计划。在命令行加在任意SELECT前面即可EXPLAIN QUERY PLAN SELECT * FROM user WHERE email lisiexample.com;如果输出里带有SEARCH user USING INDEX ...或者USING COVERING INDEX ...说明索引被用上了。如果输出的是SCAN user那就是全表扫描数据量一大必慢无疑。看到SCAN user怎么办建索引。CREATE INDEX idx_user_email ON user(email);建完索引再跑一次EXPLAIN QUERY PLAN你会发现查询计划从SCAN变成了SEARCH。这一来一回往往是性能提升最为显著的一次操作。我的一个建议是所有 WHERE 子句里频繁出现的字段都值得思考一下要不要建索引。但别贪多回头再看一眼我们前面说的——索引会拖慢插入和更新。恰到好处才是最优解。3.4 事务、回滚与上下文管理器的正确姿势Python 的sqlite3连接对象可以直接当上下文管理器用with conn: conn.execute(INSERT INTO user (name) VALUES (王五))这个with块会在进入时开启事务正常结束自动commit()发生异常自动rollback()。看起来很方便但如果有人告诉你“Python 里with conn就万事大吉了”这其实是一个误导。官方文档里说得很明确with conn只能管事务不会在退出时关闭连接。你依然需要手动conn.close()或者在另一个with块里管理连接生命周期。不少人因为这个错觉写出的代码里连接一直开着文件句柄泄漏程序跑着跑着报“too many open files”教训相当深刻。我的个人习惯是尽量显式写try/except/finally或者用contextlib.closing(conn)配合事务块既保证事务提交也保证连接一定关闭from contextlib import closing import sqlite3 with closing(sqlite3.connect(app.db)) as conn: with conn: conn.execute(UPDATE user SET age age 1 WHERE id ?, (1,))这样两层with外面管连接关闭里面管事务提交或回滚不会漏。4. 性能调优与备份方案这些 PRAGMA 是真正的钥匙4.1 关键 PRAGMA 参数及适用场景SQLite 有一个PRAGMA命令体系相当于“运行时配置”。前面那些调优点如果不配合 PRAGMA很多优化都是纸上谈兵。我挑几个真正有实战价值的参数说。journal_mode是最先值得动的。默认值是delete也就是大家说的 rollback journal 模式。每次写事务结束日志文件会被删除。在这种模式下读和写之间互相阻塞比较严重写多一点的场景很容易感觉“卡”。把它切换到walWrite-Ahead Logging模式PRAGMA journal_mode WAL;WAL 模式的核心是写操作先追加到独立的-wal日志文件不直接改主库文件。读操作照常读主库文件写操作只写日志文件读和写天然就不打架了。这带来的收益非常明显并发读写能力大幅提升。读不阻塞写写也不阻塞读。因为刷盘次数减少写入性能明显变快。代价是你会看到目录下多出两个文件app.db-wal和app.db-shm。这会让从没见过的人紧张担心是不是数据库文件损坏了。其实不是它们是 WAL 模式正常工作的一部分。等你下一次 checkpoint或者连接正常关闭之后-wal文件会合并回主库并被清空。用 SQLite 官方的话说WAL 模式适合“读多写少或读写并发”的场景默认的 delete 模式在“单连接写多读多”时表现反而更稳。个人经验是桌面应用、本地工具脚本只要库文件在自己机器上直接 WAL 没毛病但如果文件在网络盘或某些不支持文件锁的介质上WAL 反而容易出问题那种场合再退回默认模式。synchronous控制的是数据落到磁盘的策略。FULL每次写事务都强制把数据刷到物理磁盘最安全但慢。NORMALWAL 模式下这个级别安全性依然足够但性能比FULL好不少。OFF不管结果写入直接交给操作系统缓存快是快但系统一断电数据可能就没了。对普通业务和开发环境我一般用NORMAL。如果是记账软件、笔记软件这种丢数据会出大事的场合才用FULL。busy_timeout决定等待锁的时间。当多个连接竞争写锁的时候默认行为是直接返回SQLITE_BUSY。你要是在旁边给一个高一点的超时时间比如 5000 毫秒那写操作在锁被占住时会等一等而不是立刻报错。有很多“偶发性 locked”问题靠PRAGMA busy_timeout 5000;就能缓解大部分。cache_size控制页缓存大小。默认值是 -2000意思是大约 2MB。你改成PRAGMA cache_size -64000;就相当于 64MB 的页缓存。对大数据量读写、复杂查询缓存大了命中率高性能提升立竿见影。注意负号表示以 KB 为单位不带负号则是“页数量”。temp_store控制临时表和排序用的临时文件放哪。默认是0用编译时默认值设成2可以强制临时结果放到内存里。如果你经常跑ORDER BY、GROUP BY之类的大排序这个参数对速度有帮助。代价是内存占用升高别在内存本就不宽裕的环境里乱开。我整理一个常用参数对照表方便你看一眼就想到该用啥PRAGMA推荐值作用代价journal_modeWAL读写并发不打架多出 -wal/-shm 两个辅助文件synchronousNORMAL避免极端安全校验拖慢写异常断电可能丢最近一点数据busy_timeout5000锁冲突时等待而不是立即报错冲突严重时可能排队变慢cache_size-64000加大页缓存减少磁盘 IO内存占用增加temp_store2临时结果进内存加速排序/分组内存占用增加4.2 批量写入的提速序列如果你要一次性导入几十万行 CSV 或接口数据只靠一条条 INSERT 会很痛苦。经过实测按下面这个顺序组合起来速度能比“裸插入”快出几十倍打开连接后先设置PRAGMA journal_mode WAL;减少写放大。设置PRAGMA synchronous OFF;导入期间不追求极端安全。用executemany攒批插入或者自己攒几千条再executemany避免单条隐式提交。整个导入过程包在一个事务里最后统一commit()。导入完成之后执行一次PRAGMA wal_checkpoint(TRUNCATE);把 WAL 日志并回主文件顺手释放磁盘空间。这套组合拳跑完同样数据量时间开销肉眼可见地下降。我试过在普通笔记本上导一张几十万行的业务表默认写法用了快两分钟用上面的方法压到几秒以内。当时我甚至怀疑是不是数据没写进去结果一查行数都在。4.3 备份不是“拷文件”至少不总是SQLite 备份有个潜藏风险直接拷贝主库文件并不是可靠的备份方式。尤其是在 WAL 模式下可能有一部分最新数据还躺在-wal文件里没合并。你直接把app.db拷走了但没带-wal备份就是缺数据的。反过来你在 Windows 上拷一个正被其他进程打开的文件可能还会碰到文件占用锁的问题。正确的备份姿势在命令行里用.backup 备份名.db它走在线备份 API会处理好 WAL 和一致性。或者在 Python 代码里用sqlite3.backup()接口import sqlite3 src sqlite3.connect(app.db) dst sqlite3.connect(backup.db) src.backup(dst) dst.close() src.close()这个 API 会把源库“在某一时刻的状态”完整同步到目标库避开了“拷到一半文件在变”的一致性问题。我自己写定时备份脚本用的就是这个方案。另外VACUUM INTO 文件路径也是一个不错的另类做法它执行的时候会生成一个全新的、紧凑的、去掉碎片和空洞的备份文件。相比普通备份它还顺带完成了一次数据库瘦身。4.4 磁盘回收VACUUM 什么时候该用SQLite 删数据之后文件体积不会自动缩小。因为删除操作标记的只是“页可复用了”并不会把空间还给操作系统。这是很多使用者困惑的地方“我删了 80% 的数据文件怎么还是那么大”就是这个原因。这时候执行VACUUM;它会重写整个数据库文件把里面可用的空闲空间整理掉文件体积骤然缩小。同时它也会重建索引一定程度上让查询更快。但注意VACUUM是重量级操作。它在执行期间需要两倍于当前数据库文件的磁盘空间临时复制一份再替换还会短暂阻塞写操作。所以别勤快到每次删完数据就 VACUUM一般建议是定期维护的时候或者刚做完大规模删除/有大量碎片的时候才跑一次。我在生产环境里习惯把它放进每周的定时任务里频率不高效果不错。5. 常见问题排查实录那些冒烟的坑5.1database is locked怎么定位和突围这大概是 SQLite 用户遇到最多的报错没有之一。它在什么情况下出现一个连接在写事务里还没提交另一个连接也想写。默认busy_timeout是 0后者直接报SQLITE_BUSY。解决思路从松到紧排下来加大 busy_timeoutPRAGMA busy_timeout 5000;。遇到锁就等一等很多偶发冲突就消失了。改用 WAL 模式读写并发能力大幅增强锁冲突自然减少。但要注意database is locked在 WAL 模式下虽然几乎绝迹了却仍可能在“一个连接把整个数据库改为只读”或“多进程同时用一些特殊扩展”时出现。检查连接生命周期是不是有连接忘了close()特别是 Python 脚本跑完不关连接很有可能残留打开状态的文件句柄后面再开新连接写库就锁住了。用netstat或系统文件句柄查看工具能看到是谁在占着文件。事务拆分单个写事务过大、执行时间过长锁被占住的时间就长。把大事务拆小让其他写操作能及时进来。避免连接内嵌事务跨网络请求比如 Web 应用里用一个全局连接事务从收到请求就开始一直到返回响应才提交。这期间如果并发请求也来写冲突率成倍上升。解决办法是按请求管理连接或保证事务很短。我最开始调这个错的时候总以为要翻什么高深参数结果排查到最后经常就是某一个连接忘了提交或关闭。所以遇到 locked先翻代码找连接的生命周期往往比调参更有效。5.2 文件损坏SQLite 没那么脆但也别瞎折腾“数据库文件损坏”这种事情不常见但一旦发生就非常揪心。SQLite 官方多次强调它的回滚机制保证了在正常操作下文件损坏是几乎不可能的事件。真正导致损坏的原因通常是程序在写库过程中被强杀而文件系统本身不支持原子性的保证比如某些网络盘。在数据库文件所在目录上做了不恰当的文件同步比如把-wal和主文件分别拷贝到不同位置。用第三方工具在文件写入时直接暴力修改了内容。自查有没有损坏一条命令PRAGMA integrity_check;如果输出ok说明文件结构完好如果出现row ... missing from index ...或者database disk image is malformed之类信息就要警惕了。轻度的损坏是可以抢救的。常见的招数是sqlite3 corrupted.db .dump dump.sql sqlite3 new.db dump.sql通过导出 SQL 文本再重建一个新库能救回大部分还能读出来的数据。如果.dump中途都失败那可能要采用更底层的恢复工具甚至手动提取页面数据。这种时候平时那些可靠的线上备份就是你最好的一道防线。5.3 全表扫描导致的性能骤降还有一种“表面没报错、实际慢得要命”的问题就是查询不走索引。常见原因很多WHERE 条件里对字段做了函数运算比如WHERE UPPER(name) LISI或者字段类型不匹配导致类型强制转换后索引失效再或者用了LIKE %xxx这种通配符开头的模糊匹配。这些都是全表扫描的典型写照。调优方法前面提过用EXPLAIN QUERY PLAN看计划。发现SCAN就要考虑调整查询写法或者建新索引。对于模糊搜索这种没法走普通 B树索引的场景如果数据量确实不小SQLite 有一个内置的FTS5全文搜索扩展它是专门解决这个问题的。别自己用LIKE硬扛数据量上千行可能还看不出什么上万行之后就是一卡一卡的。5.4 数据库文件在网络盘上小心这是坑中坑有些项目图省事把 SQLite 数据库文件直接放在公司局域网共享目录或者做成网盘同步文件夹里的内容。这是非常危险的操作我在实际工作中见过不止一次因此引发的诡异问题。原因在于 SQLite 的正常运行依赖操作系统级别的文件锁。而很多网络文件系统SMB、NFS对锁的支持并不可靠锁可能丢失、可能不生效、可能在网络抖动时误判。表现就是数据库读着读着突然报disk I/O error或者database is locked莫名其妙高频出现连检查锁的 5 秒超时都不够用。如果应用场景是多人同时访问同一个库文件正确的解法不是“把文件放到共享盘里”而是把数据收归到一个真正支持并发的数据库服务端或者做一层“薄服务层”把 SQLite 封装起来让外部通过接口访问而不是直接连文件。5.5 大量数据误操作后能回滚吗回答可能让你失望MySQL 有二进制日志、PostgreSQL 有 WAL 归档数据库管理员经常能做“误删数据后回滚”的操作。SQLite 呢它没有日志重放这种高可用机制也没有RETURNING之前的版本快照更老版本的 SQLite 没有RETURNING现在有了但那是查询语法不是恢复机制。SQLite 的单机定位决定了它的数据恢复手段非常朴素。想恢复误删的数据前提是你之前做了备份那就用.backup或备份文件还原。没有备份那只能用.dump之后看看有没有遗漏的临时文件、撤销日志缓存之类成功率很低。更现实的做法是练好“手速”万一发现自己刚删完马上把文件目录整体复制一份留底再想办法从那份复制里找能读的数据。这也再次说明了一个道理像 SQLite 这样的嵌入式数据库备份不是可选项目而是必选项。我给自己写的任何 SQLite 项目都默认配置了每日备份一天都不落下。它不占多少空间麻烦程度也低但关键时刻能救一条命。6. 进阶内容可视化工具与扩展思路6.1 用对工具调试效率直接翻倍命令行虽然强大但看多表关联、大结果集的时候人脑确实不够用。给 SQLite 装个好用的图形化界面是性价比最高的投资。我用过的工具不少但最常推荐的就是DB Browser for SQLite。它是一个开源、免费、跨平台的 SQLite 可视化工具。日常的工作流它可以覆盖八成查看表和数据、执行 SQL、导 CSV、看索引、看外键、手动VACUUM、做.dump备份都有按钮。它的“表结构”视图可以直接增删字段、编辑索引适合不熟命令行的同事快速上手。另一个思路是用 VS Code 的 SQLite 插件很多用 Python 做数据分析的朋友会喜欢这种方式写代码和看数据在同一个窗口里预览表格结果非常方便。6.2 内置扩展JSON、全文搜索与正则SQLite 强大的一点在于它不仅仅是一个“关系型数据库”还带了一堆可以按需启用的扩展能力。JSON 扩展json()、json_extract()、json_each()这些函数能让你直接在 SQL 里解析和查询 JSON 字段。小型项目拿它当一个轻量文档数据库用完全可行。FTS5 全文搜索前面提过内置全文索引功能可以用来做像模像样的本地搜索。正则表达式函数默认没编译进去但可以自己加载扩展。具体到你用的 Pythonsqlite3模块json扩展一般随发行版自带可以直接用conn.execute(CREATE TABLE t (id INTEGER PRIMARY KEY, data TEXT)) conn.execute(INSERT INTO t (id, data) VALUES (1, {\name\: \alice\, \age\: 25})) cur conn.execute(SELECT json_extract(data, $.name) FROM t WHERE id 1) print(cur.fetchone()[0]) # alice用起来很方便很多本来要考虑上 MongoDB 的轻量场景其实这个方案已经够用了。6.3 从 SQLite 出发理解整个数据库世界的共性最后说一点扩展的思路。很多人觉得 SQLite 是个“小东西”学它没价值。但恰恰相反SQLite 是把关系型数据库核心原理压缩得最小最清楚的样例。你在 SQLite 里学会的 B树索引、事务、锁、WAL、执行计划搬去哪儿都能用。它没有 MySQL 那一堆权限、主从、分区、存储引擎的繁杂概念因此反而是最好的“数据库原理课”。也正因为这个原因很多内部工具、数据管道、原型验证我都直接用 SQLite 先跑起来。等业务成熟了、规模上去了再迁移到更重的数据库也不迟那时候你再对比两者只会觉得思路更加清晰。我自己在做的项目里遇到“需要一个本地缓存”“需要临时数据分析”“需要一个给软件打包带走的轻量存储”这类问题时已经形成了一套条件反射先问并发量再问数据量如果两个都温和直接 SQLite 走起几行代码搞定连服务都不用开。如果你也打算认真用起来我最后给你一个非常实际的建议把所有表的查询都养成交叉检查EXPLAIN QUERY PLAN的习惯把所有写入操作都养成显式commit的习惯。这两个习惯加起来会比你看一百篇教程都有用。毕竟数据库这东西纸上谈兵永远不如亲手跑一跑跑得多了坑也就填平了。