WAL不是多写入者许可
有团队把SQLite切换成WAL以后,以为多个Python进程能同时修改一个数据库文件。读请求确实更加顺畅,后台任务却仍然偶尔报database is locked。这不是WAL失效。SQLite官方明确说明:WAL允许读取和写入更好地并行,但一个时刻仍只有一名写入者。几个进程使用不同连接,也不会获得多名写入者同时提交的能力。了解这个限制以后,诊断重点就应当从一味增加超时,转到真正的事务边界。
假如作业先执行BEGIN IMMEDIATE,再调用外部HTTP接口,等待网络响应,处理文件,最后更新数据库,那么几秒钟的网络等待都可能占用写锁。第二个进程等待的并不是一条UPDATE执行了多久,而是第一个事务何时完成。优先把网络请求、文件压缩和业务计算移到事务之外,等结果准备好后再用短事务保存一致性需要的记录。这样既降低竞争,也让故障边界更加清楚。
本教程采用Python标准库sqlite3,提供可运行的初始化、幂等保存和检查点观察代码。文中数值用于说明机制,不是假称经过生产压测得到的吞吐指标。SQLite是否适合一个业务,取决于磁盘、并发模式、持锁时长、恢复需求以及是否跨主机部署。仅凭某一次开发机没有报错,不能得出多进程生产安全的结论。
读写并发与写写并发需要分别诊断
WAL写入一般先追加预写日志,读取事务可以使用已经提交的一致快照。因此很多普通SELECT在后台写入时仍能正常工作,但两个独立连接的写事务仍需要协调。若错误只在大量任务同时结束并保存状态时出现,首先应统计同一时刻请求写锁的数量。若几乎没有并行写入仍出现长时间锁等待,就应该重点查长事务和连接泄漏,而不是直接升级数据库。
排查日志至少保存作业编号、事务开始时间、获得写锁的等待时长、COMMIT时间和失败阶段。把这些记录与外部HTTP请求和定时调度日志放在同一时间轴上,才能知道慢的是数据库还是网络。日志应当去掉用户口令、令牌和私人内容,不能为了调查一条锁错误而产生新的数据泄露。连接池只负责管理连接,不会替我们改变SQLite的写锁语义。
可以在测试环境用两个进程模拟:第一个开启BEGIN IMMEDIATE并在事务内等待几秒,第二个同时请求写事务。观察第二个进程的等待和超时;然后让第一个立即提交,同样的测试往往就不再出现长时间占用。这个实验说明事务持有时间的作用,但不能从单次测试推断所有系统的性能。真正业务必须反复测量、记录负载和输入条件。
初始化要与日常业务调用分开
建议在明确的初始化步骤确认PRAGMA journal_mode=WAL实际返回wal,再创建数据表。不要让每个正在运行的任务都去反复尝试切换日志模式。新连接应设置busy_timeout与foreign_keys;其中busy_timeout只表示等待锁的预算,不能保证最终取得写锁。foreign_keys同样不应假定一次配置就自动对未来每个连接生效。
下面的storage.py将连接建立和数据库初始化分开,采用isolation_level=None以便后续手动控制写事务。连接在使用后应该明确关闭,避免长时间存活的游标、事务和线程共享行为使问题难以重现。completed_jobs的主键是业务工作编号,同一逻辑工作重复投递时仍必须得到相同主键,而不能根据重试次数生成新编号。
如果数据库不存在,初始化会建立本地文件。执行前先确认工作目录,避免一个服务启动脚本与后台作业指向两个不同数据库路径。真实系统建议使用已配置的绝对路径并记录实际文件位置。代码中的相对路径仅为了独立演示方便。任何生产数据库升级、表迁移或日志模式变更,都需要先检查备份和回滚方案。
连接与建表的完整示例
以下代码可以直接保存为storage.py。表中的result字段保存简短的已确认结果,而不是整份业务响应。主键约束防止重复记录,但只能保证这个数据库表内的唯一性,不能替外部邮件、支付或文章发布系统提供恰好一次执行保证。若外部系统已经接受操作、只是不再返回回执,必须另外核实真实结果。
from contextlib import closing
from pathlib import Path
import sqlite3
DB = Path("jobs.sqlite3")
def connect():
db = sqlite3.connect(DB, timeout=5, isolation_level=None)
db.execute("PRAGMA busy_timeout=5000")
db.execute("PRAGMA foreign_keys=ON")
return db
def initialize():
with closing(connect()) as db:
mode = db.execute("PRAGMA journal_mode=WAL").fetchone()[0]
if mode.lower() != "wal":
raise RuntimeError(f"WAL not active: {mode}")
db.execute("""
CREATE TABLE IF NOT EXISTS completed_jobs (
job_key TEXT PRIMARY KEY,
result TEXT NOT NULL,
completed_at TEXT DEFAULT CURRENT_TIMESTAMP
)
""")
if __name__ == "__main__":
initialize()
缩短事务比无限提高超时更重要
正确的工作流程通常是先完成参数校验、必要的外部请求和资源准备,再开启短事务写入结果。事务应保护明确的数据一致性约束,而不是包裹一次长达数分钟的用户流程。把不可预知的等待放在事务内,实际上会把每一次网络波动变成其他进程的数据库锁竞争。
另一个常见问题是异常处理不完整。某条SQL失败后,程序只在日志里记录错误,却没有关闭事务,让连接继续被保留。于是后续任务不断遇到锁,最终被误诊成SQLite承受不了正常并发。正确方式是在异常路径检查是否仍有未完成事务,必要时ROLLBACK,并在finally关闭连接。下一次尝试重新建立明确的事务边界。
应分别统计排队等待与真实持锁时间。如果等待在上升,而每次持锁很短,可能需要限制同时写入者、调整调度或增加合适的等待预算;如果持锁时间本身异常,就必须先去除事务中的慢操作。只提高busy_timeout可能让监控暂时安静,却会增加调用端的尾部延迟,并让故障推迟暴露。
稳定业务键、有限重试与结果确认
下面的代码只负责记录已确认完成的逻辑任务,绝不在内部重复发邮件或付款。它最多尝试四次保存:建立连接、请求BEGIN IMMEDIATE、以相同job_key插入记录;如果锁竞争导致异常,先回滚、关闭连接、短暂等待,然后再尝试。若错误不是锁竞争,或者全部尝试耗尽,就交给上层调度器处理。
ON CONFLICT DO NOTHING让相同主键重复出现时不会再次插入。函数返回真假,可区分本次新增与原先已有结果。然而原先已有一条数据库记录,不等于外部工作就一定成功;记录需要携带足够明确的状态,且能与远端结果对应。业务键应描述客户、目标操作和稳定业务流水,而不是每次重试的随机UUID。
操作结果未知时必须先查询远端,不允许把数据库提交失败自动扩展成外部重发。比如一封邮件已经发送、本地结果写入被锁,再发一次邮件会造成重复通知;安全的动作通常只是重新保存去重回执,或者先查询邮件服务实际发送记录。重试策略要按副作用划分,不能给整个工作流装一个不分步骤的统一重试循环。
import random
import sqlite3
import time
from storage import connect
def save_result(key, value):
for attempt in range(4):
db = connect()
try:
db.execute("BEGIN IMMEDIATE")
row = db.execute(
"INSERT INTO completed_jobs(job_key, result) "
"VALUES (?, ?) ON CONFLICT(job_key) DO NOTHING",
(key, value))
db.execute("COMMIT")
return row.rowcount == 1
except sqlite3.OperationalError as e:
if db.in_transaction:
db.execute("ROLLBACK")
if "locked" not in str(e).lower() or attempt == 3:
raise
time.sleep(0.05 * (2**attempt) + random.uniform(0, 0.03))
except Exception:
if db.in_transaction:
db.execute("ROLLBACK")
raise
finally:
db.close()
print(save_result("job-1", "confirmed"))
print(save_result("job-1", "confirmed"))
读取事务也可能阻碍WAL检查点

