Python SQLite 实战:参数化查询、事务回滚与数据边界
本文目录
写本地脚本、小型工具或测试夹具时,Python 自带的 sqlite3 可以让你不安装数据库服务就练习 SQL、约束和事务。它适合学习和单机小型应用,但不应被误解成“SQLite 与 PostgreSQL/MySQL 在连接、并发和运维上完全相同”。本文以 Python 3.9+ 的标准库 API 为主,所有数据库都在内存中创建,不需要下载样例数据。这里的 3.9 是示例兼容下限,不是新项目的安全建议:Python 3.9 已于 2025-10-31 结束支持;请选择官方仍维护的版本,见 Python 3.9.25 发布说明。
1. 变量值必须通过参数绑定
不要用 f-string、% 或字符串拼接把用户输入放进 SQL:
# 不要这样写:输入可能改变 SQL 的语义。
sql = f"SELECT id, title FROM notes WHERE title = '{title}'"
使用驱动提供的占位符。SQLite 的 sqlite3 支持 ? 位置占位符和 :name 命名占位符;参数值作为第二个参数传入:
row = connection.execute(
"SELECT id, title FROM notes WHERE title = ?",
(title,),
).fetchone()
单个位置参数仍然要传一个元素元组,所以写 (title,),不是 (title)。参数绑定解决的是值的边界,不会替你校验业务规则,也不能把表名、字段名或 ASC/DESC 这种 SQL 语法位置当成普通值绑定。
如果排序字段需要由用户选择,用固定白名单映射到代码里写死的 SQL 片段;未知选项直接拒绝:
SORT_COLUMNS = {
"newest": "id",
"title": "title",
}
SORT_DIRECTIONS = {"asc": "ASC", "desc": "DESC"}
def list_notes(connection, *, sort="newest", direction="desc", limit=20, offset=0):
if not isinstance(sort, str) or not isinstance(direction, str):
raise ValueError("排序参数必须是字符串")
column = SORT_COLUMNS.get(sort)
order = SORT_DIRECTIONS.get(direction.lower())
if column is None or order is None:
raise ValueError("不支持的排序方式")
if type(limit) is not int or type(offset) is not int or limit < 0 or offset < 0:
raise ValueError("分页参数必须是非负整数")
sql = (
"SELECT id, title, body, status FROM notes "
f"ORDER BY {column} {order}, id ASC LIMIT ? OFFSET ?"
)
return list(connection.execute(sql, (limit, offset)))
这里的 f-string 只插入从常量白名单中选出的列名和方向;搜索词、分页值等数据仍通过占位符绑定。输入校验仍然必要:参数绑定防 SQL 语法注入,不会自动限制页大小、权限或返回的数据范围。
2. 给数据库连接和事务明确边界
Connection 的上下文管理器负责提交或回滚事务,但不会关闭连接。外层用 contextlib.closing 负责连接生命周期,内层 with connection 包住一个完整的数据库操作:
from contextlib import closing
import sqlite3
def initialize(connection):
connection.execute("PRAGMA foreign_keys = ON")
connection.execute(
"""
CREATE TABLE notes (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL CHECK (length(title) > 0),
body TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('draft', 'published'))
)
"""
)
connection.execute(
"""
CREATE TABLE note_events (
id INTEGER PRIMARY KEY,
note_id INTEGER NOT NULL REFERENCES notes(id),
event_type TEXT NOT NULL CHECK (event_type IN ('created', 'published'))
)
"""
)
def create_note_with_event(connection, title, body, event_type="created"):
normalized_title = title.strip() if isinstance(title, str) else ""
if not normalized_title:
raise ValueError("标题不能为空")
if not isinstance(body, str):
raise TypeError("正文必须是字符串")
# 这个函数拥有自己的事务边界;不要再从更外层事务中嵌套调用。
with connection:
cursor = connection.execute(
"INSERT INTO notes(title, body, status) VALUES (?, ?, ?)",
(normalized_title, body, "draft"),
)
note_id = cursor.lastrowid
connection.execute(
"INSERT INTO note_events(note_id, event_type) VALUES (?, ?)",
(note_id, event_type),
)
return int(note_id)
with closing(sqlite3.connect(":memory:", timeout=2.0)) as connection:
connection.row_factory = sqlite3.Row
initialize(connection)
note_id = create_note_with_event(connection, " SQLite 入门 ", "先写测试,再做修改。")
row = connection.execute(
"SELECT id, title, status FROM notes WHERE id = ?",
(note_id,),
).fetchone()
print(dict(row))
如果第二条写入因外键或 CHECK 约束失败,with connection 会回滚同一块中的第一条写入;异常仍会向上抛出,调用方应只捕获自己能够恢复的具体错误。不要在事务尚未提交时发邮件、调用模型或请求第三方 API:数据库回滚无法撤销这些外部副作用。需要跨系统可靠投递时,应使用独立的 outbox/补偿设计,而不是把网络请求塞进数据库事务里。
with connection: 不是嵌套事务或 savepoint。若确实需要在大事务中局部回滚,应显式使用 SAVEPOINT / ROLLBACK TO,并测试释放行为。
3. 用内存数据库验证错误路径
下面的测试代码与上面的实现放在同一个文件中即可运行。SQL 样式的字符串会作为普通数据保存;第二个测试故意让审计事件违反数据库约束,验证笔记记录也一起回滚。
import sqlite3
import unittest
from contextlib import closing
class SQLiteGuideTests(unittest.TestCase):
def setUp(self):
self.connection_context = closing(sqlite3.connect(":memory:"))
self.connection = self.connection_context.__enter__()
self.connection.row_factory = sqlite3.Row
initialize(self.connection)
def tearDown(self):
self.connection_context.__exit__(None, None, None)
def test_sql_looking_title_is_stored_as_data(self):
title = "x'); DROP TABLE notes; --"
note_id = create_note_with_event(self.connection, title, "正文")
row = self.connection.execute(
"SELECT title FROM notes WHERE id = ?",
(note_id,),
).fetchone()
self.assertEqual(row["title"], title)
self.assertEqual(
self.connection.execute("SELECT COUNT(*) FROM notes").fetchone()[0],
1,
)
def test_failed_second_write_rolls_back_the_first(self):
with self.assertRaises(sqlite3.IntegrityError):
create_note_with_event(self.connection, "临时草稿", "正文", "unknown")
self.assertEqual(
self.connection.execute("SELECT COUNT(*) FROM notes").fetchone()[0],
0,
)
self.assertEqual(
self.connection.execute("SELECT COUNT(*) FROM note_events").fetchone()[0],
0,
)
def test_sorting_uses_allowlisted_columns_and_bound_pagination(self):
for title in ("C", "A", "B"):
create_note_with_event(self.connection, title, "正文")
rows = list_notes(self.connection, sort="title", direction="asc", limit=2, offset=1)
self.assertEqual([row["title"] for row in rows], ["B", "C"])
def test_unknown_or_malformed_sort_and_negative_page_size_are_rejected(self):
for sort in ("title; DROP TABLE notes", [], None):
with self.subTest(sort=sort), self.assertRaises(ValueError):
list_notes(self.connection, sort=sort)
with self.assertRaises(ValueError):
list_notes(self.connection, direction=[])
with self.assertRaises(ValueError):
list_notes(self.connection, limit=-1)
if __name__ == "__main__":
unittest.main()
运行方式:
python -m unittest -v sqlite_guide.py
在项目里更推荐把 schema、repository 和测试拆成独立模块;本段合并在一处只是为了让初学者可以直接复制成一个文件运行。
4. SQLite 的并发能力不是“无限并发写”
SQLite 支持多个并发读事务,但同一时间只有一个写事务。WAL 模式可以让读与写更好地并行,不会把多个写事务变成同时提交;写事务越短,锁竞争通常越容易控制。不要持有事务去等待用户输入、长任务、远程 HTTP 或文件上传。
WAL 还依赖同一台主机上的共享内存机制,不适用于把数据库文件放到网络文件系统后让多台机器共同访问。开启 WAL 后,数据库运行期间可能同时有 -wal 和 -shm 文件;备份和恢复必须遵守 SQLite 的一致性边界,不能在连接活跃时只复制主 .db 文件就假设备份完整。
如果多个应用实例都需要频繁写入、跨主机共享数据库、复杂权限或成熟的备份/监控方案,应评估 PostgreSQL 等客户端/服务端数据库。迁移不是只改连接字符串:要重新验证事务隔离、类型、锁行为、索引、分页和迁移工具。
5. 版本和工程注意事项
- 参数化查询不是业务授权。每个查询仍要按当前用户检查对象归属,不能仅靠客户端传入
note_id。 - 数据库约束是最后一道一致性保护;API 边界还要验证类型、长度和业务规则。
Connection上下文管理器提交/回滚,不负责关闭连接;使用closing或框架提供的依赖生命周期管理。- Python 3.12 增加了
sqlite3.connect(..., autocommit=...)的控制参数,旧版本使用不同的事务控制默认行为。本文示例避免传入 3.12 专属参数;升级运行时后应查看对应版本文档并明确事务策略。 - SQLite 的同步调用会占用当前线程;异步 Web 服务中不能因为函数外层写了
async就认为数据库 I/O 自动变成异步。 timeout只能改变等待锁的最长时间,不会修复长事务或数据库设计上的写竞争。
资料
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。