亿级流量接口幂等性设计:从前端防重到数据库唯一约束

亿级流量接口幂等性设计:从前端防重到数据库唯一约束

亿级流量接口幂等性设计:从前端防重到数据库唯一约束

一、一条重复扣款的代价——为什么幂等性不是"加个唯一索引就行"

某支付系统曾发生这样的事故:用户支付完成后,客户端因网络超时自动重试,同时用户手动点击了第二次。两道请求穿越网关、负载均衡,分别落到了两台不同的应用服务器上。由于缺少幂等控制,两次请求均成功——用户被扣款两次。最终客服介入退款、财务对账、用户投诉处理,一条重复请求的修复成本远超一行代码。

幂等性是分布式系统的基础课题,但真正在亿级流量下落地幂等性需要分层设计。数据库唯一索引是最后一道防线,但如果所有幂等性判断都压到数据库,高并发下会出现锁竞争、死锁、连接池耗尽等一系列衍生问题。本文讨论一套四层幂等防护体系,每层解决不同场景的重复请求问题。

二、底层机制与原理深度剖析

幂等性的核心问题是:如何在分布式环境下唯一识别一个业务请求。同一个业务操作(如创建订单),如果客户端因超时或网络抖动发送了两次,服务端需要有能力判断这两次请求代表的是同一笔操作还是两笔独立操作。

下图展示了端到端的幂等防护链路:

flowchart TB subgraph L1["L1 客户端层"] A[用户点击提交] --> B{按钮状态} B -->|已置灰/loading| C[拦截: 防抖期间禁止重复点击] B -->|可点击| D[生成客户端唯一请求ID<br/>X-Idempotency-Key] end subgraph L2["L2 网关层"] D --> E[网关Token校验] E --> F{Token是否存在且未使用} F -->|Token无效| G[拒绝: 400 重复提交] F -->|Token有效| H[标记Token为已使用<br/>原子操作] end subgraph L3["L3 业务层"] H --> I[业务幂等键提取] I --> J{状态机检查} J -->|终态| K[直接返回已有结果<br/>200 OK + cached] J -->|非终态| L[分布式锁争用] L -->|获取锁失败| M[等待或返回处理中] L -->|获取锁成功| N[执行业务逻辑] end subgraph L4["L4 数据库层"] N --> O[数据库唯一索引] O -->|插入成功| P[业务处理完成] O -->|唯一约束冲突| Q[查询已有记录<br/>判断是否幂等] Q -->|同请求ID已有结果| R[返回已有结果] Q -->|不同请求ID| S[数据冲突: 报警] end P --> T[返回成功结果]

四层防护的定位:

层级解决的问题覆盖场景失效场景
L1 客户端用户误操作重复点击正常用户行为浏览器崩溃、脚本重放
L2 网关Token客户端重试、网络重传HTTP层面重复请求多网关实例Token未共享
L3 业务状态机异步回调、MQ重复消费业务层面幂等状态机设计不完整
L4 数据库约束绕过了前面所有层的请求最终兜底分库分表后唯一索引失效

每一层都有失效场景,因此需要多层组合。任一层单独工作都不足以覆盖所有重复请求的来源。

三、生产级代码实现与最佳实践

3.1 网关层Token机制

