上一篇文章提到我在工单系统里用了 SQLite,结果评论区有人惊讶:"SQLite 也能上生产?" 这其实是个非常普遍的偏见。这篇就专门聊聊,SQLite 在中小流量的 Web 服务里,到底能不能用、怎么用得稳。
SQLite 适合什么场景
先说结论:SQLite 适合单机、读写量都不算极端的场景。它的核心优势是"零运维"——一个文件就是一整库,备份就是 cp,迁移就是把文件丢过去,不用起一个独立的数据库进程。对个人项目、内部系统、小流量 SaaS 来说,这省下的运维成本是实打实的。
常见的偏见有两条,都得纠正一下:
- "SQLite 不支持并发"——不准确。它支持多读并发,只是写操作需要串行。配合 WAL 模式,读和写还能真正做到互不阻塞。
- "SQLite 慢"——其实它单机性能非常强,没有网络往返。很多场景下查询反而比 Postgres 还快,瓶颈通常不在引擎而在并发模型。
真正会绊倒你的,是多写并发下的 SQLITE_BUSY。下面就来解决这个问题。
第一步:开启 WAL 模式
默认的回滚日志(rollback journal)模式下,写操作会加一个排他锁,期间所有读都会被阻塞。开启 WAL(Write-Ahead Logging)后,写操作先写到独立的 WAL 文件,读操作走主库文件,读写互不干扰。这是 SQLite 提升并发能力最关键的一步。
import sqlite3
def enable_wal(db_path: str) -> sqlite3.Connection:
conn = sqlite3.connect(
db_path,
check_same_thread=False, # 多线程共享连接
isolation_level=None, # 关闭自动事务,由我们手动控制
)
# 开启 WAL,开启后整库持久生效(写在文件头里)
conn.execute("PRAGMA journal_mode=WAL")
# 写操作以 NORMAL 同步级别即可,FULL 太慢、OFF 不安全
conn.execute("PRAGMA synchronous=NORMAL")
# 单次写操作忙等 5 秒,超过再抛 SQLITE_BUSY
conn.execute("PRAGMA busy_timeout=5000")
return conn
这里有几个细节值得注意:
journal_mode=WAL一旦设置就是持久的,写进数据库文件头,下次打开依然是 WAL,不用每次设。busy_timeout是个保底,让 SQLite 在锁被占用时自己等一会儿再报错,能挡掉大部分偶发冲突。synchronous=NORMAL在 WAL 下足够安全,断电最坏丢最后一次事务,但不会损坏库,是性能和安全的甜点。
第二步:用全局锁串行化写入
WAL 解决了"读和写打架"的问题,但多个写之间仍然是互斥的。如果你的服务里有多个线程同时发起写,仍然会撞上 SQLITE_BUSY。最简单稳健的办法,是用一把进程内的全局锁把所有写操作串起来。
import threading
from contextlib import contextmanager
# 进程内全局写锁,所有写都过这一道
_write_lock = threading.Lock()
@contextmanager
def write_txn(conn: sqlite3.Connection):
# 先抢进程内锁,确保同一时刻只有一个线程在写
with _write_lock:
conn.execute("BEGIN IMMEDIATE")
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
# 使用示例
def create_ticket(conn, title: str, creator_id: int):
with write_txn(conn) as c:
c.execute(
"INSERT INTO tickets (title, creator_id) VALUES (?, ?)",
(title, creator_id),
)
# 这段里可以放多条写,全部原子提交
关键在于 BEGIN IMMEDIATE:它立刻申请写锁,而不是等到第一次写才升级锁。这样能避免"读到一半想写、锁已被别人拿走"的尴尬,配合全局锁后整库的写变得完全有序,SQLITE_BUSY 基本绝迹。
busy_timeout 兜底,或者干脆每个进程一个独立的库,把写流量分摊开。单进程多线程是 SQLite 最舒服的用法。
第三步:单例 get_conn 连接
SQLite 的连接对象本身是轻量的,没必要每个请求新建一个。check_same_thread=False 后,一个连接可以被多线程共享,配合上面的写锁完全安全。写一个 get_conn 单例就够了:
from functools import lru_cache
@lru_cache(maxsize=1)
def get_conn() -> sqlite3.Connection:
# 进程内只有一个连接,所有线程共用
conn = enable_wal("data.db")
conn.row_factory = sqlite3.Row # 让结果按列名访问
return conn
读操作直接用这个连接执行 SELECT 即可,不用加锁(WAL 下读不阻塞写)。写操作一律走 write_txn(get_conn(), ...)。这样读写都干净利落。
读写分离的注意事项
有人会问:既然要分离,是不是该开两个连接,一个写、一个读?理论上可以,但实践里有几个坑:
- 读连接的可见性:WAL 下,一个连接默认能读到所有已提交的数据。但如果你在一个写事务里又开读连接查自己刚写的数据,可能读不到——因为写还没提交。所以"写后立即读"务必用同一个连接,别拆开。
- 不要为读单独开隔离级别:读连接保持默认就行,手动设
PRAGMA read_uncommitted反而可能读到脏数据,得不偿失。 - connection pooling 意义不大:SQLite 是文件级的,单连接足以承载它的吞吐上限。开一堆连接既不会更快,还增加锁争用。
结论是:一个连接 + 一把写锁,对绝大多数中小流量场景已经是最优解了。
sqlite3.connect() 再 close()。频繁开关连接在 SQLite 上开销不小,而且会让 busy_timeout 的等待失效(连接刚等来锁就关了)。用单例连接是对的。
小结:什么时候该换 Postgres
说了这么多 SQLite 的好,也得给个换 Postgres 的判断标准。我会考虑升级的信号有这么几个:
- 写 QPS 持续在几百以上:全局写锁开始成为瓶颈,请求排队明显。
- 需要多机部署:服务要水平扩展到多台机器,SQLite 的单文件没法跨机器共享。
- 需要复杂的并发控制或大事务:比如长事务、行级锁、丰富的隔离级别,这些 Postgres 完胜。
- 数据量到几十 GB:SQLite 单文件超大后备份和恢复都笨重。
在那之前,SQLite 配 WAL + 写锁,能让你以极低的运维成本撑很久。能用简单方案解决问题,就不要过早地引入复杂度——这是我用了一年多 SQLite 之后最大的体会。
← 返回文章列表