发奖接口先处理重复请求

发奖接口先看重复发奖。

这类接口一般有几个共性:

1、上游会重试

2、用户会重复点击

3、下游结果可能返回慢

4、同一笔业务可能从多个入口打进来

核心不是怎么发,而是怎么保证只发一次。

入口顺序不要写反

入口顺序直接定死:

1、先做签名或合法性校验

2、再看这笔业务是否已经成功处理过

3、再做并发互斥

4、最后才真正调发奖能力

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
checkSendPrizeSign(req);
req.setUnionId(groupService.getGroupUnionId(req));

String sentKey = RedisKey.getQuestionSendPrizeIsHaveKey(req.getPrizeId(), req.getBizNo());
if (StringUtils.isNotBlank(redisUtil.get(sentKey))) {
return alreadySent();
}

String lockKey = RedisKey.getLockKey(req.getPrizeId() + "_" + req.getUnionId());
boolean locked = redisUtil.lock(lockKey, req.getUnionId(), RedisExpire.ONE_SECOND * 5);
if (!locked) {
throw new BizException("请求过快");
}

return creSendPrize(sentKey, req.getPrizeId(), req.getUnionId(), req.getBizNo(), req.getDevice());

这里不能换顺序。先锁再查缓存、先调下游再记成功,都会放大重复发奖问题。

已发标记和互斥锁不是一回事

这两层经常被混用,但职责不同。

已发标记解决的是:

1、同一笔业务已经成功处理过

2、后续重复请求可以快速返回

互斥锁解决的是:

1、两次请求几乎同时打进来

2、缓存还没写入,但两边都准备往下执行

只做缓存挡不住并发,只做锁记不住结果。两层都要有。

锁粒度要贴住业务唯一动作

锁太粗拖吞吐,锁太细挡不住重复。

我更常见的粒度是:

1、奖品维度 + 用户维度

2、业务单号维度

3、活动维度 + 用户维度 + 行为维度

先定业务唯一动作,再定锁粒度。

成功标记要后写

这一步最容易写错。

我更认可的写法是:

1
2
3
4
5
QuestionSendPrizeResponse response = campaignRuleEngineService.executePost(request, QuestionSendPrizeResponse.class, errorCode);
QASendPrizeResponse result = QASendPrizeResponse.getQASendPrizeResponseByCode(response.getCode(), response.getMsg());
if (result.getStatus().intValue() == GroupGameSendPrizeEnum.SUCCESS.getResult()) {
redisUtil.set(sentKey, JSON.toJSONString(response), RedisExpire.ONE_WEEK);
}

顺序就是:

1、先拿到下游成功结果

2、再写成功标记

3、后续重复请求直接走已处理分支

没拿到下游成功结果就先写标记,后面一定会留下脏状态。

删锁不等于问题结束

锁放在 finally 里释放是基本操作,但问题不会因此结束。

真正危险的情况是:

1、下游超时

2、当前服务不知道这次到底成功没成功

3、finally 先把锁删掉了

4、后续请求再次打进来

锁只解决并发窗口,不解决幂等。

真正兜底还是这三件事:

1、稳定业务唯一键

2、下游是否支持幂等

3、成功结果是否被可靠记录

高并发下要看锁竞争

活动峰值时,同一用户、同一奖品的请求会在几百毫秒内打满。这里要看的不是“有没有锁”,而是锁失败后怎么兜底。

1
2
3
4
5
6
7
8
9
10
// 典型现象:大量请求卡在抢锁或直接被快速失败
@GetMapping("/send")
public Result send(PrizeReq req) {
String lockKey = "prize_lock_" + req.getUserId() + "_" + req.getPrizeId();
boolean locked = redisUtil.lock(lockKey, UUID.randomUUID(), 5000);
if (!locked) {
return new Result("当前用户请求过于频繁,请稍后重试"); // 大量失败
}
// ...
}

这类失败短时间内持续升高时,不能只靠“请求过快”兜住,要按业务重要性决定是快速失败还是走兜底路径。

常见做法就两种:

1
2
3
4
5
6
7
8
9
10
11
// 方案1:关键奖品降级到DB唯一约束或行锁兜底
if (!locked) {
if (isCriticalPrize(req.getPrizeId())) {
return sendPrizeWithDBLock(req);
} else {
return new Result("请求过快");
}
}

// 方案2:按业务键路由到串行队列,避免同一笔请求并发执行
prizeQueue.submit(req.getBizNo(), () -> processPrizeLogically(req));

不要直接写成单机单线程池。线上一般按用户、业务单号或奖品维度做分片串行,不然吞吐很快就没了。

Redis 不可用时怎么降级

Redis 不可用时,已发标记查不了,互斥锁也加不了。这时最怕代码还按 Redis 一定可用继续往下跑。

降级目标先收窄:先保不重复,再保性能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 先尝试Redis,失败时降级到DB检查或短期本地缓存
String sentKey = "prize_" + req.getBizNo();

// 1. 尝试Redis
try {
if (redisUtil.exists(sentKey)) {
return alreadySent(); // 缓存命中
}
} catch (Exception e) {
logger.warn("Redis unavailable, fallback to db/local cache", e);

// 2. 本地缓存只能做很短期的挡重
if (localCache.getIfPresent(sentKey) != null) {
return alreadySent();
}

// 3. 最终还是回DB确认这笔业务是否已经成功
if (prizeService.isPrizeAlreadySent(req.getBizNo())) {
return alreadySent();
}
}

// 处理完后优先写Redis,失败时退回本地缓存
try {
redisUtil.set(sentKey, "sent", 86400);
} catch (Exception e) {
logger.warn("Redis set failed, using local cache", e);
localCache.put(sentKey, "sent", Duration.ofDays(1));
}

Redis 异常必须单独告警,因为它直接影响幂等兜底:

1
2
3
4
5
6
try {
redisUtil.ping();
} catch (Exception e) {
metrics.incrementCounter("redis_unavailable_alert");
enableFallbackMode();
}

失败状态要分清

对用户可以统一提示,对系统内部必须拆开状态:

1、失败发生在签名校验、加锁、远程调用还是结果转换

2、下游请求到底有没有真正发出

3、这笔请求再次重试是否安全

4、是否需要人工补偿

如果这些状态全都混成一个 SEND_ERROR,后面排查会非常痛苦。

日志字段至少保留这些

日志里至少留这些字段:

1、业务单号

2、用户标识

3、奖品标识

4、锁是否拿到

5、下游返回码

6、是否写入成功标记

这样排查时才能直接看出:是重复请求被拦住了,还是下游成功了但本地没记成功标记。

小结

发奖接口防重复,不是加一个 Redis key 就完事。

入口校验、已发标记、互斥锁、下游幂等、成功结果记录,这几层一起做,链路才稳。