SQLite 生产部署指南——备份、并发、容灾、性能底线
SQLite 能跑生产吗?能。但不是你装个 sqlite3 就开始 SELECT 那种跑法。
大部分人说 SQLite 不能用在生产,其实是在说:我用过默认配置,然后被坑了。这就像买了一台手动挡跑车,一直挂一档开,然后说这车不行。
SQLite 不是 MySQL 的平替,它在另一个维度上——零运维、单文件、嵌入式。选它的场景,图的不是"功能强大",而是"你不需要一个DBA"。
但选它不意味着你没有运维责任。SQLite 在生产上的坑,比大部分人多一层:它不是 C/S 架构,你的脆弱面不在网络上,全在文件系统上。
这篇把 SQLite 在生产上最要命的几个点一个个拆清楚——备份怎么做、并发能撑多大、死锁怎么避免、Docker 里怎么跑、什么时候该换 PostgreSQL。
一、备份:最基础的保命功课
SQLite 备份最大的问题是:它不是一个独立进程,你没法在外部做一致性快照。 你 cp 一个正在被写入的 .db 文件,得到的很大概率是一个损坏的备份。
方案一:.backup 命令(最稳妥)
sqlite3 CLI 自带:
sqlite3 /path/to/prod.db ".backup /path/to/backup.db"
这个命令在 SQLite 内部执行,它知道当前有没有活跃事务、WAL 里有哪些未合并数据。它会等当前事务完成再开始备份,确保副本是一致的。
自动化脚本:
#!/bin/bash
DB=/data/app.db
BACKUP_DIR=/backups/sqlite
DATE=$(date +%Y%m%d_%H%M%S)
mkdir -p $BACKUP_DIR
sqlite3 $DB ".backup $BACKUP_DIR/app_$DATE.db"
gzip $BACKUP_DIR/app_$DATE.db
find $BACKUP_DIR -name "*.gz" -mtime +30 -delete
方案二:VACUUM INTO(SQLite 3.27+)
sqlite3 /path/to/prod.db "VACUUM INTO '/backups/app_$(date +%Y%m%d).db'"
.backup 的现代版本。同样保证一致性,而且输出的文件是优化过的(重新整理页面、回收空洞)。但磁盘空间要两倍——它创建一份压缩后的完整副本。
方案三:Litestream(流式备份)
适合需要实时增量备份到 S3 的对象存储的场景。
litestream replicate /data/app.db s3://my-bucket/sqlite/
Litestream 通过监听 SQLite 的 WAL 文件变化,把增量日志实时流到远端。崩溃时可以恢复到任意时间点。对小型生产应用来说,这比任何手动备份脚本都靠谱。
代价: 多一个进程,多一层复杂度。Litestream 本身如果挂了,你会失去实时增量(但已有全量备份不受影响)。
方案四:wal_checkpoint + 文件快照(仅限文件系统支持快照的场景)
如果数据库文件在 ZFS、btrfs、LVM 快照或云盘快照上:
PRAGMA wal_checkpoint(TRUNCATE);
# 然后在文件系统层拍快照
触发 checkpoint 把 WAL 合并回主文件,再拍快照。这样快照里拿到的是一个完整的主文件,不需要额外恢复步骤。
二、WAL 模式并发:你最多能撑多少人?
先给数字,再讲道理。
| 场景 | 并发阈值 | 说明 |
|---|---|---|
| 纯读 | 几十到几百 | WAL 下读不阻塞读,SQLite 本身很快 |
| 读写混合(写少) | 几十(50-100) | 读不受写阻塞,但写串行 |
| 读写混合(写多) | 几到十几 | 写之争锁开始明显 |
| 纯写(大量 INSERT) | 1 | 写事务串行执行 |
WAL 模式下,SQLite 的核心瓶颈只有一个:写串行。 同时间只有一个写事务能提交。这不是锁粒度的问题——SQLite 就是单线程写引擎。
但对你来说,需要多高的并发取决于你的业务。一个 B 端管理后台——几十个员工在用,SQLite 完全扛得住。一个每小时几百人的电商结算——写操作密集触发锁竞争——你需要想清楚。
怎么测你的并发阈值
# 用 sqlite-bench 或自己写个简单压测
# 模拟 20 个并发连接,每连接随机读写
apg -n 20 -c 20 -d /tmp/test.db
如果并发不够怎么办
WAIT模式(PRAGMA busy_timeout): 不报错,排队等锁。
PRAGMA busy_timeout = 5000; -- 等5秒,超时再报错
WAL 模式 + 连接池上限控制: 不是给了 100 个连接就有 100 个有效并发。SQLite 的最佳实践是:连接池大小控制在 10-20 之间,多了反而因锁争用导致更差的结果。
分库: 如果写压力大到一个 .db 扛不住,说明场景不适合 SQLite,该换 PostgreSQL 了。
三、PRAGMA 调优清单:生产环境必须改的配置
这些不是"可优化"——是不改就会出问题。
-- 1. WAL 模式(必须)
PRAGMA journal_mode = WAL;
-- 2. 同步模式(看风险接受度)
PRAGMA synchronous = NORMAL;
-- FULL = 最安全,写最慢(每次提交都 fsync)
-- NORMAL = 安全性足够,性能好(WAL 模式下 crash 最多丢一页 WAL)
-- OFF = 数据库可能随时损坏,永远不要用
-- 3. 缓存大小(内存够就设大一点)
PRAGMA cache_size = -64000; -- 64MB,负数表示 KB
-- 4. 临时存储(放内存,别写盘)
PRAGMA temp_store = MEMORY;
-- 5. 内存映射 I/O(大量读时有效)
PRAGMA mmap_size = 268435456; -- 256MB
-- 6. 外键约束(默认关闭,需要就开)
PRAGMA foreign_keys = ON;
-- 7. 忙着就等着
PRAGMA busy_timeout = 5000;
-- 8. WAL 自动 checkpoint 阈值
PRAGMA wal_autocheckpoint = 1000; -- WAL 到 1000 页时自动 checkpoint
把这些放到数据库连接初始化里,每次连接都执行一次。
一个 bug 警告
PRAGMA journal_mode 必须在没有任何活跃事务时设置。如果在事务中执行,它静默失败,不报错,但也不生效。很多人以为开了 WAL,实际上一直在 delete 模式。
验证方式:
PRAGMA journal_mode;
-- 应该返回 'wal',不是 'delete'
四、数据一致性和 crash 安全
SQLite 是 ACID 的,前提是你没做任何奇怪的操作。
原子提交
SQLite 的默认行为是每个 SQL 语句独立一个事务。如果你需要多语句原子性:
BEGIN IMMEDIATE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
BEGIN IMMEDIATE 和 BEGIN EXCLUSIVE 的区别:
| 模式 | 行为 | 使用场景 |
|---|---|---|
BEGIN DEFERRED | 等到第一个写操作才拿锁 | 你能承受"拿到锁时失败"的风险 |
BEGIN IMMEDIATE | 立即拿写锁,失败就报错 | 你确定接下来要写 |
BEGIN EXCLUSIVE | 排它锁,谁都不让进 | 你需要绝对独占访问 |
建议:如果确定要写,用 BEGIN IMMEDIATE。 避免 DEFERRED 模式下,多个连接同时 START TRANSACTION 然后一起试图升级到写锁——这种情况触发文件锁失败,要依赖 busy_timeout 重试。
crash 安全
WAL 模式 + synchronous = NORMAL 的 crash 安全保证:
- 已提交的事务不会丢
- 最多丢失一页 WAL(即最多一个事务的中间状态,不会导致文件级损坏)
- 重启后 SQLite 自动从 WAL 恢复
但如果操作系统崩溃时 WAL 文件本身也被损坏——比 SQLite 自己的数据文件先坏——这种情况极其罕见,但理论上存在。解决方案就一个:Litestream 或定期 .backup。
五、Docker 里跑 SQLite 的坑
坑很多,一个一个说。
文件系统:OverlayFS 的 WAL 问题
WAL 模式 + Docker OverlayFS = 定时炸弹。
Docker 默认的存储驱动是 overlay2。WAL 模式依赖共享文件系统的字节范围锁(POSIX advisory locks)来实现并发控制。OverlayFS 对这些锁的支持历史上坑很多——WAL 文件可能在多个层之间不同步。
标准做法:
- 把 .db 文件挂载到宿主机目录(bind mount),不要放在容器层里
- 如果跑多个副本(例如 k8s Pod 多实例),WAL 模式冲突几乎必然发生——不要在多副本场景下用 SQLite
volumes:
- /data/sqlite:/data/sqlite # bind mount,不走 overlay
文件系统:NFS 根本不能用
NFS 对文件锁的支持一塌糊涂。在 NFS 上跑 SQLite 等于定时自杀。如果多个应用实例共享同一个存储,用 PostgreSQL,别用 SQLite。
内存占用
容器限制内存会导致 WAL 模式下 checkpoint 时 OOM。因为 checkpoint 需要把 WAL 内容合并回主文件,这个过程如果内存不够,直接 OOM kill。
留够余量: cache_size 设置多少,加上 checkpoint 的临时峰值,不要卡得太死。
优雅关闭
容器终止时要确保 SQLite checkpoint 跑完:
PRAGMA wal_checkpoint(TRUNCATE);
启动脚本里加上 trap 捕捉 SIGTERM,触发 checkpoint。否则 WAL 文件里的未合并数据在下次启动时要恢复,恢复本身没问题,但恢复时间取决于 WAL 大小。WAL 太大时启动变慢。
六、SQLite vs PostgreSQL:什么时候换
这是关键问题。很多人不应该用 SQLite,也有很多人不应该上 PostgreSQL。
SQLite 适合的场景
- 单机服务、单实例部署
- 并发连接数 <100(实际同时写 <20)
- 不需要并发写入同一行(热点行竞争)
- 数据量 < 100GB(再大也能跑,但管理和恢复都不方便)
- 不需要细粒度权限控制
- 能接受分钟级恢复时间
典型例子: B 端后台、个人 SaaS 工具、物联网边缘节点、嵌入式设备、本地数据处理管道、开发测试环境。
该换 PostgreSQL 的信号
任何一个成立,就该换:
- 写并发持续 >20/s——SQLite 写串行,20 TPS 差不多是它在普通硬件上的上限
- 多个实例同时读同一个库——分实例部署但共享数据库,别用 SQLite
- 数据量超过 100GB——备份恢复时间开始变长,.backup 耗时你可能接受不了
- 需要在线迁移、不停机升级——SQLite 的 schema change 大部分情况要独占写锁
- 需要行级细粒度权限——SQLite 只在文件级有权限控制
- 团队里有人坚持要用 PostgreSQL——这个理由比上面五个加起来都重要
决策树
你的服务需要数据库吗?
├── 是,但只是临时/单机 → SQLite(零配置起步)
└── 是,生产长期运行 →
├── 单实例 + 写并发低 + 数据<100GB → SQLite
├── 多实例/高并发/大数据 → PostgreSQL
└── 中间地带 → SQLite 起步,设计时预留迁移接口
中间地带策略: 用 SQLite 起步,但所有数据库操作走抽象层(repository pattern)。上线后发现撑不住了,再换 PostgreSQL。SQLite 和 PostgreSQL 在 SQL 语法上 95% 兼容,迁移代价比 MySQL ↔ PostgreSQL 小得多。
总结
| 维度 | 结论 |
|---|---|
| 备份 | .backup / VACUUM INTO 日常用,Litestream 做实时增量 |
| 并发 | 写串行是硬上限,<20 并发写是舒适区 |
| PRAGMA | journal_mode=WAL, synchronous=NORMAL, busy_timeout=5000 是底线 |
| crash安全 | ACID 保证,但需要 checkpoint 配合 |
| Docker | bind mount 别用 overlay,别跑多副本 |
| 换库信号 | 写 >20/s,数据 >100GB,多实例共享,需要不停机迁移 |
SQLite 不是玩具。它被低估的根本原因不是它不够强,是大部分人在用它的第一天就踩进了默认配置的坑,然后再也没有回头。
你说 SQLite 不行?先告诉我你 WAL 开了没有。
SQLite in Production: Backup, Concurrency, Disaster Recovery, Performance Limits
Can SQLite run in production? Yes. But not the way you install sqlite3 and start SELECTing.
Most people say SQLite isn't production-ready because they used the default configuration and got burned. That's like driving a manual sports car in first gear and concluding the car is junk.
SQLite isn't a MySQL replacement. It lives in a different dimension — zero-ops, single-file, embedded. You don't choose it because it's "powerful." You choose it because you don't want to hire a DBA.
But that doesn't mean no operational responsibility. SQLite's pain points in production run deeper than most databases: it's not client-server. Your vulnerability isn't on the network — it's all on the filesystem.
This article breaks down the most critical production concerns one by one — backup, concurrency limits, deadlock avoidance, Docker gotchas, and when to migrate to PostgreSQL.
1. Backup: The Most Basic Life Insurance
SQLite's biggest backup problem: it's not a separate process — you can't take a consistent snapshot from outside. A cp of a .db file being written to is likely a corrupted copy.
Option 1: .backup command (safest)
sqlite3 /path/to/prod.db ".backup /path/to/backup.db"
This runs inside SQLite itself. It knows about active transactions and unmerged WAL data. It waits for the current transaction to complete before starting.
Automation script:
#!/bin/bash
DB=/data/app.db
BACKUP_DIR=/backups/sqlite
DATE=$(date +%Y%m%d_%H%M%S)
mkdir -p $BACKUP_DIR
sqlite3 $DB ".backup $BACKUP_DIR/app_$DATE.db"
gzip $BACKUP_DIR/app_$DATE.db
find $BACKUP_DIR -name "*.gz" -mtime +30 -delete
Option 2: VACUUM INTO (SQLite 3.27+)
sqlite3 /path/to/prod.db "VACUUM INTO '/backups/app_$(date +%Y%m%d).db'"
A modern version of .backup. Same consistency guarantee, but the output is optimized (pages reorganized, free space reclaimed). Requires double disk space temporarily.
Option 3: Litestream (streaming backup)
For real-time incremental backups to S3-compatible storage.
litestream replicate /data/app.db s3://my-bucket/sqlite/
Litestream monitors SQLite's WAL file changes and streams incremental logs to the cloud in real time. You can recover to any point in time after a crash.
Cost: One more process, one more layer of complexity. If Litestream itself dies, you lose real-time incrementals (but full backups are unaffected).
Option 4: wal_checkpoint + filesystem snapshot
Only if your filesystem supports snapshots (ZFS, btrfs, LVM, cloud disk):
PRAGMA wal_checkpoint(TRUNCATE);
# Take filesystem snapshot here
Trigger a checkpoint to merge the WAL back into the main file, then snapshot. The snapshot contains a complete, standalone main file.
2. WAL Mode Concurrency: How Many Users Can You Handle?
Numbers first, theory second.
| Scenario | Concurrency Threshold | Notes |
|---|---|---|
| Read-only | Tens to hundreds | WAL: reads don't block reads |
| Mixed (few writes) | 50-100 | Reads don't block writes |
| Mixed (many writes) | 5-15 | Write contention becomes visible |
| Write-only (bulk INSERT) | 1 | Serialized writes |
In WAL mode, SQLite has one core bottleneck: serialized writes. Only one write transaction can commit at a time. This isn't a lock granularity issue — SQLite is fundamentally a single-writer engine.
But what you need depends on your business. An internal admin tool with dozens of employees? SQLite handles it fine. An e-commerce settlement system processing hundreds of orders per hour with write contention? You need to think carefully.
If you need more concurrency:
- PRAGMA busy_timeout: Don't error, just queue and wait.
PRAGMA busy_timeout = 5000; -- wait 5 seconds before error - Connection pool limit: Keep it at 10-20. More connections means more lock contention, not better throughput.
- Shard: If write pressure overwhelms one .db, the scenario has outgrown SQLite. Move to PostgreSQL.
3. PRAGMA Tuning Checklist: Mandatory Production Configuration
These aren't "optimizations" — you will have problems without them.
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA cache_size = -64000; -- 64MB
PRAGMA temp_store = MEMORY;
PRAGMA mmap_size = 268435456; -- 256MB
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
PRAGMA wal_autocheckpoint = 1000;
Set these at connection initialization, every single time.
One bug to watch: PRAGMA journal_mode silently fails inside an active transaction. It won't error — it just won't work. Verify:
PRAGMA journal_mode;
-- should return 'wal', not 'delete'
4. Data Consistency and Crash Safety
SQLite is ACID — provided you don't do anything weird.
Atomic commits:
BEGIN IMMEDIATE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
Use BEGIN IMMEDIATE when you know you're going to write. It avoids the deadlock-prone scenario where multiple connections start BEGIN DEFERRED and then try to upgrade simultaneously.
Crash safety under WAL + synchronous=NORMAL:
- Committed transactions are never lost
- At most one WAL page is lost (partial transaction state, not file-level corruption)
- SQLite auto-recovers from WAL on restart
5. Docker Gotchas
Filesystem: OverlayFS + WAL = time bomb.
Docker default storage driver is overlay2. WAL mode relies on POSIX advisory locks over shared filesystem. OverlayFS support for these locks has historically been problematic.
Rules:
- Bind mount the .db file to a host directory — never use container layers
- Don't run SQLite in multi-replica scenarios (k8s with multiple Pod instances)
volumes:
- /data/sqlite:/data/sqlite # bind mount, not overlay
NFS: Unusable for SQLite. NFS file lock support is a disaster.
Memory limits: Container memory limits can trigger OOM during WAL checkpoint. Leave headroom.
Graceful shutdown: Ensure checkpoint runs before container stops:
PRAGMA wal_checkpoint(TRUNCATE);
6. SQLite vs PostgreSQL: The Decision Tree
SQLite fits:
- Single-machine, single-instance deployments
- Concurrent connections < 100 (actual concurrent writers < 20)
- No need for concurrent writes to the same row
- Data < 100GB
- Acceptable recovery time measured in minutes
Signal to switch to PostgreSQL (any one is sufficient):
- Write concurrency consistently > 20/s
- Multiple instances reading/writing the same database
- Data exceeds 100GB
- Online migration / zero-downtime schema changes required
- Row-level access control needed
- Your team insists on PostgreSQL (this outweighs all five above)
Middle-ground strategy: Start with SQLite, design with a repository abstraction layer. If you outgrow it, migrate to PostgreSQL. SQLite and PostgreSQL are about 95% compatible at the SQL syntax level — much cheaper to migrate between than MySQL ↔ PostgreSQL.
Summary
| Dimension | Conclusion |
|---|---|
| Backup | .backup / VACUUM INTO for daily, Litestream for real-time |
| Concurrency | Serialized writes is the hard cap; < 20 concurrent writers is comfort zone |
| PRAGMA | journal_mode=WAL, synchronous=NORMAL, busy_timeout=5000 is the baseline |
| Crash safety | ACID guaranteed, but needs checkpoint discipline |
| Docker | Bind mount, not overlay; no multi-replica |
| Migration signal | Writes >20/s, data >100GB, multi-instance sharing, zero-downtime needed |
SQLite isn't a toy. It's underestimated because most people trip on the default configuration on day one and never come back.
You say SQLite doesn't work in production? Did you turn on WAL first?