/** * 网关层幂等Token服务 * * 工作原理: * 1. 客户端先调用 /api/idempotent-token 获取一次性Token * 2. 实际请求在Header中携带 X-Idempotency-Token * 3. 网关Filter校验Token并原子性标记为已使用 * * Token存储使用Redis + Lua脚本保证原子性。 * 不能用"先查再删"两步操作——并发下两个请求可能同时读到Token存在。 */ @Component public class IdempotentTokenGatewayFilter implements GlobalFilter, Ordered { private static final String TOKEN_HEADER = "X-Idempotency-Token"; private static final String TOKEN_PREFIX = "idempotent:token:"; private static final int TOKEN_TTL_SECONDS = 300; // Token 5分钟有效期 private final StringRedisTemplate redisTemplate; // Lua脚本:原子性地校验并删除Token // KEYS[1]: Token的Redis Key // 返回 1 表示Token有效且被成功消费 // 返回 0 表示Token不存在或已被使用 private static final String TOKEN_CONSUME_SCRIPT = """ local key = KEYS[1] local token = redis.call('GET', key) if token == false then return 0 end redis.call('DEL', key) return 1 """; private final DefaultRedisScript<Long> consumeScript; public IdempotentTokenGatewayFilter(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; this.consumeScript = new DefaultRedisScript<>(TOKEN_CONSUME_SCRIPT, Long.class); } @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); // 仅拦截写操作(POST/PUT/PATCH/DELETE) HttpMethod method = request.getMethod(); if (method == null || method == HttpMethod.GET || method == HttpMethod.HEAD || method == HttpMethod.OPTIONS) { return chain.filter(exchange); } String token = request.getHeaders().getFirst(TOKEN_HEADER); if (token == null || token.isBlank()) { return writeErrorResponse(exchange, "缺少幂等Token", HttpStatus.BAD_REQUEST); } String redisKey = TOKEN_PREFIX + token; // 原子校验并消费Token return Mono.fromCallable(() -> redisTemplate.execute(consumeScript, List.of(redisKey)) ) .subscribeOn(Schedulers.boundedElastic()) .flatMap(result -> { if (result != null && result == 1L) { // Token有效,移除Header后继续转发 ServerHttpRequest mutatedRequest = request.mutate() .headers(headers -> headers.remove(TOKEN_HEADER)) .build(); return chain.filter( exchange.mutate().request(mutatedRequest).build()); } else { return writeErrorResponse( exchange, "重复提交或Token已过期", HttpStatus.CONFLICT); } }); } @Override public int getOrder() { // 在限流Filter之后、路由Filter之前执行 return Ordered.LOWEST_PRECEDENCE - 2; } private Mono<Void> writeErrorResponse( ServerWebExchange exchange, String message, HttpStatus status) { exchange.getResponse().setStatusCode(status); exchange.getResponse().getHeaders() .setContentType(MediaType.APPLICATION_JSON); byte[] body = String.format( "{\"code\":%d,\"message\":\"%s\"}", status.value(), message).getBytes(StandardCharsets.UTF_8); return exchange.getResponse() .writeWith(Mono.just( exchange.getResponse().bufferFactory().wrap(body))); } }

3.2 业务层状态机幂等

/** * 基于状态机的业务幂等处理器 * * 适用场景:订单、支付、审批等有明显状态流转的业务实体。 * * 核心思想:业务操作前检查当前状态,仅允许从特定状态迁移。 * 同一请求再次到达时,因为状态已变迁,不会重复执行。 * * 与数据库行锁配合: * SELECT ... FOR UPDATE 在事务中锁定目标行, * 确保状态检查与状态更新的原子性。 */ @Service public class OrderIdempotentProcessor { private final OrderRepository orderRepository; /** * 状态机驱动的幂等支付确认 * * 状态流转:PENDING_PAYMENT → PAID → (DELIVERED → COMPLETED) * * 幂等保证: * 1. 如果订单已经是PAID状态,直接返回已有支付结果 * 2. 如果并发请求同时到达,FOR UPDATE锁保证只有一个能执行 */ @Transactional(isolation = Isolation.READ_COMMITTED, timeout = 10) public PaymentResult confirmPayment(String orderId, String idempotencyKey) { // 1. 用SELECT ... FOR UPDATE锁定订单行 Order order = orderRepository.findByIdForUpdate(orderId) .orElseThrow(() -> new OrderNotFoundException(orderId)); // 2. 状态机检查:仅PENDING_PAYMENT状态可支付 switch (order.getStatus()) { case PAID: // 已支付,幂等返回已有结果 log.info("订单已支付,幂等返回: orderId={}, idempotencyKey={}", orderId, idempotencyKey); return PaymentResult.alreadyPaid(order.getPaymentId(), order.getPaidAmount(), order.getPaidAt()); case PENDING_PAYMENT: // 正常路径:执行支付 break; case CANCELLED: throw new OrderCancelledException(orderId); case EXPIRED: throw new OrderExpiredException(orderId); default: throw new IllegalOrderStateException(orderId, order.getStatus()); } // 3. 执行支付逻辑(调用支付网关等) PaymentResult result = executePayment(order); // 4. 更新订单状态 order.setStatus(OrderStatus.PAID); order.setPaymentId(result.getPaymentId()); order.setPaidAmount(result.getAmount()); order.setPaidAt(Instant.now()); order.setIdempotencyKey(idempotencyKey); orderRepository.save(order); return result; } /** * Repository层:FOR UPDATE查询 * * 注意点: * - FOR UPDATE只在InnoDB行级锁下有效 * - 如果WHERE条件不走索引,会升级为表锁,严重影响并发 * - 锁持有时间 = 事务持续时间,务必设置合理的timeout */ // JPA写法: // @Lock(LockModeType.PESSIMISTIC_WRITE) // @Query("SELECT o FROM Order o WHERE o.id = :orderId") // Optional<Order> findByIdForUpdate(@Param("orderId") String orderId); }

3.3 数据库层最终防线

-- 幂等键唯一索引:最后一道防线 -- -- 设计要点: -- 1. idempotency_key 由 "业务类型 + 业务唯一标识 + 客户端请求ID" 组成 -- 2. 唯一索引保证即使前面所有层失效,数据库层也不会产生重复数据 -- 3. 配合 ON DUPLICATE KEY 或 INSERT IGNORE 处理冲突 CREATE TABLE payment_records ( id BIGINT AUTO_INCREMENT PRIMARY KEY, payment_id VARCHAR(64) NOT NULL COMMENT '支付流水号', order_id VARCHAR(64) NOT NULL COMMENT '订单号', idempotency_key VARCHAR(128) NOT NULL COMMENT '幂等键', amount DECIMAL(18,2) NOT NULL COMMENT '支付金额', status VARCHAR(32) NOT NULL COMMENT '支付状态', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), -- 幂等键唯一约束 UNIQUE KEY uk_idempotency_key (idempotency_key), KEY idx_order_id (order_id), KEY idx_created_at (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付记录表';
/** * 利用数据库唯一约束实现幂等插入 * * 采用 INSERT ... ON DUPLICATE KEY 方式, * 将"判断是否存在"和"插入"合并为一次数据库操作, * 避免SELECT + INSERT的竞态窗口。 */ @Repository public class PaymentRecordDao { private final JdbcTemplate jdbcTemplate; public InsertResult insertIdempotently(PaymentRecord record) { String sql = """ INSERT INTO payment_records (payment_id, order_id, idempotency_key, amount, status) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE -- 如果已存在,不更新任何数据,但可以通过affected rows判断 id = id """; try { int affected = jdbcTemplate.update(sql, record.getPaymentId(), record.getOrderId(), record.getIdempotencyKey(), record.getAmount(), record.getStatus().name()); if (affected == 1) { // 新插入成功 return InsertResult.SUCCESS; } else if (affected == 0) { // 唯一键冲突,已有记录(affected=0是因为ON DUPLICATE KEY更新了相同值) return InsertResult.DUPLICATE; } else { // affected=2 表示ON DUPLICATE KEY确实更新了一行 // 这种情况理论上在当前SQL下不会发生(id=id无实质更新) return InsertResult.DUPLICATE; } } catch (DuplicateKeyException e) { // 并发INSERT(无ON DUPLICATE KEY时)可能抛出此异常 log.warn("幂等键冲突: idempotencyKey={}", record.getIdempotencyKey()); return InsertResult.DUPLICATE; } } }

四、边界分析与架构权衡

1. 幂等键的粒度选择

幂等键可以设计为全局唯一(如UUID),也可以设计为业务语义唯一(如"订单号+操作类型")。全局UUID方案实现简单但丧失了业务可读性,排查问题时需要额外的映射关系。业务语义方案直观但需要谨慎设计组合字段——组合字段的任何一部分变化都会导致幂等失效。生产实践中的折中方案:使用业务语义作为幂等键的前缀,拼接内容哈希实现唯一性,如ORDER:PAY:ORD20260706001:sha256(请求体)

2. 幂等键的过期策略

幂等键通常需要存储一段时间以应对延迟重试。存储多久?建议按业务场景分级:支付类幂等键永久保留(涉及资金,查询成本可接受),订单类保留30天(超过退款周期),普通业务操作保留1-7天。过期清理采用TTL + 定时归档策略,避免存储膨胀影响查询性能。

3. 分库分表后的唯一索引

这是最容易被忽略的陷阱。ShardingSphere按订单号分片后,唯一索引只在单个物理分片上生效。如果幂等键不包含分片键或使用了不同的分片算法,同一个请求的两次调用可能落到不同分片,唯一索引形同虚设。解决方案:将分片键嵌入幂等键前缀,或者在网关层通过一致性哈希将同一幂等键的请求路由到固定实例处理。

4. 客户端Token与服务端Token的协作

客户端Token(防重点击)不能替代服务端Token。客户端Token可以被脚本绕过——攻击者完全可以跳过前端直接构造HTTP请求。但也不能因此否定客户端Token的价值:它消除了最大比例的重复请求来源(用户误操作),显著降低了后端各层幂等机制的压力。L1+L2的组合是性价比最高的投资。

五、总结

四层幂等防护的核心思想是纵深防御:每一层解决特定场景的重复请求,没有哪一层是完美的,但四层组合可以让漏过概率降至业务可接受的水平。

实施优先级建议:如果资源有限,先做L4(数据库唯一索引),它覆盖面最广,是最后的安全网。然后做L3(业务状态机),它处理MQ重复消费和异步回调重试。L2(网关Token)和L1(前端防重)在前两层稳定后追加,优化用户体验并降低后端压力。

幂等性设计的另一层价值在于:它迫使团队在系统设计阶段就思考"这个操作能否被安全地重复执行"。这个问题的答案往往能暴露业务建模中的模糊地带,其设计价值不亚于实现价值。