接手,接二连三出现账不平衡的问题。作为一个比较有毅力的程序员,不解决是决不罢休的。终于,经过两次尝试和许多天,它终于被连根拔起。不容易,记录在一篇特写文章中。文章不仅会讲使用悲观锁踩过的坑,还会讲我是如何排查问题的。一些想法和方法可能对大家有帮助。事情的由来,运营同事提出需要不时检查和调整账目。原因很简单,账目不平衡,不查就不行。如果你有财务相关系统的工作经验,会计问题总是最难攻克的。虽然刚接手项目,虽然很多业务逻辑还不懂,但这样的技术挑战还是需要坚决攻克的。其实出现这种问题的原因很简单:账号火爆。当许多服务或线程对同一个用户的帐户进行操作时,会出现一个更新覆盖另一个更新的情况。从上图可以很容易看出账户不平衡,当两个服务或线程同时查询数据库中的一条数据(热点账户),然后在内存中修改,最后更新到数据库中。如果并发,两个线程都读取100,一个计算80,一个计算60,后面的更新可能会覆盖前面的。解决方案通常包括:单一服务线程锁;集群分布式锁;集群数据库悲观锁;什么是悲观锁?悲观锁是对数据修改持悲观态度,在整个数据处理过程中都会对数据加锁。悲观锁的实现往往依赖于数据库提供的锁机制(只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则即使在应用层实现了锁机制,也有不保证外部系统不会修改数据)。通常,select...forupdate语句用于实现对数据的约束。forupdate只适用于InnoDB,必须在一个事务块(BEGIN/COMMIT)中才能生效。在进行事务操作时,通过“forupdate”语句,MySQL会对查询结果集中的每一行数据加排它锁,其他线程会阻塞对记录的更新和删除操作。独占锁包括行锁和表锁。下面的例子展示了悲观锁的基本使用流程:setautocommit=0;//设置autocommit后,进行正常业务。详情如下://0.开始事务begin;/beginwork;/starttransaction;(三者任选其一)//1.查询商品信息selectstatusfromt_goodswhereid=1forupdate;//2.根据商品信息生成订单insertintot_orders(id,goods_id)values(null,1);//3.修改商品状态为2updatet_goodssetstatus=2;//4。提交事务commit;/commitwork;因为关闭了数据库自动提交,这里事务由begin/commit管理。悲观锁是通过数据库使用select...forupdate实现的。其中,id为1的那条数据被锁定,其他事务必须等待这个事务提交后才能执行。这样可以保证数据在运行过程中不会被其他事务修改。原因初步分析了解了账户不齐的原因和悲观锁的基本原理后,就可以排查问题了。既然系统已经使用了悲观锁,还存在问题,那肯定是少了什么。因此,我检查了所有帐户(帐户表)的更新位置,并发现了一个错误。悲观锁用的最多的地方,先查询更新,再计算新的余额,再更新数据库。但是有一个地方是先查询和计算余额,然后锁定,最后更新。基本过程如下:错误加锁上面的情况,线程B虽然进行了加锁处理,但是由于新余额的计算不在锁中,所以即使使用悲观锁,还是有问题。正确的使用方式是将计算余额的逻辑放在锁中。当然如果线程B完全忘记加锁也会出现同样的问题。在排查解决了以上的bug后,我开始慌了,以为账号不齐的问题彻底解决了。一个月过去了,结果一个月过去了,运营同事又来找,偶尔还是出现账不齐的问题。起初,我认为这是一个错误。历史的不公导致了现在最后的不公。但最后我决定再去看看。第一天,把失衡账户的所有会计记录、相关代码、日志都过一遍。这期间也遇到了很多小困难,最后都注意克服了。难点一:无法查资料。会计记录表数据太多,原设计者没有为千万级数据建立索引。这快要死我了,按过滤条件根本查不到数据。这里用到了SQL优化的两个技巧点:限制查询次数和高效的分页策略。很明显limit限制了查询条件。不仅对结果集进行了缩减,而且在遇到符合条件的数据后立即返回。在列表页查询数据时,经常会遇到高效的分页策略。为了避免一次返回过多的数据,影响界面的性能,查询界面一般都是分页的。Mysql中一般用于分页的limit关键字:selectid,name,agefromuserlimit10,20;数据量小的时候,limitpaging没有问题。但是,如果表中的数据量很大,就会出现性能问题。例如分页参数变为:selectid,name,agefromuserlimit1000000,20;mysql会找1000020条数据,然后丢弃前1000000条数据,只检查后面的20条数据,很浪费资源。优化sql:selectid,name,agefromuserwhereid>1000000limit20;当然也可以使用between来优化分页:selectid,name,agefromuserwhereidbetween1000000and1000020;好在表的ID是自增的,所以用了id大于的条件,只差最近的交易记录勉强够查询数据。难点二:日志太多由于系统日志比较详细,一个项目每天大概有几G日志。中间查询有用的日志也是一个调整。排查时,首先使用grep命令找到问题交易的账户日志:grep123info.log大致定位到日志输出时间后,使用interval缩小日志范围:grep'2021-11-1719:23:23'info.log>temp.log这里也是使用grep命令搜索对应时间间隔的日志,将搜索到的日志输出到temp.log文件中,然后使用sz命令下载到本地进行筛选和分析。在这里你可以很好地利用grep命令。同时,我们也要利用好输出到一个新的文件,这比每次检查好几个G的内容方便多了。当然,把过滤后的日志下载到本地再对比分析更方便。关于代码筛选没有其他技巧。除了从头摸到尾,没有别的好办法。但是,这个过程可以很好地利用IDE的搜索和“查找用法”功能。经过上述排查,日终收获终于在下班时间定位了问题的原因:一个线程更新余额后,另一个线程覆盖了。会计记录中有两条计算前余额相同的相邻记录。得到结果后,检查其他类似问题就方便多了。例如groupby可以用来快速筛选:selectcount(id)asnum,balancefromaccountgroupbybalancehavingnum>1;通过上面的语句,可以快速的找出计算前的相同余额记录。当然,上面的语句还可以加上条件和结果维度。虽然找到了问题发生的地方,但是并没有完全找到问题的原因。对于更深层次的bug,我以为找到问题发生的点,就能很快解决问题,但是我真的低估了这个bug,又花了一整天的时间才找到根本原因。模拟高并发找到有问题的代码,看了一下实现逻辑,没问题,还加了悲观锁,数据库事务没有失败,也没有和Service调用方法。怎么会有问题?既然肉眼看不到,那就用程序来运行吧。于是,我写了一个单元测试,创建一个线程池来调用相应的加锁方法。结果还是没有问题。由于测试库运行,生产库使用云服务,担心数据库的差异,所以在Navicat验证悲观锁是否生效:STARTtransaction;选择*fromaccountwhereid=1forupdate;然后在另一个查询窗口中执行:select*fromaccountwhereid=1forupdate;发现数据库的锁确实有效,执行commit操作之前查不到数据。僵局与希望这时候就彻底僵局了。于是开始大量查找资料,多次阅读代码。最后在一篇很水但给出了Hibernatejavadoc文档链接的文章中,无意间点开了链接,得到了很大的启发。看了javadoc中session实现悲观锁的方法。项目中使用废弃的get方法:get@DeprecatedObjectget(Classclazz,Serializableid,LockModelockMode)**Deprecated。**LockMode参数应该换成LockOptions返回给定标识符的给定实体类的持久化实例,如果有则为null没有这样的持久实例。(如果实例已经与会话相关联,则返回该实例。此方法永远不会返回未初始化的实例。)如果实例存在,则获取指定的锁定模式。其中“如果实例已经与会话关联,则返回那个实例”让我眼前一亮。缓存在工作吗?上面的重点是:如果session中已经存在这样的对象实例,则直接返回。感觉回去看代码,果然如此,伪代码如下:Accountaccount=accountService.getAccount(type,userNo);if(account==null){//...}accountService.getAccountAndLock(account.getId());//...以上代码首先值得肯定的有两点:第一,在加锁前对对象进行一次检查,避免因为对象不存在而将整个表加锁;二、尽量锁定一条数据库记录使用id准确定位具体记录,避免锁定其他记录或整个表。那么,是不是因为前面的查询,后面的getAccountAndLock方法才真正生效呢?我们再验证一下。因此,将之前的查询添加到单元测试中并再次执行。哈哈,bug终于出现了!为了进一步确认,在底层公共方法中添加了清除操作:publicTfindAndLock(Classcls,StringprimaryKey)throwsDataAccessException{Sessionsession=getHibernateTemplate().getSessionFactory().getCurrentSession();//添加验证是否Cache问题session.clear();Objectobject=session.load(cls,primaryKey,LockOptions.UPGRADE);return(T)object;}再次执行单元测试,可以正常加锁。至此,bug定位完成。问题解决既然问题已经定位,那么解决问题就很方便了。上面session.clear()的使用只是为了验证。这种方法实际生产使用影响太大,后面再处理。解决方案:将基于Hibernate的普通查询改为基于原生SQL的查询。因为之前的普通查询只需要id,那么id只需要一次SQL查询。如果id为空,则不存在;如果id不为空,则继续下一步。至此,问题完美解决。总结在解决上述问题的过程中,看似只是一个很简单的悲观锁,但是在排查的过程中也用到了和涉及到很多其他的知识,比如@Transactional事务失败场景的排查,transaction隔离级别、Hibernate多级缓存、Spring的事务管理、多线程、Linux运行、Navicat手动事务、SQL优化、单元测试、Javadoc审查等。因此,在解决问题之后,我觉得非常有必要分享给大家。你从这个案例中学到了什么?