WAL并不应该被看作无限增长的日志仓库。数据提交后,检查点会逐步将日志中的已提交页合并回主数据库文件。SQLite有自动检查点机制,文档描述默认阈值通常与约一千页的日志规模有关。但一个长时间保持旧快照的读取事务,可能阻碍检查点越过那个读取者仍然依赖的位置。结果是写入继续进行,日志文件却持续增大。
有些程序一边遍历大型查询结果,一边向外部接口发请求;也有系统在等待用户操作时仍持有游标或读取事务。这些问题表面上与后台写入没有直接关系,实际上会改变WAL能否顺利回收。检查时应同时统计读取事务持续多久,以及写事务是否积压。不能因为数据库主文件不大,就忽略与它同目录的预写日志。
判断是否存在异常,不应只看某一时刻-wal文件尺寸。应记录一段时间内的写入速率、日志页数、检查点推进情况和长时间读取任务,再观察业务低谷是否能够恢复。一次日志文件没变小不能立即推导出数据库损坏,也不能仅凭重启后变小就宣布根因找到了。
检查点的读数不是万能健康分
PRAGMA wal_checkpoint(PASSIVE)提供一种观察检查点的方式。PASSIVE尝试在不激进等待其他数据库参与者的条件下推进可行的工作,并返回包含日志页数和已检查点页数的信息。脚本应记录这些读数和实际采集时间,而不是把一次返回值转化成绝对的成功或失败。长期趋势比单个数字更有解释力。
如果检查点在多个采样周期持续落后,接下来应追查是否有持久的读事务、是否存在大量短写入,以及应用是否按照预期关闭连接。不要看到指标变化就直接在生产请求里强制执行截断检查点。更不能把后缀为-wal与-shm的文件当作临时垃圾,在数据库仍然打开时直接删除,这会破坏SQLite自身依赖的持久化与协调过程。
运维措施必须和风险范围相匹配。必要时可在受控维护窗口安排更积极的检查点操作,但应先确认应用连接与备份情况,使用官方文档解释具体模式。一次有界、可验证的修复优于一条每天不分场景强制整理数据库的自动化脚本。
只读指标采集脚本
下面inspection.py与前文storage.py位于同一目录,可查看当前日志模式并调用PASSIVE检查点读数。返回的数据用于诊断,不是性能压测结果。生产系统应将采样频率控制在合理范围,并给输出加上明确时间戳、数据库实例标识和业务流量背景。不要将不同数据库文件的读数混合成一条平均值。
除了检查点状态,更值得一起观察的是锁等待时长、事务开始到提交的间隔、数据库错误类别和整条业务的端到端耗时。四种指标回答的问题不同:有些服务慢在网络,有些慢在写锁,有些慢在后台队列,还有一些是因为读取游标占用资源。没有分阶段指标时,单纯看到请求变慢容易选错维修方向。
from contextlib import closing
from storage import connect
def inspect_wal():
with closing(connect()) as db:
mode = db.execute("PRAGMA journal_mode").fetchone()[0]
busy, total, checkpointed = db.execute(
"PRAGMA wal_checkpoint(PASSIVE)"
).fetchone()
return {
"journal_mode": mode,
"busy": busy,
"wal_pages": total,
"checkpointed_pages": checkpointed,
"remaining_pages": total - checkpointed,
}
if __name__ == "__main__":
print(inspect_wal())
多进程写入需要有界调度
若多个后台工作进程同时完成任务并集中写状态,可以考虑将写操作交给一个明确的队列消费者,由其他工作进程负责网络、转换和计算,再提交简短的写入请求。这样不是让SQLite变成多写入者数据库,而是将写入争用集中到可度量和可限制的入口。也可以把适合合并的结果组成小批次,但批次越大,持锁时间可能越长,需要通过实际负载寻找平衡。
任务队列通常只能确保消息会被再次投递,不能保证外部业务恰好执行一次。一个消费者可能已经COMMIT,却在确认队列消息之前崩溃,消息于是再次到达。稳定job_key、唯一约束与可查询的完成状态能够识别这次重复,让恢复程序知道应该跳过、继续还是核对。把每次投递都当成一条全新业务,会让重复问题更加隐蔽。
如果未来将应用拆成多台机器,也不能直接把同一个SQLite WAL文件放在网络共享目录让所有机器写。官方说明WAL机制依赖同一主机上的共享内存协调。多主机要求应该通过服务器数据库或者一个负责协调本地文件的服务来解决,不能依靠更大的busy_timeout假装拓扑不变。
真实排障建议按顺序验收
第一步确认进程确实连接到同一个数据库路径,并检查当前日志模式。第二步记录发生锁竞争时各个进程的BEGIN、COMMIT以及等待时长。第三步在隔离的测试数据库中,用两个独立进程制造可控的锁竞争,确认异常路径会ROLLBACK并释放连接。第四步将事务内的远程调用移出,再测量持锁时间和尾部延迟是否改善。
第五步排查长期存在的读取事务与游标,观察检查点能否随着业务负载变化正常前进。第六步让同一job_key模拟重复派发,验证数据库只接受一条结果记录,同时确认业务侧没有重复调用外部平台。第七步在接近真实流量的负载下重测,保存输入、硬件、数据库版本和监控结果,为以后回归提供依据。
故障报告不要只写更改busy_timeout以后不再报错。应该说明真正的持锁环节是什么、为什么原事务设计会放大等待、新设计把哪些工作移到了事务之外、相应的回滚步骤是什么。只要能够完整解释这些问题,即使未来负载继续增长,也能更快判断是不是同一类型的故障。
把外部副作用与数据库重试分隔
很多后台任务还会发布文章、发送邮件、修改权限或调用支付接口。SQLite记录了任务状态,并不意味着它能将第三方平台纳入同一个原子事务。当外部请求被接受但回执丢失,本地数据库可能恰好又因为锁竞争而写入失败,这是一种常见的模糊结果。业务流程不能据此自动重复外部操作,应该先查询远端状态。
可将作业阶段划分成准备、已尝试、结果未知、已确认以及需要人工核对,并为每次不可逆操作保留稳定业务键。数据库中的记录只能说明自己观察到了什么,不能替代平台真实回执。在目标系统提供幂等接口时,将同一业务键传给对方;不提供时,只读核查与人工确认可能是控制重复副作用的必要成本。
恢复流程应该有清晰的所有权和范围。发生一个作业的异常,不必重置所有用户、清空整个队列或删除历史账本。操作人员能够只读检查这条作业已有的本地状态和相关远端证据,再决定下一步。这样的设计不仅减少重复执行,也方便交接与追溯。
代码验证与迁移回归
示例代码在真正用于服务之前,应在隔离测试目录先执行initialize,再用同一个job_key调用保存函数两次,检查首次写入与第二次识别重复的结果是否符合预期。之后打开两个独立进程,模拟第一名写入者长时间持有事务,观察另一名写入者等待与异常回滚的行为。故意持锁的实验不应直接对含真实用户数据的生产数据库执行。记录程序版本、依赖版本和测试负载,才能在未来复现相同条件。
还应测试网络调用已经成功而本地记录失败的恢复分支,确认上层业务会先查询第三方状态,而不是重发通知或付款。若服务使用线程池,检查连接是否跨线程共享、游标何时释放、异常路径是否会留下未提交事务。对状态机的每一种错误分支至少构造一条可观察测试,包括写入成功后客户端立刻中断、队列消息重复到达以及相同业务键携带冲突结果的情况。
升级Python或SQLite版本时,需要在测试环境比较关键PRAGMA返回、事务失败后的连接行为与检查点指标。若同时变更表结构,先准备一致性备份、迁移脚本和明确回滚步骤,再小范围验证业务读取。不要为了方便把整份生产数据库和密钥拷贝到临时测试目录,也不要把监控数据当作可以无期限保存的调试资产。
为什么不急着换成服务器数据库
SQLite擅长单机环境下的轻量存储,事务设计得当时可以很好地承担定时作业记录、桌面工具、本地服务与规模适中的后台状态。锁定错误本身不构成必须立刻迁移数据库的证据,因为错误可能只是一次事务跨越了慢速HTTP请求。先测量持锁时间、减少事务内工作、区分安全重试与外部副作用,常常能够更低成本地恢复可预测行为。
但如果真正需求是多个主机同时修改统一的业务状态、持续高并发写入、复杂的多租户权限隔离和独立运维容错,服务器数据库可能更符合架构要求。选择时要考虑运维成本、备份、授权、网络延迟、迁移风险以及现有SQL语义。换数据库不是零成本的快速修复,写在事务里面的慢网络请求即使迁移到其他数据库仍可能造成锁争用与队列积压。
迁移前先列出真正的瓶颈证据和新数据库需要保证的业务不变量,再使用有代表性的工作负载检验目标方案。不要把一张宣传中的吞吐图表当作自己机器的性能基准,也不要以数据库类型替代对外部任务幂等协议的设计。正确性要求应保持稳定,优化和迁移才能有清楚的验收标准。
一份可以直接使用的排障清单
确认日志模式是否真的启用了WAL,确认各进程连接路径一致,确认busy_timeout按连接设置。找出最长的写事务并检查里面是否有外部调用。统计拿写锁等待和事务持有时长,区分偶尔争用与持续排队。检查异常路径是否执行ROLLBACK和连接关闭,再用相同业务键模拟重复记录,验证主键约束是否发挥作用。
随后查看有没有长时间读取事务阻挡检查点,定期保存WAL页数趋势。不要删除正在使用的-wal或-shm文件,也不要无依据地全面重启进程。涉及外部副作用的任务先核对远端状态,结果未知时保留明确状态,不把数据库错误解释为允许再次发布或支付。最后在测试负载下复测并保存对比指标,使后来接手的人能够知道这一轮改动解决了什么。
工程结论应该从可观察事实出发:WAL让许多读取和写入可以同时进行,但不是无限写并发许可;可靠的服务依赖短事务、有限等待、稳定业务键、清楚的连接所有权和检查点监控。SQLite官方资料可见https://www.sqlite.org/wal.html及https://www.sqlite.org/pragma.html,Python接口以官方sqlite3文档为准。