线上问题——一次几经反转惊心动魄的问题排查记录

2024-05-13 05:44

本文主要是介绍线上问题——一次几经反转惊心动魄的问题排查记录,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

背景简述

线上某业务由于查询大量数据频繁FullGc优化,依赖于用户完课进度,测试时需要造大量的用户和专栏内容发放数据,且需要用户完课触发,因此在FAT造了一个5000人的班级,这个班级只有3个课时,测试通过脚本调用接口让着5000人都完课,3个课时都完成会发送班级完成事件,最终发现只有4999人发送了班级完成事件。

分析过程

流程简述

image.png
上图是完课进度更新流程(数据初始化流程和本次问题无关),首先客户端上传环节分数,会先保存环节完成进度,之后会串行处理课时完成进度,判断课时先所有必学环节是否都已经完成,若都已完成,则更新课时进度为已完成,否则结束流程;课时完成则会注册异步任务,更新主题月和班级的完成数量以及完成状态,最后班级完成会发送班级已完成事件(在关键节点都加了分布式锁避免并发问题,只是上图未呈现)。

分析过程

主要问题分析

通过上面的流程很容易想到:既然是少发了一个用户的班级完成事件,那么就可以先看看上述几个进度表的状态和完成数量是否正确。

  • 班级进度

image.png

  • 主题月进度

image.png

  • 课时进度

image.png
从数据结果很容易就发现了问题所在,课时有3个已完成,两个主题月都已完成,且完成数量正确,但班级进度表中完成数量只有2,状态也是学习中。
WTF!!!线上跑的好好的代码,又没改过,难道又发现隐藏BUG了?出于这个心理始终偏向于是数据问题,或是测试的操作方式不对(咳~锅是一定要先甩出去的)!
于是通过日志找出两个主题月的保存链路:
image.png
image.png
发现两条链路上最终更新班级进度数量都是2,且没有更新状态。下面是班级进度保存的代码:

public class UserClassRecordChain extends UserRecordBaseChain<UserClassRecord, Object> {@Override@Transactional(rollbackFor = Exception.class)protected boolean finish(UserClassRecord record) {UserClassRecord existRecord = userClassRecordService.queryOne(UserClassLessonStepQueryVo.builder().userId(record.getUserId()).classId(record.getClassId()).build());if (existRecord == null) {doRecord(record);existRecord = record;}if (ProgressStatus.FINISH.equals(existRecord.getStatus())) {return false;}// 班级排期的主题月List<Long> segmentIds = this.getClassSegmentIds(existRecord);// 学习了的主题月记录List<UserSegmentRecord> segmentRecords = this.getSegmentRecords(existRecord, segmentIds);// 完成的主题月idList<Long> finishedSegmentIds = this.getFinishedSegmentIds(segmentRecords);// 计算完成课时数int finishLessonCount = this.getSegmentFinishLessonCount(segmentRecords);record.setId(existRecord.getId());record.setCreateTime(null);record.setFinishCount(finishLessonCount);if (CollectionUtils.isEmpty(finishedSegmentIds) || !finishedSegmentIds.containsAll(segmentIds)) {userClassRecordService.update(record);processCacheAfterCommit(record);return false;}record.setStatus(ProgressStatus.FINISH);initCourseId(record);userClassRecordService.update(record);userRecordFinishNotifyService.notifyClassFinish(record);processCacheAfterCommit(record);return true;}public List<Long> getFinishedSegmentIds(List<UserSegmentRecord> segmentRecords){List<UserSegmentRecord> finishedSegmentRecords = segmentRecords.stream().filter(segmentRecord -> ProgressStatus.FINISH.getValue().equals(segmentRecord.getStatus().getValue())).collect(Collectors.toList());return finishedSegmentRecords.stream().map(UserSegmentRecord::getSegmentId).distinct().collect(Collectors.toList());}public int getSegmentFinishLessonCount(List<UserSegmentRecord> segmentRecords){int finishLessonCount = 0;for (UserSegmentRecord segmentRecord : segmentRecords) {finishLessonCount += segmentRecord.getFinishCount();}return finishLessonCount;}public List<UserSegmentRecord> getSegmentRecords(UserClassRecord userClassRecord, List<Long> segmentIds) {List<UserSegmentRecord> segmentRecords = userSegmentRecordService.queryList(UserClassLessonStepBatchQueryVo.builder().userIds(Lists.newArrayList(userClassRecord.getUserId())).classIds(Lists.newArrayList(userClassRecord.getClassId())).segmentIds(segmentIds).build());if (CollectionUtils.isEmpty(segmentRecords)) {return Lists.newArrayList();}return segmentRecords;}/*** 注册事务回调事件* @param record*/public void processCacheAfterCommit(UserClassRecord record) {try {if (TransactionSynchronizationManager.isSynchronizationActive()) {log.info("事务开启,注册事务提交后处理班级学习数据缓存回调");TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {@Overridepublic void afterCommit() {userClassRecordCacheClient.deleteClassRecordCache(record.getUserId(), record.getClassId());}});} else {log.warn("事务未开启,直接处理班级学习数据缓存");userClassRecordCacheClient.deleteClassRecordCache(record.getUserId(), record.getClassId());}} catch (Exception e) {log.error("更新班级学习数据缓存,注册异步任务", e);timerTaskClient.startTask(JSON.toJSONString(record), BizType.USER_CLASS_RECORD_CACHE_PROCESS.name());}}}

这里的逻辑是先查出班级下所有的主题月id和主题月进度,累加题月进度完成数量,判断班期下所有主题月是否都已经完成,是则更新状态和数量,淘汰缓存,并发送事件,否则只更新数量和淘汰缓存。那么说明一定是走的下面这个条件分支去完成更新的:

if (CollectionUtils.isEmpty(finishedSegmentIds) || !finishedSegmentIds.containsAll(segmentIds)) {userClassRecordService.update(record);processCacheAfterCommit(record);return false;
}

而能进入这个if则说明一定是 !finishedSegmentIds.containsAll(segmentIds)这个条件不满足,即班级下主题月没有都完成。

那什么原因会导致这种问题的产生呢?

