悲观锁并没有你想的那么简单,隐藏了2年的Bug,终于被根拔起

文章讲述了运营同事反馈账不平问题的解决过程,涉及SQL查询优化(如limit和索引使用)、高效分页策略,以及在日志管理中运用grep技巧。作者还揭示了设计模式在代码理解和维护中的重要性,特别提及了Spring和MyBatis框架的理解和源码解读。
摘要由CSDN通过智能技术生成

结果一个月之后,运营同事又来找了,偶尔依旧会出现账不平的问题。刚开始我还以为是不是搞错了,历史的账不平导致现在最终的不平。但最终还是下定决心再排查一次。

第一天,把账不平的账户的账务流水、涉及到代码、日志全部捋一遍。这期间还遇到了很多小困难,最终注意克服。

困难一:数据查不动

账务记录表数据太多,上千万的数据,最初的设计者并没有创建索引。这就要了老命了,根据筛选条件根本查不出数据来。

这里就用到SQL优化的两个技能点:limit限制查询条数和高效的分页策略。

关于limit限制查询条件这一点很明显,不仅减少了结果集,而且在遇到符合条件的数据之后会立马返回。

高效的分页策略在列表页在查询数据经常遇到,为了避免一次性返回过多的数据影响接口性能,一般会对查询接口做分页处理。

在Mysql中分页一般用的limit关键字:

select id,name,age from user limit 10,20;

复制代码

少量数据时,limit分页没啥问题。但如果表中数据量很多,就会出现性能问题。

比如分页参数变成了:

select id,name,age from user limit 1000000,20;

复制代码

Mysql会查到1000020条数据,然后丢弃前面的1000000条,只查后面的20条数据,非常浪费资源。

优化sql:

select id,name,age from user where id > 1000000 limit 20;

复制代码

当然还可以使用between优化分页:

select id,name,age

from user where id between 1000000 and 1000020;

复制代码

值得庆幸的是那张表的ID是自增的,于是用了id大于的条件,只差了最近的交易记录,才勉强把数据查询出来。

困难二:日志过多

由于系统日志打的比较详细,一个项目每天大概几个G的日志。要在这中间查询到有用的日志,也是一个调整。

排查问题时,先使用了grep 命令找到出问题交易的账号日志:

grep 123 info.log

复制代码

当大概定位的到日志输出时间了,再利用区间缩小日志范围:

grep ‘2021-11-17 19:23:23’ info.log > temp.log

复制代码

这里同样使用grep命令查找对应时间区间的日志,并将查找到日志输出到temp.log文件中,然后通过sz命令,下载到本地进行筛选分析。

这里大家可以善用grep命令。同时也要善用输出到新文件,这样比每次查几个G的内容方便多了。当然更方便的就是把筛选之后的日志下载本地,再次比对分析。

其他

关于代码筛选这块,没有什么诀窍,除了从头到位的捋一捋,没有别的好方法。不过这个过程善用IDE的搜索和“Find usages”功能即可。

日终收获

经过上述排查,最终在临下班时,定位到了问题的原因:一个线程将余额更新之后,另外一个线程将其覆盖了。在账务流水记录中存在了两笔紧邻,且计算前余额一样的记录。

得出结果之后,再排查其他的同类问题就方便多了,比如可采用group by来进行快速筛选:

select count(id) as num , balance from account group by balance having num > 1;

复制代码

通过上述语句就可以快速查出有同样计算前余额的记录。当然,上述语句还可以添加条件和结果维度。

虽然找到的问题发生的地方,但并未完全找到问题的原因。

更深层次的Bug


本以为找到了问题发生的点,就能快速解决问题的,但的确小觑了这个Bug,又是一整天才排查出根本原因。

模拟高并发

找到出问题的代码,看了实现逻辑,没问题啊,也加了悲观锁,数据库事务也没失效,也没有同Service的方法调用。怎么就会出现问题呢?

既然肉眼看不出来,那就用程序跑。于是,写了一个单元测试,创建一个线程池,来调用对应加锁方法。结果,依旧没问题。

由于跑的是测试库,生产库用的是云服务,担心是数据库的差异,于是在Navicat验证了悲观锁是否生效:

START transaction ;

select * from account where id = 1 for update;

复制代码

然后在另外一个查询窗口执行:

select * from account where id = 1 for update;

复制代码

