java中唯一键和幂等键的应用

前言:

在分布式系统(多台独立的计算机(或节点)通过网络连接起来,协同工作以完成共同任务的系统 )、支付接口或高并发场景中,唯一键(Unique Key)和**幂等键(Idempotency Key)**是保证数据一致性和防止重复操作的核心机制。虽然两者经常配合使用,但它们的定义、作用域和实现方式有所不同。

一.概念

1.唯一键 (Unique Key)

  • 定义:数据库层面的约束,确保某列 或某几列的组合值在表中不重复。
  • 作用:从存储层防止脏数据产生。例如,一个用户的手机号不能注册两个账号,或者一笔订单号不能对应两条订单记录。
  • 特点:它是静态的、结构性的约束。一旦违反,数据库直接报错。

2.幂等键 (Idempotency Key)

  • 定义:业务层面的标识符,通常由客户端生成,用于标记"这一次请求"。服务端通过检查该键是否已处理过,来决定是执行新逻辑还是返回之前的结果。
  • 作用:防止因网络超时 、用户重复点击 导致的重复提交。即使请求发送多次,对服务器状态的影响只有一次。
  • 特点:它是动态的、流程性的控制。它依赖于"先查后插"或"唯一索引+异常捕获"的逻辑。

关键区别:

  • 唯一键是手段(数据库怎么存)。
  • 幂等性是目的(业务怎么做才安全)。
  • 通常我们利用数据库的唯一键 约束来实现业务的幂等性。

二.实现方式

最稳健的实现方式是:数据库唯一索引 + 应用层异常处理/预检查。

方案 A:依赖数据库唯一索引(推荐)

  1. 客户端每次发起重要写操作时,生成 一个唯一 的 request_id(即幂等键)。
  2. 服务端将该 request_id作为字段存入业务表,并建立唯一索引。 如果插入成功,说明是第一次请求,继续执行业务逻辑。
  3. 如果抛出"唯一键冲突"异常,说明该request_id 已存在,直接返回之前处理过的结果(或查询结果),不再执行业务逻辑。

方案 B:Redis 分布式锁 + 状态机(高性能场景)

  1. 收到请求,用 idempotency_key 去 Redis 设置一个带过期时间的 Key (SETNX)。
  2. 如果设置失败,说明正在处理或已处理,直接返回"处理中"或"已完成"。
  3. 如果设置成功,执行业务逻辑,最后更新 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 +服务端唯一索引兜底"的模式,是目前工业界最简单、最可靠、成本最低的幂等实现方案。
相关推荐
程序猿乐锅1 小时前
【黑马点评 | 第八篇】Redisson分布式锁
java·数据库·spring boot·redis·分布式·spring·缓存
GreenTea2 小时前
我把 Agent 的 while 循环拆掉了:一种你可能没想到的 Agent 架构
前端·后端·架构
考虑考虑2 小时前
synchronized字符串常量
java·后端·java ee
长谷深风1112 小时前
Tool与Skill:AI能力设计的分水岭
java·人工智能·ai·大模型·aiagent
m0_587383002 小时前
广州24小时自助健身房解决方案实战指南与系统部署要点
java·spring·小程序·架构·需求分析
Escalating_xu2 小时前
【C 语言】深入理解指针(1·下):指针运算、野指针、assert 与传址实战
java·c语言·开发语言
周杰偷奶茶2 小时前
【Java】数据类型与变量
java·开发语言
code斗2 小时前
Java数据结构:堆详解
java·开发语言·数据结构
颜进强2 小时前
16 · NestJS LifecycleEvents 生命周期事件:五个钩子、三条边界,和 rag-server 的优雅停机
前端·后端·ai编程