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

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

相关文章

SpringBoot3应用中集成和使用Spring Retry的实践记录

《SpringBoot3应用中集成和使用SpringRetry的实践记录》SpringRetry为SpringBoot3提供重试机制,支持注解和编程式两种方式,可配置重试策略与监听器,适用于临时性故... 目录1. 简介2. 环境准备3. 使用方式3.1 注解方式 基础使用自定义重试策略失败恢复机制注意事项

Python UV安装、升级、卸载详细步骤记录

《PythonUV安装、升级、卸载详细步骤记录》:本文主要介绍PythonUV安装、升级、卸载的详细步骤,uv是Astral推出的下一代Python包与项目管理器,主打单一可执行文件、极致性能... 目录安装检查升级设置自动补全卸载UV 命令总结 官方文档详见:https://docs.astral.sh/

SpringBoot排查和解决JSON解析错误(400 Bad Request)的方法

《SpringBoot排查和解决JSON解析错误(400BadRequest)的方法》在开发SpringBootRESTfulAPI时,客户端与服务端的数据交互通常使用JSON格式,然而,JSON... 目录问题背景1. 问题描述2. 错误分析解决方案1. 手动重新输入jsON2. 使用工具清理JSON3.

MySQL 设置AUTO_INCREMENT 无效的问题解决

《MySQL设置AUTO_INCREMENT无效的问题解决》本文主要介绍了MySQL设置AUTO_INCREMENT无效的问题解决,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参... 目录快速设置mysql的auto_increment参数一、修改 AUTO_INCREMENT 的值。

关于跨域无效的问题及解决(java后端方案)

《关于跨域无效的问题及解决(java后端方案)》:本文主要介绍关于跨域无效的问题及解决(java后端方案),具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录通用后端跨域方法1、@CrossOrigin 注解2、springboot2.0 实现WebMvcConfig

统一返回JsonResult踩坑的记录

《统一返回JsonResult踩坑的记录》:本文主要介绍统一返回JsonResult踩坑的记录,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录统一返回jsonResult踩坑定义了一个统一返回类在使用时,JsonResult没有get/set方法时响应总结统一返回

Go学习记录之runtime包深入解析

《Go学习记录之runtime包深入解析》Go语言runtime包管理运行时环境,涵盖goroutine调度、内存分配、垃圾回收、类型信息等核心功能,:本文主要介绍Go学习记录之runtime包的... 目录前言:一、runtime包内容学习1、作用:① Goroutine和并发控制:② 垃圾回收:③ 栈和

Go语言中泄漏缓冲区的问题解决

《Go语言中泄漏缓冲区的问题解决》缓冲区是一种常见的数据结构,常被用于在不同的并发单元之间传递数据,然而,若缓冲区使用不当,就可能引发泄漏缓冲区问题,本文就来介绍一下问题的解决,感兴趣的可以了解一下... 目录引言泄漏缓冲区的基本概念代码示例:泄漏缓冲区的产生项目场景:Web 服务器中的请求缓冲场景描述代码

Java死锁问题解决方案及示例详解

《Java死锁问题解决方案及示例详解》死锁是指两个或多个线程因争夺资源而相互等待,导致所有线程都无法继续执行的一种状态,本文给大家详细介绍了Java死锁问题解决方案详解及实践样例,需要的朋友可以参考下... 目录1、简述死锁的四个必要条件:2、死锁示例代码3、如何检测死锁?3.1 使用 jstack3.2

解决JSONField、JsonProperty不生效的问题

《解决JSONField、JsonProperty不生效的问题》:本文主要介绍解决JSONField、JsonProperty不生效的问题,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑... 目录jsONField、JsonProperty不生效javascript问题排查总结JSONField