幂等防重最佳实践

1、幂等定义

任意多次执行 和 执行一次 产生的业务影响相同

2、业务场景

系统概述潜在风险
支付系统同一笔订单,用户点击两次支付用户重复支付
库存系统同一笔调度请求,库存重复消费处理超卖、库存错误

3、Bad case

问题概述详情
缺少分布式锁(查询)缺少锁并发修改售卖量,导致售卖量被覆盖
分布式锁过期时间设置不合理分布式锁超时时间小于事务执行时间事务重复提交,锁自动过期,且第一个事务还未执行完导致锁失效
数据库大小写导致幂等冲突数据库字段大小不写敏感DB00x 和 Db00X,对应的是同一个号。插入db会唯一键冲突
幂等UniqueKey异常未抛出数据库捕获幂等key异常异常未抛出,导致下游事务重复幂等异常,理论上需要抛出来,终止流程。
数据一致性问题服务A依赖服务B,B服务超时(实际事务已成功)或主从延时A事务置为失败,且未重试导致实际上服务B有数据,但是因为超时,服务A没有拿到数据。服务A后续流程都”业务“上失败了时间上服务B写入数据了,服务A读的时候,主从延时没有读取到。方法一:走主,但可能压力大方法方法二:正常情况下走从,A的total和B获取到的total不一致时再走主
上下游任务状态一致性问题A服务异常终止了,任务状态置为失败同时需要告知上游,此任务失败了。保证上下游任务状态一致性A服务的任务失败了,但是状态还是初始化告诉上游的B服务,任务状态为失败(终态)造成上下游任务状态不一致

4、目标

调用方实现合理的重试策略,被调用方实现应对重试的幂等策略

  • 判断出重复请求

单据号幂等、任务状态防重

  • 处理重复请求

一般是拒绝、忽略、日志打出来

5、方案:

5.1 明确定义具体业务场景下什么是相同请求

场景分类常见case识别方案
有业务唯一标识同一个单据号下发两次任务号以唯一标识判断请求是否重复
无业务唯一标识“相同”类型的触发任务:同天、同地区、同业务类型。算是相同类型的任务走主查询db,任务A是否已经存在了,且状态为未完成。是的话,则相同类型的任务B需要被拒绝掉

5.2 判断重复方式

MQ、接口调用、数据库唯一键

约束方式优势劣势建议使用业务场景
唯一键约束数据库UniqueKey利用存储优势,确保数据的唯一性灵活性较差业务UniqKey发生变动或者不动场景的UniqKey不一致,会带来较大改动高并发下容易产生主从延迟,影响业务订单、支付等常见唯一单据使用操作
insert前先select减少主库压力资源串行【推荐】数据库集群模式下,主从延迟,会带来风险核心的防重逻辑,可以走主select极端场景下,防不住并发(两个相同的MQ同时过来),即使走主页可能防不住并发库存、余额扣减操作
分布式锁redis的setNx内存计算,高并发可以防重、也可以防并发【推荐】需要防并发 + 幂等的场景MQ可能重复,使用redis的setNx做幂等相同的MQ可能同时过来,使用redis的setNx防并发
防重表灵活性较高,可根据不同的业务场景建立UniqKey带来额外存储基本是等量业务单据量的存储业务唯一key变更频繁
状态机约束依据业务本身流转进行乐观锁约束可以防重、也可以防并发【推荐】任务、单据有对应的完整状态机。
悲观锁约束绝对化串行,利用数据库特性保证资源操作的原子性容易出现死锁,对数据库性能有影响账户流水余额操作:依据某条流水、增或减用户余额不建议使用

唯一键约束-利用数据库的UniqueKey

  • 最为常见的约束重复单据的方式,利用数据库的唯一键进行兜底

  • 加了唯一索引之后,第一次请求数据可以插入成功。但后面的相同请求,插入数据时会报Duplicate entry 异常,表示唯一索引有冲突

  • 业务中捕获DuplicateKeyException异常,并返回成功

  • 和状态机update方式思想类似,不过状态机方式不用担心后续唯一键会改变 以及 也不用吃异常

唯一键约束-insert前先select

  • 先查,没有再写逻辑

  • 但是主从延时场景下,select可能不准 -> ,主库压力不大场景下,可以走主select

  • 但是高并发的场景下,t1和t2,即使都select主,发现都没有数据,结果写时,则会有一个写报错或不符合预期。

    可以在select之前,再加一层redis锁setNx,专门用来防并发

唯一键约束-分布式锁[redission、zk]

但分布式锁只能保障一段时间内最多只有一个操作,无法保障无论什么时候都只允许一个操作(除非不设置锁超时的时间也不释放锁)

