云计算百科
云计算领域专业知识百科平台

SpringCloud 银行保险业务 外部调用接口幂等实战

目录

方案选型(银行保险生产常用)

技术栈

实战完整代码

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。

幂等核心逻辑

  • 请求进来,拿到业务唯一编号 tradeNo/policyNo
  • 先查幂等记录:
    • 如果已处理完成:直接返回历史结果,不再调用银行外部接口
    • 如果处理中:返回处理中,拒绝重复执行业务
    • 如果不存在:抢占幂等标记,执行业务(调用银行外部接口)
  • 业务成功:标记幂等为完成,缓存返回结果
  • 业务失败:删除 / 置为失败,允许后续重试
  • ⚠️重点:调用第三方银行接口一定要防回滚漏洞:抢占了幂等标记,但是调用银行接口成功,本地服务宕机,不能把幂等 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 → 尝试获取分布式锁 → 查询幂等记录表

  • status = 成功:直接返回历史 response,不执行原方法(不再调用银行外部接口)
  • status = 处理中:抛出重复请求异常
  • status = 失败 / 无记录:执行业务逻辑(调用银行 Feign 接口)
  • 执行成功:保存幂等记录状态成功,存储外部银行返回报文
  • 执行异常:更新状态为失败;⚠️如果第三方银行已经执行成功本地宕机,这里不能删除幂等记录!
  • 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. 其他补充

  • 前端提交投保:搭配 Token 幂等,防止前端狂点,Token 只拦截入口;真正防重复还是后端业务幂等表,因为外部回调、MQ 不走前端。
  • 保单内部更新操作:搭配乐观锁 update policy set status=2 where id=xxx and status=1,防止状态错乱。
  • Feign 调用银行接口,做好超时、重试配置;重试一定配合幂等,否则重试 = 重复扣款。
  • yaml

    feign:
    client:
    config:
    default:
    connectTimeout: 10000
    readTimeout: 30000
    retryer: feign.Retryer.Default #开启重试,前提业务方法必须加@Idempotent

    整体流程总结

  • 重复请求进来,AOP 切面获取业务唯一编号
  • Redisson 分布式锁拦截并发
  • 查询 Mysql 幂等记录表
    • SUCCESS:直接返回历史结果,不访问银行外部接口
    • PROCESSING:拒绝请求
    • FAIL:允许重试执行业务
  • 执行业务调用银行 Feign 接口
  • 成功更新幂等记录,保存银行返回报文;失败更新状态为 FAIL
  • 定时补偿任务扫描处理中记录,调用银行查询接口对账兜底。
  • 赞(0)
    未经允许不得转载:网硕互联帮助中心 » SpringCloud 银行保险业务 外部调用接口幂等实战
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!