这是整个系列最有含金量的一篇。
不是因为事务难——用 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 设计到数据建模,从查询优化到认证安全,从事务并发到幂等设计——七篇文章覆盖了后端系统设计中最核心的判断和取舍。它们不绑定任何具体框架或项目,换一个技术栈,这些决策的逻辑依然成立。
Some information may be outdated