LOADING
1769 words
9 minutes
事务、并发与幂等(六)

这是整个系列最有含金量的一篇。

不是因为事务难——用 ORM 写一个事务只需要几行代码。而是因为事务背后的问题不是语法问题,是并发场景下的数据正确性问题。这些问题在单用户测试时永远不会出现,在生产环境的高并发下才会暴露。


一、Lost Update:发生在两个操作之间

场景

两个管理员同时编辑同一篇文章。管理员 A 改标题为 “Hello World” 并保存。管理员 B 几秒后改标题为 “Hi” 并保存。最终标题是 “Hi”——管理员 A 的修改静默丢失了,没有任何错误提示。

这就是 Lost Update。它发生的根本原因是:B 的更新操作基于的是过时的数据。 B 打开编辑页面时标题是 “Hello”,但 B 保存时 A 已经改了——B 不知道。

乐观锁:假设冲突不频繁

在需要防并发修改的表上加 version 字段。更新时检查版本号——如果在你读取之后有人改过,版本号不再匹配,更新不生效:

const result = await prisma.post.updateMany({
where: { id: postId, version: currentVersion },
data: { title: newTitle, version: { increment: 1 } }
});
if (result.count === 0) {
// 版本号不匹配——有人在你看完之后修改了这条记录
throw new ConflictError("数据已被他人修改,请刷新后重试");
}

where 条件里的 version 是乐观锁的核心。如果 result.count === 0,说明在你读取之后、写入之前,有另一个请求已经更新了这条记录并递增了版本号。

乐观锁适合冲突低概率的场景:两个管理员同时编辑同一篇文章的概率很低。用户被通知”请刷新重试”是可接受的。

悲观锁:假设冲突不可避免

扣库存是另一种场景——抢购时同一商品同时有几百个并发请求减库存。乐观锁意味着大部分请求会失败需要重试,这不是好体验。

悲观锁主动锁住行,其他人等待:

SELECT stock FROM products WHERE id = ? FOR UPDATE;
-- 这一行被锁住了,其他 SELECT ... FOR UPDATE 在等
UPDATE products SET stock = stock - 1 WHERE id = ?;

悲观锁的代价:持有锁的时间越长,所有等待这个锁的请求都在排队。所以规则是:锁住行之后立刻完成操作、立刻提交——不要在持有锁期间调用外部 API 或做复杂计算。

选择标准

冲突概率低、重试成本低(用户刷新即可)→ 乐观锁。冲突概率高、重试成本高(抢购失败意味着用户流失)→ 悲观锁。不是哪个”更好”,是哪个适合你的场景。


二、幂等键必须是数据库约束

代码检查为什么不够

用户点击”发布文章”按钮,网络卡了,没看到响应。又点了一次。

如果后端没有处理,两篇内容完全相同的文章被创建。代码里检查”是否已存在”挡不住这个场景——两个请求可能同时通过检查、同时写入。

幂等键必须是数据库唯一约束

async function createPost(input, idempotencyKey: string) {
// 先检查
const existing = await db.post.findUnique({ where: { idempotencyKey } });
if (existing) return existing;
try {
return await db.post.create({ data: { ...input, idempotencyKey } });
} catch (error) {
if (error.code === "P2002") {
// 唯一约束冲突
// 并发场景下另一个请求已经创建了——返回它的结果
return db.post.findUnique({ where: { idempotencyKey } });
}
throw error;
}
}

正常路径不是”检查→创建”,而是”检查→创建→捕获冲突→返回已有结果”。 唯一约束是最后防线——代码检查可能被并发绕过,数据库约束不会。


三、唯一约束竞争不是错误,是正常分支

点赞是典型案例:

model PostLike {
userId String
postId String
@@id([userId, postId]) // 复合主键 = 数据库保证唯一
}

用户快速双击点赞按钮,两个请求几乎同时到达:

请求 A:SELECT → 没找到 → INSERT → 成功
请求 B:SELECT → 没找到(A 还没写入)→ INSERT → 唯一约束冲突!

请求 B 的 INSERT 触发了 P2002。这不是系统错误——这是正常的并发竞争。你的代码应该捕获这个异常,返回”已点赞”状态:

try {
await prisma.postLike.create({ data: { userId, postId } });
} catch (error) {
if (error.code === "P2002") {
return { liked: true }; // 正常分支,不是报错
}
throw error;
}

关键认知P2002 不是 bug,是你依赖数据库约束处理并发竞争的正常结果。不要返回 500 错误——返回和”已点赞”一样的业务状态。


四、死锁:两个事务互相等对方的锁

怎么发生的

事务 A:UPDATE posts SET ... WHERE id = 1; → 锁住 post 1
UPDATE posts SET ... WHERE id = 2; → 等待 post 2(被 B 锁住)
事务 B:UPDATE posts SET ... WHERE id = 2; → 锁住 post 2
UPDATE posts SET ... WHERE id = 1; → 等待 post 1(被 A 锁住)

数据库检测到死锁后,选择其中一个事务回滚。你的代码需要能重试:

async function transfer(from: string, to: string, amount: number, attempt = 1) {
try {
return await prisma.$transaction(async (tx) => {
await tx.account.update({
where: { id: from },
data: { balance: { decrement: amount } }
});
await tx.account.update({
where: { id: to },
data: { balance: { increment: amount } }
});
});
} catch (error) {
if (isDeadlockError(error) && attempt < 3) {
await new Promise((r) => setTimeout(r, 100 * attempt)); // 递增等待
return transfer(from, to, amount, attempt + 1);
}
throw error;
}
}

减少死锁:事务中按相同的顺序访问资源。 如果所有事务都是”先锁账户 A,再锁账户 B”,就不会出现交叉等待。这不是技术限制,是编码约定——但比任何锁策略都有效。

事务要短

事务里持有数据库连接和锁的时间越长,并发能力越差。事务中不应该做的事:调外部 API、上传文件、等待用户输入。


小结

  • Lost Update 发生在”读-改-写”之间有其他人抢先提交——乐观锁靠版本号检测冲突,悲观锁靠 FOR UPDATE 阻止并发
  • 幂等键必须放在数据库约束里——代码检查挡不住两个请求同时通过检查
  • 唯一约束冲突(P2002)不是 500 错误——是正常的并发竞争分支,应该返回正常的业务状态
  • 死锁不可避免——但按相同顺序访问资源可以大幅减少,代码必须能重试

这个系列到此结束。从架构决策到工程基础设施,从 API 设计到数据建模,从查询优化到认证安全,从事务并发到幂等设计——七篇文章覆盖了后端系统设计中最核心的判断和取舍。它们不绑定任何具体框架或项目,换一个技术栈,这些决策的逻辑依然成立。

事务、并发与幂等(六)
/posts/2026-7-24/6-事务并发与数据一致性/
Author
Atopos
Published at
2026-07-24
License
CC BY-NC-SA 4.0

Some information may be outdated