基于 Redis SETNX 的后端审批防抖与幂等补偿机制

基于 Redis SETNX 的后端审批防抖与幂等补偿机制

本文以钉钉卡片防抖改造为案例,系统梳理后端防抖、分布式锁、状态补偿三方面的知识体系。


一、问题背景:审批卡片多次点击导致状态不一致

1.1 现象

钉钉卡片上点击「同意/拒绝」后,审批流程正常推进,但卡片 UI 状态未更新,仍显示待审批。用户自然再次点击,请求再次进入审批逻辑。

1.2 根因

问题

说明

无防抖保护

四个审批入口均无幂等/防抖机制,重复请求会完整走一遍审批逻辑

分布式锁非原子

handleFinalApproval 中用 hasKey() + set() 两步获取锁,两个并发请求可能同时通过 hasKey 检查

缺少状态前置检查

未检查审批状态,已完成的审批仍继续执行

1.3 核心矛盾

审批逻辑卡片更新是两个独立操作,二者之间无原子性保证。审批成功但卡片更新失败时,系统缺少"感知失败并自动补偿"的能力。


二、知识点一:Redis SETNX 实现原子性首点击检测

2.1 原理

SETNX(SET if Not eXists)是 Redis 原生命令,仅当 key 不存在时才设置值,整个操作在 Redis 服务端原子执行:

SETNX key value
→ key 不存在:设置成功,返回 1
→ key 已存在:不设置,返回 0

Spring Data Redis 封装为 opsForValue().setIfAbsent(key, value, timeout, unit)

2.2 对比:非原子 hasKey + set 的竞态条件

改造前(有 Bug):

// ❌ 两个请求可能同时通过 hasKey 检查
if (!redisUtil.hasKey(operateLock)) {
    redisUtil.set(operateLock, "1", 30);
    // 执行审批逻辑...
}

时刻

请求 A

请求 B

T1

hasKey → false

T2

hasKey → false

T3

set → 成功

T4

进入审批逻辑 ❌

set → 成功(覆盖写入)

T5

进入审批逻辑 ❌

改造后(原子 SETNX):

// ✅ 原子操作,只有第一个请求能成功
boolean locked = redisUtil.setIfAbsent(operateLock, "1", DigitDef.EFFECTIVE_TIME_30);
if (!locked) {
    return new UniResult(CodeEnum.ACS_API_TOO_FREQUENTLY);
}

2.3 本案例中的 RedisUtil 封装

public boolean setIfAbsent(String key, Object value, long time) {
    try {
        if (time > 0) {
            return Boolean.TRUE.equals(
                redisTemplate.opsForValue().setIfAbsent(key, value, time, TimeUnit.SECONDS));
        } else {
            return Boolean.TRUE.equals(
                redisTemplate.opsForValue().setIfAbsent(key, value));
        }
    } catch (Exception e) {
        log.error("except.", e);
        return false;
    }
}

设计要点:

  • time > 0 时带 TTL,防止死锁(key 永不过期导致后续请求永远被拦截)

  • time = 0 时不设过期,用于需要显式删除锁的场景

  • 异常返回 false,语义为"加锁失败",调用方走锁冲突分支而非放行

2.4 拓展:SETNX vs 其他分布式锁方案

方案

优点

缺点

适用场景

SETNX + TTL

简单、原子、性能高

不可重入、无续期、无释放保证

防抖、短时互斥

Redisson Lock

可重入、自动续期(watchdog)、释放可靠

引入新依赖、复杂度高

长时互斥、需续期

RedLock

多节点强一致

性能差、争议大

极端强一致要求

数据库悲观锁

无额外依赖

性能差、锁粒度粗

无 Redis 场景

本案例选 SETNX + TTL 的理由: 防抖场景下锁持有时间极短(审批逻辑通常 < 5 秒,TTL 设 30 秒足够兜底),无需续期和可重入,SETNX 最简最优。


三、知识点二:Redis Key 的三态设计实现防抖 + 结果缓存

3.1 设计思路

防抖不仅要"阻止重复执行",还要在重复请求到来时告知请求方上一次的执行结果,避免用户困惑。

