快速连点 Checkbox 触发中间表竞态:前端防抖 + 后端 INSERT IGNORE 双保险
后台管理系统里有一个「用户角色」编辑面板:一排 Checkbox,每勾一下立即保存。上线之后,偶尔有用户反馈「改角色有时候会报错」。复现路径很隐蔽——必须快速连续勾选多个选项,才能稳定触发。
现象
- 用户快速勾选 3 个角色 Checkbox,有 1 个返回 HTTP 500。
- 后端日志:
ER_DUP_ENTRY: Duplicate entry 'user-1-role-2' for key 'user_roles.PRIMARY' - 打开浏览器 Network 面板:3 个 PUT 请求几乎同时发出,时间戳几乎重叠。
根因
后端的「更新角色」逻辑是经典的「先删后插」:
// 后端 PUT /api/users/:id
await pool.execute('DELETE FROM user_roles WHERE user_id=?', [userId])
for (const roleId of body.roleIds) {
await pool.execute(
'INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)',
[userId, roleId]
)
}两个请求并发时,时序可能是这样的:
请求 A: DELETE(u,*) → INSERT(u,r1) → INSERT(u,r2)
请求 B: DELETE(u,*) → INSERT(u,r1) ← 此时 A 的 (u,r1) 已写入 → DUP_ENTRY!
请求 B 的 DELETE 发生在请求 A 的 INSERT 之间,后续 INSERT 踢到了 A 刚写进去的行,直接炸主键冲突。
坑:只在前端加防抖以为就够了
前端加了 300ms debounce,确实大幅降低了并发请求数——但不能根治。 当第一个请求处理较慢(网络延迟 / 服务器繁忙),第二个请求在前一个尚未完成时就触发了, debounce 窗口已经关闭,照样产生竞态。生产环境的偶发 500 往往就是这个场景。
正解
两层防护缺一不可:
前端:按用户维度 debounce,不是全局防抖
const saveTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
const toggleRole = useCallback((userId: string, roleId: string) => {
setUsers((prev) => {
const updated = prev.map((u) =>
u.id === userId
? { ...u, roleIds: u.roleIds.includes(roleId)
? u.roleIds.filter((r) => r !== roleId)
: [...u.roleIds, roleId] }
: u
)
// 每个用户独立计时,不同用户间互不干扰
clearTimeout(saveTimers.current[userId])
saveTimers.current[userId] = setTimeout(() => {
const user = updated.find((u) => u.id === userId)
if (user) saveUser(user)
}, 300)
return updated
})
}, [saveUser])后端:INSERT IGNORE 让写操作幂等
await pool.execute('DELETE FROM user_roles WHERE user_id=?', [userId])
for (const roleId of body.roleIds) {
await pool.execute(
'INSERT IGNORE INTO user_roles (user_id, role_id) VALUES (?, ?)',
[userId, roleId]
)
}如果是大批量插入,可以合并为单条语句减少往返:
INSERT INTO user_roles (user_id, role_id) VALUES (?, ?), (?, ?), ...
ON DUPLICATE KEY UPDATE user_id = user_id
-- 命中重复键时什么都不做,纯粹幂等正解:前端 debounce 降频 + 后端 INSERT IGNORE 兜底
前端 300ms per-user debounce 处理常见的快速连点;
后端 INSERT IGNORE(或 ON DUPLICATE KEY UPDATE noop)让并发写入在语义上是幂等的。
两层组合——前者降低发生概率,后者保证即使发生也能优雅降级,不返回 500。
一句话外卖
任何「先删后插」的中间表写操作,前端 debounce 只是降频,后端 INSERT IGNORE / ON DUPLICATE KEY UPDATE 才是真正的幂等保障——两层都要做。