  • 第一种可能:分布式锁没锁住,并发问题。

但入口处加了分布式锁呀?难道是锁的粒度有问题?于是查看代码发现锁粒度确实有问题:

    @Override@Transactional(rollbackFor = Exception.class)protected boolean finish(UserSegmentRecord userSegmentRecord) {return RedisLockTemplate.doTemplate(() -> this.doSegmentFinish(userSegmentRecord), RedisLockKeyGenerator.generateSegmentRecordFinishLockKey(userSegmentRecord));}public static String generateSegmentRecordFinishLockKey(UserSegmentRecord userSegmentRecord) {return LOCK_KEY_PREFIX+ USER_SEGMENT_RECORD_FINISH+ userSegmentRecord.getUserId() + COMBINE_STR+ userSegmentRecord.getClassId() + COMBINE_STR+ userSegmentRecord.getSegmentId();}

锁的粒度是在主题月维度,两个主题月同时完成确实会导致上面的问题(其实这里的代码还有一个问题,稍后再说)。小小的兴奋一下,以为找到了根因?
image.png
看完成时间,两个主题月完成时间足足差了半个小时…

  • 第二种可能:主题月进度更新的事务与班级进度更新的事务非同一个事务,在班级进度更新前查询时,主题月进度更新的事务还未提交。

image.png
image.png
image.png
上面截图都是最后一个主题月保存链路上的,第一条SQL说明主题月进度更新成功,完成数量为1,状态是FINISH(该主题月下只有一个课时),紧接着查询该表,第二张截图说明确实查到了两条数据,但最后保存班级进度数量时数量却只有2,那说明前面更新的数量1没有被查到,查到的是初始化的数据。这看起来真的像是事务引起的,对吗?但排除,因为这里是一个串行的链路,主题月和班级进度使用的也是同一个事务。
至此,排查陷入了困局…

