目录
方案选型(银行保险生产常用)
技术栈
实战完整代码
1. 幂等数据库表(金融必存,兜底)
2. 幂等状态枚举
3. 自定义注解:@Idempotent 方便 AOP 切面实现幂等(银行保险业务)
4. AOP 切面核心实现(幂等核心逻辑)
5. 场景一:我方服务 Feign 调用银行外部支付接口(银行保险支付)
6. 场景二:银行异步回调我方保险接口(银行回调 Controller)
7. 场景三:RocketMQ 消费保险投保消息(防止 MQ 重复消费)
8. 关键金融坑点(银行保险业务重点)
坑 1:调用外部银行接口成功,本地服务宕机
坑 2:银行接口是结果未知(网络超时)
坑 3:幂等 key 不要用 requestId,网关重试 requestId 会变
坑 4:锁时间不能太短,银行外部接口有时候慢,几十秒才返回
坑 5:失败状态要允许重试
9. 配套:定时任务补偿(金融生产必备)
10. 其他补充
整体流程总结
业务背景:银行保险场景,调用银行外部支付 / 对账 / 投保回执接口,网络抖动、重试、MQ 重复消费、前端重复提交,会导致:重复扣款、重复投保、重复生成保单、重复对账。 核心痛点:外部银行接口不保证只回调一次;我方服务重试;网关重试;MQ 重复投递。 幂等目标:同一个业务请求,无论调用 N 次,业务结果只生效一次,返回相同响应。
方案选型(银行保险生产常用)
| 数据库唯一索引 | 新增类(保单、流水) | ✅ 基础兜底 |
| Token 令牌机制 | 前端提交、网关入口 | ✅ 投保提交 |
| 业务唯一键 + Redis 幂等锁(重点实战) | 调用外部银行接口、MQ 消费、回调接口 | ⭐⭐⭐⭐⭐ 本案例主方案 |
| 状态机流转 | 订单 / 保单状态变更 | ✅ 保单状态流转校验 |
| 数据库乐观锁 | 更新操作 | ✅ 保单更新 |
银行外部接口场景:我方主动调用银行接口;银行异步回调我方接口。两套都要做幂等。 幂等 Key:业务全局唯一标识,不能用 requestId(网关会变),要用业务号:投保单号policyNo、交易流水号tradeNo。
幂等核心逻辑
- 如果已处理完成:直接返回历史结果,不再调用银行外部接口
- 如果处理中:返回处理中,拒绝重复执行业务
- 如果不存在:抢占幂等标记,执行业务(调用银行外部接口)
⚠️重点:调用第三方银行接口一定要防回滚漏洞:抢占了幂等标记,但是调用银行接口成功,本地服务宕机,不能把幂等 key 删掉,否则重启后又会重复调用银行。
技术栈
- SpringCloud Alibaba(Nacos)
- SpringBoot 2.7.x
- RedisTemplate / Redisson(推荐 Redisson 分布式锁,避免 redis 超时问题)
- Mysql 幂等记录表
- Feign 调用银行外部接口
- MQ (RocketMQ) 保险投保消息消费
生产建议:Redis 做快速拦截,Mysql 幂等表做持久兜底,防止 Redis 宕机丢失幂等数据。双保险,金融业务不能只依赖 Redis。
实战完整代码
1. 幂等数据库表(金融必存,兜底)
sql
CREATE TABLE `biz_idempotent_record` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`biz_type` varchar(32) NOT NULL COMMENT '业务类型:BANK_PAY银行支付、INSURE_APPLY投保申请、BANK_CALLBACK银行回调',
`biz_no` varchar(64) NOT NULL COMMENT '业务唯一号:交易流水/投保单号,幂等key',
`status` tinyint NOT NULL COMMENT '0处理中 1处理成功 2处理失败',
`response_data` text COMMENT '外部接口返回结果,重复请求直接返回该数据',
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_biztype_bizno` (`biz_type`,`biz_no`) COMMENT '唯一索引,数据库兜底幂等,防止重复插入'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务幂等记录表';
2. 幂等状态枚举
java
public enum IdempotentStatusEnum {
PROCESSING(0, "处理中"),
SUCCESS(1, "处理成功"),
FAIL(2, "处理失败");
private final int code;
private final String desc;
//构造 getter
}
3. 自定义注解:@Idempotent 方便 AOP 切面实现幂等(银行保险业务)
注解作用:加在 Feign 调用银行外部接口方法、Controller 回调接口、MQ 消费方法上。
java
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
/**
* 业务类型 对应biz_idempotent_record biz_type
*/
String bizType();
/**
* SPEL表达式,获取业务唯一编号bizNo
* 例如 "#tradeNo" "#policyApplyDTO.policyNo" "#param.tradeNo"
*/
String bizNoSpel();
/**
* 幂等锁过期时间 秒,调用银行外部接口最大超时时间,比如300s,防止死锁
*/
long lockExpireSeconds() default 300;
/**
* 是否需要存储返回结果,重复请求直接返回旧结果
*/
boolean saveResponse() default true;
}
4. AOP 切面核心实现(幂等核心逻辑)
使用 Redisson RLock 分布式锁,解决 Redis 单点、锁续期问题,金融环境优先 Redisson,不要手写 setnx。 流程:解析 spel 拿到 bizNo → 尝试获取分布式锁 → 查询幂等记录表
java
@Aspect
@Component
@Slf4j
public class IdempotentAspect {
@Autowired
private RedissonClient redissonClient;
@Autowired
private BizIdempotentRecordMapper idempotentRecordMapper;
@Autowired
private SpelExpressionParser spelExpressionParser;
private static final String LOCK_PREFIX = "idempotent:lock:";
@Around("@annotation(idempotentAnno)")
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotentAnno) throws Throwable {
String bizType = idempotentAnno.bizType();
String bizNoSpel = idempotentAnno.bizNoSpel();
long lockExpire = idempotentAnno.lockExpireSeconds();
boolean saveResp = idempotentAnno.saveResponse();
//1 SPEL解析获取业务唯一号 bizNo
Object[] args = joinPoint.getArgs();
EvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < args.length; i++) {
context.setVariable("param" + i, args[i]);
}
//兼容直接写 #tradeNo
Expression expression = spelExpressionParser.parseExpression(bizNoSpel);
String bizNo = expression.getValue(context, String.class);
if(StringUtils.isBlank(bizNo)){
throw new RuntimeException("业务唯一编号bizNo不能为空,无法做幂等");
}
String lockKey = LOCK_PREFIX + bizType + ":" + bizNo;
RLock rLock = redissonClient.getLock(lockKey);
//2 获取分布式锁
boolean lockOk = rLock.tryLock(0, lockExpire, TimeUnit.SECONDS);
if(!lockOk){
log.warn("幂等获取锁失败,重复请求 bizType:{},bizNo:{}",bizType,bizNo);
throw new RuntimeException("请求正在处理中,请勿重复提交");
}
try {
//3 查询数据库幂等记录兜底
BizIdempotentRecord record = idempotentRecordMapper.selectByBiz(bizType, bizNo);
if(record != null){
if(IdempotentStatusEnum.SUCCESS.getCode() == record.getStatus()){
//已经处理完成!直接返回历史存储的结果,不再调用银行外部接口!!幂等核心
log.info("幂等命中,直接返回历史结果 bizType:{},bizNo:{}",bizType,bizNo);
if(saveResp){
//json反序列化返回对象;如果是controller可以返回json
return JSON.parse(record.getResponseData());
}else {
return null;
}
}
if(IdempotentStatusEnum.PROCESSING.getCode() == record.getStatus()){
throw new RuntimeException("业务处理中,禁止重复调用银行接口");
}
//status=FAIL,允许重试,往下走执行业务
}else{
//无记录,插入幂等记录 状态=处理中
BizIdempotentRecord insert = new BizIdempotentRecord();
insert.setBizType(bizType);
insert.setBizNo(bizNo);
insert.setStatus(IdempotentStatusEnum.PROCESSING.getCode());
insert.setResponseData(null);
try {
idempotentRecordMapper.insert(insert);
}catch (DuplicateKeyException e){
//唯一索引冲突,并发场景另一个线程插入成功,视为重复请求
throw new RuntimeException("并发重复请求");
}
}
// ============ 执行业务逻辑:调用银行外部Feign接口 ============
Object result = joinPoint.proceed();
//业务执行成功,更新幂等记录为成功,保存第三方返回报文
BizIdempotentRecord update = new BizIdempotentRecord();
update.setBizType(bizType);
update.setBizNo(bizNo);
update.setStatus(IdempotentStatusEnum.SUCCESS.getCode());
if(saveResp){
update.setResponseData(JSON.toJSONString(result));
}
idempotentRecordMapper.updateByBiz(update);
return result;
}catch (Throwable e){
log.error("幂等执行业务异常 bizType:{},bizNo:{}",bizType,bizNo,e);
//⚠️金融场景关键点:这里不能删除幂等记录!
//如果调用银行外部接口已经扣款成功,我方本地抛出异常,删除幂等key会导致重启后再次扣款!
//只更新状态为失败,人工/定时任务根据bizNo判断是否重试银行接口
BizIdempotentRecord failRecord = new BizIdempotentRecord();
failRecord.setBizType(bizType);
failRecord.setBizNo(bizNo);
failRecord.setStatus(IdempotentStatusEnum.FAIL.getCode());
idempotentRecordMapper.updateByBiz(failRecord);
throw e;
}finally {
if(rLock.isHeldByCurrentThread()){
rLock.unlock();
}
}
}
}
Mapper 简单示例
java
public interface BizIdempotentRecordMapper{
BizIdempotentRecord selectByBiz(@Param("bizType") String bizType,@Param("bizNo")String bizNo);
int insert(BizIdempotentRecord record);
int updateByBiz(BizIdempotentRecord record);
}
5. 场景一:我方服务 Feign 调用银行外部支付接口(银行保险支付)
java
@Service
@Slf4j
public class BankInsurancePayService {
@Autowired
private BankOuterFeignClient bankOuterFeignClient;
/**
* 调用银行外部扣款接口
* bizNo:银行交易流水号,全局唯一
*/
@Idempotent(bizType = "BANK_PAY", bizNoSpel = "#tradeNo", lockExpireSeconds = 300)
public BankPayResp bankPay(String tradeNo, Long amount){
log.info("开始调用银行外部扣款接口 tradeNo={}",tradeNo);
BankPayReq req = new BankPayReq();
req.setTradeNo(tradeNo);
req.setAmount(amount);
//feign调用银行外部http接口
BankPayResp resp = bankOuterFeignClient.pay(req);
log.info("银行外部接口返回 resp={}",resp);
return resp;
}
}
效果:
- 第一次调用:插入幂等记录 processing → 调用银行接口成功 → 更新为 SUCCESS,存返回报文
- 网络重试再次调用同一个 tradeNo:AOP 直接读取数据库 responseData 返回,Feign 不会发起第二次 http 请求到银行,避免重复扣款!
6. 场景二:银行异步回调我方保险接口(银行回调 Controller)
银行会重复回调保险投保结果接口,必须幂等,防止重复生成保单。
java
@RestController
@RequestMapping("/bank/callback")
public class BankCallbackController {
@Autowired
private InsureService insureService;
/**
* 银行投保结果回调,bankTradeNo银行侧交易号作为幂等key
*/
@PostMapping("/insure")
@Idempotent(bizType = "BANK_CALLBACK", bizNoSpel = "#callbackDTO.bankTradeNo",lockExpireSeconds=600)
public R<?> insureCallback(@RequestBody BankCallbackDTO callbackDTO){
//执行业务:生成保单、更新投保状态
return insureService.handleBankCallback(callbackDTO);
}
}
7. 场景三:RocketMQ 消费保险投保消息(防止 MQ 重复消费)
RocketMQ 至少一次投递,必然重复消息,业务不能重复投保。
java
@Component
@RocketMQMessageListener(topic = "INSURE_TOPIC", consumerGroup = "insure-consumer-group")
public class InsureMQConsumer implements RocketMQListener<InsureMessage> {
@Autowired
private InsureService insureService;
@Override
@Idempotent(bizType = "INSURE_APPLY", bizNoSpel = "#message.policyNo",lockExpireSeconds=600)
public void onMessage(InsureMessage message) {
//投保业务逻辑,生成保单
insureService.doInsure(message);
}
}
8. 关键金融坑点(银行保险业务重点)
坑 1:调用外部银行接口成功,本地服务宕机
错误做法:redis 成功才执行业务,redis 删除 key。宕机 key 丢失,重试再次调用银行,重复扣款。 ✅本方案:先落 mysql 幂等记录状态 processing,即使 Redis 全挂,Mysql 唯一索引 + 记录依然拦截重复请求。Redis 锁做并发控制,Mysql 做持久化兜底。
坑 2:银行接口是结果未知(网络超时)
我方调用银行超时,不知道银行有没有扣款。
不能直接标记 SUCCESS!业务层需要额外做银行查询接口轮询对账,根据银行查询结果更新幂等表状态。 定时任务:扫描幂等表 status=processing 超过 5 分钟的数据,调用银行查询接口核对真实状态。
坑 3:幂等 key 不要用 requestId,网关重试 requestId 会变
幂等 key 必须是业务侧真实唯一编号:tradeNo policyNo bankTradeNo。
坑 4:锁时间不能太短,银行外部接口有时候慢,几十秒才返回
lockExpireSeconds 设置大于第三方接口最大超时时间,Redisson 自动看门狗续期。
坑 5:失败状态要允许重试
status=FAIL,幂等切面允许业务重新执行;status=SUCCESS 永远拦截。
9. 配套:定时任务补偿(金融生产必备)
扫描biz_idempotent_record表,处理长时间处于PROCESSING的数据,主动调用银行查询接口核对真实交易状态,修正幂等记录。
java
@Component
@EnableScheduling
public class IdempotentCompensateTask {
@Scheduled(fixedRate = 60000)
public void compensate(){
//查询处理中超过5分钟记录
List<BizIdempotentRecord> list = idempotentRecordMapper.selectProcessingTimeout();
for(BizIdempotentRecord record : list){
//调用银行查询接口,查询该tradeNo真实状态
BankQueryResp resp = bankOuterFeignClient.queryTrade(record.getBizNo());
if(resp.isTradeSuccess()){
//银行侧已经成功,更新幂等记录为成功,执行补业务逻辑
}else if(resp.isTradeFail()){
record.setStatus(IdempotentStatusEnum.FAIL.getCode());
idempotentRecordMapper.updateByBiz(record);
}else{
//银行处理中,跳过,下次定时再扫
}
}
}
}
10. 其他补充
yaml
feign:
client:
config:
default:
connectTimeout: 10000
readTimeout: 30000
retryer: feign.Retryer.Default #开启重试,前提业务方法必须加@Idempotent
整体流程总结
- SUCCESS:直接返回历史结果,不访问银行外部接口
- PROCESSING:拒绝请求
- FAIL:允许重试执行业务
网硕互联帮助中心






评论前必须登录!
注册