本案例用一个 Redis Key 存储三种状态:

Key:  EZOPS:EXAMINE:APPROVING:{examineId}:{userId}
Value: "processing"  → 审批处理中
       "1"           → 审批已通过
       "0"           → 审批已拒绝
TTL:   30 秒

3.2 状态流转图

                     SETNX 成功
  首次点击 ──────────────────────→ 写入 "processing"
       │                                │
       │                          执行审批逻辑
       │                                │
       │                     ┌──────────┴──────────┐
       │                审批成功                审批异常
       │                     │                     │
       │              写入 "1" 或 "0"          删除 Key(允许重试)
       │                     │
       │              TTL 到期自动删除
       │
  重复点击 ──── SETNX 失败 ──── 读取 Value
                                    │
                           ┌────────┴────────┐
                      "processing"        "1" 或 "0"
                           │                    │
                    返回"处理中"          调用补偿修复卡片
                    (不再执行审批)        返回 SUCCESS

3.3 代码实现

// ========== 审批防抖逻辑 ==========
String approvingKey = RedisKey.EZOPS_EXAMINE_APPROVING + examineId + ":" + userId;
​
// 1. SETNX 尝试占位
boolean isFirstClick = redisUtil.setIfAbsent(approvingKey, "processing", DigitDef.EFFECTIVE_TIME_30);
​
if (!isFirstClick) {
    // 2. 非首次点击 → 读值判断状态
    Object lockValue = redisUtil.get(approvingKey);
    if (lockValue != null && "processing".equals(lockValue.toString())) {
        // 2a. 还在处理中 → 静默返回成功(前端无感知)
        log.info("publishApprove: 审批处理中, examineId={}, userId={}, 跳过", examineId, userId);
        return new UniResult(CodeEnum.SUCCESS);
    }
    // 2b. 已完成 → 补偿修复卡片状态
    log.info("publishApprove: 重复点击, examineId={}, userId={}, 补检查卡片状态", examineId, userId);
    boolean isApproved = lockValue != null && "1".equals(lockValue.toString());
    repairPublishCardStatus(examineId, userId, isApproved);
    return new UniResult(CodeEnum.SUCCESS);
}
// ========== 防抖逻辑结束 ==========
​
// ... 审批逻辑 ...
​
try {
    result = handleApproval / handleRejection;
} catch (Exception e) {
    // 3. 异常时删除锁,允许重试
    redisUtil.del(approvingKey);
    throw e;
}
​
// 4. 成功后写入结果值
redisUtil.set(approvingKey, String.valueOf(isApproval), DigitDef.EFFECTIVE_TIME_30);
return result;

3.4 Key 设计的维度选择

Key 由 examineId + userId 组成,含义为 "某个审批单的某个审批人"

为什么不用 examineId 一个维度?

同一审批单可能有多级审批,不同审批人应各自独立防抖,互不影响:

场景

examineId

userId

Key

是否冲突

A 审批人同意

100

2001

APPROVING💯2001

-

B 审批人同意

100

2002

APPROVING💯2002

不冲突 ✅

A 再次点击

100

2001

APPROVING💯2001

SETNX 失败,走补偿 ✅

拓展:更多维度的 Key 设计

业务场景

Key 维度

示例

单用户防抖

action:userId

SUBMIT:2001

单资源防抖

action:resourceId

CANCEL:orderId

资源+用户防抖

action:resourceId:userId

APPROVE:100:2001

多租户隔离

tenant:action:resourceId

T1:APPROVE:100

原则:Key 的维度 = 需要独立防抖的最小单元,宁多勿少——多一个维度只是多一个 key,少一个维度则会产生误拦截。


四、知识点三:幂等补偿 — 让"已成功但未生效"的操作最终生效

4.1 问题模型

分布式系统中,操作 A(审批)和操作 B(更新卡片)是两个步骤:

A(审批) ──成功──→ B(更新卡片) ──失败──→ 卡片状态不一致

B 失败后,A 的结果已落库,但用户看到的卡片仍是旧状态。此时需要一种补偿机制:在检测到不一致时,重新执行 B。

