bigywater.tech
分布式事务 · 14

幂等键:去重表的设计

“重复提交不重复执行”靠幂等键和去重表。可是先查后插能不能防住并发?去重记录和业务要放在哪里才不丢不重?请求还在处理时重复到达怎么办?去重表要保留多久?这篇用真实的 MySQL 把每个问题量出来。

1590 vs 200
200 个 key 各 8 个并发重复请求:先查后插与先插唯一键的业务执行次数
201 / 210 / 0
去重记录先提交丢的 key 数 / 业务先提交重复的 key 数 / 同一个事务里的错误数(1000 个 key、20% 失败)
13.5 MiB
去重表存 30 万个 key 占用的空间(每行约 47 字节)

唯一键要在业务之前占位

实测 · 本机执行输出
  写法                         | 收到的请求 | 业务实际执行次数 | 被执行了多次的 key | 多执行的次数 | 用时   
  ---------------------------+-------+----------+-------------+--------+------
  先 SELECT 看有没有,没有再做业务并插记录   | 1600  | 1590     | 200         | 1390   | 3.9 s
  先 INSERT 唯一键,成功才做业务(同一个事务) | 1600  | 200      | 0           | 0      | 3.8 s
  “先查后插”在两个请求都查到“没有”之后各自去做业务,唯一键只管得住记录,管不住业务;先插唯一键的那个请求才有资格执行业务

先查后插时,两个并发请求都查到“没有”就各自执行业务;先插唯一键,抢到的那个请求才能做业务。

同一个事务

实测 · 本机执行输出
  写法             | key 数 | 恰好执行 1 次 | 一次都没执行(丢) | 执行了多次(重复)
  ---------------+-------+----------+-----------+----------
  去重记录先单独提交,再做业务 | 1000  | 799      | 201       | 0        
  业务先单独提交,再记去重   | 1000  | 790      | 0         | 210      
  去重记录和业务在同一个事务里 | 1000  | 1000     | 0         | 0        
  去重记录先提交:业务失败后重试会被当成重复拒绝,这笔业务永远丢了;业务先提交:记去重失败后重试会再执行一遍;同一个事务里两者一起成功或一起回滚

去重记录和业务分开提交,崩溃时要么业务丢失(记录先提交),要么重复执行(业务先提交);放在同一个本地事务里才既不丢也不重复。

状态型幂等键

实测 · 本机执行输出
  请求 1(到达于 0 ms,重复请求遇到“处理中”就返回 409):第一次执行,结果 扣款成功#s1,耗时 307 ms
  请求 2(到达于 100 ms,重复请求遇到“处理中”就返回 409):409 正在处理中,结果 None,耗时 1 ms
  请求 3(到达于 500 ms,重复请求遇到“处理中”就返回 409):返回已保存的结果,结果 扣款成功#s1,耗时 1 ms
  请求 1(到达于 0 ms,重复请求等待处理完成再返回):第一次执行,结果 扣款成功#s2,耗时 307 ms
  请求 2(到达于 100 ms,重复请求等待处理完成再返回):返回已保存的结果,结果 扣款成功#s2,耗时 210 ms
  payments 里这两个 key 的执行次数: {'s1': 1, 's2': 1}
  如果第一个请求在 PROCESSING 时进程被 kill -9,这行 PROCESSING 会一直留着;租约(locked_until)过期之后才允许别的请求接管:
   0 秒时 s3:status=PROCESSING,租约未过期=True → 重复请求只能等
   1.2 秒后:租约过期,接管用 UPDATE ... WHERE locked_until < now() 抢到的行数 = 1(抢到 1 才能重新执行;两个请求同时抢,只有一个能抢到)

PROCESSING 行占位、DONE 行保存结果:处理中遇到重复请求返回 409 或等待,处理完直接返回保存的结果;进程崩溃留下的 PROCESSING 靠租约过期后抢占。

去重表的成本

实测 · 本机执行输出
  单行插入(各 3000 次的中位数):只插 payments 97 µs;只插 dedup 96 µs
  dedup 里 30 万个 key(形如 order-3-000123):13.5 MiB,平均每行约 47 字节(主键聚簇索引含 created 列);其中 15 万个是 120 分钟前的、15 万个是 1 分钟前的
  按保留期 60 分钟分批清理(每批 5000 行):删掉 150000 行,用时 1.40 s;清理期间同时插入的 p99:1280 µs(没清理时 p99 1561 µs,中位数 169 µs → 235 µs)。单次测量、样本只有两三千次,同一个实验重跑时 p99 的高低方向会变,所以只能说“清理的 1 秒多里写入照常在进行”,不能据此断言清理对写入延迟有多大影响
  保留期(60 分钟)比客户端的最长重试窗口(假设 2 小时)短:2 小时前处理过的 late-1 被清掉了,它的重试到达时 INSERT 成功 —— 被当成新请求,业务会再执行一次

插入与普通插入几乎一样,每行约 47 字节;保留期必须长于客户端的最长重试窗口,否则被清掉的 key 的重试会被当成新请求。

三种占位写法

实测 · 本机执行输出
  写法                                            | 请求/秒(3 轮中位数) | 3 轮的范围        | 业务执行次数 | 去重表里不同的 key | payments 行数
  ----------------------------------------------+--------------+---------------+--------+-------------+------------
  INSERT IGNORE,看 rowcount                      | 25750        | 20975 ~ 27276 | 2499   | 2499        | 2499       
  INSERT ... ON DUPLICATE KEY UPDATE,看 rowcount | 23617        | 22259 ~ 25552 | 2499   | 2499        | 2499       
  直接 INSERT,捕获唯一键异常                             | 24481        | 24209 ~ 25731 | 2499   | 2499        | 2499       
  三种写法都是“只有抢到唯一键的那个请求才执行业务”,执行次数都等于不同 key 的个数;吞吐的差别落在各轮的波动范围附近,不是明确的高低

INSERT IGNORE、ON DUPLICATE KEY UPDATE、捕获唯一键异常都是正确的,吞吐在波动范围内没有明确高低。

分步演示

幂等键
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / MySQL 26.7.0 与 PostgreSQL 16.15(Homebrew,每个实验自己 initdb、自己起实例,端口 9150 以上,只监听 127.0.0.1)/ Python 3.14.8 + PyMySQL 1.2.3 + psycopg 3.3.6 / Redis 8.10.2 / Kafka 4.3.1 / RabbitMQ 4.3.6 / etcd 3.7.2(按各篇注明用到哪些);不连任何外部服务,不碰本机已有的数据库实例;故障用 kill -9 进程、让连接断开或在代码里的指定位置抛异常来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。本篇用一个 MySQL 26.7.0 实例(payments、dedup、idem 三张表),客户端 PyMySQL,并发用线程 + 屏障;第二节的 20% 失败是在代码里用固定种子的随机数抛错;吞吐是 3 轮交替的中位数,每轮 4 个线程各 5000 次
    • 没有验证:分布式环境下多个服务实例共用去重表的跨库 / 跨机房情形;用 Redis 做去重(SETNX)与 MySQL 唯一键的差别;幂等键的生成方式(客户端 UUID、业务单号、消息 offset)的比较;去重表分区 / 按时间分表来清理;租约时长怎么取;保留期的取值只是示例,没有对应真实业务的重试窗口
    京ICP备2026067502号-1京公网安备11010502063487号