发奖接口先处理重复请求
发奖接口先看重复发奖。
这类接口一般有几个共性:
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
| if (!locked) { if (isCriticalPrize(req.getPrizeId())) { return sendPrizeWithDBLock(req); } else { return new Result("请求过快"); } }
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
| String sentKey = "prize_" + req.getBizNo();
try { if (redisUtil.exists(sentKey)) { return alreadySent(); } } catch (Exception e) { logger.warn("Redis unavailable, fallback to db/local cache", e); if (localCache.getIfPresent(sentKey) != null) { return alreadySent(); } if (prizeService.isPrizeAlreadySent(req.getBizNo())) { return alreadySent(); } }
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 就完事。
入口校验、已发标记、互斥锁、下游幂等、成功结果记录,这几层一起做,链路才稳。