前言:
在分布式系统(多台独立的计算机(或节点)通过网络连接起来,协同工作以完成共同任务的系统 )、支付接口或高并发场景中,唯一键(Unique Key)和**幂等键(Idempotency Key)**是保证数据一致性和防止重复操作的核心机制。虽然两者经常配合使用,但它们的定义、作用域和实现方式有所不同。
一.概念
1.唯一键 (Unique Key)
- 定义:数据库层面的约束,确保某列 或某几列的组合值在表中不重复。
- 作用:从存储层防止脏数据产生。例如,一个用户的手机号不能注册两个账号,或者一笔订单号不能对应两条订单记录。
- 特点:它是静态的、结构性的约束。一旦违反,数据库直接报错。
2.幂等键 (Idempotency Key)
- 定义:业务层面的标识符,通常由客户端生成,用于标记"这一次请求"。服务端通过检查该键是否已处理过,来决定是执行新逻辑还是返回之前的结果。
- 作用:防止因网络超时 、用户重复点击 导致的重复提交。即使请求发送多次,对服务器状态的影响只有一次。
- 特点:它是动态的、流程性的控制。它依赖于"先查后插"或"唯一索引+异常捕获"的逻辑。
关键区别:
- 唯一键是手段(数据库怎么存)。
- 幂等性是目的(业务怎么做才安全)。
- 通常我们利用数据库的唯一键 约束来实现业务的幂等性。
二.实现方式
最稳健的实现方式是:数据库唯一索引 + 应用层异常处理/预检查。
方案 A:依赖数据库唯一索引(推荐)
- 客户端每次发起重要写操作时,生成 一个唯一 的 request_id(即幂等键)。
- 服务端将该 request_id作为字段存入业务表,并建立唯一索引。 如果插入成功,说明是第一次请求,继续执行业务逻辑。
- 如果抛出"唯一键冲突"异常,说明该request_id 已存在,直接返回之前处理过的结果(或查询结果),不再执行业务逻辑。
方案 B:Redis 分布式锁 + 状态机(高性能场景)
- 收到请求,用 idempotency_key 去 Redis 设置一个带过期时间的 Key (SETNX)。
- 如果设置失败,说明正在处理或已处理,直接返回"处理中"或"已完成"。
- 如果设置成功,执行业务逻辑,最后更新 DB 并删除/更新Redis 状态。
三.代码示例
假设场景一:创建订单接口。我们需要防止用户重复点击导致创建多个相同金额的订单。
步骤 1:数据库设计 (MySQL)
sql
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
order_no VARCHAR(64) NOT NULL COMMENT '内部生成的订单号',
idempotency_key VARCHAR(64) NOT NULL COMMENT '客户端传来的幂等键,全局唯一',
user_id BIGINT NOT NULL,
amount DECIMAL(10, 2) NOT NULL,
status TINYINT DEFAULT 0 COMMENT '0:待支付, 1:已支付',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- 核心:为幂等键建立唯一索引
UNIQUE KEY uk_idempotency_key (idempotency_key), //重点在这里(****)
INDEX idx_user_id (user_id)
);
步骤 2:Java Spring Boot 实现示例
这里展示如何利用唯一键 冲突来实现幂等
依赖的是数据库的 唯一索引约束(Unique Index) 和 Java 的 异常处理机制。
java
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service
public class OrderService {
private final OrderRepository orderRepository; // 假设这是 MyBatis/JPA Repository
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
/**
* 创建订单(具备幂等性)
* @param userId 用户ID
* @param amount 金额
* @param idempotencyKey 客户端生成的唯一键 (如 UUID)
*/
public OrderResult createOrder(Long userId, BigDecimal amount, String idempotencyKey) {
// 1. 构造订单对象
Order order = new Order();
order.setUserId(userId);
order.setAmount(amount);
order.setIdempotencyKey(idempotencyKey); // 将幂等键存入实体
order.setStatus(0); // 初始状态
try {
// 2. 尝试插入数据库(此处为唯一索引约束)
// 如果 idempotencyKey 不存在,插入成功 -> 正常返回
// 如果 idempotencyKey 已存在,DB 抛出 DuplicateKeyException
int rows = orderRepository.insert(order); //insert会自动发起是否重复校验
if (rows > 0) {
return OrderResult.success("订单创建成功", order.getId());
}
} catch (DuplicateKeyException e) {
// 3. 【核心幂等逻辑】捕获唯一键冲突异常
// 说明这个 idempotencyKey 已经被处理过了
// 选项 A:直接返回之前的结果(需要额外查询一次获取原订单信息)
Order existingOrder = orderRepository.findByIdempotencyKey(idempotencyKey);
if (existingOrder != null) {
return OrderResult.success("订单已存在(幂等命中)", existingOrder.getId());
}
// 选项 B:如果业务允许,也可以直接返回错误码,告诉前端"请勿重复提交"
// throw new BusinessException("请勿重复提交");
}
return OrderResult.fail("系统异常");
}
}
步骤 3:前端/调用方如何生成幂等键?
幂等键必须由调用方**(客户端)**生成,并且在重试时保持不变。
JavaScript 示例 (Axios):
javascript
import { v4 as uuidv4 } from 'uuid';
async function submitOrder() {
// 1. 生成一个唯一的 ID,这次请求的生命周期内保持不变
const idempotencyKey = uuidv4();
try {
await axios.post('/api/orders', {
userId: 1001,
amount: 99.99,
idempotencyKey: idempotencyKey // 传递给后端
});
} catch (error) {
// 2. 如果因为网络超时失败,用户再次点击"提交"按钮时
// 必须复用同一个 idempotencyKey,而不是重新生成一个新的!
// 这样后端才能识别出这是"同一次请求的重试",从而触发幂等逻辑。
console.log("网络错误,请重试。注意:重试时需携带相同的 idempotencyKey");
}
}
假设场景二(不涉及前端客户操作) :后端对后端(Server-to-Server) 或 系统对系统 的集成场景
标准做法:使用上游系统 (定义如下)的命名空间 + 操作类型+ 业务单据号 组合作为幂等键 。
注:在数据流或调用链中: 上游 (Upstream):发起请求、提供数据的一方。就像河流的源头。 下游 (Downstream):接收请求、消费数据的一方。就像河流的入海口。
推荐方案 :基于源单号 的幂等设计
比如sourceOrderNumber ( "659001") 已经是 上游侧生成的唯一 业务标识,那么它本身就是天然的幂等素材。
1. 幂等键的构造规则
不要直接用 659001 做 Key,建议加上命名空间和操作类型,防止未来扩展冲突:
java
IdempotencyKey = "{SystemCode}:{OperationType}:{SourceOrderNo}"
为什么要加前缀?
如果未来 DCOS 还要调 "取消"、"修改"接口,或者有其他系统也传了 659001,纯数字会导致误判。加上 DCOS:CREATE_PPR: 就保证了只有 DCOS 发起的、针对 659001 的创建操作才命中这条幂等记录。
2. 数据库/存储层设计
在 对应服务的数据库中,增加一个字段并建立唯一索引:
sql
ALTER TABLE sourceOrder ADD COLUMN idempotency_key VARCHAR(128);
ALTER TABLE sourceOrder ADD UNIQUE INDEX uk_idem_key (idempotency_key);
3. 后端代码逻辑(伪代码)
java
public Result createPpr(DcosRequest req) {
// 1. 构造幂等键
String idemKey = "DCOS:CREATE_PPR:" + req.getSourceOrderNumber();
try {
// 2. 尝试插入占位记录或直接执行业务
// 注意:这里利用 DB 唯一索引做分布式锁是最轻量的方式
PprOrder order = new PprOrder();
order.setIdempotencyKey(idemKey);
order.setSourceOrderNumber(req.getSourceOrderNumber());
// ... 其他字段映射
pprMapper.insert(order);
// 3. 执行后续复杂业务逻辑(扣额度、发MQ等)
doBusinessLogic(order);
return Result.success(order.getPprNo());
} catch (DuplicateKeyException e) {
// 4. 【核心】捕获重复提交
log.warn("PPR已存在, 幂等返回. key={}", idemKey);
// 查询之前生成的结果直接返回,保证 DCOS 拿到一致的响应
PprOrder existing = pprMapper.selectByIdempotencyKey(idemKey);
if (existing != null && existing.getStatus() == SUCCESS) {
return Result.success(existing.getPprNo());
} else {
// 极端情况:上次处理到一半失败了,状态不是SUCCESS
// 此时可能需要重试或报错,视业务容忍度而定
throw new BusinessException("PPR处理中或异常,请稍后查询");
}
}
}
四.常见误区与注意事项
| 误区 | 正确做法 |
|---|---|
| 用时间戳做幂等键 ❌ 时间戳可能重复,且精度不够。 | ✅ 使用 UUID 或 雪花算法ID 或 MD5(参数+盐)。 |
| 后端自己生成幂等键 ❌ 如果后端在事务开始前生成,若事务回滚,下次请求又生成新的,就失效了。 | ✅ 必须由客户端生成并传入,确保"同一业务意图"对应"同一个键"。 |
| 只查不锁 (Check-Then-Act) ❌ 在高并发下,两个线程同时查到"无记录",然后都去插入,会导致数据不一致。 | ✅ 要么依赖数据库唯一索引(如上例),要么使用分布式锁保护查询和插入过程。 |
| 幂等键永久有效 ❌ 如果用户想稍后重新下单同样的商品,旧键会阻止新操作。 | ✅ 幂等键应有生命周期(如 24 小时),或结合业务语义(如"取消订单"后应使原幂等键失效)。 |
总结:
怎么写唯一键?
- 在数据库建表时,给关键字段加 UNIQUE KEY。
怎么写幂等键?
- 客户端生成唯一字符串(UUID)。
- 服务端接收该字符串,存入带有唯一索引的表中。 利用数据库的唯一约束来拦截重复插入。
- 捕获冲突异常,返回之前的处理结果。
- 这种"客户端生成 Key +服务端唯一索引兜底"的模式,是目前工业界最简单、最可靠、成本最低的幂等实现方案。