4.2 本案例的补偿方法

private void repairPublishCardStatus(Long examineId, Long userId, boolean isApproval) {
    try {
        // Step 1: 查卡片元数据,获取 applicantId
        CardDOExample cardDOExample = new CardDOExample();
        cardDOExample.createCriteria()
                     .andExamineIdEqualTo(examineId)
                     .andCardTypeEqualTo(0);
        List<CardDO> cardDOS = cardDOMapper.selectByExample(cardDOExample);
        if (cardDOS.isEmpty()) {
            log.warn("repairPublishCardStatus: No cards found for examineId={}", examineId);
            return;
        }
        Long applicantId = cardDOS.get(0).getApplicantId();
​
        // Step 2: 调用已有的卡片更新方法(幂等)
        UniResult result = updatePreCardStatus(examineId, userId, applicantId, isApproval);
        if (UniResult.isSuccess(result)) {
            log.info("repairPublishCardStatus: 卡片状态修复成功");
        } else {
            log.warn("repairPublishCardStatus: 卡片状态修复失败");
        }
    } catch (Exception e) {
        // Step 3: 补偿失败仅记日志,不影响返回
        log.warn("repairPublishCardStatus: 卡片状态修复异常", e);
    }
}

4.3 补偿方法的设计原则

原则

说明

本案例体现

幂等性

多次调用与一次调用效果相同

updatePreCardStatus 内部调用钉钉 updateCard,是覆盖写

安全降级

补偿失败不影响主流程返回

catch 只记 log.warn,不抛异常

可观测性

成功/失败都有日志可追踪

info 记成功、warn 记失败/异常

数据自足

方法入参只有 ID,内部自行查询所需数据

通过 examineId 查 CardDO 获取 applicantId

4.4 补偿触发的四个时机

                    ┌──────────────────────────────────────┐
                    │        审批入口方法                    │
                    ├──────────────────────────────────────┤
  时机1 ──────────→ │  SETNX 失败 + 值为 "1"/"0"          │ ← 重复点击
                    │  → repairCardStatus()               │
                    ├──────────────────────────────────────┤
  时机2 ──────────→ │  审批状态 ≠ 0(已审批)              │ ← 状态前置检查
                    │  → repairCardStatus()               │
                    ├──────────────────────────────────────┤
                    │  审批状态 = 0,SETNX 成功             │
                    │  → 正常执行审批逻辑                   │
                    └──────────────────────────────────────┘
  • 时机 1:用户重复点击,审批已完成,卡片可能未更新 → 补偿

  • 时机 2:请求到达时审批已被其他通道处理完 → 补偿

4.5 拓展:补偿模式分类

模式

描述

适用场景

同步补偿(本案例)

在业务入口同步调用补偿方法

补偿操作轻量、延迟敏感

异步补偿

将补偿任务投递到 MQ,消费者异步执行

补偿操作重、可接受延迟

定时巡检

定时任务扫描不一致数据并修复

需要兜底、可能漏补偿

Saga 反补偿

补偿 = 回滚,取消前一步操作

分布式事务回滚

本案例选同步补偿:卡片更新是 HTTP 调用(轻量),且用户正在等待响应,同步补偿可让卡片在当前请求内就更新,体验最佳。


五、知识点四:状态前置检查 — 拦截"不该进来的请求"

5.1 原则

在执行业务逻辑前,先检查前置条件。条件不满足时,不应继续执行,更不应报错给用户——因为"已完成"对用户来说就是成功。

5.2 本案例的改造

改造前(publishDingTalkCardApprove,有检查但行为不合理):

if (!examineDO.getState().equals(DigitDef.ZERO)) {
    // ❌ 返回错误,用户看到报错但审批实际已成功
    return new UniResult(CodeEnum.INVALID_PARAM);
}

改造后:

if (!examineDO.getState().equals(DigitDef.ZERO)) {
    log.warn("publishDingTalkCardApprove,examine state:{}, 已处理", examineDO.getState());
    // ✅ 补偿修复卡片 + 返回成功
    boolean isApproved = "accept".equals(action);
    repairPublishCardStatus(cardDO.getExamineId(), matchedApproverId, isApproved);
    return new UniResult(CodeEnum.SUCCESS);
}

