Spring Boot 接口幂等性设计:从唯一索引到 Redis+Lua 的工程实践

重复下单、重复扣款、MQ 重复消费------幂等几乎是每个后端系统绕不开的问题。但"加个分布式锁"并不等于"接口幂等",很多线上事故恰恰出在那些看起来原子、其实有竞态窗口的实现上。

本文不停留在罗列方案,而是用一个可运行的并发压测工程,先证明"先查再写"为什么一定会在并发下被击穿,再逐层给出唯一索引、SETNX 原子占位、Lua 滑动窗口的正确实现,最后讨论锁超时、唯一约束、消息重放等工程边界。所有结论都附带真实压测数据。

1. 先对齐:幂等、防重、分布式锁不是一回事

  • 幂等(Idempotency) :同一操作执行一次或多次,对系统状态的最终影响相同,即 f(f(x)) = f(x)。
  • 防重复提交:幂等的一个子集,目标是在一个短时间窗内挡住用户/网络造成的重复请求。
  • 分布式锁 :一种互斥手段,解决临界区并发,但锁释放后的重放仍可能造成重复,因此锁不是幂等的充分条件。

真正可靠的幂等通常是分层的:入口前置拦截(挡掉绝大多数重复流量)+ 资源层强约束(兜住所有漏网情况) 。理解这一点,后面的方案选型才不会纠结"到底用锁还是用唯一索引"------答案往往是都要。

2. 用并发压测,先看清错误实现是怎么被击穿的

最常见的写法是"先判断有没有,再决定做不做":

kotlin 复制代码
// 错误示范:check-then-act,两步之间存在竞态
if (!kv.exists(key)) {
    // ... 这里可能有任意耗时:查库、RPC、计算
    kv.set(key, ttl);
    return doCreateOrder();
}
return "重复请求";

它在单线程压测下"看起来没问题",但在并发下:两个线程可能同时通过 exists == false 的判断,随后都执行创建。对应到 Redis,就是把本应由一条命令完成的"判断+写入"拆成了 EXISTS 和 SET 两条命令,中间的网络/计算间隙就是并发窗口。

为了不靠"我觉得"下结论,我写了一个零依赖的并发压测工程,用 100 个线程经 CountDownLatch 同时抢占同一个 key。非原子实现里我特意留了 5ms 间隙来稳定复现(真实系统里这个间隙是查库和网络往返,只会更长):

vbnet 复制代码
@Override
public boolean tryAcquire(String key, int limit, long windowMillis) {
    if (kv.exists(key)) {
        return false;
    }
    Thread.sleep(5);          // 放大竞态窗口,真实系统里是 IO 耗时
    kv.set(key, windowMillis);
    return true;
}

压测结果(100 线程、同一 key、期望只放行 1 次):

sql 复制代码
non-atomic(check-then-set)  passed=100   ← 完全击穿
set-nx(atomic)              passed=1     ← 精确
sliding-window(atomic)      passed=1     ← 精确

把允许次数放宽到 5、并发提到 200:

sql 复制代码
non-atomic(check-then-set)  passed=200   ← 击穿
set-nx(atomic)              passed=1     ← 占位语义,天然只放 1
sliding-window(atomic)      passed=5     ← 精确按 limit 计数

非原子写法在 100 并发下放行 100 次,等于完全没防住。这组数据也是后面所有正确实现的出发点:幂等判断必须是一个不可分割的原子操作。

3. 第一性原理:把"判断 + 写入"变成单命令

3.1 Redis SETNX:一条命令的原子占位

Redis 的 SET key value NX PX ttl 在单条命令里完成"仅当不存在才设置 + 带过期时间",Redis 单线程模型保证这期间不会插入其他命令:

vbnet 复制代码
Boolean first = redisTemplate.opsForValue()
        .setIfAbsent("order:" + orderNo, "1", Duration.ofSeconds(10));
if (Boolean.FALSE.equals(first)) {
    throw new RepeatSubmitException("请求处理中,请勿重复提交");
}
return doCreateOrder(orderNo);
  • NX:仅不存在时写入,保证同一业务号只有一个请求占位成功;
  • PX/EX:必须带过期时间,避免服务宕机后 key 永驻导致"永远无法重试"。

压测工程里用 ConcurrentHashMap.compute 在单机内存复现了同样的原子语义(compute 在桶锁内完成判断与写入),结论与 Redis 完全一致------并发 100 只放行 1:

ini 复制代码
public boolean setIfAbsent(String key, long ttlMillis) {
    long now = System.currentTimeMillis();
    boolean[] first = {false};
    map.compute(key, (k, old) -> {
        if (old == null || old.expireAt() <= now) {
            first[0] = true;
            return new Value(now + ttlMillis);
        }
        return old;
    });
    return first[0];
}

