这段时间在整理一个本地小工具的数据存储,又双叒叕把 SQLite3 数据库拿出来用。说实话,平时工作里 MySQL、PostgreSQL 用得都不少,但真正到了给桌面端程序、临时分析脚本、课程设计搭数据层的时候,我第一个想到的还是 SQLite3。这次就当是完整复习一遍,把命令行、SQL、Python 操作、并发锁、备份迁移这些知识点串起来,顺便把所有我不小心踩过的坑都记录一下。
如果你也正处于 SQLite3 的复习阶段,或者刚学完基础不知道该从哪里继续,这篇笔记应该能帮你省一点时间。它不会讲太多底层 C 实现,而是围绕“怎么更快、更安全地用好它”来展开。
1. 重新认识 SQLite3:它到底解决什么问题
1.1 无服务器、单文件、零配置,到底是一种什么体验
很多人在用 SQLite3 时,其实并没有认真想过它的定位。它不是一个需要单独启动服务的数据库,而是一个嵌入式关系型数据库。用大白话说,你的程序直接通过官方库文件去读写一个后缀通常为 .db 或 .sqlite3 的普通文件,不需要监听端口,不需要账号密码,也不需要安装独立服务端。
这种设计在真实项目里非常方便。比如我写一个桌面端的个人记账工具,程序本体再带一个数据文件,用户拿到压缩包解压就能跑。换到 MySQL,得先装服务、建库、配账号、开放端口,用户根本不会愿意折腾。SQLite3 的数据持久化逻辑也很像我们日常用 Excel 管理明细,但它的能力远不止表格,它支持一套相当完整的 SQL 语法、事务、索引、视图、触发器,同时还保证 ACID 特性。
我心里一直有个类比:SQLite3 就像随身携带的瑞士军刀,没法跟大型机床比加工能力,但绝大多数轻量场景下,拿出来就用,还不用担心维护问题。数据库文件拷给别人,数据也就过去了,这种“文件即数据库”的感觉用惯了以后,真的会很难戒掉。
1.2 它和 MySQL、PostgreSQL 的差异,决定了选型方向
既然要复习,就不能只停留在“能用”的层面。我习惯把 SQLite3 和主流客户端/服务器型数据库放在一起对比,这样才能真正记住它的边界。
| 维度 | SQLite3 | MySQL / PostgreSQL |
|---|---|---|
| 运行方式 | 嵌入在应用进程内 | 独立服务进程 |
| 连接方式 | 直接打开数据库文件 | 通过网络端口连接 |
| 配置文件 | 基本不需要 | 需要配置较多参数 |
| 并发写入 | 同一时刻只能一个写事务 | 支持更成熟的并发控制 |
| 权限体系 | 文件系统权限为主 | 独立用户权限体系 |
| 数据容量 | 适合中小规模,单库通常建议几十 GB 以内 | 可以支撑较大规模业务 |
| 部署成本 | 极低 | 较高 |
| 典型场景 | 本地工具、移动端、嵌入式、测试环境 | Web 后端、高并发业务系统 |
不看这个对比,很容易把 SQLite3 用错地方。我见过有人拿它做高并发的 Web 后端主库,结果业务量一起来,大量请求同时写库,频繁出现 database is locked,最后只能紧急迁移数据。反过来也见过有人只是做一个离线工具,居然坚持部署 MySQL,白白增加运维成本。
所以复习 SQLite3,第一件应该记住的事情不是某个命令,而是它的能力边界:适合低并发、中等数据量、应用与数据紧密绑定的场景。一旦并发写入很高,或者需要远程多人访问同一套数据,就要考虑换更重的数据库了。
1.3 我复习时采用的路线
数据库这块内容繁杂,如果只是漫无目的地翻文档,很难形成体系。我这次给自己定的路线很简单:先用手摸一遍命令行,再回到 SQL 本身,重点练习更新和覆盖场景,然后切入 Python 操作,最后看并发热点问题和备份迁移。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从打开一个数据库开始:安装、命令、基础操作
2.1 安装和验证:不同环境下的快速起步
SQLite3 的发行形态比较特殊,很多场景下它已经被内置了,比如 Python 自带的 sqlite3 模块、PHP 的 pdo_sqlite 扩展、安卓系统内部。因此第一步不是盲目跑安装命令,而是先确认当前环境里到底有没有。
Windows 上想用一个纯命令行客户端,可以去 SQLite 官网下载 Precompiled Tools 包,里面包含 sqlite3.exe。把解压后的目录加到系统 PATH 环境变量里,之后打开 CMD 或 PowerShell,输入 sqlite3 --version,能输出版本号就表示成功。Linux 上更简单,大部分发行版的软件源里都有 sqlite3 包,Debian/Ubuntu 可以直接执行:
bash复制sudo apt install sqlite3
Python 环境下的验证更快,直接运行:
python复制import sqlite3
print(sqlite3.sqlite_version)
如果是在 PHP 环境中遇到“未检测到 sqlite3 数据库扩展”这种提示,通常是在 php.ini 中没有开启 sqlite3 或 pdo_sqlite 扩展。把这两行前边的分号去掉,然后重启 PHP-FPM 或 Apache 服务即可:
ini复制extension=sqlite3
extension=pdo_sqlite
2.2 最容易忽略的 dot 命令,却是命令行效率的关键
进入命令行之后,很多人只会写 SQL 语句,却不知道有一堆以点号开头的命令能提高效率。先创建一个测试库文件:
bash复制sqlite3 demo.db
进入 sqlite3 交互界面后,我基本上必用这么几条命令:
sql复制.databases
.tables
.schema users
.headers on
.mode column
.databases 会列出当前连接着的数据库文件;.tables 能看到库里的表;.schema users 能查看建表语句;.headers on 和 .mode column 组合使用以后,查询结果会把列名显示出来,并且对齐输出。刚开始用 SQLite3 时,如果不设置这两个选项,查询结果就是简单的管道分隔文本,字段一多很容易看花眼。
如果已经有写好的 SQL 脚本,直接在命令行里执行:
bash复制sqlite3 demo.db < test.sql
也可以用 .read 命令在交互界面里导入:
sql复制.read test.sql
这几个命令虽然不 SQL,但真实使用频率非常高。很多小伙伴复习时捧着 SQL 语法看半天,实际到了命令行却不知道怎么把现有脚本跑起来,问题往往就出在这里。
2.3 用一次完整的增删改查把记忆拉回来
复习基础 SQL,我一般会建一张用户表,把增删改查整个流程走一遍:
sql复制CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
age INTEGER DEFAULT 0,
email TEXT UNIQUE
);
然后插入几条数据:
sql复制INSERT INTO users (name, age, email) VALUES ('张三', 23, 'zhangsan@example.com');
INSERT INTO users (name, age, email) VALUES ('李四', 25, 'lisi@example.com');
查询时加上条件、排序和限制:
sql复制SELECT id, name, age, email FROM users WHERE age > 20 ORDER BY age DESC LIMIT 10;
更新某一行也很直接:
sql复制UPDATE users SET age = 26, email = 'zhangsan_new@example.com' WHERE id = 1;
删除时一定要先确认 WHERE 条件,不然容易把整张表清空:
sql复制DELETE FROM users WHERE id = 2;
这套流程看起来毫无难度,但它把 SQLite3 最核心的操作全串起来了。复习阶段不要只看不敲,最好自己建一个练习库,把每个命令都执行一遍。哪怕只是简单的增删改查,也建议在事务里多走几遍,后面对学习 Python 操作特别有帮助。
2.4 给命令行查询结果做一点“美化”
命令行输出默认很朴素,调试的时候信息不够友好。我会在当前会话中执行:
sql复制.headers on
.mode box
.mode box 会让结果变成带边框的表格,比 column 模式更直观。也可以用 .nullvalue NULL 把空值显示成可读的字符串,否则空字段在命令行里会显示成一片空白,容易误判。
3. 建表不随意:类型、约束和覆盖更新的正确姿势
3.1 字段类型与约束,藏着不少小细节
SQLite3 是动态类型系统,它支持 NULL、INTEGER、REAL、TEXT、BLOB 这些存储类型,同时又允许你随意写 VARCHAR(100)、DATETIME 之类的声明。很多人第一次接触时会被吓到,担心写错了类型会不会报错,实际上 SQLite3 会执行“类型亲缘性”规则,比如声明为 VARCHAR 的列依然采用 TEXT 亲缘性,大多数时候能正常工作。
不过这不代表可以胡写,项目里该加约束还是要加。PRIMARY KEY、NOT NULL、UNIQUE、DEFAULT、CHECK 这些关键字都支持,设计表结构时提前规划好,远比以后在应用层补逻辑靠谱。例如给最常用的查询字段建立索引,但不要给每列都加索引,因为索引会拖慢写入速度,还占用额外空间。
3.2 两个字段相同时覆盖旧数据,不要再用“先查再写”
很多人在业务里会遇到一种更新场景:某个唯一记录已经存在就更新,不存在就插入。搜索热词里也有“数据中的2个字段相同则覆盖”,说明这是特别常见的需求。
比如用户收藏文章表,我只关心同一个用户对同一篇文章只保留一条有效记录:
sql复制CREATE TABLE user_favorite (
user_id INTEGER NOT NULL,
article_id INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, article_id)
);
这里把 user_id 和 article_id 组成联合主键,本质就是联合唯一约束。接下来需要插入或更新时,可以用 UPSERT 语法:
sql复制INSERT INTO user_favorite (user_id, article_id, updated_at)
VALUES (1, 100, datetime('now'))
ON CONFLICT(user_id, article_id) DO UPDATE SET
updated_at = excluded.updated_at;
这段 SQL 的意思很直白:如果 user_id 和 article_id 组合冲突了,就把 updated_at 更新成本次要插入的值。excluded 这个关键字代表“本来准备插入但发生冲突的那一行数据”,用起来非常顺手。
为什么不推荐先 SELECT 再决定 INSERT 或 UPDATE?因为它不是一个原子操作。两条请求如果同时执行,都在第一步查不到记录,然后同时走到 INSERT,后提交的那条就会撞上唯一约束报错。UPSERT 把“判断是否存在”和“写入或更新”合并成一次数据库操作,从根上避免了竞态问题。
需要注意,UPSERT 是 SQLite 3.24.0 版本开始支持的。如果项目跑在比较老的环境里,需要先检查版本,版本不够就得考虑 INSERT OR REPLACE。但 INSERT OR REPLACE 的原理是删除旧记录再插入新记录,副作用很大,比如会把没涉及的字段丢掉,也会触发外键和触发器,我一般不太推荐。
3.3 事务到底是干什么用的,复习时必须搞懂
数据库事务其实就是一组要么全部成功、要么全部失败的操作。SQLite3 默认每条 SQL 语句都有隐式事务,但如果你把多条 SQL 当成一个整体业务,就必须手动开事务。
最典型的例子是转账:A 扣钱、B 加钱,如果第一步成功、第二步失败,钱就会凭空消失。包在事务里执行,任何一步失败都能整体回滚。命令行的试验方法是:
sql复制BEGIN;
INSERT INTO users (name, age, email) VALUES ('王五', 30, 'wangwu@example.com');
UPDATE users SET age = 31 WHERE name = '王五';
COMMIT;
如果 COMMIT 之前发现写错了,执行 ROLLBACK,这一组修改全部撤销。Python 里也一样,事务没有提交前,数据只存在于当前连接内部,其他连接根本读不到。
说到底,事务的两个核心作用是原子性和隔离性。出了故障能回滚,多连接并发时又不会读到中间状态。SQLite3 虽然是文件型数据库,但它的 ACID 特性是经过大量实践中验证过的,这也是它敢用于生产环境的原因之一。
3.4 常用函数和日期处理,简单但容易忘
SQLite3 内置的一些函数很实用。比如统计行数使用 COUNT(*),空值替换使用 IFNULL 或 COALESCE,字符串拼接在较新版本里用 || 操作符,日期时间函数则依赖 datetime、date、strftime 这几个。
我在实际项目里更常用 strftime,因为它能灵活格式化时间:
sql复制SELECT strftime('%Y-%m-%d %H:%M:%S', 'now');
有一点要提醒:SQLite3 默认的日期时间函数基于 UTC 时区,而不是你电脑当前的本地时间。如果你需要存储本地时间,要么在连接后执行:
sql复制SELECT datetime('now', 'localtime');
要么在写入前由应用层生成时间字符串。很多人查出来后发现时间差了 8 小时,不是数据库坏了,而是没有处理时区。
4. Python 操作 SQLite3:连接、更新和查询
4.1 连接对象和游标对象,先分清再动手
Python 内置的 sqlite3 是标准库模块,不需要额外安装。打开一个数据库文件只需要:
python复制import sqlite3
conn = sqlite3.connect("demo.db")
cur = conn.cursor()
有人会疑惑,为什么既有 connection 又有 cursor?简单理解,connection 代表到数据库文件的连接,负责管理事务、提交、回滚、关闭;cursor 则是执行 SQL 和执行后取结果的工具。Python 的 cursor 还支持上下文管理器,但要注意,它并不会自动提交事务。
实际开发中我喜欢把 row_factory 设置为 sqlite3.Row,这样查询结果可以通过字段名访问,而不是只有数字下标:
python复制conn.row_factory = sqlite3.Row
连接对象创建以后,如果不再使用,记得调用 conn.close() 释放资源。但不要在每个函数里都新建连接,也不要让连接长期不关闭,尤其不能写一个线程一个函数短连接还开一大堆。连接数量建议与业务保持匹配,避免文件锁竞争。
4.2 插入数据时,第一原则是使用参数占位
很多教程里喜欢这样写:
python复制username = "张三"
cur.execute(f"INSERT INTO users (name, age) VALUES ('{username}', 18)")
这种写法极其危险。表面看只是字符串拼进去,遇到特殊字符会语法报错,如果参数是从用户输入来的,还会产生严重的安全漏洞。一个包含单引号和 SQL 指令的字符串,很可能直接把你的表删了。所以复习阶段宁可多写一行代码,也要养成使用占位符的习惯:
python复制cur.execute(
"INSERT INTO users (name, age, email) VALUES (?, ?, ?)",
("张三", 18, "zhangsan@example.com"),
)
批量插入时,executemany 能省下大量执行时间:
python复制data = [
("李四", 19, "lisi@example.com"),
("王五", 20, "wangwu@example.com"),
]
cur.executemany(
"INSERT INTO users (name, age, email) VALUES (?, ?, ?)",
data,
)
我建议把“SQL 永远用占位符”当成一种肌肉记忆。不是为了防止每次都被攻击,而是保证哪怕参数里带引号、空字符串或换行符,也不会破坏 SQL 结构。
4.3 更新某一行并通过 rowcount 确认结果
Python 里更新某一行,最终还是要回到 SQL 的 UPDATE 语句:
python复制def update_user_age(user_id: int, age: int) -> int:
with sqlite3.connect("demo.db") as conn:
cur = conn.execute(
"UPDATE users SET age = ? WHERE id = ?",
(age, user_id),
)
return cur.rowcount
这段代码里容易忽略的有两个点。第一,execute 是 connection 对象直接可以调用的,并不一定要先创建 cursor;第二,cursor.rowcount 返回的是这条 UPDATE 实际影响的行数。如果用户不存在,返回 0,方便业务层做判断。
再强调一下事务:上面用了 with sqlite3.connect("demo.db") as conn 这种写法。Python sqlite3 中,连接对象作为上下文管理器时,如果代码块正常结束,事务会自动提交;如果抛出异常,事务会自动回滚。所以只要把“执行多条 SQL”的事务逻辑放进 with 块里,就不容易漏掉 commit。
但要注意,这个 with 块结束时,连接并不会自动关闭,只是提交或回滚了事务。真正要关闭连接,还得手动调用 conn.close()。如果代码块很短,进程结束后系统会关闭它;但在长驻进程里,忘记关闭连接可能慢慢耗尽文件句柄。
4.4 查询结果如何优雅地取出来
查询不像插入那么注重安全问题,但取值方式会影响代码可读性。单个结果可以用 fetchone:
python复制row = cur.execute("SELECT id, name, age FROM users WHERE id = ?", (1,)).fetchone()
print(row["name"], row["age"])
多个结果用 fetchall 会一把梭把所有行加载到内存,如果查询结果有几万行,内存占用会突然增大。更好的方式是利用 cursor 的迭代特性:
python复制cur.execute("SELECT id, name, age FROM users WHERE age >= ?", (18,))
for row in cur:
print(row["name"], row["age"])
如果只需要一行且不想写循环,可以这样:
python复制user = cur.execute(...).fetchone()
cursor 本身包含迭代状态,遍历完毕后,后续再执行新的 SQL,上一个结果集就会作废。这点在处理大量数据时尤其重要,避免把所有数据都先读进 Python 列表。
5. 并发、锁和查询提速
5.1 “database is locked”究竟在说什么
SQLite3 的并发模型和主流关系型数据库不太一样。它允许多个连接同时读同一个数据库文件,但写入时为了保证文件一致性,同一时间只允许一个写事务获得写锁。如果另一个写事务没有在超时时间内拿到锁,就会抛出“database is locked”。
这个报错第一次遇到时很吓人,尤其上一秒单线程测试还好好的,下一秒用多个线程写数据就开始频繁出现。它背后主要有两类原因:一是某个事务长时间不提交,占着写锁不释放;二是连接之间并发争抢激烈,而默认的 busy_timeout 很短,几乎是立即失败。
5.2 我常用的两个 PRAGMA:busy_timeout 和 WAL
在 Python 中连接 SQLite3 后,我通常固定执行两条 PRAGMA:
python复制conn = sqlite3.connect("demo.db", timeout=10)
conn.execute("PRAGMA journal_mode=WAL;")
conn.execute("PRAGMA busy_timeout=5000;")
connect 里的 timeout 参数是 Python 层的等待超时;PRAGMA busy_timeout 则是让 SQLite 在遇到锁冲突时继续等待 5000 毫秒。配合使用能减少很多“database is locked”的偶发报错。
journal_mode=WAL 是写入日志模式,中文常翻译为预写式日志。它把写操作先追加到 -wal 文件里,而不是每次直接改主数据库文件,这样读操作不会被写操作阻塞,多个连接之间的并发能力会明显变好。开启 WAL 后,目录下会多出两个文件,形如:
text复制demo.db
demo.db-shm
demo.db-wal
这三个文件是配套的。备份或者拷贝数据库时,不能只拿 demo.db 文件复制,最好先执行 checkpoint,或者使用专门备份命令,否则可能丢失 WAL 里还没合并的数据。
5.3 内存数据库到底是怎么一回事
SQLite3 支持把数据库完全放在内存里,连接字符串用 :memory: 即可:
python复制conn = sqlite3.connect(":memory:")
cur = conn.cursor()
cur.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
这种连接不创建任何文件,数据全部保存在内存中,连接一旦关闭,数据库自动消失。我一般拿它来做单测、临时计算、或者需要快速跑一遍 SQL 逻辑的场景。
要特别注意的是,每次执行 sqlite3.connect(":memory:") 都会创建一个全新的、独立的内存数据库,不同连接之间看不到对方的数据。如果需要在多个连接间共享同一个内存库,需要设置 shared cache,并且以特殊 URI 语法打开,但这种玩法复杂度高,日常测试不需要这么做。
5.4 索引不是越多越好,用 EXPLAIN 看执行计划
看到查询变慢,第一反应往往是加索引。这个思路没问题,但要把索引加在点子上。比如有一个订单表,经常要根据 user_id 和 status 查询,那就可以创建联合索引:
sql复制CREATE INDEX idx_orders_user_status ON orders (user_id, status);
创建索引后可以用 EXPLAIN QUERY PLAN 检查 SQL 是否走了索引:
sql复制EXPLAIN QUERY PLAN SELECT * FROM orders WHERE user_id = 1 AND status = 10;
如果输出里有 SEARCH ... USING INDEX,说明它用到了索引。如果显示 SCAN,那可能字段条件写错了,或者索引顺序和查询条件不匹配。联合索引的字段顺序是有讲究的,通常把查询等值条件放前面。
5.5 多线程使用 SQLite3 的几条经验
Python 的 sqlite3 模块默认禁止同一个连接对象被多个线程直接使用,只要跨线程访问就会抛出 ProgrammingError。很多人第一反应是去设置 check_same_thread=False,然后开心地共享一个连接。我个人的建议是:除非你很清楚自己在做什么,否则不要这么改。
更安全的方式是让每个线程自己创建独立的数据库连接,同时打开 WAL,并调大 busy_timeout。这样多线程读基本没有冲突,写操作会通过锁机制排队。只要每个线程都采用“短事务、快提交”的风格,实际大部分桌面工具都够用了。
6. 备份、迁移与常见问题排查
6.1 备份方案:.dump 和 .backup 的正确用法
为了不丢失数据,SQLite3 备份不能简单地在程序运行时复制文件,至少在 WAL 模式下不能这么干。最稳妥的命令行备份方式是:
bash复制sqlite3 source.db ".backup backup.db"
.backup 会把主数据库文件和未合并的 WAL 数据完整备份到目标文件,整个过程由 SQLite 自己管理锁状态,比直接 cp 安全得多。
如果想把数据库导出成纯 SQL 文件,以后能导入其他数据库做分析,可以用:
bash复制sqlite3 source.db ".dump" > dump.sql
.dump 会把建表语句和所有 INSERT 语句全部输出。需要恢复时执行:
bash复制sqlite3 newdb.db < dump.sql
相比之下,.backup 生成的是二进制格式数据库文件,体积小、恢复快;.dump 生成的是文本格式 SQL,兼容性好但导入速度稍慢。
6.2 迁移到 MySQL、PostgreSQL 之类数据库的路线
如果业务变大,SQLite3 撑不住并发写入,打算迁到 PostgreSQL 或 MySQL,也不要慌。虽然类型系统有差异,但迁移路线基本是通用的:先用 .dump 导出 SQL,再整理字段类型,把双引号、单引号等细节处理好,最后导入目标数据库。
比 SQL 本身更麻烦的是数据类型。SQLite3 里很多表声明的是 VARCHAR、DATETIME,实际存储时可能比较随意;目标数据库对约束更严格,一些脏数据往往在导入阶段才会暴露出来。所以迁移前先做一次数据质量检查,把所有空字符串、非法日期处理掉,再执行导入,能省去大量调试时间。
SQLite3 也支持 ATTACH DATABASE 同时打开多个库,需要跨库复制数据时,可以用 INSERT INTO ... SELECT ... 在同一个连接里完成。我在做数据归档时经常用这种方式,比导出再导入少绕一圈。
6.3 高频错误速查表:报错的时候优先翻这里
| 错误 / 现象 | 可能原因 | 解决方向 |
|---|---|---|
| sqlite3.OperationalError: no such table: xxx | 没有执行建表语句,或数据库文件路径不对 | 检查连接路径,执行 CREATE TABLE IF NOT EXISTS |
| sqlite3.OperationalError: table xxx has no column named xxx | INSERT 里的列名写错 | 列名与表结构同步,使用真实字段名 |
| sqlite3.IntegrityError: UNIQUE constraint failed | 插入或更新时撞了唯一约束 | 先查约束定义,需要覆盖时用 UPSERT |
| sqlite3.ProgrammingError: Cannot operate on a closed database | 连接已经关闭还在执行 SQL | 检查代码分支,连接关闭后再查询会报该错 |
| 忘记 commit,数据没有写入 | 显式开启事务但没提交 | 使用 with conn 自动提交事务模式,或手动 conn.commit() |
| 文件体积一直不变小 | DELETE 大量数据后没有收缩 | 执行 VACUUM 重建数据库文件 |
| 运行报错同时多线程写入 | 写锁冲突 | 开启 WAL,调大 busy_timeout,事务尽量短小 |
| Python 普通字符串拼接 SQL 报错 | 引号处理不当,甚至存在注入风险 | 换成 ? 占位符传参 |
6.4 我实际遇到过的几个坑,这次一起写下来
第一个坑是 DELETE 删除大量行之后,数据库文件体积却一点没变。SQLite3 删除数据默认只是标记为“可复用”状态,并不会把磁盘空间归还给操作系统。我处理完历史数据后,显式执行一次 VACUUM,文件才真正瘦下来。VACUUM 需要额外磁盘空间,生产环境里应该安排在低峰期执行。
第二个坑是外键约束默认关闭。SQLite3 为了兼容旧项目,即使你在建表时写了 FOREIGN KEY,如果不手动开启 PRAGMA foreign_keys=ON,外键约束实际上不会生效。我曾在课程设计里天真地以为有外键就有保护,结果重复数据写了很多。最佳实践是每次建连接后立刻执行:
python复制conn.execute("PRAGMA foreign_keys=ON")
第三个坑是事务写了一半,代码抛异常,连接对象可能自动进入一种无法正常提交的状态。因此凡是牵涉多条写语句的代码,我都会用 try/except 包住,异常时回滚,并将连接状态清理正确。
第四个坑和 Python 的 Row 有关。设置 row_factory = sqlite3.Row 后,row["列名"] 很顺手,但某些第三方库只能接收普通 dict,所以我会在返回给外部前调用 dict(row) 转换一次。
第五个坑是备份策略。文件型数据库太“轻”,容易让人误以为把文件复制走就万事大吉。等到恢复时才发现当前是 WAL 模式,备份遗漏了 -wal 里的最新数据,就会丢失最近几分钟的记录。现在不管是临时项目还是长期项目,我都强制使用 sqlite3 source.db ".backup backup.db" 的方式做备份,不直接复制文件。
其实数据库这门课,很多知识点看着简单,真正用起来还是在细节里。SQLite3 的优点很突出,零配置、单文件、跨平台,特别适合做本地工具和小体量应用。如果能把命令行、事务、UPSERT、锁机制和 Python 操作这些主线都过一遍,再遇到实际项目,基本不会慌。
我个人最后再分享一个小习惯:每次写好一批 Python 操作 SQLite3 的代码,先不要急着跑业务,单独写一个测试脚本,连接临时数据库,把“插入、更新、回滚、再次查询”全流程验证一遍。这样既能确认 SQL 语法,又能提前发现事务边界有没有写错。实际开发中,很多线上问题都是因为事务没提交或连接没关导致的,这类问题测试阶段多走几遍,比事后排查效率高太多了。
