“重复提交不重复执行”靠幂等键和去重表。可是先查后插能不能防住并发?去重记录和业务要放在哪里才不丢不重?请求还在处理时重复到达怎么办?去重表要保留多久?这篇用真实的 MySQL 把每个问题量出来。
写法 | 收到的请求 | 业务实际执行次数 | 被执行了多次的 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、捕获唯一键异常都是正确的,吞吐在波动范围内没有明确高低。