3.2 占位 ≠ 计数:SETNX 的适用边界

注意第二节 limit=5 那组数据:SETNX 只放行了 1 次。这不是 bug,而是语义差异------SETNX 解决的是"这个业务键是否第一次出现",它是占位/防重原语,不是限流器。如果你要"一个窗口内允许 N 次",需要计数型算法(滑动窗口、令牌桶),这正是下一节 Lua 的场景。把工具和场景对应清楚,比记住命令更重要。

4. 滑动窗口:当你需要"窗口内 N 次"时

固定窗口在边界处可能出现 2 倍突刺(窗口末尾和下个窗口开头各打满一次)。更精确的是滑动窗口:维护窗口内的通过时间戳,先剔除过期记录,再判断当前计数。

难点同样在原子性------"清理 + 计数 + 入队"三步必须一起完成。Redis 里要把它们放进一段 Lua(在 Redis 单线程内一次执行完)。内存版用于演示算法本身:

arduino 复制代码
@Override
public boolean tryAcquire(String key, int limit, long windowMillis) {
    Deque<Long> deque = windows.computeIfAbsent(key, k -> new ArrayDeque<>());
    synchronized (deque) {
        long now = System.currentTimeMillis();
        long boundary = now - windowMillis;
        while (!deque.isEmpty() && deque.peekFirst() <= boundary) {
            deque.pollFirst();                 // 清理过期时间戳
        }
        if (deque.size() >= limit) {
            return false;                      // 窗口内已满
        }
        deque.addLast(now);                    // 记录本次通过
        return true;
    }
}

对应的 Redis Lua(ZSet 时间戳方案)大致是:

lua 复制代码
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count >= limit then
  return 0
end
redis.call('ZADD', key, now, now .. '-' .. math.random())
redis.call('PEXPIRE', key, window)
return 1

压测里滑动窗口在 200 并发、limit=5 时精确放行 5 次,既挡住了重复,又不会像 SETNX 那样把合法的第 2~5 次也拒掉。

5. 资源层兜底:唯一索引为什么不可替代

入口层的 Redis 占位很快,但它不是绝对真理:key 可能因过期被删、Redis 可能故障切主丢数据、代码可能在占位后、落库前崩溃。只要存在"占位成功但最终没写库"的路径,就必须有最后一道防线。

对有天然唯一号(订单号、支付流水号)的写操作,数据库唯一索引是最强兜底:

sql 复制代码
ALTER TABLE t_order ADD UNIQUE KEY uk_order_no (order_no);
csharp 复制代码
try {
    jdbcTemplate.update("INSERT INTO t_order(order_no, ...) VALUES (?, ...)", orderNo, ...);
} catch (DuplicateKeyException e) {
    // 唯一约束兜底:说明这是重复请求
    throw new BusinessException("订单已存在:" + orderNo);
}

为什么它"最强":约束由存储引擎在提交时保证,不依赖应用层时序,不存在竞态窗口。代价是重复请求会真正打到 DB 并抛异常,因此它适合兜底而非唯一手段------Redis 在前面挡掉 99%,唯一索引接住极端的 1%。

一个完整的下单因此通常长这样(压测工程中的精简版):

arduino 复制代码
public boolean createOrder(String orderNo, long ttlMillis) {
    boolean first = kv.setIfAbsent("order:" + orderNo, ttlMillis);  // 入口原子拦截
    if (first) {
        // 落库;真实项目这里 INSERT 带唯一索引做最终兜底
        created.computeIfAbsent(orderNo, k -> new AtomicInteger()).incrementAndGet();
    }
    return first;
}

业务实测:同一订单串行调 5 次,仅第 1 次 created=true;50 个并发同时打同一订单,真正落库只有 1 次:

ini 复制代码
50 并发中 created=true 次数: 1   (期望 1)

6. 工程边界:这些问题比命令本身更值得想

6.1 占位 key 过期了,业务还没执行完,会不会重复?

会有这个窗口。假设 TTL=10s,但下单因下游卡顿用了 12s,第二次请求在第 11s 进来时 key 已过期,可能再次占位。应对要组合:

  1. TTL 设为显著大于接口正常耗时(如 P99 的数倍);
  2. 真正强一致靠唯一索引/状态条件更新兜底;
  3. 需要"同一业务键在整个生命周期内唯一"时,幂等键不应只靠短 TTL,而应落一张幂等记录表(唯一键即业务号),用"插入是否成功"判定首次。

6.2 幂等记录表:没有 Redis 或要求强持久时的选择

sql 复制代码
CREATE TABLE t_idempotent (
  biz_key VARCHAR(64) PRIMARY KEY,
  status TINYINT,
  created_at DATETIME
);

