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 Class):NULL:空值。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 AUTOINCREMENT,SQLite 会专门维护一个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(f"SELECT ... WHERE name = '{name}'")这样写有两个问题。第一是安全性,用户传入的值如果带了引号或恶意 SQL 片段,分分钟把你的查询改得面目全非,形成 SQL 注入。第二是性能,每执行一条带变量的 SQL,SQLite 都得重新解析一遍文本;用参数化查询则可以复用预编译好的语句,效率更好。把参数化查询当成铁律来执行,不管做什么数据库都适用。插入操作类似,但注意用完记得 commit:conn.execute("INSERT INTO user (name, email, age) VALUES (?, ?, ?)", ("李四", "lisi@example.com", 22)) conn.commit()批量插入推荐使用executemany,配合一个事务,性能提升非常明显:users = [ ("u1", "u1@example.com", 20), ("u2", "u2@example.com", 21), ("u3", "u3@example.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 = 'lisi@example.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 模式。每次写事务结束,日志文件会被删除。在这种模式下,读和写之间互相阻塞比较严重,写多一点的场景很容易感觉“卡”。把它切换到wal(Write-Ahead Logging)模式:PRAGMA journal_mode = WAL;WAL 模式的核心是:写操作先追加到独立的-wal日志文件,不直接改主库文件。读操作照常读主库文件,写操作只写日志文件,读和写天然就不打架了。这带来的收益非常明显:并发读写能力大幅提升。读不阻塞写,写也不阻塞读。因为刷盘次数减少,写入性能明显变快。代价是你会看到目录下多出两个文件:app.db-wal和app.db-shm。这会让从没见过的人紧张,担心是不是数据库文件损坏了。其实不是,它们是 WAL 模式正常工作的一部分。等你下一次 checkpoint(或者连接正常关闭)之后,-wal文件会合并回主库并被清空。用 SQLite 官方的话说:WAL 模式适合“读多写少或读写并发”的场景,默认的 delete 模式在“单连接写多读多”时表现反而更稳。个人经验是:桌面应用、本地工具脚本,只要库文件在自己机器上,直接 WAL 没毛病;但如果文件在网络盘或某些不支持文件锁的介质上,WAL 反而容易出问题,那种场合再退回默认模式。synchronous控制的是数据落到磁盘的策略。FULL:每次写事务都强制把数据刷到物理磁盘,最安全,但慢。NORMAL:WAL 模式下,这个级别安全性依然足够,但性能比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_timeout:PRAGMA 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的习惯。这两个习惯加起来,会比你看一百篇教程都有用。毕竟数据库这东西,纸上谈兵永远不如亲手跑一跑,跑得多了,坑也就填平了。
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 Class):NULL:空值。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 AUTOINCREMENT,SQLite 会专门维护一个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(f"SELECT ... WHERE name = '{name}'")这样写有两个问题。第一是安全性,用户传入的值如果带了引号或恶意 SQL 片段,分分钟把你的查询改得面目全非,形成 SQL 注入。第二是性能,每执行一条带变量的 SQL,SQLite 都得重新解析一遍文本;用参数化查询则可以复用预编译好的语句,效率更好。把参数化查询当成铁律来执行,不管做什么数据库都适用。插入操作类似,但注意用完记得 commit:conn.execute("INSERT INTO user (name, email, age) VALUES (?, ?, ?)", ("李四", "lisi@example.com", 22)) conn.commit()批量插入推荐使用executemany,配合一个事务,性能提升非常明显:users = [ ("u1", "u1@example.com", 20), ("u2", "u2@example.com", 21), ("u3", "u3@example.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 = 'lisi@example.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 模式。每次写事务结束,日志文件会被删除。在这种模式下,读和写之间互相阻塞比较严重,写多一点的场景很容易感觉“卡”。把它切换到wal(Write-Ahead Logging)模式:PRAGMA journal_mode = WAL;WAL 模式的核心是:写操作先追加到独立的-wal日志文件,不直接改主库文件。读操作照常读主库文件,写操作只写日志文件,读和写天然就不打架了。这带来的收益非常明显:并发读写能力大幅提升。读不阻塞写,写也不阻塞读。因为刷盘次数减少,写入性能明显变快。代价是你会看到目录下多出两个文件:app.db-wal和app.db-shm。这会让从没见过的人紧张,担心是不是数据库文件损坏了。其实不是,它们是 WAL 模式正常工作的一部分。等你下一次 checkpoint(或者连接正常关闭)之后,-wal文件会合并回主库并被清空。用 SQLite 官方的话说:WAL 模式适合“读多写少或读写并发”的场景,默认的 delete 模式在“单连接写多读多”时表现反而更稳。个人经验是:桌面应用、本地工具脚本,只要库文件在自己机器上,直接 WAL 没毛病;但如果文件在网络盘或某些不支持文件锁的介质上,WAL 反而容易出问题,那种场合再退回默认模式。synchronous控制的是数据落到磁盘的策略。FULL:每次写事务都强制把数据刷到物理磁盘,最安全,但慢。NORMAL:WAL 模式下,这个级别安全性依然足够,但性能比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_timeout:PRAGMA 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的习惯。这两个习惯加起来,会比你看一百篇教程都有用。毕竟数据库这东西,纸上谈兵永远不如亲手跑一跑,跑得多了,坑也就填平了。