当你不了解“异步”时请慎用“异步”——记一次生产环境故障排查之旅

前言

最近运营人员在进行对账时向研发反馈存在一些订单状态无法对应上,存在部分订单用户已经支付了,但是系统显示还是未支付,涉及到订单状态对应不上这可是个大问题,搞不好还有资损,于是赶紧拿上电脑开始了问题排查。

这里把简单的订单状态数据更新逻辑用下图表示,整个系统使用了一种比较诡异的"异步"订单状态更新办法,使用了 Google 的 EventBus 和 MQ 两种方式触发订单数据更新。

笔者在平时编写代码时对"异步"两字都是非常敬畏的,因为异步能带来效率的提升但是也会带来了并发更新的风险。就上图而言至少就有以下三个数据更新风险。

1、状态更新顺序问题,如果三方确认的消息比支付消息先消费,最后的订单终态一定不对,所以需要进行订单状态的比较

2、并行消费问题,如果订单被并发修改肯定最终数据更新的结果也不对,所以需要考虑对订单添加分布式锁

3、做订单的数据更新时需要查询最新的订单状态,不能直接拿收到的消息内容中的数据直接做数据更新

排查之路

按照前言部分的分析,梳理项目中对订单表的更新逻辑,逐步检查代码对上面的三个问题有没有做妥善处置,因为按照组件日志的记录,确实有支付日志产生,那么造成数据库最终订单状态还是未支付状态一定是存在写覆盖的问题

排查方向 1: 分布式锁加了没?状态比对做了没?

首先排查有没有对订单进行数据更新的时候添加分布式锁控制,添加分布式锁控制是非常必要的,因为在更新订单时我们需要先查出目前的订单状态,例如,如果当前订单已经是已完成状态,那么对于当前的消息应该跳过不处理,也就是说我们不能把订单的状态给往回改 。所以这就要求我们在读取订单数据并对订单数据进行数据更新时不能有另一个线程或者实例也在做同一笔订单同样的读取和修改动作。

java 复制代码
RLock lock = redissonClient.getLock(orderSeq);
try {

    OrderInfo orderInfo = orderRepository.getSimpleOrderInfo(msgObj.getOrderSeq());
    if(order.getStatus() >= OrderStatusEnum.Paid){
        return;
    }

    if(lock.tryLock(11, 15, TimeUnit.SECONDS)){
        try {

             OrderInfo orderInfo = orderRepository.getSimpleOrderInfo(msgObj.getOrderSeq());
             updateOrderToFinishStatus(orderInfo)
            
        } finally {
            lock.unlock();
        }
    }
} catch (Exception e) {
    handleException(e,orderSeq)
}

通过对上面的代码分析发现,在支付消息消费和三方确认MQ消息消费时确实有加锁,但是整体代码写的存在瑕疵,瑕疵在于:

对于订单的状态判断没有放到加锁的逻辑里面,虽然在加锁的逻辑里面中确实又查询了一次订单信息,但是此时应该再次判断一遍当前最新的订单状态是不是需要更新的目标订单状态,否则可能导致数据被更新多次,因为订单等待锁获取锁的过程中可能存在其他线程又修改了订单状态

虽然代码写的有点瑕疵,但是应该不是造成这次事故的主要原因,还有没有其他造成故障的可能呢?

排查方向 2: EventBus 用的稳不稳?

下面看看下单逻辑执行时是如何进行 EventBus 的事件发送的,也许问题就出在这里:

java 复制代码
OrderInfo orderInfo = orderRepository.getSimpleOrderInfo(msgObj.getOrderSeq());
RLock lock = redissonClient.getLock(orderSeq);
try {
    if(lock.tryLock(11, 15, TimeUnit.SECONDS)){
        try {
            // 其他业务逻辑 省略
            
            OrderUnPayEvent event = new OrderUnPayEvent();
            event.setResponse(response);
            event.setChargeInfoDO(orderInfo);
           
            // 向 EventBus 发送异步事件
            EventBusUtil.asyncPubEvent(event);
        } finally {
            lock.unlock();
        }
    }
} catch (Exception e) {
    handleException(e,orderSeq)
}

看到这里我已经隐约嗅到了一丝 bug 的气息

首先这里加锁之后没有再查询最新的订单信息,此外这里 直接向 OrderUnPayEvent 塞进去了整个订单信息,该不会直接在 event 订阅侧直接拿着这个订单信息去直接更新 DB 吧,眉头一皱,感觉问题好像就在这里。

果然怕什么来什么,代码中真的在订阅侧直接拿着 OrderUnPayEvent 的数据去更新了订单:

