参考 / 2026-09-05
D1 没有交互式事务,怎么安全地扣余额
在 db.transaction() 会直接抛错的 Cloudflare D1 上安全地扣积分:一条条件语句、什么时候该用 batch(),以及 ShipKit 用的只追加账本:根本没有余额可以算错。

Cloudflare D1 不支持交互式事务。db.transaction() 会在运行时抛错,再怎么配置 Drizzle 也改不了:这个限制来自平台本身,因为「跨多次往返一直开着的事务」恰恰是分布式 SQLite 给不了的东西。这是在 Cloudflare 上开发要接受的取舍之一,其他取舍,以及你换来了什么,我们单独写过一篇。
只要你手上有一个需要扣减的余额,这件事立刻就要紧起来。
朴素写法,以及它为什么会亏钱
const { balance } = await db.select(...) // 读
if (balance < cost) throw new Error('余额不足')
await db.update(...).set({ balance: balance - cost }) // 写
两个同时到达的请求会读到同一个余额,双双通过检查,然后双双写入。账户变成负数,而账本再也解释不清这是怎么发生的。
正确做法:把检查塞进写操作里
不要「读 → 判断 → 写」。改成条件写入,把判断交给数据库:
const spent = await db
.update(credits)
.set({ balance: sql`${credits.balance} - ${cost}` })
.where(and(eq(credits.userId, userId), gte(credits.balance, cost)))
.returning({ balance: credits.balance })
if (!spent.length) throw new InsufficientCredits()
一条语句。WHERE 里的 gte 就是余额检查,因此它和更新在同一把锁下求值。返回零行意味着这次更新没有生效——调用方是从影响行数得知这一点的,而不是从之前的那次读取。
一条语句不够用的时候
扣减通常还要写一条账本记录,而你希望两者要么都成功、要么都不发生。这正是 db.batch() 的用途:多条语句、一次往返、原子生效。它不是交互式事务——你没法根据第一条语句的结果去分支——但对「这几条写操作必须同进同退」这种需求,它刚好合适。
await db.batch([
db.update(credits).set(...).where(...),
db.insert(ledger).values(...),
])
ShipKit 实际的做法:账本就是余额
上面的例子是在账本旁边再留一个余额列。ShipKit 更进一步,干脆去掉了余额列:余额就是一张只追加的账本的总和,扣费是一条 INSERT … SELECT,只有总和够扣时才会插入一行。简化后是这样:
INSERT INTO credit_ledger (id, user_id, delta, reason, ref_id)
SELECT :id, :user, -:cost, :reason, :ref
WHERE (SELECT coalesce(sum(delta), 0)
FROM credit_ledger WHERE user_id = :user) >= :cost
ON CONFLICT DO NOTHING
影响行数为零,要么是余额不够,要么是这个 ref_id 已经扣过了:它上面有唯一索引,所以重试的请求不会扣第二次,只是什么都不做。也不存在余额和账本对不上的问题,因为只有账本。实际的语句还多做了一件事:先扣会过期的月度额度,再扣购买的积分,把一次扣费拆成两行。但它仍然是一条语句。
经验法则
如果一个判断依赖当前状态,就把这个判断推进 WHERE 子句。如果多条写操作必须一起落地,就用 batch()。如果你发现自己想要「先读、在 JavaScript 里分支、再写」——那正是 D1 给不了你的形状,值得在它变成一个只在高并发下才现形的 bug 之前先重构掉。如果你发现到处都需要这种写法,那你的产品可能更适合 Postgres:这里是从 D1 迁到 Postgres 要做些什么。


