后端

SQLite 在小型 Web 服务里的并发写入实践

上一篇文章提到我在工单系统里用了 SQLite,结果评论区有人惊讶:"SQLite 也能上生产?" 这其实是个非常普遍的偏见。这篇就专门聊聊,SQLite 在中小流量的 Web 服务里,到底能不能用、怎么用得稳。

SQLite 适合什么场景

先说结论:SQLite 适合单机、读写量都不算极端的场景。它的核心优势是"零运维"——一个文件就是一整库,备份就是 cp,迁移就是把文件丢过去,不用起一个独立的数据库进程。对个人项目、内部系统、小流量 SaaS 来说,这省下的运维成本是实打实的。

常见的偏见有两条,都得纠正一下:

真正会绊倒你的,是多写并发下的 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

这里有几个细节值得注意:

第二步:用全局锁串行化写入

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 基本绝迹。

关于多进程:上面的全局锁只在单进程内有效。如果是多进程(比如 gunicorn 多 worker)共享同一个 SQLite 文件,得靠 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(), ...)。这样读写都干净利落。

读写分离的注意事项

有人会问:既然要分离,是不是该开两个连接,一个写、一个读?理论上可以,但实践里有几个坑:

结论是:一个连接 + 一把写锁,对绝大多数中小流量场景已经是最优解了。

别踩的坑:用 FastAPI/Flask 时,不要每个请求都 sqlite3.connect()close()。频繁开关连接在 SQLite 上开销不小,而且会让 busy_timeout 的等待失效(连接刚等来锁就关了)。用单例连接是对的。

小结:什么时候该换 Postgres

说了这么多 SQLite 的好,也得给个换 Postgres 的判断标准。我会考虑升级的信号有这么几个:

在那之前,SQLite 配 WAL + 写锁,能让你以极低的运维成本撑很久。能用简单方案解决问题,就不要过早地引入复杂度——这是我用了一年多 SQLite 之后最大的体会。

← 返回文章列表