SQLite 实战:轻量嵌入式数据库的并发优化与适用边界

在很多中小型项目里,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 的上限很清晰:单写者、数据量适中、部署简单。一旦越过边界,就该选服务端数据库。下面这张表帮你快速判断:

维度SQLitePostgreSQL / MySQL
并发写单写者串行,适合低中频写多写者,行级锁,高并发写
部署形态单文件,零进程独立服务,需运维
数据规模通常 < 几十 GBTB 级、可水平扩展
网络访问本机文件,不支持远程原生客户端 / 服务端
典型场景本地 / 边缘 / 单机服务多租户 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,二者并不冲突,反而各取所长。

上一篇 Spring Boot 3 升级踩坑实录:从 2.x 平滑迁移到 3.x
下一篇 WebSocket 实战:用实时双向通信构建即时应用
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]