商品发布超时问题排查

起因

一天下午,线上监控日志报警突然提示商品发布失败次数过多,紧接着前线运营小二就找上门来,XX,商家反馈某几个商品发布一直提示“发布失败,请重新试试”,编辑其他商品都没有问题。

过程

菜鸟: 打开监控平台发现调用商品发布服务超时,目前商品发布超时时间配置的是3S,什么情况导致商品发布超时呢?看看商品发布服务找找原因吧
菜鸟”: 哦,商品服务集群平均CPU使用率80%以上;好吧,应该是定时任务搞的鬼;搜索有个同步全量商品数据的任务,每隔一段时间就会执行一次,目前商品数据量是X级(数量不少,就不透露了哈),多台搜索任务同步数据时会采用多线程方式,导致商品服务压力过大;手动扩几台机器不就解决了嘛,我可真是一个小机灵鬼;

几分钟后,机器扩容完毕。。。

菜鸟”: 运营小二,再让商家试下
运营小二:还是不行啊!
菜鸟”: 不会吧,我再看看;

于是菜鸟抓取了商家提示失败的商品数据模拟线上发布流程,确实提示失败;看线上日志,每天这种情况也会发生几起,使用arthas看下导致慢在哪里;由于当时没有截图,这里简单写了个伪代码来介绍吧;
发现在更新SKU时,执行updateAttributes方法超过1分钟

// 更新商品
@Transactional(rollbackFor = Exception.class)
public void updateProduct(product){
    // 更新商品信息
    product.update(product);
    // 更新商品属性
    updateAttributes(product.getAttributes(), updateSku.getProductId(), updateSku.getProductId(),RefTypeEnum.PRODUCT.getType());

    // 更新sku信息
    if (!CollectionUtils.isEmpty(updateSKUList)) {
        updateSKUList.parallelStream().forEach(updateSku -> {
            // 更新sku信息
            sku.update(updateSku);
            // 更新SKU属性
            updateAttributes(updateSku.getAttributes(), updateSku.getSkuId(), updateSku.getProductId(),null);
        });
    }
}

// 更新属性
private void updateAttributes(List<AttributeModel> attributes, String refId, String productId, Integer refType) {
    attr.deleteByRef(refId,refType);
    if (!CollectionUtils.isEmpty(attributes)) {
        for (AttributeModel entity : attributes) {
            entity.setRefId(refId);
            entity.setProductId(productId);
            entity.setRefType(refType);
        }
        List<AttributeEntity> entities = attributes.stream().map(this::getAttrEntity).collect(Collectors.toList());
        attr.insertBatch(entities);
    }
}

以上操作执行的SQL列表为:

update product set xx = xx where product_id = xx;
update attribute set is_delete = 1 where ref_id = xx and ref_type = 'product'; 
insert attribute(ref_id,ref_type,product_id,value) values(product_id,'product',product_id,value1);

# 这里模拟并发执行SQL顺序
update sku set xx = xx where sku_id = xx;
update attribute set is_delete = 1 where ref_id = xx; 
update sku set xx = xx where sku_id = xx;
update attribute set is_delete = 1 where ref_id = xx; 
insert attribute(ref_id,ref_type,product_id,value) values(sku_id,'sku',product_id,value1);
insert attribute(ref_id,ref_type,product_id,value) values(sku_id,'sku',product_id,value1);

线上抛出的部分错误日志信息

Error updating database. Cause: java.sql.SQLException: Lock wait timeout exceeded; try restarting transaction
The error may involve defaultParameterMap
The error occurred while setting parameters

菜鸟: 噫,锁等待超时?一个事务不可能出现锁等待问题,MySQL读与读之间不会发生锁操作;读与写之间也不会发生锁操作,使用MVVC来解决;只有写与写之间才会发生行级锁;,那就看看哪里会同时修改一行数据吧。

猜想与验证:

猜想:

  1. 多个用户同时在修改同一个商品,但是这个商品由于某种情况倒是更新比较慢导致另外一个事务更新这个数据时只能等待;
  2. 商品及相关信息更新时,不在同一个事务。

验证
验证猜想一: 抓取了商家提示失败的商品数据模拟线上发布流程,点击一次提示失败,再点击一次,还是提示失败,这个商品必复现啊,可以直接排除猜想一了。
验证猜想二: 额,这代码不是我写的,不是很熟悉,但是看代码中已经加上了Transactional,使用的事务传播行为是默认的,调用其他的service更新时,也都在同一个事务内,故此也排除猜想二;
陷入窘境!!
这,这可如何是好?好奇怪呀?旁边的同事也深入沉思。。。
我又仔细看了下前人写的代码,似乎发现了问题,updateSKUList.parallelStream(),这里使用的是并行流,会导致事务丢失,应该就是这里的问题;但是心中有一个问题出现了,那为什么其他商品更新没有问题呢?目前就不得而知了。