java 复制代码
@Subscribe
public void handler(OrderUnPayEvent event){
    
    if (event == null){
        return;
    }
    RespDTO response = event.getResponse();
    if (response.getSuccStat()==0){
    
        OrderStatusEnum respStatus = OrderStatusEnum.getByValue(Integer.parseInt(response.getStartChargeSeqStat()));

        OrderInfoDO info = event.getChargeInfoDO();
        info.setChargeStatus(respStatus);
        info.setUpdateDate(LocalDateTime.now());
        OrderRepository.updateChargeOrder(info);

    }
}

接下来通过日志的时间线 也确实佐证了是这里的问题 ,因为这里的数据更新比较靠后,所以他执行的时候就把前面两次 MQ 消费的数据更新给覆盖了。

PS:这里猜测一下当时写这段代码的同学估计是以为发布事件的时候有锁环境,就认为事件订阅的时候也有锁环境,但是事实上由于事件订阅的处理逻辑是异步的,在事件订阅逻辑执行的时候 订单锁早已经释放了。

bug 背后感觉还暗藏玄机

核心原因找到了,但是就止步于此了吗?会不会这个 bug 的背后还有"高手"

分析执行日志时间线发现,下单动作比支付动作还有三方确认动作早了接近 4s ,也就是说这个 event 发布的时间应该比后面两个消息消费的时间还要早了至少4s,并且由于 EventBus 是单实例非分布式的没有网络开销,为什么反而这个 OrderUnPayEvent 的订阅的处理执行的时间这么靠后呢?

于是继续检查了下,这个 EventBus 的使用

java 复制代码
private static AsyncEventBus asyncEventBus;

private static final Executor EXECUTOR = new ThreadPoolExecutor(1, 10, 60,
        TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>(100000), Executors.defaultThreadFactory(), new ThreadPoolExecutor.CallerRunsPolicy());

/**
    * 异步事件单例模式
    * @return
    */
private static synchronized AsyncEventBus getAsyncEventBus() {
    if(asyncEventBus == null){
        asyncEventBus = new AsyncEventBus(EXECUTOR);
    }
    return asyncEventBus;
}

/**
    * 异步发送事件
    * @param event
    */
public static void asyncPubEvent(Object event) {
    getAsyncEventBus().post(event);
}

在下单的动作触发的时候就是调用上面代码中的 asyncPubEvent 发布事件的,post(event) 方法直接追踪源码的时候, 发现核心逻辑如下:

java 复制代码
final void dispatchEvent(final Object event) {
    this.executor.execute(new Runnable() {
        public void run() {
            try {
                Subscriber.this.invokeSubscriberMethod(event);
            } catch (InvocationTargetException e) {
                Subscriber.this.bus.handleSubscriberException(e.getCause(), Subscriber.this.context(event));
            }

        }
    });
}

也就是相当于说 eventbus 的事件订阅具体处理逻辑,就是在我们定义的线程池里面提交了一个 Runnable 任务,但是把我看乐的是这个线程池核心线程数被定义成一个的同时还把队列长度搞得这么大,显然很多任务都会在阻塞队列排队,迟迟得不到执行。

代码定义了一个线程池看起来像是搞了个异步,实际上却是默默的在串行排队执行......

代码修改与总结

怎么修改这里的代码呢?

仔细检查下单逻辑发现虽然这里发布了一个 EventBus 事件,但是这个事件在处理的时候也就是做了下数据更新。这个异步搞得是真的是没有太大必要。

笔者也是直接去掉了 EventBus的事件发送,在下单完成之后直接在分布式锁上下文中更新订单,还去掉了大量代码。(PS 这里因为之前是异步操作,还导致日志中的 TraceId 没能很好的传递,对定位问题还带来了一些麻烦)

由此可见 在日常代码实现的时候,如果你对异步一知半解还喜欢大量用异步就可能搞出来大问题,简单的就是最好的,对异步这种东西还是应该当做"战略核武器"不要轻易就拿出来打"蚊子"哦。

相关推荐
苏打水com16 分钟前
数据库进阶实战:从性能优化到分布式架构的核心突破
数据库·后端
间彧1 小时前
Spring Cloud Gateway与Kong或Nginx等API网关相比有哪些优劣势?
后端
间彧1 小时前
如何基于Spring Cloud Gateway实现灰度发布的具体配置示例?
后端
间彧1 小时前
在实际项目中如何设计一个高可用的Spring Cloud Gateway集群?
后端
间彧1 小时前
如何为Spring Cloud Gateway配置具体的负载均衡策略?
后端
间彧2 小时前
Spring Cloud Gateway详解与应用实战
后端
EnCi Zheng3 小时前
SpringBoot 配置文件完全指南-从入门到精通
java·spring boot·后端
烙印6013 小时前
Spring容器的心脏:深度解析refresh()方法(上)
java·后端·spring
Lisonseekpan3 小时前
Guava Cache 高性能本地缓存库详解与使用案例
java·spring boot·后端·缓存·guava
4 小时前
JUC专题 - 并发编程带来的安全性挑战之同步锁
后端