Chapter 06

事务、并发与一致性

SQLite 的 WAL 并发模型、libSQL 的事务类型、副本延迟的代价——这一章回答"我的写什么时候能被别人看到"。

6.1 SQLite 的并发模型

SQLite 有两种日志模式:

Rollback Journal(默认)
写的时候锁全库;读写互斥;并发性差。已被基本淘汰。
WAL(Write-Ahead Log)
写只追加到 WAL 文件,读直接从主库读。多读一写并发。Turso/libSQL 全部启用 WAL。

WAL 意味着:一个写正在进行时,其他读可以照常(读取旧 snapshot);但两个写会互相阻塞。

6.2 libSQL 的事务类型

第 3 章已经见过,这里归纳三种:

模式行为用法
deferredSQL 里碰到 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:

副本异步复制的是已提交的 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 问题

典型场景:用户提交表单 → 跳转列表页。如果:

  1. 写 POST → primary(ams)写入
  2. 100ms 后浏览器 GET 列表 → 命中 nrt replica
  3. 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 标签有:

若 BUSY 开始增长,优化方向:

  1. 拆分大事务为小事务(减少锁持有时间)
  2. 把冷路径改成 batch(一次 RTT 多条)
  3. 读密集走 replica/嵌入式副本
  4. 极端场景:多个 db-per-tenant,避免写路径争抢

6.10 一致性保证速查

场景保证
同事务内读自己写✅ 总是看到
同客户端、同会话,写后读同地副本⚠ 可能落后,需 replicationIndex
不同客户端读同副本✅ 一致快照
嵌入式副本读✅ 总是一致(sync 到某一时刻)
读 primary✅ 最新

小结

libSQL 继承 SQLite 完整 ACID,默认 SERIALIZABLE 隔离。副本是异步的——"读自己写"要用 replicationIndex、写事务保持在 primary、或走嵌入式副本。并发写冲突表现为 SQLITE_BUSY,需要应用层指数退避重试。下一章进入 AI 时代的主角:向量搜索。