返回博客

快速连点 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 才是真正的幂等保障——两层都要做。