方式适合场景优点缺点
关系数据库mysql基于幂等key排除重复场景资源抢占不激烈数据库特有原子性和一致性,使用简单基于事务,所以难以实现“阻塞等待”特性,锁竞争多时链接多不支持锁超时功能,需额外任务实现高并发下性能相对较差、竞争过多是会导致链接变多
K-V存储redis高并发资源强占场景可以支持防并发+防重性能好,时延低适合高并发场景天然支持ttl解锁集群模式下,master阶段宕机有都是数据风险(redlock一定程度商可解决)TTL机制强依赖系统时钟,在极端情况下 时钟漂移问题可能会导致问题
分布式一致性协调服务zookeeper持有锁场景较长的场景对锁安全性要求较高天生为分布式协调而生,依据自有特性天然支持读写性能较差**,在高并发场景下不适合**

状态机约束

  • 任务状态流转:已创建1—>已落表2 ->已计算3 -> 全部完成8
  • eg:两个重复的MQ(taskCode = A)并发的进来了。二者都需要将任务状态从1update到2。update更新语句执行结果返回result = 1代表更新成功
  • thread1更新时,db中任务A状态为1,t1将A从1更新为2,result = 1
  • 同时thread2也进行更细操作,db中任务A状态为2了,将A从2更新为2,result = 0
  • 可以通过result值1和0,判断哪个thread是第一个MQ。t2是重复的MQ,后续操作就可以终止了。即防重了,又防并发了

点击展开内容

int result = update();
if(result <= 0) { 
	return;//是重复的任务,可以忽略掉
}

5.3 对于重复的处理

影响方方案常见case概述
上游返回错误前端重复提交同一时间创建多个相同类型的任务同时执行会带来业务损失,且需要让上游感知
返回成功异步消息上游重复发送eg:如果返回上游成功,则下游的任务状态也应该是成功,保持状态的一致性。返回上游成功了,上游可能有重试机制,对于重试的又需要防重。防重逻辑中可能涉及对上一个任务状态的判断,若上一个任务状态上下游没有保持状态的一致性,则有可能导致防重失败。
下游放行下游暂不了解场景

5.4 重试

方案简介场景
retryLog对于失败的任务插入DB 日志按照一定周期轮询失败任务,进行重试每隔5min重新跑一次定时任务下游任务执行返回失败,上游自身,再重试需要明确哪些异常、状态才能重试
消息队列异步解耦,并发高单个消费保证 at least once,也能保证任务一定被执行
最大努力通知型事务可靠的重试操作,保障业务数据的最终一致性支持TCC、柔性事务等

6、实践

6.1 订单支付

代码1

public String buildLockName(String orderId){  
    return distributedLockNamePrefix + "_" + orderId; 
}

代码2

public static final  int orderPayLockExpireTime = 60; //分布式锁过期时间,单位秒    
 
public void payOrderId(String orderId){   
  String lockName = buildLockName(orderId);        
  Lock lock = distributedLockManager.getReentrantLock(lockName, orderPayLockExpireTime);        
  try{            
    // 尝试获取锁失败            
    if(!lock.tryLock()){                
      log.warn("尝试获取分布式锁失败,订单已经在处理中");                
      throw new Exception("订单已在处理中,请勿重复支付");           
    }            
    // 修改数据库订单状态为"支付中"            
    updateOrderToPaying(orderId);           
    // 真正处理支付请求            
    doOrderPay(orderId);           
    // 修改数据库订单状态为"支付成功"           
    updateOrderToSuccess(orderId);        
  }catch (Exception e){            
    // 异常处理....            
    handleException(e);        
  }finally {          
    lock.unlock();      
  }   
}

代码3