  • 第三种可能:主从读写延迟,查询走的从库,导致未查询到最新的更新结果。

从一开始就没往这方面想,因为线下环境未配置从库。直到都想把锅甩给MySQL、甩给Vitess连接池时,才终于决定看下数据库配置。这不看不知道,一看真吓一跳,DBA啥时候配了从库了!!!(所以锅又甩到了DBA身上?)
问题确实是由于读写分离+主从延迟导致的,但你以为到此就结束了么?No,年轻人,从上面的代码也可以看出两个finish都是加了事务注解的,我们的项目早就接入了基础架构的SDK,开启事务后,该事务中的所有SQL就会强制走主库,避免主从延迟的问题,那这里的事务竟然没有生效!

image.png
其实一开始也注意到日志里打印了“事务未开启的日志”,只是是否开启事务都不会导致查询不到刚更新的结果的问题产生,所以选择性先忽视了,现在可以来详细分析下这个问题了。

延申问题分析

事务为什么没生效?

一开始就说了从主题月进度保存开始就是异步任务触发的,所以主题月进度和班级进度保存需要确保在一个事务中,事务的入口则是应该在异步任务开始执行的地方,下面是主要代码:

public class SegmentRecordFinishHandler implements TimerTaskHandler {@Autowiredprivate UserSegmentRecordChain userSegmentRecordChain;@Overridepublic void handle(String data) throws Exception {UserSegmentRecord userSegmentRecord = JSON.parseObject(data, UserSegmentRecord.class);userSegmentRecordChain.doFinish(userSegmentRecord);}@Overridepublic boolean needHandle(JojoTimerTask task) {return BizType.SEGMENT_CHAIN_FINISH.name().equals(task.getTaskType());}
}
public abstract class UserRecordBaseChain<T, R> {/*** 结束记录** @param record*/public void doFinish(T record) {if (finish(record) && nextChain() != null) {R nextRecord = assembleNextRecord(record);nextChain().doFinish(nextRecord);}}
}
public class UserSegmentRecordChain extends UserRecordBaseChain<UserSegmentRecord, UserClassRecord> {@Override@Transactional(rollbackFor = Exception.class)protected boolean finish(UserSegmentRecord userSegmentRecord) {return RedisLockTemplate.doTemplate(() -> this.doSegmentFinish(userSegmentRecord), RedisLockKeyGenerator.generateSegmentRecordFinishLockKey(userSegmentRecord));}public boolean doSegmentFinish(UserSegmentRecord userSegmentRecord) {.......}
}

在SegmentRecordFinishHandler中首先会调用userSegmentRecordChain.doFinish(userSegmentRecord)方法,这里实际会调用UserSegmentRecordChain抽象父类中的方法doFinish,该方法中又会调用子类的finish
我相信有经验的老司机应该已经发现问题所在了,对吧?没发现也没关系,我们接着往下看。
因为在SegmentRecordFinishHandler中是直接调用的父类中的doFinish方法,该方法中是通过隐含的this调用子类的finish方法,事务注解是加在finish方法上的,该注解又是通过动态代理实现的,所以doFinish没有被事务切面增强,通过this调用也只会调用到非代理类的finish方法,因此导致了事务失效(发现这个问题时真想给自己两嘴巴子,因为这个注解是前不久因为发现这里竟然一直没有加事务,才加上的,但当时却没有注意这个细节,顺手就加上了,没有仔细思考,也没有验证事务是否成功加上,啪!啪!啪!老司机也翻车了)。

到这里所有问题都分析完毕了吧!
小伙子,还是太年轻了,忘记了刚刚遗留的坑了吗?
image.png
这里有什么问题呢?看官且先思考下再往下看哦!

填坑

上面事务的使用还有一个小细节,仔细的看官应该已经发现了,finish方法中使用了分布式锁,而该方法上又标记需要开启事务,没错,事务内加锁!!!锁释放了,但事务可能会卡住未提交,其它线程也查不到该事务的更新结果
又是一个老问题了,想到这里又想再给自己几个嘴巴子!当时在搞个啥子,这么明显的问题居然没发现。
到这里分析是真的结束了,但还得把线上所有数据全部重新跑一遍,所幸目前该表在业务上没有强依赖。下面再总结一下本次排查心得。

总结

本次排查暴露的技术问题如下:

