云计算百科
云计算领域专业知识百科平台

分布式ID与接口幂等设计实战指南

分布式 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 进阶——搜索与分析引擎的进阶之路。

(全文完)

赞(0)
未经允许不得转载:网硕互联帮助中心 » 分布式ID与接口幂等设计实战指南
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!