3、唯一键方案:全局唯一ID生成策略与去重表设计
聊到幂等性,我个人最常用的手段就是「唯一键去重」。说白了,就是在数据库里建一张去重表,利用数据库的唯一索引来挡住重复请求。这个方案简单、可靠,而且落地成本很低。
但这里有个关键问题——唯一键从哪来?你想想看,如果唯一键本身会重复,那去重表就形同虚设了。所以今天咱们重点聊聊全局唯一ID的生成策略,以及怎么把去重表设计得既高效又干净。
3.1 全局唯一ID的三种主流生成方式
我在项目中见过不少团队在ID生成上踩坑。有的直接用数据库自增ID做幂等键,结果分库分表后全乱了。有的用UUID,性能又扛不住。下面我把三种主流方案掰开揉碎讲清楚。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| UUID | 生成简单,本地生成无网络开销 | 无序、太长(36字符)、索引性能差 | 小规模系统、非核心链路 |
| 雪花算法 | 趋势递增、高性能、不依赖DB | 依赖时钟、workerId需分配 | 分布式系统、高并发场景 |
| 数据库自增 | 实现简单、天然有序 | 单点瓶颈、分片后难保证全局唯一 | 单库单表、低并发场景 |
3.1.1 UUID:简单但别滥用
UUID生成确实方便,Java里一句 UUID.randomUUID().toString() 就搞定了。但我建议你别在核心业务上用它。为什么?
- 太长:36个字符,存到数据库里占空间,做索引时B+树的层数会增加,查询性能会下降。
- 完全无序:插入时随机写,页分裂频繁,InnoDB的聚簇索引会很难受。
- 有碰撞概率:虽然极低,但在万亿级调用下不是零风险。
我曾经在一个支付系统中看到同事直接用UUID做幂等键,结果去重表才500万行数据,插入性能就掉到了每秒不到2000。后来换成雪花算法,直接飙到每秒5万+。嗯,UUID真的只适合做「临时标识」,别用在核心链路上。
3.1.2 雪花算法:分布式场景的首选
雪花算法(Snowflake)是Twitter开源的ID生成方案。它的结构很简单:
| 1 bit 符号位 | 41 bit 时间戳 | 10 bit 工作机器ID | 12 bit 序列号 |
算下来,同一毫秒内一台机器能生成4096个ID。对于绝大多数业务场景,这个吞吐量完全够用。
我个人习惯用雪花算法做幂等键,原因有三:
- 趋势递增:对MySQL的聚簇索引友好,插入性能稳定。
- 本地生成:不依赖Redis或数据库,延迟极低。
- 全局唯一:只要workerId不冲突,ID就不会重复。
避坑指南:雪花算法依赖系统时钟。如果服务器时钟发生回拨,就可能生成重复ID。我建议在实现时加一个「时钟回拨检测」:如果当前时间小于上次生成ID的时间,就等待时钟追上,或者直接抛异常。别问我怎么知道的……
3.1.3 数据库自增:简单但有限制
数据库自增ID是最朴素的方式。单库单表时很好用,但一旦分库分表,问题就来了——每个库的自增ID会重复。虽然可以通过设置不同的步长来解决,但扩展性很差。
举个例子:
-- 库1:自增步长2,起始值1 → 1,3,5,7...
-- 库2:自增步长2,起始值2 → 2,4,6,8...
这样确实能保证全局唯一,但新增分库时需要重新规划步长,非常不灵活。所以我只在单库场景下用这个方案。
3.2 唯一键去重表设计
有了全局唯一ID,接下来就是怎么用这张去重表了。我把它拆成三个核心点:表结构、插入策略、过期清理。
3.2.1 表结构设计
去重表不需要太多字段,够用就行。我一般这样设计:
CREATE TABLE `idempotent_record` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增主键',
`biz_id` VARCHAR(64) NOT NULL COMMENT '业务唯一键',
`biz_type` VARCHAR(32) NOT NULL COMMENT '业务类型',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '处理状态:0-处理中,1-成功,2-失败',
`result` TEXT COMMENT '处理结果(JSON)',
`expire_time` DATETIME NOT NULL COMMENT '过期时间',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_biz` (`biz_type`, `biz_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='幂等去重表';
这里有几个设计要点:
- 联合唯一索引:
(biz_type, biz_id)是核心,保证同一业务类型下的请求不会重复处理。 - 状态字段:记录处理状态,方便做补偿和重试。
- 过期时间:防止去重表无限膨胀,定期清理历史数据。
3.2.2 插入策略:先查后插 vs 直接插
很多新手会先查一次数据库,判断biz_id是否存在,再决定是否插入。这种做法在高并发下会有竞态问题——两个请求同时查到不存在,然后都去插入,结果一个成功一个报错。
我建议直接用 INSERT ... ON DUPLICATE KEY UPDATE 或者 INSERT IGNORE:
-- 尝试插入,如果唯一键冲突则忽略
INSERT IGNORE INTO idempotent_record (biz_id, biz_type, status, expire_time)
VALUES ('order_20231001_001', 'order_create', 0, '2023-10-02 00:00:00');
-- 检查受影响行数
-- 1:插入成功,第一次请求
-- 0:插入失败,重复请求
核心逻辑:利用数据库的唯一索引做「乐观锁」。插入成功就是首次请求,插入失败就是重复请求。不需要显式加锁,性能很高。
3.2.3 过期清理机制
去重表不能无限增长。我一般设置一个TTL(比如24小时),超过这个时间的记录就可以删掉或归档。
清理策略有两种:
- 定时任务批量删除:每小时跑一次,删除
expire_time < NOW()的记录。简单但要注意别在高峰期跑。 - 分区表按时间分区:按天或按月分区,直接DROP掉过期分区。效率最高,但需要提前规划分区策略。
我个人更倾向分区表方案。你想想看,DELETE操作会产生大量binlog,而且碎片化严重。DROP分区是元数据操作,秒级完成,对业务无影响。
3.3 整体流程:一张图说清楚
下面这张图展示了唯一键去重的完整流程。从请求进来,到生成唯一ID,再到去重表判断,最后返回结果。
流程其实不复杂。关键就两步:生成唯一ID,然后利用数据库的唯一索引做去重判断。剩下的就是业务逻辑和结果返回。
3.4 避坑指南与最佳实践
最后,我把自己踩过的坑和总结的经验列出来,希望能帮你少走弯路。
我曾经在一个高并发订单系统中,直接用业务订单号做唯一键。结果上游系统重试时换了订单号,导致重复下单。后来我改成「业务前缀+业务ID+时间戳」的组合,才彻底解决。
- 唯一键要包含业务语义:比如
order_create_user_123_20231001_001,方便排查问题。 - 去重表和业务表放在同一个数据库:利用本地事务保证原子性,避免分布式事务的麻烦。
- 设置合理的过期时间:太短会导致重复请求漏过,太长会浪费存储。一般建议24-48小时。
- 监控去重表的命中率:如果命中率突然升高,说明上游重试逻辑有问题,需要及时告警。
小技巧:如果业务量特别大,可以把去重表按biz_type做分表。比如order_create的去重记录放在一张表,payment的回调放在另一张表。这样既能分散写入压力,也方便单独清理。