Python sqlite3 实战:事务提交、参数化防注入、WAL 并发与 row_factory 取字典
Python 标准库自带 sqlite3,不用装任何东西就能有一个完整的关系型数据库,做本地缓存、小工具、原型再合适不过。但正因为「拿来就用」,很多人踩了一堆坑:写了数据重启后没了、字符串拼 SQL 被注入、多进程一读一写就 database is locked、查出来的行还得靠下标 row[0] 猜列。这篇把这几个高频问题一次讲透。
坑一:数据「写了又没了」------你忘了 commit
新手最常见的困惑:代码明明执行了 INSERT,程序重跑数据却是空的。
python
import sqlite3
conn = sqlite3.connect("app.db")
cur = conn.cursor()
cur.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)")
cur.execute("INSERT INTO users (name) VALUES ('alice')")
conn.close() # 直接关了,没 commit!
重新连上去查,users 是空的。因为 sqlite3 默认开启事务,你的 INSERT 只是写进了未提交的事务 里,close() 时未提交的更改会被回滚丢弃 。必须显式 commit():
python
conn = sqlite3.connect("app.db")
cur = conn.cursor()
cur.execute("INSERT INTO users (name) VALUES ('alice')")
conn.commit() # 提交,数据才真正落盘
conn.close()
更省心的写法是用 with 管理事务------with conn 块正常结束会自动 commit ,抛异常则自动 rollback:
python
# 注意:with 管的是「事务」,不是「连接关闭」,conn 仍需另外 close
with conn:
conn.execute("INSERT INTO users (name) VALUES ('bob')")
conn.execute("INSERT INTO users (name) VALUES ('carol')")
# 走到这里两条一起提交;中途抛异常则整体回滚
记牢:with conn: 是「事务边界」,不是「上下文关闭连接」。它保证了这一组操作的原子性------要么全成功,要么全回滚。
坑二:字符串拼 SQL = SQL 注入,永远用参数化
这是安全红线。绝对不要用 f-string 或 % 把用户输入拼进 SQL:
python
# 危险!绝对不要这样写
name = input("输入用户名: ")
cur.execute(f"SELECT * FROM users WHERE name = '{name}'")
# 用户输入 ' OR '1'='1 就能绕过条件拖走全表
# 输入 '; DROP TABLE users; -- 甚至能删表
正确做法是参数化查询 :SQL 里用 ? 占位,值作为第二个参数(一个元组)传进去。数据库驱动会把值当纯数据处理,永远不会被解释成 SQL 语句:
python
name = input("输入用户名: ")
cur.execute("SELECT * FROM users WHERE name = ?", (name,)) # 值单独传
rows = cur.fetchall()
注意 (name,) 那个逗号------它是单元素元组 ,漏了逗号 (name) 就成了普通括号,会报参数类型错误。批量插入用 executemany,同样是参数化,还比循环 execute 快很多:
python
users = [("dave",), ("eve",), ("frank",)]
cur.executemany("INSERT INTO users (name) VALUES (?)", users)
conn.commit()
一条铁律:只要值来自变量,就用 ? 占位,永远不用字符串拼接 。表名、列名不能用 ? 占位(占位符只能替换值),如果表名必须动态,得用白名单校验,不能直接拼用户输入。
坑三:查出来靠 row0 猜列?用 row_factory 取字典
默认 fetchall() 返回的是元组,取值只能靠下标,列一多就得数第几个,加个列还全乱套:
python
cur.execute("SELECT id, name FROM users")
row = cur.fetchone()
print(row[0], row[1]) # 到底哪个是 id?靠数下标,易错
设置 conn.row_factory = sqlite3.Row,就能像字典一样按列名 取值,还支持 keys():
python
conn.row_factory = sqlite3.Row # 设在连接上,之后所有 cursor 生效
cur = conn.cursor()
cur.execute("SELECT id, name FROM users")
row = cur.fetchone()
print(row["id"], row["name"]) # 按列名取,清晰不易错
print(dict(row)) # {'id': 1, 'name': 'alice'} 直接转字典
print(row.keys()) # ['id', 'name']
sqlite3.Row 几乎零成本,还兼容下标访问,强烈建议连上去就设。要序列化成 JSON 时,dict(row) 一步到位。
坑四:多进程一读一写就 database is locked
SQLite 是文件数据库,默认的日志模式(rollback journal)下,写操作会锁住整个数据库文件,同一时刻只能有一个写者,并发读写很容易撞到:
sqlite3.OperationalError: database is locked
两个关键调优。第一,开启 WAL(Write-Ahead Logging)模式------读和写可以并发(读不阻塞写、写不阻塞读),大幅缓解锁冲突:
python
conn = sqlite3.connect("app.db")
conn.execute("PRAGMA journal_mode=WAL") # 开启 WAL,一次设置持久生效
WAL 是数据库级别的持久设置 ,设一次后一直有效,不用每次连接都设(但不影响重复设)。开了 WAL 后目录里会多出 app.db-wal 和 app.db-shm 两个文件,这是正常的,别手动删。
第二,设置 busy_timeout------遇到锁时不立刻报错,而是等一段时间重试:
python
# 遇到锁最多等 5 秒再报错,而不是瞬间抛 database is locked
conn = sqlite3.connect("app.db", timeout=5.0)
# 等价于 PRAGMA busy_timeout=5000
connect(timeout=5.0) 就是设 busy_timeout。WAL + busy_timeout 组合能解决绝大多数轻量并发场景。但要清醒:SQLite 始终只允许一个写者,WAL 让「读写并发」变好,但没让「多写并发」成为可能。如果你的场景是高并发多进程狂写,那不该用 SQLite,该上 PostgreSQL。
坑五:datetime 存进去取出来变字符串
往表里存 datetime 对象,取出来会发现是字符串,不能直接做时间运算。因为 SQLite 没有原生日期类型,datetime 被存成了文本。推荐做法是统一存 ISO 格式字符串或时间戳,读的时候自己解析,别依赖已被官方标记弃用的自动转换器:
python
from datetime import datetime, timezone
# 存:统一用 UTC 的 ISO 字符串,可排序、可跨时区
now = datetime.now(timezone.utc).isoformat()
conn.execute("INSERT INTO logs (ts) VALUES (?)", (now,))
# 读:取出字符串后自己解析回 datetime
row = conn.execute("SELECT ts FROM logs").fetchone()
dt = datetime.fromisoformat(row[0])
存 UTC、用 ISO 格式,既能在 SQL 里按字符串排序(ISO 格式字典序==时间序),又避免了时区混乱。
完整示例:把上面几点串起来
python
import sqlite3
from contextlib import closing
def get_conn(path="app.db"):
conn = sqlite3.connect(path, timeout=5.0) # busy_timeout 5s
conn.row_factory = sqlite3.Row # 按列名取值
conn.execute("PRAGMA journal_mode=WAL") # 读写并发
conn.execute("PRAGMA foreign_keys=ON") # SQLite 默认不开外键约束,要手动开
return conn
with closing(get_conn()) as conn: # closing 保证 conn.close()
conn.execute(
"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)"
)
with conn: # 事务:自动 commit / 出错 rollback
conn.executemany(
"INSERT INTO users (name) VALUES (?)",
[("alice",), ("bob",)],
)
for row in conn.execute("SELECT id, name FROM users"):
print(dict(row))
这里 closing() 负责最后关连接,with conn: 负责事务边界,两者职责不同、可以嵌套使用。另外注意 PRAGMA foreign_keys=ON------SQLite 默认不强制外键约束,需要每个连接手动打开,否则你定义的外键形同虚设。
小结
- 默认开事务,写完必须
commit(),否则close()时更改被回滚;用with conn:自动提交/回滚,但它管的是事务不是关连接。 - 值来自变量就用
?参数化查询 ,绝不字符串拼接------这是防 SQL 注入的红线;单值参数记得写成(x,)。 conn.row_factory = sqlite3.Row让结果按列名 取值、dict(row)一键转字典,告别row[0]猜列。- 并发场景开 WAL 模式 (读写并发)+ busy_timeout (遇锁重试)解决大部分
database is locked;但 SQLite 只允许一个写者,重写并发该换 PostgreSQL。 - datetime 统一存 UTC 的 ISO 字符串,读时自己
fromisoformat;外键约束要每连接PRAGMA foreign_keys=ON手动开。 - 一句话记忆:commit 别忘、参数化别拼、Row 取字典、并发上 WAL------四招覆盖 sqlite3 九成的坑。