//更新数据库中的订单状态为支付中
public void updateOrderToPaying(String orderId) throws Exception { 
  // 根据订单id将订单更新为支付中        
  int row = orderRepo.updateOrderStatus(orderId, StatusEnum.ORDER_READY_TO_PAY.getCode(),StatusEnum.ORDER_PAYING.getCode();                                                       
   if (row < 1) {      
     throw new Exception("订单状态错误,订单初始状态不是待支付");    
   }    
 }

代码4

//真正处理支付请求  
public OrderPayResponse doOrderPay(String orderId){     
  try{           
    OrderPayRequest orderPayRequest = buildOrderPayRequest(orderId);      
    OrderPayResponse orderPayResponse = payTService.pay(orderPayRequest);  
    if(orderPayResponse.getCode != ResponseStatus.SUCCESS){        
      log.warn("请求支付平台返回错误,{}",orderPayResponse);            
      throw new BusinessException(OrderPayResponseCodeEnum.FAIL.getCode(),"支付平台返回错误");   
    }           
    return orderPayResponse;       
  } catch (Exception e){         
    log.error("请求支付平台发生异常,orderId:{}",orderId,e);         
    // 判断哪些异常是可以让业务发起重试          
    if(e instanceof ExceptionCanRetry){            
      // 可以直接让业务发起重试的异常             
      throw new BusinessException(OrderPayResponseCodeEnum.RETRY.getCode(),"支付平台返回错误,可重试");            }          
    // 其余情况都不能直接发起重试,需要明确告诉业务          
    throw new BusinessException(OrderPayResponseCodeEnum.NO_RETRY.getCode(),"支付平台返回错误,不可重试");        }  
}
//修改订单状态为支付成功
public void updateOrderToSuccess(String orderId){      
  // 根据订单id将订单更新为支付中       
  int row = orderRepo.updateOrderStatus(orderId, StatusEnum.ORDER_PAYING.getCode(),                StatusEnum.ORDER_PAY_SUCCESS.getCode();       
   if (row < 1) {         
     throw new Exception("更新订单成功错误,订单初始状态不是支付中");      
   }   
                                        }

代码6

` //处理异常   
  public void handleException(Exception e) throws Exception {    
  // 将订单状态变为失败        
  updateOrderToFail(orderId);        
  // 可以直接进行重试的异常        
  if (e instanceof ExceptionCanRetry) {           
    // write Into retryLog            
    // .....            throw e;        
  }        
  // 异常无法进行重试,报警处理,需要人工进行介入查看        
  alaramService.pushNotify();       
  throw e;    
} `
步骤说明代码示例
1.锁单据组装分布式锁key订单id唯一键,一个订单在同一时间只能有一个线程在处理代码1
尝试获取锁,统一使用分布式锁组件失败则直接报错(表明订单已经被其他线程处理中,支付中)代码2
2.更改数据库状态将数据库中订单状态由"待支付"变为"支付中"代码3
3.发送请求给下游支付平台发送请求给支付平台明确规定哪些异常可以重试,哪些不能重试代码4
4.修改订单状态为成功数据库中订单状态由"支付中"变为"支付成功"代码5
5.Catch Exception异常处理针对异常,需要分情况进行处理特定场景的异常可以由系统直接发起支付,将数据库中的单据改为失败状态,然后由重试retryLog拉起重新支付其余未知异常,默认系统无法自动处理,需要报警人工介入代码6
6.主动释放锁finally中释放分布式锁确保锁被主动释放、确保即使有异常也不会影响锁的释放见步骤1

为什么分布式锁获取失败需要直接丢弃,而不是阻塞等待

此处分布式锁的作用是为了防止单据重复,业务特性确认一个订单只能有一次请求,所以重复请求直接丢弃

为什么有了数据库状态的单向变更乐观锁,前面还需要缓存分布式锁去重操作?

减少极端情况下,大量重复请求对数据库的压力

给支付平台发送请求,为什么需要区分可重试和不可重试的两大类异常?

  1. 针对特定的错误类型,系统做到可重试,无需人工介入

    2.针对不可重试错误类型,必须人工和下游确认后再次进行发起,避免造成金额损失

6.2 账户余额扣减加操作:针对账户A,需要往账户A内加100元。

代码1

//给账户余额加上amount金额
public void addMountToAccount(String accountId,Long amount){       
  String lockName = buildLockName(accountId);        
  Lock lock = distributedLockManager.getReentrantLock(lockName, accountLockExpireTime);        
  try{            
    lock.lock();           
    // 获取账户原始余额           
    Long originAmount = getAccountAmount(accountId);             
    // 更新后的余额           
    Long newAmount = originAmount + amount;            
    // 更新账户余额           
    updateAccountAmount(accountId,newAmount,originAmount);        
  }catch (Exception e){            
    // 异常处理....            
    handleException(e);       
  }finally {       
    lock.unlock();     
  }   
} `

代码2

/**     
* 更新账户余额     *    
* @param accountId    账户id    
* @param originAmount 库中现有余额   
* @param newAmount    更新后的余额     
* @return     */   
public void updateAccountAmount(String accountId, Long originAmount, Long newAmount) {  
  // update account set amount = $newAmount where id = $accountId and amount = $originAmount;        
  int row = accountRepo.updateAmountByAccountId(accountId, originAmount, newAmount);        
  if (row < 1) {            
    throw new Exception("更新账户余额错误");     
  }    
}

代码3

@Schedule("reprocess_task")//执行定时补偿任务
public void reProcessTask(){     
  Integer offset = 0;       
  Integer limit = 10;       
  while (true) {          
    List<Task> taskList =  reTryLogRepo.getUnProcessList(offset,limit);         
    // 未找到待执行的任务,则退出         
    if(CollectionUtils.isEmpty(taskList)){         
      return;          
    }                      
    for(Task task:taskList){       
      // 重试任务中的重试次数+1       
      task.addRetryTime();          
      boolean resultFlag = doReprocess(task);         
      if(resultFlag){                 
        // task置为成功                 
        task.setTaskSuccess();          
      }                               
      // 更新DB中的task信息,            
      //.....                              
      // 判断任务是否达到重试最大次数             
      if(task.reachMaxRetryTime()){                  
        // 报警                
      }           
    }           
    offset += limit;     
  }   
} 
步骤说明代码示例
1.锁单据组装分布式Key账户id操作具有原子性,public static final String distributedLockNamePrefix = "account_id_";
失败则阻塞等待(账户操作串行执行)代码1public String buildLockName(Long accountId){ return distributedLockNamePrefix + “_” + accountId; }
2.获取账户现有余额根据账户id获取现有账户余额originAmount无需强制读主库 public Long getAccountAmount(String accountId){ return accountRepo.getAmountByAccountId(accountId); }
3.更新账户余额根据账户id和现有余额,更新账户新的余额代码2
4.Catch Exception异常处理所有异常一律进入reTryLog
5.重试任务处理定时任务处理重试任务代码3

为什么未获取到锁需要阻塞等待

  • 账户流水操作串行化,同一时间会有多个线程对账户余额进行加减操作
  • 获取锁失败,需要等待其他线程完成对账户的操作后,继续执行,不能直接丢弃

为什么不能直接用数据库余额累加。amount+=addAmount

例:账户余额有300元,需要给账户加100元。由于某种原因,重复请求给了DB。

  • 1.如果用amount+= addAmount。 update account set amount = amount+100 where id=1234 被执行两次
  • 2.如果用悲观锁条件。 update account set amount=400 where id=1234 and amount = 300 被执行两次。

明显方案1中会出现金额错误

为什么获取余额时,不用强制读主库,主从延迟会不会有影响?

不会有影响,update操作的where条件会强制读主库。当主从延迟存在时候,此时更新会报错

为什么所有异常一律进入reTryLog,不用区分不同异常来做重试

  • 因为针对账户余额操作,收到操作,表明最终必须成功
  • 账户余额操作不涉及分布式事务,没有下游的事务错误

7、中间件使用规范

场景事项备注
数据库插入数据[强制]有业务唯一键,数据库Unique key建立或幂等表
[强制]线上禁止delete语句(包括业务逻辑和刷数)
[强制]catch 唯一键重复异常 DuplicateKeyException备注不要cache SQLIntegrityConstraintViolationException 完整性约束异常,如果未给一个必填字段设值,也会抛这个异常。
[建议]没有业务唯一键,根据实际情况先select再insert
[建议]利用数据库select判重,防止主从延迟问题备注对于强一致业务需求,建议强制读主库数据库有Unique Key兜底,select从库
分布式锁使用[强制]分布式锁必须设置过期时间
[建议]有业务唯一属性,插入&&更新操作前使用分布式锁进行并发判重 public void process(){ try{ // 尝试获取锁 lock.tryLock(); //... }catch (Exception e){ log.error(""); return }finally{ lock.release(); } }
[建议]根据实际业务判断锁被占用的情况情况一:阻塞等待:常见于业务需要串行执行的情况 (例如订单支付的消息还在处理中,订单完成的消息就来了) 情况二:丢弃:常见于相同消息重复发送 (例如订单支付的消息同时来了两条)
[建议]合理设置锁过期时间
[强制]手动释放锁资源 public void process(){ try{ // 尝试获取锁 lock.tryLock(); //... }catch (Exception e){ log.error(""); return }finally{ lock.release(); }
[强制]确保释放锁是本线程加的锁,避免错误释放其他线程的锁
单库事务使用[强制]insert、update多表联动加事务注解@Transactional public void updateFoo(Foo foo) { // DB transaction1 // DB transaction2 }
[强制]有状态单据,乐观锁更新 //更新订单为成功状态 public void updateOrderToSuccess(String orderId){ // 将订单从ready状态更新为success // update order set status="success" where order_id=$orderId and status="ready" int affectedRows = orderDao.updateOrderFromReady(orderId); if(affectedRows < 1){ throw new Exception("update fail"); } }
[强制]事务注解内禁止调用RPC接口/MQ
跨库事务[强制]分布式事务失败后必须有重试机制概述系统自动重试,依赖reTryLog,最大努力通知,人工介入处理,高优报警
[建议]柔性事务保证最终一致性
重试机制[强制]重试机制达到最大次数后,需要人工介入
[强制]重试任务和正常业务流程共用方法类
[建议]分时段阶梯重试
异常处理[强制]异常信息必须向上抛出或者记录,不可直接丢弃 public void process(){ try{ doSomething(); }catch (Exception e){ throw BusinessException(); log.error("something is wrong"); return; } }
[强制]涉及金额打款等核心逻辑异常,报警必须人工感知
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值