operateDingTalkCardApprove 改造前甚至没有这个检查,审批完成后再次点击会继续执行审批逻辑——这是更严重的 Bug。

5.3 拓展:状态检查的通用模式

public Result doAction(Request req) {
    // 1. 参数校验 → 返回参数错误
    // 2. 状态前置检查 → 返回成功 + 补偿(因为动作已完成)
    // 3. 防抖锁 → 处理中/已完成+补偿
    // 4. 业务逻辑
    // 5. 更新防抖锁值
}

注意顺序:参数校验 → 状态检查 → 防抖锁 → 业务逻辑。 状态检查在防抖锁之前,因为状态检查成本低(一次 DB 查询),且能在防抖锁未命中时(如 TTL 过期后)仍能拦截。


六、知识点五:异常路径的锁清理 — 允许重试

6.1 问题

如果审批执行过程中抛异常,Redis Key 的值仍为 "processing",后续 30 秒内所有点击都会被拦截,用户无法重试。

6.2 解决

try {
    if (isApproval == DigitDef.NUMBER_ONE) {
        result = handleApproval(examineDO, userId, examineId);
    } else {
        result = handleRejection(examineDO, userId, examineId, notes);
    }
} catch (Exception e) {
    // 异常时删除防抖锁,允许重试
    redisUtil.del(approvingKey);
    throw e;
}

6.3 拓展:锁清理的三种策略

策略

做法

适用场景

异常即删(本案例)

catch 中 del(key)

业务异常可重试

TTL 兜底

不主动删,等 TTL 过期

异常后不希望立即重试(如限流)

仅成功写结果

成功写 "1"/"0",异常不写也不删

异常后下次点击仍走"处理中"分支

本案例选"异常即删":审批异常通常是临时故障(网络超时、DB 死锁),允许用户重试是合理的。


七、整体架构:防抖 + 幂等 + 补偿 的组合模式

7.1 完整的审批入口方法结构

publishApprove / operateApprove
├── 1. 参数校验
├── 2. 防抖锁(SETNX)
│   ├── 首次点击 → 继续
│   ├── 值="processing" → 返回 SUCCESS(静默)
│   └── 值="1"/"0" → 补偿修复卡片 + 返回 SUCCESS
├── 3. 状态前置检查
│   ├── state=0 → 继续
│   └── state≠0 → 补偿修复卡片 + 返回 SUCCESS
├── 4. 审批逻辑(try-catch)
│   ├── 成功 → 写入结果值("1" 或 "0")
│   └── 异常 → 删除锁 + 抛异常
└── 5. 返回审批结果
​
publishDingTalkCardApprove / operateDingTalkCardApprove
├── 1. 参数校验
├── 2. 身份匹配(钉钉用户 → 内部用户)
├── 3. 状态前置检查
│   ├── state=0 → 继续
│   └── state≠0 → 补偿修复卡片 + 返回 SUCCESS
├── 4. 防抖锁(SETNX)
│   ├── 首次点击 → 继续
│   ├── 值="processing" → 返回 SUCCESS
│   └── 值="1"/"0" → 补偿修复卡片 + 返回 SUCCESS
├── 5. 审批逻辑(try-catch)
│   ├── 成功 → 写入结果值
│   └── 异常 → 删除锁 + 抛异常
└── 6. 返回审批结果

两种入口的顺序略有不同:Web 端入口先防抖再状态检查;钉钉回调入口先状态检查再防抖。原因:钉钉回调需要先完成身份匹配才能构造防抖 Key,而身份匹配依赖 CardDO 查询,此时状态信息已拿到,先做状态检查更高效。

7.2 各层级的职责划分

层级

职责

失败策略

参数校验

拒绝非法输入

返回参数错误

状态前置检查

拦截已完成/已取消的审批

返回成功 + 补偿

防抖锁

拦截并发重复请求

处理中→静默;已完成→补偿

业务逻辑

执行审批

异常→清锁+重试