发现,数据库的锁的确是生效的,在没有执行commit操作之前,是查不到数据的。

僵局与希望

此时,完全陷入僵局。于是就开始大量搜索资料,多次阅读代码。

最终,在一篇写得很水,但给了一个Hibernate javadoc文档链接的文中,无意点了一下链接,获得了巨大的启发。

在javadoc看了一下session实现悲观锁的方法。项目中用了已经废弃的get方法:

get

@Deprecated

Object get(Class clazz,

Serializable id,

LockMode lockMode)

复制代码

Deprecated.* LockMode parameter should be replaced with LockOptions*

Return the persistent instance of the given entity class with the given identifier, or null if there is no such persistent instance. (If the instance is already associated with the session, return that instance. This method never returns an uninitialized instance.) Obtain the specified lock mode if the instance exists.

其中的“If the instance is already associated with the session, return that instance”让我眼前一亮。难道是缓存在作祟?

上面的重点是:如果session中已经存在这么个对象实例,会直接返回这个实例

感觉回去看代码,还真是的,伪代码如下:

Account account = accountService.getAccount(type, userNo);

if(account == null){

//…

}

accountService.getAccountAndLock(account.getId());

// …

复制代码

上述代码首先值得肯定的有两点:第一,在加锁之前先查了一次对象,这样能避免因为对象不存在,锁住全表;第二,就是锁一条数据库记录时尽量采用id,精确定位到具体的记录,避免锁住其他记录或整张表。

那么,是不是因为前面的查询导致后面getAccountAndLock方法的实效呢?再来验证一下。

于是,在单元测试中添加了前面的查询,再次执行。哈哈,Bug终于复现了!

为了进一步证实,在底层的公共方法中添加了clear操作:

public T findAndLock(Class cls, String primaryKey) throws DataAccessException {

Session session = getHibernateTemplate().getSessionFactory().getCurrentSession();

// 添加验证是否缓存问题

session.clear();

Object object = session.load(cls, primaryKey, LockOptions.UPGRADE);

return (T) object;

}

复制代码

再次执行单元测试,可正常加锁。至此,Bug定位完毕。

问题的解决

既然已经定位问题,解决起来就非常方便了。上面使用session.clear()只是为了验证,真实生产使用这种方法影响太大,而且是事后处理。

解决方案:将基于Hibernate的普通查询,改为基于原生SQL的查询。因为前面的普通查询只需要id,那么只用一条SQL查询ID即可,如果id为空,则不存在;如果id非空,则再进行下一步处理。

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数Java工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Java开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注Java获取)

img

言尽于此,完结

无论是一个初级的 coder,高级的程序员,还是顶级的系统架构师,应该都有深刻的领会到设计模式的重要性。

  • 第一,设计模式能让专业人之间交流方便,如下:

程序员A:这里我用了XXX设计模式

程序员B:那我大致了解你程序的设计思路了

  • 第二,易维护

项目经理:今天客户有这样一个需求…

程序员:明白了,这里我使用了XXX设计模式,所以改起来很快

  • 第三,设计模式是编程经验的总结

程序员A:B,你怎么想到要这样去构建你的代码

程序员B:在我学习了XXX设计模式之后,好像自然而然就感觉这样写能避免一些问题

  • 第四,学习设计模式并不是必须的

程序员A:B,你这段代码使用的是XXX设计模式对吗?

程序员B:不好意思,我没有学习过设计模式,但是我的经验告诉我是这样写的

image

从设计思想解读开源框架,一步一步到Spring、Spring5、SpringMVC、MyBatis等源码解读,我都已收集整理全套,篇幅有限,这块只是详细的解说了23种设计模式,整理的文件如下图一览无余!

image

搜集费时费力,能看到此处的都是真爱!
《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!
四,学习设计模式并不是必须的

程序员A:B,你这段代码使用的是XXX设计模式对吗?

程序员B:不好意思,我没有学习过设计模式,但是我的经验告诉我是这样写的

[外链图片转存中…(img-AcblawiR-1713686549206)]

从设计思想解读开源框架,一步一步到Spring、Spring5、SpringMVC、MyBatis等源码解读,我都已收集整理全套,篇幅有限,这块只是详细的解说了23种设计模式,整理的文件如下图一览无余!

[外链图片转存中…(img-rO3A2pkN-1713686549206)]

搜集费时费力,能看到此处的都是真爱!
《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!

  • 29
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值