分布式 ID 与接口幂等设计实战指南
高并发系统两大基础能力:分布式环境下怎么生成不重复的 ID?重复请求来了怎么保证只生效一次?分布式 ID 与接口幂等是 CSDN 架构板块最热门的话题之一。本文从 ID 需求讲起,覆盖常见分布式 ID 方案(UUID/雪花/号段)、接口幂等的原理、方案对比、实战落地(Redis/数据库/Token),全程带可运行代码。
一、为什么需要分布式 ID
1.1 单机自增的问题
单库自增主键没问题,分库分表后:
– 两个库各自从 1 开始 → 主键冲突
– 订单号要全局唯一 → 需要分布式 ID
1.2 分布式 ID 的需求
| 全局唯一 | 所有机器生成的 ID 不重复 |
| 趋势递增 | 对索引友好(MySQL 主键) |
| 高性能 | 高并发下生成快 |
| 高可用 | 生成服务不能挂 |
| 安全性(可选) | 不暴露订单量(不连续) |
二、常见方案对比
| UUID | 128 位随机 | 绝对唯一、无中心 | 无序、太长(索引差) |
| 数据库自增 | 独立发号表 | 简单有序 | 单点、性能低 |
| 号段模式 | 批量取号段 | 高性能 | 需独立服务 |
| 雪花算法 | 时间戳+机器+序列 | 高性能、趋势递增 | 依赖时钟 |
| Redis 自增 | INCR | 简单 | 依赖 Redis |
2.1 UUID(简单但别乱用)
// 生成
String id = UUID.randomUUID().toString();
// 形如:550e8400-e29b-41d4-a716-446655440000
// 问题:36 位字符串、无序
// 用做 MySQL 主键 → 页分裂严重、索引大
// 适用:文件名、消息 ID、临时标识(不当数据库主键)
2.2 雪花算法(最流行)
Snowflake 64 位结构:
| 1 bit 符号位 | 41 bit 时间戳(毫秒) | 10 bit 机器 ID | 12 bit 序列号 |
– 时间戳:69 年内不重复
– 机器 ID:1024 台
– 序列号:每毫秒 4096 个
– 特点:趋势递增、高性能、无中心
// 雪花算法实现(核心逻辑)
public class SnowflakeIdWorker {
// 各部分位数
private final long workerIdBits = 10L;
private final long sequenceBits = 12L;
private final long workerId; // 机器 ID
private long sequence = 0L; // 序列号
private long lastTimestamp = –1L;
// 时间纪元(可自定义起点)
private final long epoch = 1700000000000L;
public SnowflakeIdWorker(long workerId) {
this.workerId = workerId;
}
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
// 时钟回拨保护
if (timestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨,拒绝生成");
}
// 同一毫秒内:序列号递增
if (lastTimestamp == timestamp) {
sequence = (sequence + 1) & 4095; // 4096 个
if (sequence == 0) {
// 序列号用尽,等下一毫秒
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0; // 新毫秒,序列重置
}
lastTimestamp = timestamp;
// 组装 64 位 ID
return ((timestamp – epoch) << 22)
| (workerId << 12)
| sequence;
}
private long tilNextMillis(long lastTimestamp) {
long ts = System.currentTimeMillis();
while (ts <= lastTimestamp) {
ts = System.currentTimeMillis();
}
return ts;
}
}
生产建议:不用自己写——用现成的(MyBatis-Plus 内置、Hutool IdUtil、美团 Leaf)。
2.3 号段模式(Leaf-segment)
原理:每次从数据库取一个号段(如 1-1000),内存里慢慢发
用完了再取下一个号段(1001-2000)
– 性能:一次取号段 = 发 1000 个 ID(DB 压力小)
– 高可用:多实例各自拿不同号段
– 趋势递增:对数据库索引友好
— 发号表
CREATE TABLE id_alloc (
biz_tag VARCHAR(50) PRIMARY KEY, — 业务类型
max_id BIGINT NOT NULL, — 已分配到的最大 ID
step INT NOT NULL DEFAULT 1000 — 号段步长
);
// 取号段
public IdSegment nextSegment(String bizTag) {
// 1. 更新 max_id += step(CAS/锁)
int affected = idAllocMapper.incrementMaxId(bizTag, step);
if (affected == 0) {
throw new RuntimeException("号段不存在");
}
// 2. 返回新号段 [max_id-step+1, max_id]
IdSegment seg = idAllocMapper.selectByTag(bizTag);
return new IdSegment(seg.getMaxId() – seg.getStep() + 1,
seg.getMaxId());
}
选型建议:
| 简单单体 | 数据库自增 / UUID |
| 分库分表主键 | 雪花 / 号段 |
| 高并发订单号 | 号段(Leaf)/ 雪花 |
| 无中心诉求 | 雪花(不依赖 DB/Redis) |
三、什么是接口幂等
3.1 问题场景
用户点了两次"提交订单"→ 生成了两笔订单
支付回调重试了 3 次 → 入账 3 次
MQ 重复消费 → 短信发了 2 遍
本质:同一个请求执行多次,结果只生效一次 = 幂等
3.2 哪些接口需要幂等
| 创建订单 | 重复提交 → 重复订单 |
| 支付回调 | 网络重试 → 重复入账 |
| 扣减库存 | 重复扣 → 超卖 |
| 发短信/邮件 | 重复发 → 骚扰 |
| 转账 | 重复转 → 资金错误 |
3.3 不幂等的本质
– 插入型操作:每次插入都新增一条 → 不幂等
– 更新型操作:UPDATE 固定值 → 幂等(天然)
– 更新型操作:UPDATE +1(自增)→ 不幂等(每次都加)
四、幂等方案对比
| 唯一索引 | DB 约束唯一键 | 最可靠 | 需设计唯一键 |
| Token 机制 | 先取 token 再提交 | 简单 | 多一次请求 |
| 状态机 | 校验当前状态 | 业务耦合 | 有状态才行 |
| 分布式锁 | 锁住操作 | 通用 | 性能开销 |
| 去重表 | 记录处理过的 ID | 可靠 | 一张表 |
| Redis 去重 | SETNX 标记 | 快 | 依赖 Redis |
选型建议:
写库操作 → 唯一索引(DB 兜底,最可靠)
无状态接口 → Token 机制
状态流转 → 状态机校验
通用兜底 → Redis SETNX + 业务校验
五、方案一:唯一索引(最可靠)
— 订单表加唯一键:防止同一用户同一笔请求重复下单
CREATE TABLE `order` (
id BIGINT PRIMARY KEY,
order_no VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
request_id VARCHAR(64) NOT NULL, — 幂等键(前端生成/请求头带)
...
UNIQUE KEY uk_request (user_id, request_id) — 唯一约束
);
@Transactional
public Order createOrder(OrderDTO dto) {
Order order = buildOrder(dto);
try {
orderMapper.insert(order);
return order;
} catch (DuplicateKeyException e) {
// 唯一键冲突 = 重复请求 → 返回已存在的订单
return orderMapper.selectByRequestId(
dto.getUserId(), dto.getRequestId());
}
}
要点:
- 幂等键:前端生成 requestId(UUID),请求时带上;
- 数据库唯一约束兜底(并发下也安全);
- 捕获 DuplicateKeyException 返回旧结果——重复请求返回首次的结果。
六、方案二:Token 机制(提交前取令牌)
// 流程:
// 1. 打开页面 → GET /token 拿一个 token(存 Redis)
// 2. 提交时带 token → 校验并删除(SETNX + 删)
// 3. token 已被用过 → 拒绝(重复提交)
@RestController
public class OrderController {
@Autowired
private StringRedisTemplate redis;
// 1. 取 token
@GetMapping("/order/token")
public Result getToken() {
String token = UUID.randomUUID().toString();
redis.opsForValue().set("token:order:" + token,
"1", 5, TimeUnit.MINUTES);
return Result.ok(token);
}
// 2. 提交订单(带 token)
@PostMapping("/order")
public Result createOrder(@RequestHeader("X-Idempotent-Token")
String token,
@RequestBody OrderDTO dto) {
// 校验 token:存在则删除(原子),不存在 = 重复
Boolean first = redis.opsForValue()
.setIfAbsent("used:order:" + token, "1", 5, TimeUnit.MINUTES);
if (!Boolean.TRUE.equals(first)) {
return Result.error("请勿重复提交");
}
// 或者:先 GET 再 DELETE(用 Lua 保证原子)
// 这里用 SETNX 变体,简单可靠
return Result.ok(orderService.create(dto));
}
}
注意:
- token 用后即焚(删除/标记),防重放;
- SETNX 失败说明已用过 → 拒绝。
七、方案三:状态机校验(有状态操作)
// 场景:订单状态流转(创建 → 已支付 → 已发货 → 已完成)
// 重复支付回调:已支付状态再收到回调 → 直接忽略
@Transactional
public void payCallback(Long orderId, BigDecimal amount) {
Order order = orderMapper.selectById(orderId);
// 状态校验:只有"待支付"才能支付
if (!OrderStatus.WAIT_PAY.equals(order.getStatus())) {
// 已支付/已关闭 → 重复回调,直接忽略(幂等)
log.info("订单 {} 状态已是 {},忽略重复回调",
orderId, order.getStatus());
return;
}
// 条件更新(CAS):status=WAIT_PAY 才更新
int affected = orderMapper.updateStatusIf(orderId,
OrderStatus.WAIT_PAY, OrderStatus.PAID);
if (affected == 0) {
// 状态已变(并发处理过)→ 幂等成功
return;
}
// 支付成功业务:入账、发通知
}
— 条件更新 SQL(状态机 + CAS)
UPDATE `order`
SET status = #{newStatus}, pay_time = NOW()
WHERE id = #{orderId} AND status = #{expectStatus};
— 影响行数 = 0 → 状态已变 → 重复请求
要点:WHERE 带期望状态,更新成功才算第一次——并发下也只有一个请求能成功。
八、方案四:Redis 去重(通用兜底)
// 通用幂等注解 + Redis 实现
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
String key(); // 幂等键表达式(SpEL)
int expireSeconds() default 60; // 有效期
}
@Component
public class IdempotentAspect {
@Autowired
private StringRedisTemplate redis;
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint pjp,
Idempotent idempotent) throws Throwable {
// 1. 解析幂等键(SpEL 从参数里取)
String key = parseKey(pjp, idempotent.key());
String fullKey = "idem:" + key;
// 2. SETNX 标记(第一次成功,重复失败)
Boolean first = redis.opsForValue()
.setIfAbsent(fullKey, "1",
idempotent.expireSeconds(), TimeUnit.SECONDS);
if (!Boolean.TRUE.equals(first)) {
throw new BusinessException("重复请求,请勿提交");
}
try {
return pjp.proceed();
} catch (Exception e) {
// 业务失败:删标记(允许重试)
redis.delete(fullKey);
throw e;
}
}
}
// 使用
@PostMapping("/order")
@Idempotent(key = "#dto.userId + ':' + #dto.requestId",
expireSeconds = 120)
public Result createOrder(@RequestBody OrderDTO dto) {
return Result.ok(orderService.create(dto));
}
注意:Redis 方案适合"防高频重复",最终可靠性靠数据库唯一索引兜底——Redis 和 DB 双保险。
九、实战:下单防重完整方案
// 组合方案:Token(防连点)+ 唯一索引(DB 兜底)+ 状态机
@RestController
public class OrderController {
@PostMapping("/order")
public Result createOrder(@RequestHeader(value =
"X-Request-Id", required = false)
String requestId,
@RequestBody OrderDTO dto) {
// 1. 幂等键缺失则生成
if (requestId == null || requestId.isBlank()) {
requestId = UUID.randomUUID().toString();
}
// 2. Redis 快速去重(挡 99% 重复)
Boolean first = redis.opsForValue().setIfAbsent(
"order:idem:" + dto.getUserId() + ":" + requestId,
"1", 60, TimeUnit.SECONDS);
if (!Boolean.TRUE.equals(first)) {
return Result.error("请勿重复提交");
}
try {
// 3. 创建订单(DB 唯一索引兜底)
Order order = orderService.create(dto, requestId);
return Result.ok(order);
} catch (DuplicateKeyException e) {
// 4. 唯一索引拦截 = 并发重复 → 返回已有订单
return Result.ok(orderService.findByRequestId(
dto.getUserId(), requestId));
}
}
}
三层防线:
Redis SETNX → 挡连点(快)
DB 唯一索引 → 挡并发(稳)
状态机校验 → 挡状态重复(业务准)
十、常见坑速查
| 重复订单 | 没幂等键 | 唯一索引 + requestId |
| Redis 标记失效 | 过期太短 | 业务时长+1 分钟 |
| 业务失败不能重试 | 标记没删 | 异常时删标记 |
| 并发都过了 | 只查不插 | 唯一索引兜底 |
| 幂等键生成不可靠 | 前端生成重复 | 服务端校验+生成 |
| 回调重试成功 | 状态没校验 | 状态机 CAS |
| 分布式锁误删 | 锁无标识 | token 校验 |
| 只防单机 | 用本地锁 | 分布式锁/DB 约束 |
本章小结
分布式 ID 与幂等的核心心法:ID 要"全局唯一 + 趋势递增 + 高性能"(雪花/号段最优),幂等要"先防后兜"(Redis 挡前面、DB 唯一索引兜底、状态机管业务)。三句话总结:主键用雪花或号段、重复请求用幂等键、数据库唯一约束是最后防线——ID 和幂等设计好了,高并发系统的数据一致性问题就解决了一大半。
下一篇讲 Elasticsearch 进阶——搜索与分析引擎的进阶之路。
(全文完)
网硕互联帮助中心




评论前必须登录!
注册