关于mysql:隐藏了2年的Bug终于连根拔起悲观锁并没有那么简单

8次阅读

共计 5007 个字符,预计需要花费 13 分钟才能阅读完成。

接手的新我的项目,接踵而至的呈现账不平的问题,作为程序员中比拟执着的人,不解决誓不罢休。最终,通过两次,历时多日终于将其连根拔起。实属不易,特写篇文章记录一下。

文章中不仅会讲到应用乐观锁踩到的坑,以及自己是如何排查问题的,某些思路和办法或者能对大家有所帮忙。

事件的起源

经营共事时不时就提出查账调账的需要,起因很简略,账不平,不查不行。如果你有过财务相干零碎的工作经验,账务问题始终是最难攻克的。

尽管刚接手我的项目,尽管很多业务逻辑还不理解,但呈现这样的技术挑战,还是要坚定攻克的。

其实,这类问题的起因很简略:热点账户。当很多服务或线程操作同一个用户的账户时,就会呈现一个更新把另外一个更新笼罩掉的状况。

上图可轻易看出,当两个服务或线程同时查询数据库的一条数据(热点账户),而后内存中做批改,最初更新到数据库。如果呈现并发状况,两个线程都读取了 100,一个计算得 80,一个计算得 60,后更新的就有可能将后面的笼罩掉。

解决方案通常有:

  • 单服务线程锁;
  • 集群分布式锁;
  • 集群数据库乐观锁;

我的项目中已采纳了乐观锁,就基于来进行排查追踪起因。

何谓乐观锁

乐观锁是在对数据被的批改持乐观态度,在整个数据处理过程中会将数据锁定。

乐观锁的实现,往往依附数据库提供的锁机制(也只有数据库层提供的锁机制能力真正保证数据拜访的排他性,否则,即便在应用层中实现了加锁机制,也无奈保障内部零碎不会批改数据)。

通常会应用 select … for update 语句来实现对数据的桎梏。

for update 仅实用于 InnoDB,且必须在事务块 (BEGIN/COMMIT) 中能力失效。在进行事务操作时,通过“for update”语句,MySQL 会对查问后果集中每行数据都增加排他锁,其余线程对该记录的更新与删除操作都会阻塞。排他锁蕴含行锁、表锁。

如下示例展现了乐观锁的根本应用流程:

set autocommit=0;  
// 设置完 autocommit 后,执行失常业务。具体如下://0. 开始事务
begin;/begin work;/start transaction; (三者选一就能够)
//1. 查问出商品信息
select status from t_goods where id=1 for update;
//2. 依据商品信息生成订单
insert into t_orders (id,goods_id) values (null,1);
//3. 批改商品 status 为 2
update t_goods set status=2;
//4. 提交事务
commit;/commit work;

因为敞开了数据库主动提交,这里通过 begin/commit 来治理事务。

应用 select…for update 的形式通过数据库实现了乐观锁。其中,id 为 1 的那条数据就被锁定,其它的事务必须等本次事务提交之后能力执行。这样就保障了在操作期间数据不会被其它事务批改。

起因初步剖析

在理解了账不平的起因和乐观锁的基本原理之后,就能够进行问题的排查了。既然零碎曾经应用了乐观锁,居然还会呈现问题,那必定是哪里漏掉了什么。

于是,排查了所有账户(account 表)更新的中央,还真找到一处 bug。

大多数中央都应用了乐观锁,先 for update 查问一下,而后计算新的余额,再进行更新数据库。但有一处居然先查问到了计算了余额,而后再进行加锁,最初更新。

根本流程如下:

在上述情况中,尽管线程 B 进行了加锁解决,但因为计算新余额并未在锁中,导致尽管应用了乐观锁,但仍旧存在问题。正确的应用形式就是将计算余额的逻辑放在锁中。

当然,如果线程 B 齐全被忘记加锁了,也会呈现同样的问题。

在排查解决了上述 bug,我开始嘚瑟了,认为彻底解决了账不平的问题。

一个月之后

后果一个月之后,经营共事又来找了,偶然依旧会呈现账不平的问题。刚开始我还认为是不是搞错了,历史的账不平导致当初最终的不平。但最终还是下定决心再排查一次。

第一天,把账不平的账户的账务流水、波及到代码、日志全副捋一遍。这期间还遇到了很多小艰难,最终留神克服。

艰难一:数据查不动

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

这里就用到 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 非空,则再进行下一步解决。

至此,问题完满解决。

小结

在解决上述问题的过程中,看似只是很简略的乐观锁,但在排查的过程中还用到和波及到了大量的其余常识,比方 @Transactional 事务生效场景的排查、事务的隔离级别、Hibernate 的多级缓存、Spring 的事物治理、多线程、Linux 操作、Navicat 手动事务、SQL 优化、单元测试、Javadoc 查阅等。

所以,在解决问题之后,感觉非常有必要分享给大家。通过这个案例,你又学到了什么呢?

博主简介:《SpringBoot 技术底细》技术图书作者,热爱钻研技术,写技术干货文章。

公众号:「程序新视界」,博主的公众号,欢送关注~

技术交换:请分割博主微信号:zhuan2quan

正文完
 0