写点什么

充值卡业务接口的幂等设计 (附伪代码)

用户头像
林一
关注
发布于: 2021 年 05 月 25 日

购买充值卡

​ 购买充值卡的业务,一般是上游支付系统负责订单付款,下游充值卡系统负责给用户发放虚拟充值卡,第三方系统付款成功通过 MQ 异步通知,consumer 服务负责专门处理付款成功的消息,然后调用充值卡系统更新订单状态并发放虚拟充值卡,流程可以简化如下:


整个设计思路看上去很简单,但是有如下 2 个问题点需要进行考虑:


  • 多次收到付款成功消息应如何处理幂等?

  • consumer 服务调用充值卡系统超时或者报错如何重试,如何处理重试带来的幂等性问题?


以上两个问题,总的来说就是如何处理消息重发以及如何保证异常情况下订单状态的最终一致性

重试

​ 因为自己业务这边用到的是 kafka,consumer 的 spring-boot 版本为 2.2.6,spring 集成 kafka 用的是 spring-kafka,版本是 2.3.7**(这里如果要方便的进行重试,spring-kafka 必须要选用 2.3 以上,2.3 以上版本提供了 org.springframework.kafka.support.Acknowledgment#nack(long) 方法来快速的进行否定确认)**重试的伪代码如下:


try {    //处理订单付款成功逻辑} catch (Exception e) {    //打印相关日志,1S后重试    acknowledgment.nack(1000);    return;}//成功处理数据,手动提交偏移量acknowledgment.acknowledge();
复制代码


通过以上代码你可以很方便的对付款成功的逻辑进行重试,但是你这里要注意的一个点是:以上写的代码将会进行无限重试假设你此次的参数传的有问题,导致下游充值卡系统一直报错,那你不断进行重试就会导致后续其它订单的消息无法处理所以这里重试的次数需要自己根据业务进行把握。

付款成功幂等

​ 上面已经讲了如何方便的进行重试,但是上面还存在幂等的问题,这里考虑以下两种情况需要处理幂等问题:


  • 调用下游充值卡系统超时,实际下游已经处理成功,但是上游 consumer 认为处理失败

  • 由于 mq 的种种原因导致了消息重发


对于订单来说,我们一般都有一个 pay_status 来表示订单的付款状态,那最简单判断订单是否已经支付成功过的方式是不是可以用如下代码来表示:


@Transactional(rollbackFor = Exception.class)public void doPaySuccess(String orderNum) {    Order order = orderRepository.findOne(orderNum);    if (order.getPayStatus() != "待付款") {        //订单只处理付款状态为待付款的订单        return;    }    //处理订单付款成功逻辑,生成充值卡    // do someThing}
复制代码


这里第一眼看上去逻辑好像没啥问题,但是考虑一种场景,由于上游 consumer 超时时间设置的较短、导致报错、然后发起了重试,这里就会有可能两个线程同时走到了 order.getPayStatus() != "待付款" 这个逻辑,他们都认为此时订单未付款,然后执行订单付款成功的逻辑,这时候就会导致充值卡的超发、这个对于充值卡系统来说是致命的。可以将代码修改成如下:


@Transactional(rollbackFor = Exception.class)public void doPaySuccess(String orderNum) {    Order order = orderRepository.findOneForUpdate(orderNum);    if (order.getPayStatus() != "待付款") {        //订单只处理付款状态为待付款的订单        return;    }    //处理订单付款成功逻辑,生成充值卡    // do someThing}
//JPA代码,语句执行结果如下:select t from Order t where t.orderNum=:orderNum for update@Lock(value = LockModeType.PESSIMISTIC_WRITE)@Query(value = "select t from Order t where t.orderNum=:orderNum")Order findOneForUpdate(@Param("orderNum") String orderNum);
复制代码


这里通过 mysql 的 select for update 语法对订单加了一个写锁(关于 select for update 的使用注意事项可以参考:https://xie.infoq.cn/article/60ec650d1ecd72dfe48781ed5)当然这里如果你的业务不需要订单的其它的信息的话,可以不需要将 order 查出来,可以直接使用 update 语句来判断是否可以进行后续逻辑,此时伪代码如下:


@Transactional(rollbackFor = Exception.class)public void doPaySuccess(String orderNum) {    int effect = orderRepository.paySuccess(orderNum);    if (effect != 1) {        //影响行数为1代表存在此订单为未付款        return;    }    //处理订单付款成功逻辑,生成充值卡    // do someThing}
@Query(value = "update Order t set t.payStatus = '已付款' where t.orderNum=:orderNum and t.payStatus = '未付款'")Order paySuccess(@Param("orderNum") String orderNum);
复制代码

生成充值卡的策略

​ 上面讲的逻辑都比较简单,现在我们再深入一下业务回想一下平常我们用的充值卡是不是都有一个兑换码,这个兑换码一般只有 8-12 位,由字母和数字组成,要保证兑换是唯一的。下面可以使用如下的简单生成策略来生成


private String getCashCode() {    StringBuffer sb = new StringBuffer();    Random random = new Random();    for (int j = 0; j < 12; j++) {        sb.append(BASE32[random.nextInt(BASE32.length)]);    }    return sb.toString();}
复制代码


其中 BASE32 为一个长度为 32 的字符数组,具体的字符内容可以由自己来指定。由代码可知,兑换码是通过随机生成的 12 位字符,那就存在兑换码冲突的可能,那每次新生成兑换码要保存数据库的时候(这里假设有一张 entity 表,其中 cash_code 做唯一索引),**是否需要查询一下兑换码是否存在呢?**这里给的建议最好是不要,存在以下问题:


  • 每次生成充值卡的时候都要查询兑换码是否冲突,多了一次查询,性能存在问题

  • 在高并发的情况下,你上次兑换码是否冲突的结果会失效,其它线程可能已经保存了兑换码跟你相同的充值卡


那是否可以换一种思路,在数据库插入 entity 报唯一索引错误的时候重新获取兑换码再次进行保存呢?答案当然是可以,所以接下来会写出如下类似代码:


try {    entityRepository.insert(entity);} catch (Exception e) {    //兑换码冲突重新获取兑换码    entity.setCashCode(getCashCode());    entityRepository.insert(entity);}
复制代码


这上面代码看上去是没啥问题的,但是仔细一想还是存在问题的:


  • 要是在 catch 中重新插入也失败了要咋办呢?

  • 能不能不用 try catch 来实现呢?


那我们把代码改进如下:


//最大重试次数int totalTry = 20;
while (true) { int upsert = entityRepository.upsert(entity); //等于1说明插入成功,等于2说明兑换码冲突 if (upsert == 1) { break; } totalTry--; if (totalTry < 0) { throw new RuntimeException("插入充值卡出现异常,需人工排查"); }}
复制代码


@Query(value = "insert into entity (id, cash_code, version) values (entity.id, entity.cashCode, entity.version) " +        "ON DUPLICATE KEY UPDATE version = version + 1")int upsert(@Param("entity") Entity entity);
复制代码


上面的两段伪代码有一些改动点:


  • 加入了 while 循环,重试次数 totalTry=20

  • insert 的方式取消了原本的普通保存,采取了 upsert,upsert 方法在冲突的时候不会报错,而是返回影响行数。1 等于插入成功,2 等于冲突


这样代码看起来更简洁优雅,但是使用 ON DUPLICATE KEY UPDATE 还有一个点需要注意的是 ON DUPLICATE KEY UPDATE 后面跟着的是在冲突时需要更新的内容,这里指定的是 version = version + 1 此时如果你改成 timeUpdate = entity.timeUpdate 也就是冲突的时候把更新时间更新为此次最新的,这样在第一次冲突的时候返回的影响行数是没有问题的,此时影响行数为 2,但是如果重新保存兑换码又冲突了,由于 timeUpdate 是 entity 原本已经设置好的参数,没有发生变更,所以就算此时兑换码冲突了,返回的影响行数也是 1,导致代码出现问题。所以在使用 ON DUPLICATE KEY UPDATE 要特别注意后面的更新字段要每次都不一样

总结

​ 本来讨论的是只在最简单的数据库层面来实现幂等,不引入第三方组件,当然实现幂等的方式还有很多,不在这里讨论。mysql 来实现幂等要考虑的重要点如下:


  • 资产与钱相关的业务数据库要加唯一索引

  • 使用 mq 解耦的同时要考虑消息重发,业务上要考虑在下游失败的时候如何进行重试,重试的终止条件要思考清楚

  • 如果你项目使用的是 jpa,更新订单状态不要使用 save 方法,改用原生的 update。

用户头像

林一

关注

没有幽默的笔风👉 2020.02.13 加入

还未添加个人简介

评论

发布
暂无评论
充值卡业务接口的幂等设计(附伪代码)