本文以钉钉卡片防抖改造为案例,系统梳理后端防抖、分布式锁、状态补偿三方面的知识体系。
一、问题背景:审批卡片多次点击导致状态不一致
1.1 现象
钉钉卡片上点击「同意/拒绝」后,审批流程正常推进,但卡片 UI 状态未更新,仍显示待审批。用户自然再次点击,请求再次进入审批逻辑。
1.2 根因
1.3 核心矛盾
审批逻辑与卡片更新是两个独立操作,二者之间无原子性保证。审批成功但卡片更新失败时,系统缺少"感知失败并自动补偿"的能力。
二、知识点一:Redis SETNX 实现原子性首点击检测
2.1 原理
SETNX(SET if Not eXists)是 Redis 原生命令,仅当 key 不存在时才设置值,整个操作在 Redis 服务端原子执行:
SETNX key value
→ key 不存在:设置成功,返回 1
→ key 已存在:不设置,返回 0Spring Data Redis 封装为 opsForValue().setIfAbsent(key, value, timeout, unit)。
2.2 对比:非原子 hasKey + set 的竞态条件
改造前(有 Bug):
// ❌ 两个请求可能同时通过 hasKey 检查
if (!redisUtil.hasKey(operateLock)) {
redisUtil.set(operateLock, "1", 30);
// 执行审批逻辑...
}改造后(原子 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 的理由: 防抖场景下锁持有时间极短(审批逻辑通常 < 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"
│ │
返回"处理中" 调用补偿修复卡片
(不再执行审批) 返回 SUCCESS3.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 一个维度?
同一审批单可能有多级审批,不同审批人应各自独立防抖,互不影响:
拓展:更多维度的 Key 设计
原则: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 补偿方法的设计原则
4.4 补偿触发的四个时机
┌──────────────────────────────────────┐
│ 审批入口方法 │
├──────────────────────────────────────┤
时机1 ──────────→ │ SETNX 失败 + 值为 "1"/"0" │ ← 重复点击
│ → repairCardStatus() │
├──────────────────────────────────────┤
时机2 ──────────→ │ 审批状态 ≠ 0(已审批) │ ← 状态前置检查
│ → repairCardStatus() │
├──────────────────────────────────────┤
│ 审批状态 = 0,SETNX 成功 │
│ → 正常执行审批逻辑 │
└──────────────────────────────────────┘时机 1:用户重复点击,审批已完成,卡片可能未更新 → 补偿
时机 2:请求到达时审批已被其他通道处理完 → 补偿
4.5 拓展:补偿模式分类
本案例选同步补偿:卡片更新是 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 拓展:锁清理的三种策略
本案例选"异常即删":审批异常通常是临时故障(网络超时、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 各层级的职责划分
八、拓展:更通用的防抖框架提取
如果系统中有大量需要防抖的接口,可以将上述模式提取为通用组件:
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耗时 × 安全系数, 最小可接受锁定时间)9.3 TTL 过长/过短的影响
过短:业务还未完成锁就过期,第二个请求进入,防抖失效
过长:业务异常后用户长时间无法重试;已完成的 Key 长期占用 Redis 内存
十、总结:防抖幂等补偿三板斧
核心思想:
防抖挡住 99% 的重复请求,只有首次点击走完整逻辑
幂等保证即使漏防抖也不会产生副作用,审批逻辑本身是幂等的
补偿兜住最后 1%,首次执行成功但外部副作用(卡片更新)失败时,后续点击触发修复
三层防线环环相扣,缺一不可:
只有防抖没有幂等 → 锁过期后重复执行可能产生副作用
只有幂等没有防抖 → 每次都执行完整逻辑,浪费资源
只有防抖+幂等没有补偿 → 卡片状态不一致无法自愈