一个真实的线上事故
2024年5月,我们团队负责的支付网关系统突然出现大量超时告警。排查后发现,罪魁祸首是业务侧为了方便,直接用 SQLite 存了商户交易流水。当日峰值 TPS 到 800 时,SQLite 的写入延迟从平均 2ms 飙升到 300ms+,数据库文件从 200MB 膨胀到 1.2GB,磁盘 IO 被打满。
我们当时用了 SQLite 3.45.1,默认 journal mode 为 DELETE,没有开启 WAL,也没有调整 page_size 和 cache_size。这个配置下,每个写事务都要去写 rollback journal,并且 B 树频繁分裂导致页分裂风暴,性能自然崩了。
这篇文章不是教你用 SQLite,而是深入 SQLite 源码,看它的 B 树存储引擎到底怎么工作。看完你就明白为什么会出现上面的问题,以及怎么避免。
问题定义:高并发写入时,SQLite 为什么慢
先明确场景:我们测试的写入负载是「高频小额写入」,每次写入产生一条 256 字节左右的记录,事务隔离要求 READ COMMITTED。
在开启 WAL 前,压测结果:单连接顺序写入 10 万条,平均每条耗时 3.2ms,TPS 约 120 条。这个性能远远达不到需求。
问题拆解成三个层面:
- 1. B 树节点的大小固定(默认 4096 字节 page),插入时如果节点满了要分裂,分裂涉及父节点更新、兄弟节点指针改动,这个在 SQLite 里是递归操作,节点越大分裂成本越高。
- 2. 事务提交要保证持久性,SQLite 默认在 COMMIT 时 fsync 一次。磁盘每 fsync 一次约 1-5ms(普通 SATA SSD),这个开销跑不掉。
- 3. 锁粒度太粗,SQLite 的锁是数据库级,DELETE 模式下写事务会锁住整个库,读事务被阻塞。
方案对比:两种优化思路
我们验证了两种方案,各有优劣。
方案 A:调整 PRAGMA 参数(零侵入)
不换数据库,通过参数调整 B 树和缓存的行为。
PRAGMA journal_mode=WAL; -- 改为 WAL 模式
PRAGMA synchronous=NORMAL; -- 降级 fsync,牺牲部分可靠性换性能
PRAGMA wal_autocheckpoint=1000;
PRAGMA cache_size=-64000; -- 64MB page cache
PRAGMA page_size=4096;
效果:写入 TPS 从 120 提升到 2800。但这里有个隐藏约束:PRAGMA page_size 必须在第一次建表前设置。而且 cache_size 设置的过大会吃掉内存,如果机器只有 2G 内存,64MB 还凑合,但再大就不合适了。
方案 B:自己控制事务批处理 + 预编译语句(改代码)
把高频写入改成「攒一批,写一次」的模式,减少事务提交次数,同时显著降低 B 树内部节点分裂的频率。
<?php
// PHP 8.3 + SQLite3 扩展
$db = new SQLite3('/data/payment.sqlite', SQLITE3_OPEN_READWRITE | SQLITE3_OPEN_CREATE);
$db->exec('PRAGMA journal_mode=WAL;');
$db->exec('PRAGMA synchronous=NORMAL;');
$stmt = $db->prepare('INSERT INTO txn_log (txn_id, amount, created_at) VALUES (?, ?, ?)');
$db->exec('BEGIN IMMEDIATE');
for ($i = 0; $i < 10000; $i++) {
$stmt->bindValue(1, 'TXN' . $i, SQLITE3_TEXT);
$stmt->bindValue(2, rand(1, 9999), SQLITE3_INTEGER);
$stmt->bindValue(3, time(), SQLITE3_INTEGER);
$stmt->execute();
$stmt->reset();
}
$db->exec('COMMIT');
这个方案把 10 万条写入分成 10 个显式事务,每个事务 1 万条,耗时明显下降。
方案 A 改动小,方案 B 效果更好,代码量也就 50 行。最后我们两个都用上了。
深入源码:SQLite B 树的页面结构
看代码之前,说明 ptrmap、cell、page 三个概念:
- page:B 树节点,固定大小。SQLite 用
sqlite3_file抽象磁盘文件,一个 page 对应一页磁盘数据。 - cell:节点里面的「槽位」,包含 key 长度、value 长度、实际数据。类似 MySQL InnoDB 的「记录」。
- ptrmap:每个 page 只存一个指针,指向其父节点,用于向上找。
源码文件位置
SQLite 3.45.1 的 btree.c,关键结构体定义在 sqliteInt.h,写入核心逻辑在 balance_nonroot() 函数。
页分裂的完整触发路径
我们跟踪一条 SQL:INSERT INTO txn_log ...,调用链是:
| 函数 | 作用 |
|---|---|
| sqlite3BtreeInsert | B 树层入口,判断 key 是否已存在,决定插入或覆盖 |
| insertCell | 向节点追加 cell,如果空间不够,调用 balance_nonroot |
| balance_nonroot | 节点分裂核心。分配新 page,搬移 cell,更新父节点指针 |
| allocateBtreePage | 从 freelist 或文件尾部获取新 page |
这里是简化版(非真实源码)逻辑,用 PHP 代码模拟 SQLite 的找位点 + 分裂过程,方便理解:
<?php
// 模拟 SQLite 的 cell 插入与分裂
class BTreeNode {
public array $cells = [];
public int $pageSize = 4096;
public array $children = [];
public function insert(array $cell): bool {
$this->cells[] = $cell;
usort($this->cells, fn($a, $b) => $a['key'] <=> $b['key']);
// 检查这个节点是否溢出
$usedSpace = array_sum(array_map(fn($c) => strlen($c['value']), $this->cells));
return $usedSpace < $this->pageSize * 0.75; // SQLite 设 75% 阈值
}
public function split(): array {
$mid = intdiv(count($this->cells), 2);
$right = new BTreeNode();
$right->cells = array_slice($this->cells, $mid);
$this->cells = array_slice($this->cells, 0, $mid);
return [$this, $right];
}
}
WAL 模式的实现核心
WAL 不是简单的「先写日志再写库」,SQLite 的 WAL 有独立的 -wal 文件,通过 walIndex 的共享内存管理读视图。关键数据结构是 WalIndexHdr,定义在 wal.c:
// wal.c line 841 (SQLite 3.45.1)
struct WalIndexHdr {
u32 iVersion; /* Wal-index version */
u32 iSensor; /* 校验和 */
u32 iMaxFrame; /* 最后一个有效的 wal frame 编号 */
u32 aReadMark[SQLITE_SYNC_NORMAL]; /* 读标记 */
...
};
这个头结构记录当前 WAL 文件哪个位置是已提交的。每个读事务启动时复制一份 WalIndexHdr 到内存,之后即使有新的写事务提交,读事务看到的还是旧快照,这就是 WAL 模式实现多版本并发读(MVCC)的方式。
代码实现:从行到页
你写一条 INSERT,最终 B 树要把它变成节点中的一个 cell。下面是核心路径的 Python 模拟,简单但不影响理解:
<?php
// 表格定义:txn_log(txn_id TEXT, amount INT, created_at INT)
// 模拟一条记录如何被编码进 cell
class SQLiteRecord {
public static function encode(array $values): string {
// SQLite 使用 record format:
// header (type + size) + payload
$header = "";
$payload = "";
foreach ($values as $v) {
if (is_int($v)) {
$header .= pack('C', 1); // type 1 = 8-bit int
$payload .= chr($v);
} elseif (is_string($v)) {
$len = strlen($v);
$header .= pack('C', $len * 2 + 13); // type >= 13 表示字符串
$payload .= $v;
}
}
return pack('v', strlen($header)) . $header . $payload;
}
}
// 写入一条记录
$record = SQLiteRecord::encode([
'TXN000001', // txn_id, 10 chars
9999, // amount
1715088000 // created_at
]);
var_dump(strlen($record)); // 实际 24 bytes
B 树查找实现
索引查询走的是 sqlite3BtreeMovetoUnpacked(),这个函数本质上是「二分查找 + 从根节点向下递归」。我们贴出核心分支逻辑(伪代码级):
<?php
function btreeMoveto(BTreeNode $root, int $key): ?array {
$node = $root;
while (true) {
// 对当前节点的 cells 做二分查找
$lo = 0;
$hi = count($node->cells) - 1;
while ($lo <= $hi) {
$mid = intdiv($lo + $hi, 2);
$cmp = $key <=> $node->cells[$mid]['key'];
if ($cmp === 0) return $node->cells[$mid]['value'];
if ($cmp < 0) $hi = $mid - 1;
else $lo = $mid + 1;
}
// 没找到,进入子节点继续
if (empty($node->children)) return null;
$node = $node->children[$lo];
}
}
// 时间复杂度 O(log n),和 InnoDB 的 B+ 树一样
// 主要区别是 B 树的数据储存在所有节点,B+ 树只在叶子节点
效果数据:实测对比
环境:Linux 5.15.0-91-generic,8 核 16 线程,32GB RAM,NVMe SSD,SQLite 3.45.1,PHP 8.3.2,数据量 100 万行,单表 1.2GB。
| 配置 | 写入耗时(10万条单连接) | 读取 QPS(点查) | 事务延迟 p99 | 数据库文件大小 |
|---|---|---|---|---|
| 默认配置 | 312s | 8,500 | 25.1ms | 1.2GB |
| 仅开启 WAL | 18.7s | 7,200 | 8.2ms | 1.2GB + 8MB WAL |
| WAL + 批处理(100 条/事务) | 1.8s | 7,100 | 1.7ms | 1.2GB + 8MB WAL |
写入性能提升约 173 倍。注意文件大小没变化:B 树页大小不变,但页分裂次数明显减少。
监控 B 树页分裂次数的方法:sqlite3_status 接口返回过 SQLITE_STATUS_MEMORY_USED,但页分裂次数没有直接暴露。可以用 PRAGMA page_count 在事务前后对比新分配页面数:
PRAGMA page_count; -- 分裂前
-- 执行 10 万条 INSERT
PRAGMA page_count; -- 分裂后
实测默认配置下,10 万条 INSERT 导致 page_count 增加了约 29,400 页(约 117MB),说明产生大量新页,全是页分裂导致的内部碎片。
事务回滚的实现:两个日志文件
SQLite 有回滚日志(rollback journal)和预写日志(WAL)两种机制。它们的回滚方式不同。
- DELETE 模式:事务启动时把原数据 copy 到
journal文件,提交后删除。如果崩溃,就用 journal 内容覆盖回数据文件。 - WAL 模式:事务写入到
-wal文件末尾,提交时只 fsync WAL。如果崩溃,SQLite 启动时对 WAL 文件里的每一帧做 redo,把没来得及合并的页重放回去。如果需要回滚,直接丢弃 WAL 文件末尾未提交的 frame。
-- 查看当前产品的日志模式
PRAGMA journal_mode;
-- 如果返回 wal,说明正在使用 WAL
-- 崩溃恢复测试:模拟断电后 WAL 重放
.shell kill -9 {} -- 伪代码,实际通过 kill -9 进程模拟崩溃
sqlite3 payment.sqlite "PRAGMA integrity_check;"
-- 如果显示 ok,说明 WAL 重放成功
避坑指南
下面 5 个坑是我们实际遇到过的,每一条都浪费过至少一天时间。
坑 1:PRAGMA journal_mode=WAL 不是持久化设置
每次连接都要重新设置,否则回退到 DELETE 模式。我们当时只在维护脚本里设置了 WAL,业务进程连接后没设置,结果性能依旧拉胯。写法:每次连接后执行一次 PRAGMA journal_mode=WAL;。注意 WAL 模式依赖操作系统共享内存(shm),某些容器环境(比如 Docker 默认 --ipc=none)会初始化失败,保底方案:PRAGMA journal_mode=OFF;(但容灾性能会降低,不推荐生产)。
坑 2:synchronous=NORMAL 不是「关掉 fsync」
NORMAL 在 WAL 模式下只保证最终一致,可能丢最近几笔数据,但不会损坏数据库。我们的支付场景接受「崩溃时丢 100ms 内的数据」,但对于账务系统绝对不能这么干。写代码前先跟业务对好可靠性级别,千万别替业务做决定。
坑 3:batch 事务里必须处理「部分成功」
我们的批处理代码一开始长这样:
<?php
$db->exec('BEGIN IMMEDIATE');
foreach ($rows as $row) {
$stmt->execute();
}
$db->exec('COMMIT');
如果执行到第 5000 条时报错(比如唯一约束冲突),前面的 4999 条还在事务里。SQLite 默认 ROLLBACK 了整个事务,但实际运行中我们发现:如果错误不是 SQLITE_CONSTRAINT,而是 SQLITE_BUSY(锁超时),事务会保持打开。最终我们显式包了一层 try-catch:
<?php
try {
$db->exec('BEGIN IMMEDIATE');
foreach ($rows as $row) {
$stmt->execute();
}
$db->exec('COMMIT');
} catch (Exception $e) {
if ($db->querySingle('SELECT 1') === false) {
$db->exec('ROLLBACK');
}
throw $e;
}
坑 4:wal_autocheckpoint 太频繁
如果 WAL 文件增长到几百 MB,checkpoint 时会阻塞读线程几十毫秒。我们最初设置 PRAGMA wal_autocheckpoint=100(100 页就 checkpoint),结果写一千条就开始卡。调到 PRAGMA wal_autocheckpoint=1000(4MB 才 checkpoint)后,阻塞降到 2ms 以内。注意:这个参数不是越大越好,WAL 文件太大会占磁盘空间,而且如果崩溃,重放耗时更长。
坑 5:对主键做字符串索引,B 树退化成链表
我们把 txn_id 设为 TEXT 主键,导致 B 树每次插入都要做字符串比较(memcmp),在高频写入下开销极大。改成 INTEGER 自增主键后,B 树查找走整数比较,速度提升明显。实测纯 INSERT 下 INTEGER 主键比 TEXT 主键快 23%。
最后:什么场景不要用 SQLite
以上优化都在「单机、单进程、并发写低(<500 TPS)、数据量 <1TB」的前提下。如果超过这个量级,还是用 MySQL/PostgreSQL 吧。SQLite 的 B 树设计得再 nice,它也受限于锁粒度——写事务必须拿到排他锁(WAL 模式下降为写锁),这个架构层面没法改。
我们的支付流水后来迁移到了 MySQL 8.0.35 + InnoDB,用 RR 隔离。这属于另一个话题,下次写。