临时方案

此时商家那里催的着急,排查过程并不顺利,根据目前掌握的内容先来个死马当活马医?
把并行流换成普通for循环来更新SKU信息,其他代码不变,接着发布预发环境,模拟数据发布下商品试下,奇迹般的事情发生了,商品发布成功!由于改动了不大,经过简单测试就上线了
上线两天后,查询日志未再发现此种情况,说明确实是事务问题,临时方案也就变成了永久方案,但是问题根本原因却没有找到,这是一个难得提升自己的机会,先留着这个问题,等有时间再来排查;

再次排查

在发现问题后,与DBA联系备份了一下这个商家的数据到仿真环境,该环境与完全复制线上一套环境,展示所有服务只有一台,平时仅用于压测,本次用来排查问题;
事情已经过去一个周了,今天下午抽时间再次排查下,本地连接仿真环境数据库,直接跑junit调试下。
然而事情并为想象那么顺利,一步一步调试也没有结果;心里有点失望,转头发现水杯没有水了,在接水路上忽然想起一位大佬的话,“越奇怪的问题,越要看日志,日志才是最真实的,个人经验有时候反而成为排查问题的阻碍”

找到原因

回到工位,仔仔细细的看了下SQL执行的顺序、入参(可以通过本地的SQL日志,也可以通过监控平台中的日志,这里使用的是监控平台的日志执行,主要是因为监控平台中的SQL参数都已经与SQL拼接在一起了),终于找到了原因!,我们先看下SQL执行日志:

# 在这里开启执行,更新商品信息时都在一个事务内
update product set name = '商品名称' where product_id = 12345;
update attribute set is_delete = 1 where ref_id = 12345 and ref_type = 'product'; 
insert attribute(ref_id,ref_type,product_id,value) values(12345,'product',12345,value1);

# 这里模拟并发执行SQL顺序(注意以下SQL全部没有事务,每执行一个SQL执行完毕后都会自动提交)
update sku set update_date = now() where sku_id = 12345;
update attribute set is_delete = 1 where ref_id = 12345; 
update sku set update_date = now() where sku_id = 12346;
update attribute set is_delete = 1 where ref_id = 12346; 
insert attribute(ref_id,ref_type,product_id,value) values(12345,'sku',12345,value1);
insert attribute(ref_id,ref_type,product_id,value) values(12346,'sku',12346,value1);

看完这个SQL日志后,相信聪明的你已经发现了问题,我这里来献献丑吧:
更新商品信息时开启了事务A,有更新SKU时使用的是并行流,一个SQL执行完事务就会自动提交,按照SQL执行顺序依次开启事务B、C、D、E、F、G;但事务A只有SKU信息全部更新完毕才会提交事务,这是spring的事务机制导致的;
其中事务A与事务C更新的数据集合中是有交集的,A事务更新完属性后并未提交事务,事务C更新时发现数据已经被A事务加锁所以只能等待,C事务的等待时间超过MySQL的最大锁等待超时时间后就会抛出异常,A事务回滚,B、D、E、G都会执行成功,C执行失败,F在C后,在事务C抛出异常后就不在执行了;
把并行流换成for循环后,所有的操作都在一个事务,SQL执行都是有顺序的,所以也不存在锁等待的问题;至此,问题的根本原因就找到了。

总结

  1. 对于多线程使用应当小心谨慎,spring的事务是与线程绑定的,新开启线程会导致事务丢失。
  2. 尽量减少事务的范围,尤其涉及到更新操作时。innodb在更新某个数据时会先持有该行的锁,其他事务更新该数据时只能等待,这也是MySQL高并发更新同一个数据时性能急剧下降的根本原因;去年双十一商品详情打开慢就是因为个操作开启事务后持续几十秒才提交,流量上来后导致营销服务出现问题,从而影响商品详情页。
  3. 在将并行流修改为普通for循环后其实是有BUG的,相信聪明的你已经发现了(提示:请查看更新商品属性与更新SKU属性的SQL),这里要总结的是,排查到了问题之后不要忘记回归测试,总有一些隐藏的坑是你意想不到的;
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章