结果写回

更新 Redis 值供后续读取

TTL 兜底自动过期


八、拓展:更通用的防抖框架提取

如果系统中有大量需要防抖的接口,可以将上述模式提取为通用组件:

8.1 注解式防抖

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Debounce {
    /** Redis Key 的 SpEL 表达式,基于方法参数计算 */
    String key();
    /** TTL 秒数 */
    long ttl() default 30;
    /** 防抖触发时的返回策略:SILENT(返回成功) / REJECT(返回频繁) */
    DebouncePolicy policy() default DebouncePolicy.SILENT;
}

8.2 AOP 切面

@Aspect
@Component
public class DebounceAspect {
​
    @Around("@annotation(debounce)")
    public Object around(ProceedingJoinPoint pjp, Debounce debounce) throws Throwable {
        String key = parseKey(debounce.key(), pjp.getArgs());
        boolean isFirst = redisUtil.setIfAbsent(key, "processing", debounce.ttl());
        if (!isFirst) {
            // 根据 policy 决定返回策略
            return handleDuplicate(debounce.policy());
        }
        try {
            Object result = pjp.proceed();
            // 可选:将结果写入 Redis 供后续读取
            return result;
        } catch (Exception e) {
            redisUtil.del(key);
            throw e;
        }
    }
}

8.3 使用示例

@Debounce(key = "'APPROVE:' + #req.examineId + ':' + #uid", ttl = 30)
public UniResult publishApprove(PublishApproveReqDTO req, String uid) {
    // 直接写业务逻辑,防抖由切面处理
}

注解式方案的局限:本案例中"重复点击需读取结果并执行补偿"的逻辑与业务强耦合,难以泛化。适合泛化的是纯防抖(首次执行/后续拒绝)部分;补偿逻辑仍需业务方法自行处理。


九、拓展:TTL 选择的考量

9.1 本案例 TTL = 30 秒

审批逻辑的典型耗时:

  • DB 读写:< 100ms

  • 钉钉 API 调用(updateCard):< 2s

  • 总计:< 5s

TTL = 30s 提供了 6 倍安全余量,覆盖:

  • 网络抖动导致的 API 超时

  • DB 慢查询

  • GC 停顿

9.2 TTL 选择的通用公式

TTL = max(业务P99耗时 × 安全系数, 最小可接受锁定时间)

场景

建议安全系数

说明

轻量级操作(DB 读写)

3~5

覆盖偶尔的慢查询

外部 API 调用

5~10

外部服务延迟不可控

长时任务

不适合 SETNX

应改用 Redisson Lock + watchdog

9.3 TTL 过长/过短的影响

  • 过短:业务还未完成锁就过期,第二个请求进入,防抖失效

  • 过长:业务异常后用户长时间无法重试;已完成的 Key 长期占用 Redis 内存


十、总结:防抖幂等补偿三板斧

防抖

幂等

补偿

解决什么问题

重复请求重复执行

同一操作多次执行结果一致

成功但未生效时自动修复

本案例实现

Redis SETNX + 三态值

updatePreCardStatus 覆盖写

repairCardStatus 同步补偿

关键设计

Key 维度 = 防抖最小单元

下游 API 本身幂等

失败降级、数据自足

拓展方向

注解 + AOP 泛化

请求级唯一 ID + 去重表

异步 MQ / 定时巡检兜底

核心思想:

  1. 防抖挡住 99% 的重复请求,只有首次点击走完整逻辑

  2. 幂等保证即使漏防抖也不会产生副作用,审批逻辑本身是幂等的

  3. 补偿兜住最后 1%,首次执行成功但外部副作用(卡片更新)失败时,后续点击触发修复

三层防线环环相扣,缺一不可:

  • 只有防抖没有幂等 → 锁过期后重复执行可能产生副作用

  • 只有幂等没有防抖 → 每次都执行完整逻辑,浪费资源

  • 只有防抖+幂等没有补偿 → 卡片状态不一致无法自愈

密码学1:前言和哈希函数 2026-06-14
深入剖析Tomcat的底层架构与运行机制 2026-07-02

评论区