6.1 SQLite 的并发模型
SQLite 有两种日志模式:
- Rollback Journal(默认)
- 写的时候锁全库;读写互斥;并发性差。已被基本淘汰。
- WAL(Write-Ahead Log)
- 写只追加到 WAL 文件,读直接从主库读。多读一写并发。Turso/libSQL 全部启用 WAL。
WAL 意味着:一个写正在进行时,其他读可以照常(读取旧 snapshot);但两个写会互相阻塞。
6.2 libSQL 的事务类型
第 3 章已经见过,这里归纳三种:
| 模式 | 行为 | 用法 |
|---|---|---|
| deferred | SQL 里碰到 BEGIN 才开始;首条 SELECT 开只读锁 | 普通查询 |
| read | 立刻开只读事务;可路由到 replica | 纯读批处理 |
| write | 立刻开写事务;锁住 primary | 要写的事务 |
SDK 层面:
// batch 的第二个参数就是这个
await db.batch([...], 'write');
// interactive transaction 开头选
const tx = await db.transaction('write');
6.3 ACID 承诺
libSQL 继承 SQLite 的完整 ACID:
- Atomic — 事务里所有语句要么全成功要么全回滚
- Consistent — 约束(FK/UNIQUE/CHECK)严格检查
- Isolated — 默认 SERIALIZABLE(WAL 下是快照隔离)
- Durable — fsync 到磁盘后才算 commit
副本异步复制的是已提交的 frame——即 primary 写已 fsync 后才会传播。所以副本看到的任何状态都是一致的过去时刻,不会看到"未提交数据"。
6.4 副本延迟的真相
时间线:
t=0 Primary: INSERT users(...)
t=0.05 Primary: COMMIT done
t=0.06 Client: 收到写成功响应
t=0.12 Replica(fra): frame 拉到
t=0.18 Replica(nrt): frame 拉到
在 t=0.06 到 t=0.18 之间,nrt 区域的 replica 还看不到这条记录。
副本延迟通常 50-200ms(跨洋可能更久)。这对大多数应用无感,但特定场景会出问题:下一节。
6.5 read-your-writes 问题
典型场景:用户提交表单 → 跳转列表页。如果:
- 写 POST → primary(ams)写入
- 100ms 后浏览器 GET 列表 → 命中 nrt replica
- nrt 还没拉到刚才的 frame → 用户看不到自己刚提交的数据
libSQL 提供两种解决方案:
方案 A:用客户端保留的 replication_index
const writeResult = await db.execute('INSERT ...');
const replIndex = db.protocol.lastReplicationIndex();
// 后续读请求带上这个 index
const readClient = createClient({
url, authToken,
replicationIndex: replIndex, // 等到 replica 追上才返回
});
await readClient.execute('SELECT ...');
方案 B:刚写完的读直接走 primary
// 写事务当然在 primary
const tx = await db.transaction('write');
await tx.execute('INSERT...');
const { rows } = await tx.execute('SELECT...'); // 仍在 primary,read-your-writes OK
await tx.commit();
方案 C:嵌入式副本天然支持
嵌入式副本写完会主动拉 frame(第 4 章),所以本地读永远一致。
6.6 写冲突与重试
两个并发写事务会有一方拿锁——另一方收到 SQLITE_BUSY。libSQL HTTP 驱动通常会自动重试几次;如果还失败就抛错。
async function withRetry<T>(fn: () => Promise<T>, max = 3): Promise<T> {
for (let i = 0; i < max; i++) {
try { return await fn(); }
catch (e: any) {
if (e.code !== 'SQLITE_BUSY' || i === max - 1) throw e;
await new Promise(r => setTimeout(r, 50 * Math.pow(2, i)));
}
}
throw new Error('unreachable');
}
6.7 乐观并发控制
高并发写热点可以用版本号避免锁:
// 每行加 version 列
const row = await db.execute('SELECT id, counter, version FROM items WHERE id=?', [1]);
const updated = await db.execute({
sql: 'UPDATE items SET counter=counter+1, version=version+1 WHERE id=? AND version=?',
args: [row.id, row.version],
});
if (updated.rowsAffected === 0) {
// 有人抢先改了,重读重试
}
6.8 锁与超时配置
libSQL 事务默认超时 5 秒,超过会自动 rollback。可以调整:
const db = createClient({
url, authToken,
concurrency: 10, // 同时允许的并发请求
requestTimeoutMs: 10000, // 单请求超时
});
6.9 监控并发状况
Turso dashboard 的 Monitoring 标签有:
- 每秒查询数
- P50/P95/P99 延迟
- BUSY 次数
- 副本延迟
若 BUSY 开始增长,优化方向:
- 拆分大事务为小事务(减少锁持有时间)
- 把冷路径改成 batch(一次 RTT 多条)
- 读密集走 replica/嵌入式副本
- 极端场景:多个 db-per-tenant,避免写路径争抢
6.10 一致性保证速查
| 场景 | 保证 |
|---|---|
| 同事务内读自己写 | ✅ 总是看到 |
| 同客户端、同会话,写后读同地副本 | ⚠ 可能落后,需 replicationIndex |
| 不同客户端读同副本 | ✅ 一致快照 |
| 嵌入式副本读 | ✅ 总是一致(sync 到某一时刻) |
| 读 primary | ✅ 最新 |
小结
libSQL 继承 SQLite 完整 ACID,默认 SERIALIZABLE 隔离。副本是异步的——"读自己写"要用 replicationIndex、写事务保持在 primary、或走嵌入式副本。并发写冲突表现为 SQLITE_BUSY,需要应用层指数退避重试。下一章进入 AI 时代的主角:向量搜索。