在很多中小型项目里,SQLite 是性价比最高的嵌入式数据库选择:零运维、单文件存储、不需要单独起服务进程。但它常被误用——高并发写、多进程抢同一文件时频繁报 “database is locked”。本文聚焦 SQLite 的并发模型与优化手段,帮你把单机吞吐吃满,同时明确它的适用边界。
一、为什么 SQLite 仍是很多场景的首选
SQLite 不是”玩具数据库”。它被广泛嵌进手机、浏览器、操作系统和边缘设备,每天被调用次数以万亿计。对以下场景它几乎是完美解:
- 单机或单进程的后台服务,数据量在几十 GB 以内;
- CLI 工具、桌面应用、移动 App 的本地存储;
- 嵌入式 / 边缘设备、IoT 网关的离线缓存;
- 需要”一个文件就能带走”的轻量分析库。
它免去了部署 Postgres / MySQL 实例、管理账号与连接池的成本。当你只需要可靠地存结构化数据,SQLite 的开箱即用体验无可替代。更重要的是,它的 ACID 保证是认真的:经过数十亿设备验证,不是”简化版事务”。
二、核心机制:单文件、WAL 与锁
理解 SQLite 的并发,先要理解它的锁层级:UNLOCKED → SHARED(多读)→ RESERVED(单写预备)→ PENDING → EXCLUSIVE(写)。写操作会短暂拿 EXCLUSIVE 锁,期间其他写被阻塞;读不阻塞读,但读会阻塞写、写也会阻塞读,默认配置下容易触发 SQLITE_BUSY。
关键开关是 WAL(Write-Ahead Logging)模式:开启后写操作追加到 -wal 文件,读可以继续读主库,读写可以并发,大幅降低锁冲突。生产环境几乎应该默认开启 WAL。
2.1 几种 journal 模式怎么选
默认的回滚 journal 是 DELETE 模式(事务中写 -journal 文件,结束后删除)。此外还有 TRUNCATE、PERSIST、MEMORY、OFF 和 WAL。绝大多数服务端 / 桌面场景直接上 WAL 即可;MEMORY 只在测试或无持久化需求时用;TRUNCATE / PERSIST 是 DELETE 的变种,写性能略好,但并发读写能力远不如 WAL。一句话:只要你的程序有并发读写,就别犹豫,开 WAL。
三、并发优化实战:busy_timeout 与 WAL
两个 PRAGMA 是并发优化的命门。journal_mode=WAL 让读写并发;busy_timeout 让遇到锁的线程自动等待而不是立刻报错。
-- 连接后先执行,建议写入数据库配置或建表脚本
PRAGMA journal_mode = WAL; -- 开启预写日志,读写可并发
PRAGMA synchronous = NORMAL; -- WAL 下 NORMAL 已足够安全,性能更好
PRAGMA busy_timeout = 5000; -- 遇到锁最多等 5 秒再报 SQLITE_BUSY
PRAGMA foreign_keys = ON; -- 开启外键约束
PRAGMA cache_size = -8000; -- 约 8MB 页缓存,按内存调整
在 Python 中,务必在每次建立连接后立即设置这两个参数,并且优先用上下文管理器保证连接及时归还:
import sqlite3
def get_conn(path: str) -> sqlite3.Connection:
conn = sqlite3.connect(path, timeout=5, check_same_thread=False)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.row_factory = sqlite3.Row
return conn
# 写操作示例:短事务 + 自动提交 / 回滚
def insert_event(conn, payload: dict):
with conn: # 上下文管理器自动 commit / rollback
conn.execute(
"INSERT INTO events(ts, topic, payload) VALUES (?, ?, ?)",
(payload["ts"], payload["topic"], payload["payload"]),
)
timeout=5 与 busy_timeout 配合,能把瞬时锁竞争消化掉,把 “database is locked” 从运行时异常变成几乎不可见的等待。
3.1 WAL 的 checkpoint 机制
WAL 模式下数据先写 -wal 文件,再通过 checkpoint 合并回主库。默认 wal_autocheckpoint=1000 页(约 4MB)会自动触发。如果写很频繁且长时间没有读,wal 文件可能涨到很大——可以手动 PRAGMA wal_checkpoint(TRUNCATE) 或在低峰期定时 checkpoint,避免崩溃恢复时回放过长。注意 checkpoint 本身需要短暂拿写锁,别在高频写瞬间强行触发。
四、连接池与多进程的正确姿势
SQLite 支持多进程同时读,但写是串行的。Web 服务常犯的错误是每个请求 new 一个连接、又高频写,导致锁雪崩。正确做法:
- 用连接池复用连接,控制并发写数量;
- 写事务尽量短,把多条写合并到一个事务;
- 多进程部署时,把写集中在单个 worker,或前置队列串行化写;
- 需要服务端数据库能力时再考虑迁移,别硬扛。
# SQLAlchemy 连接池示例(单写库场景)
from sqlalchemy import create_engine, event
engine = create_engine(
"sqlite:///./app.db",
connect_args={"check_same_thread": False, "timeout": 5},
pool_size=5, max_overflow=2, pool_pre_ping=True,
)
# 建池时统一设置 PRAGMA
@event.listens_for(engine, "connect")
def _set_pragma(dbapi_con, con_record):
cur = dbapi_con.cursor()
cur.execute("PRAGMA journal_mode=WAL")
cur.execute("PRAGMA busy_timeout=5000")
cur.close()
更彻底的做法是把写集中到单一后台线程:所有写请求丢进队列,由一个写线程串行消费,从架构上消灭锁竞争:
import queue, threading, sqlite3
_write_q: "queue.Queue" = queue.Queue()
def writer_worker(db_path):
conn = sqlite3.connect(db_path, timeout=5, check_same_thread=False)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
while True:
job = _write_q.get()
if job is None:
break
with conn: # 单写者,天然无锁竞争
conn.execute(job["sql"], job["params"])
_write_q.task_done()
# 启动写线程,业务侧只管 enqueue
threading.Thread(target=writer_worker, args=("./app.db",), daemon=True).start()
def enqueue_write(sql, params=()):
_write_q.put({"sql": sql, "params": params})
五、何时不该用 SQLite(适用边界)
SQLite 的上限很清晰:单写者、数据量适中、部署简单。一旦越过边界,就该选服务端数据库。下面这张表帮你快速判断:
| 维度 | SQLite | PostgreSQL / MySQL |
|---|---|---|
| 并发写 | 单写者串行,适合低中频写 | 多写者,行级锁,高并发写 |
| 部署形态 | 单文件,零进程 | 独立服务,需运维 |
| 数据规模 | 通常 < 几十 GB | TB 级、可水平扩展 |
| 网络访问 | 本机文件,不支持远程 | 原生客户端 / 服务端 |
| 典型场景 | 本地 / 边缘 / 单机服务 | 多租户 Web、分析平台 |
如果你的业务需要分区表来压住大表查询,可以参考 PostgreSQL 分区表实战 的落地方式;如果是高写入吞吐的时序数据,TDengine 时序数据库 往往比硬扛 SQLite 更合适。
六、实战踩坑清单
把最常见的问题和修复方式列成一张速查表,发布前对照一遍能省大量排障时间:
| 现象 | 根因 | 修复 |
|---|---|---|
| database is locked | 写冲突且未等待 | 开 WAL + busy_timeout |
| wal 文件不消失 | 有连接未关闭 | 用上下文管理器 / 连接池回收 |
| 多进程偶尔损坏 | 跨网络文件系统 | 改用本地磁盘,勿放 NFS |
| 写入越来越慢 | 未做 VACUUM | 定期 PRAGMA optimize / vacuum |
| 类型被悄悄转换 | 列类型亲和 | 显式 CAST,校验写入值 |
七、迁移到服务端数据库的信号
当出现以下信号,就该认真考虑迁移:写 QPS 持续超过单机承载力、需要多实例共享同一份数据、要做跨表跨库的复杂分析。还有几个容易被忽略的拐点:
- 需要读写分离或副本:SQLite 没有原生主从复制,靠文件同步既慢又易裂脑;
- 热备份困难:虽然可以整体复制文件,但必须停写或借助
.backup命令,运维成本高; - 复杂窗口函数 / 大 join 性能不足:服务端优化器与并行执行更成熟;
- 多租户需要独立权限与连接隔离:SQLite 的授权模型太粗。
Schema 迁移本身也有成熟工具链,例如用 Flyway 做版本化迁移与回滚,能平滑过渡到 PostgreSQL 这类服务端数据库。备份时若在 WAL 模式,可用 sqlite3 app.db ".backup backup.db" 拿到一致性快照,比直接 cp 更稳。
八、小结
SQLite 的并发噩梦几乎都能用 WAL + busy_timeout 化解,连接池与单写者队列则是高吞吐的搭档。记住它的边界——单写者、本机、适中的数据量——在边界内它是王者,越界了就大方迁移。先想清楚场景,再选数据库,比盲目上重架构更省心。很多团队的真实路径是:原型与边缘用 SQLite,中心化服务用 Postgres,二者并不冲突,反而各取所长。




