Python sqlite3 实战:事务提交、参数化防注入、WAL 并发与 row_factory 取字典

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-walapp.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 九成的坑。
相关推荐
花间相见1 小时前
【AI应用开发|Agent记忆】—— Codex 与 Claude Code 持久化记忆对比:两条不用向量库的路线
后端·agent
葡萄城技术团队1 小时前
GcExcel V9.2 新特性揭秘:公式对象的无损往返
后端
蜗牛互联网1 小时前
Claude放宽生命科学限制:代价是验证、分级和30天留存
java·人工智能·后端
上进小菜猪1 小时前
被 32 位整数卡了 30 年脖子:PostgreSQL 事务号困局与 V9 的 64 位解法
后端
论文复现现场1 小时前
ComfyUI 怎么同时调用 4 张/8 张 RTX 4090?AI 视频批量生成的多实例队列与 Python 调度方案
人工智能·python·comfyui·rtx4090
泡海椒2 小时前
评分系统最佳实践:JQuick-Java实现权重、阈值动态配置评分
java·人工智能·python
weixin199701080162 小时前
《二手ERP对接的对账机制:按日巡检 + 自动补偿,消灭“幽灵订单“》(附Python源码)
python
m0_547486662 小时前
《Python数据分析与实践》全套PPT课件(杭州电子科技大学)
python·数据分析
OKkankan2 小时前
Python 基础进阶(三):从函数、类到 asyncio 与 FastAPI 后端开发实战
开发语言·python