INSERT IGNORE / INSERT ... ON DUPLICATE 的"影响行数是否为 1"就是判定结果。它牺牲了一部分性能,换来不依赖缓存、可审计、生命周期可控。高并发下可配合 Redis 前置减压。

6.3 MQ 重复消费怎么做

消费端用"业务唯一键 + 唯一约束/幂等表",而不是依赖消息中间件的"恰好一次"(多数 MQ 是 at-least-once)。消费成功才 ACK;重复消息在落库时被唯一约束挡住,捕获后直接当成功处理,避免无限重试。

6.4 分布式锁能替代上面的方案吗

不能。锁保证的是临界区互斥,但:①锁有获取失败、超时释放、主从切换丢锁等问题;②锁一旦释放,迟到的重放请求仍可能成功。锁适合保护"查库存→扣减→建单"这类复合临界区,而最终幂等仍要靠唯一约束或状态机。

6.5 状态机:带生命周期业务的幂等

支付、审批这类有状态流转的场景,用条件更新让"只有合法前置状态才能流转":

ini 复制代码
UPDATE t_payment SET status = 'PAID' WHERE id = ? AND status = 'INIT';

影响行数为 0 即说明状态已被推进,重复回调直接忽略。第三方支付异步通知会重投,靠的就是这个。

7. 方案选型一览

方案 一致性强度 成本 适用场景 主要边界
数据库唯一索引 最强(兜底) 极低 有唯一业务号的写 只在落库层拦截,异常有代价
Redis SETNX 占位 强(受 TTL/切主影响) 低 高频接口前置防重 需配 TTL,极端情况要兜底
Lua 滑动窗口 强、可计数 中 窗口内允许 N 次、限流+幂等 需写对 Lua、管理窗口键
幂等记录表 强、可持久 中 无缓存/要求审计、全生命周期 多一次 DB 写
分布式锁 互斥 中高 复合临界区 不等于幂等,仍需兜底
状态条件更新 强 低 有明确状态流转 仅限状态机型业务

实践组合:注解/AOP + Redis 原子占位做前置拦截,唯一索引或状态机做资源层兜底;需要限流语义时用 Lua 滑动窗口;关键资金链路再叠幂等记录表。

8. 可运行源码与复现方式

工程基于 Spring Boot 3.2.5 / Java 17,零外部中间件(用内存原语复现 Redis 原子语义),已放在仓库的掘金专区:

bash 复制代码
mvn spring-boot:run
​
# 并发压测
curl "http://localhost:8082/benchmark?threads=100&limit=1&window=2000"
curl "http://localhost:8082/benchmark?threads=200&limit=5&window=2000"
​
# 业务接口
curl -X POST "http://localhost:8082/orders/ORD001?ttl=60000"

迁到生产时,把 KvStore.setIfAbsent 换成 stringRedisTemplate.opsForValue().setIfAbsent(...)、把滑动窗口换成文中 Lua,再加数据库唯一索引,算法与结论保持一致。

9. 小结

  • 幂等的核心是让"判断 + 写入"成为原子操作,任何 check-then-act 在并发下都可能被击穿(本文压测 100 并发放行 100 次)。
  • SETNX 解决"是否首次",Lua 滑动窗口解决"窗口内 N 次",二者不要混用。
  • Redis 前置拦截很快但不是真理,唯一索引/状态机的资源层兜底不可省。
  • 锁是互斥工具,不是幂等的替代品;MQ 消费端用业务键 + 唯一约束做幂等。

如果这篇对你有帮助,欢迎点赞收藏,也可以在评论区聊聊你在线上是怎么设计资金类接口幂等的。

相关推荐
用户3721574261351 小时前
Java 实现 Excel 与 Markdown 相互转换:表格导出、格式设置与多表提取
java
胡写代码1 小时前
删掉的数据还占着唯一键:逻辑删除 + 唯一索引的四次取舍
spring boot·后端
SL_staff1 小时前
制造业订单交付总踩坑?用结构化节点协同模型重构计划系统
java·开源·全栈
永遠_2 小时前
YAML格式化工具
java
曼岛_2 小时前
[图解密码学]第4章 古典密码
java·数据库·密码学
一枕槐安4872 小时前
RAG 服务扛住高并发:缓存、队列、限流三件套
java·spring·缓存·异步任务·rag·断路器
Wang's Blog2 小时前
Java 项目实战: 交友项目 - 动态查询开工:昨日回顾与今日任务
java·python·交友
云和数据.ChenGuang3 小时前
光哥说Python 异常深度解析:从异常模型到工程化实践
java·前端·人工智能·python·langchain
SL_staff3 小时前
APS动态重排实践:在多品种小批量场景下实现5分钟插单响应
java·开源·产品