  • 读写分离架构下的主从延迟问题,需要时刻注意是否需要强制走主库
  • Spring声明式事务失效问题,应该尽量使用编程式事务,使用声明式事务时要确保事务正常开启(同时也要注意事务范围,避免大事务)
  • 不要在事务内加锁!
  • 使用锁时需要考虑清除锁的粒度,考虑锁中业务范围扩大的影响。像本例中的业务因为线上不可能出现同一个班级下两个主题月同时完成的情况,所以锁放在主题月维度是没有问题的,但反过来说,既然不存在同一个班级下两个主题月同时完成,那锁粒度放在班级维度其实也不会影响什么。所以使用锁时一定要根据业务仔细考虑锁的粒度

而对于个人工作习惯和态度的方式反思如下:
读写分离+主从延迟,并不是新面孔,自己或团队中其他小伙伴踩过不止一次的坑了,也都曾将在团队中分享强调过,那为什么这次还是排查了一两个小时呢?主要是由于先入为主的认为线下环境是没有从库的,花了大量的时间和精力在其它方向上进行排查,最终没有思路时才从头想想还有什么遗漏的,所以在此告诫各位看官,警惕先入为主的陷阱呀,可能的原因都应该依次排查一遍。除此之外,同类或父子类中方法A this调用使用了声明式事务的方法B会导致事务失效以及事务内加锁问题也暴露了我在之前的开发中不仔细、不谨慎的问题,也再次提醒了我对于任何的改动都不应该抱有轻视之心

最后,遗留一个小问题,针对本文发现的问题,这个代码应该如何优化呢?

这篇关于线上问题——一次几经反转惊心动魄的问题排查记录的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/984835

相关文章

好题——hdu2522(小数问题:求1/n的第一个循环节)

好喜欢这题,第一次做小数问题,一开始真心没思路,然后参考了网上的一些资料。 知识点***********************************无限不循环小数即无理数,不能写作两整数之比*****************************(一开始没想到,小学没学好) 此题1/n肯定是一个有限循环小数,了解这些后就能做此题了。 按照除法的机制,用一个函数表示出来就可以了,代码如下

hdu1043(八数码问题,广搜 + hash(实现状态压缩) )

利用康拓展开将一个排列映射成一个自然数,然后就变成了普通的广搜题。 #include<iostream>#include<algorithm>#include<string>#include<stack>#include<queue>#include<map>#include<stdio.h>#include<stdlib.h>#include<ctype.h>#inclu

如何解决线上平台抽佣高 线下门店客流少的痛点!

目前,许多传统零售店铺正遭遇客源下降的难题。尽管广告推广能带来一定的客流,但其费用昂贵。鉴于此,众多零售商纷纷选择加入像美团、饿了么和抖音这样的大型在线平台,但这些平台的高佣金率导致了利润的大幅缩水。在这样的市场环境下,商家之间的合作网络逐渐成为一种有效的解决方案,通过资源和客户基础的共享,实现共同的利益增长。 以最近在上海兴起的一个跨行业合作平台为例,该平台融合了环保消费积分系统,在短

购买磨轮平衡机时应该注意什么问题和技巧

在购买磨轮平衡机时,您应该注意以下几个关键点: 平衡精度 平衡精度是衡量平衡机性能的核心指标,直接影响到不平衡量的检测与校准的准确性,从而决定磨轮的振动和噪声水平。高精度的平衡机能显著减少振动和噪声,提高磨削加工的精度。 转速范围 宽广的转速范围意味着平衡机能够处理更多种类的磨轮,适应不同的工作条件和规格要求。 振动监测能力 振动监测能力是评估平衡机性能的重要因素。通过传感器实时监

缓存雪崩问题

缓存雪崩是缓存中大量key失效后当高并发到来时导致大量请求到数据库,瞬间耗尽数据库资源,导致数据库无法使用。 解决方案: 1、使用锁进行控制 2、对同一类型信息的key设置不同的过期时间 3、缓存预热 1. 什么是缓存雪崩 缓存雪崩是指在短时间内,大量缓存数据同时失效,导致所有请求直接涌向数据库,瞬间增加数据库的负载压力,可能导致数据库性能下降甚至崩溃。这种情况往往发生在缓存中大量 k

6.1.数据结构-c/c++堆详解下篇(堆排序,TopK问题)

上篇:6.1.数据结构-c/c++模拟实现堆上篇(向下,上调整算法,建堆,增删数据)-CSDN博客 本章重点 1.使用堆来完成堆排序 2.使用堆解决TopK问题 目录 一.堆排序 1.1 思路 1.2 代码 1.3 简单测试 二.TopK问题 2.1 思路(求最小): 2.2 C语言代码(手写堆) 2.3 C++代码(使用优先级队列 priority_queue)

Node.js学习记录(二)

目录 一、express 1、初识express 2、安装express 3、创建并启动web服务器 4、监听 GET&POST 请求、响应内容给客户端 5、获取URL中携带的查询参数 6、获取URL中动态参数 7、静态资源托管 二、工具nodemon 三、express路由 1、express中路由 2、路由的匹配 3、路由模块化 4、路由模块添加前缀 四、中间件

【VUE】跨域问题的概念,以及解决方法。

目录 1.跨域概念 2.解决方法 2.1 配置网络请求代理 2.2 使用@CrossOrigin 注解 2.3 通过配置文件实现跨域 2.4 添加 CorsWebFilter 来解决跨域问题 1.跨域概念 跨域问题是由于浏览器实施了同源策略,该策略要求请求的域名、协议和端口必须与提供资源的服务相同。如果不相同,则需要服务器显式地允许这种跨域请求。一般在springbo

题目1254:N皇后问题

题目1254:N皇后问题 时间限制:1 秒 内存限制:128 兆 特殊判题:否 题目描述: N皇后问题,即在N*N的方格棋盘内放置了N个皇后,使得它们不相互攻击(即任意2个皇后不允许处在同一排,同一列,也不允许处在同一斜线上。因为皇后可以直走,横走和斜走如下图)。 你的任务是,对于给定的N,求出有多少种合法的放置方法。输出N皇后问题所有不同的摆放情况个数。 输入

vscode中文乱码问题,注释,终端,调试乱码一劳永逸版

忘记咋回事突然出现了乱码问题,很多方法都试了,注释乱码解决了,终端又乱码,调试窗口也乱码,最后经过本人不懈努力,终于全部解决了,现在分享给大家我的方法。 乱码的原因是各个地方用的编码格式不统一,所以把他们设成统一的utf8. 1.电脑的编码格式 开始-设置-时间和语言-语言和区域 管理语言设置-更改系统区域设置-勾选Bata版:使用utf8-确定-然后按指示重启 2.vscode