我如何修复实时座位预订系统中的竞争条件(为此失眠)
发布: (2026年3月29日 GMT+8 08:28)
3 分钟阅读
原文: Dev.to
Source: Dev.to
问题
两个用户,一个座位——一片混乱。
用户 A 和用户 B 在几乎同一毫秒查询座位,看到它是可用的,然后继续预订。数据库被更新了两次,导致同一个座位出现了两个拥有者。
失败的尝试
简单的读后写检查
我首先尝试了一个直接的 if 检查:读取座位状态,然后写入新的预订。由于读后写的序列 不是原子操作,在任何真实的并发环境下都会立即失效。两次操作之间的微小窗口正是竞争条件产生的地方。
客户端标志
随后我添加了一个客户端标志来表示锁定。这同样失败——Socket.io 事件可能会乱序到达,UI 状态并不是可靠的真相来源。
解决方案
将读取和写入合并为单个原子 MongoDB 操作,并配合 Socket.io 广播,使每个已连接的客户端能够即时看到锁定。
// reserveSeat.js
async function reserveSeat(tripId, seatNo, userId) {
// Atomically find the seat if it is still free and mark it as taken
const seat = await Seats.findOneAndUpdate(
{ tripId, seatNo, taken: false }, // query condition (the guard)
{ $set: { taken: true, userId } }, // update (atomic at document level)
{ returnDocument: 'after' } // return the updated document
);
if (!seat) {
throw new Error("Seat already taken!");
}
// Broadcast the lock to all travelers
io.emit('seatLocked', {
seatNo,
userId,
message: `Seat ${seatNo} secured for User ${userId}`
});
}关键步骤
- 查询条件即为守卫——仅匹配仍然空闲的座位。
findOneAndUpdate在文档层面是原子的——读取和写入在一次操作中完成。- 空值检查是你的错误边界——如果没有返回文档,说明座位已经被占用。
- Socket.io 立即广播锁定——所有客户端实时收到更新。
最大的教训
这个修复不仅仅是关于 MongoDB;更是一种思维方式的转变:在并发系统中,永远不要在写入之前依赖读取。把你的守卫逻辑推入数据库操作本身,使其能够保持原子性。
轮到你了
你在自己的项目中遇到过竞争条件吗?欢迎分享你是如何处理的。