MySQL InnoDB 锁的二三事

近日, 在一个小型项目中, 遇到了一个触及我知识盲区的bug.

项目用的是MySQL 5.7.25, 其中有一张表 config_data, 包含四个字段, id, name, value, expireAt. 其中id为主键, name建有唯一索引, 表的用途大概就是存放一些有时效性的配置. 以上就是这次故事的背景.

(不要问我为什么要用这么奇怪的方式处理需要过时的配置, 项目太简陋以至只有一台虚拟主机和一个数据库, 别的什么都没了, 包括Redis)

这张表的使用场景大致为, 假设需要使用某配置 a, 先尝试从表中查找 a, 若找到, 判断是否过期, 过期或者值不存在则从外部获取配置的值并存入表中, 以便下次使用. 伪代码流程如下:

config = query('select value, expireAt from config_data where name = "a" lock in share mode;');

if (!config || config.expireAt < now) {    // &#x4E0D;&#x5B58;&#x5728;&#x6216;&#x5DF2;&#x8FC7;&#x671F;
    beginTransaction();
    config = query('select value, expireAt from config_data where name = "a" for update;');
    if (config && config.expireAt > now) {
        rollback();
        return config.value;
    }
    value = getConfigValueFrom3rdPartyServer();    // &#x4ECE;&#x5916;&#x90E8;&#x670D;&#x52A1;&#x5668;&#x83B7;&#x53D6;&#x914D;&#x7F6E;&#x503C;
    execute('insert config_data (name, value, expireAt) value ("a", value, newExpireTime) on duplicate key update value=value, expireAt=newExpireTime;');    // &#x63D2;&#x5165;&#x6216;&#x66F4;&#x65B0;&#x914D;&#x7F6E;&#x503C;&#x4EE5;&#x53CA;&#x8FC7;&#x671F;&#x65F6;&#x95F4;
    commit();
    return value;
}

return config.value;

由于配置的值需要从外部服务器通过接口调用获取, 执行代价较大, 更重要的是, 第三方服务器的接口有每日调用次数限制, 因此必须控制出现并发更新配置值时 (即同一时间多个请求到来时配置项过期了) 只有一个进程发起请求获取配置值并更新数据库, 其余进程需等待更新完成并使用更新后的数据.

Again, 只有虚拟主机+DB, 故只好借用数据库方式加锁. 基本思路就是, 开始时使用共享锁 (S Lock) 查找配置值 (数据库使用了默认的autocommit, 语句执行完后共享锁自动释放), 如果需要更新, 开启事务, 使用排他锁 (X Lock) 锁住待更新行, 从外部服务器获取配置值 (不考虑获取失败情况, 配置值都获取不了只能直接往外抛异常了) , 使用 insert ... on duplicate key update 方式插入或更新数据库, 提交事务, done~

假设有两个进程A, B同时获取配置值, A, B均能同时获得共享锁并查询到已过期的配置, 然后尝试获取排他锁, 但只会有一个进程能成功获取排他锁, 这里假设是A, 则B在第5行时会被block住, 在A更新完成并提交事务后, B才能从第5行继续并获取到最新的配置值. 假如在A更新完成前, 第三个进程C又需要获取这个配置值, 则会在第1行尝试获取共享锁时由于排他锁已被A获得而被block住. 同样, 待A提交事务后C就能获得共享锁并拿到最新的值.

粗看逻辑没有问题, 并发的问题貌似完全可以由MySQL的行锁 (Record Lock) 解决. Perfect~ 于是就简单试了下功能, 扔代码上主机, 项目就上线运行了.

就这样过了两三天, 项目体量实在太迷你了, 每天最多也就1~2k的访问量, 因此服务器配置也是低得令人发指. 期间偶尔收到反馈说接口会报500错误, 我一概以”服务器配置太低”或者”网络问题”为由搪塞过去 (甩锅小能手~) , 倒也无惊无险地过来了. 直到那一次, 收到某个需求要小改一下前端界面, 调试的时候偶遇了这个神秘的500, 好奇看了一眼报错内容……

Deadlock found when trying to get lock; try restarting transaction

WTF? Deadlock???

一顿操作排查之后, 基本可以确定问题就是出在上面这段查找配置值的代码上. 当配置值过期后需要更新时, 如果同时有多个进程尝试执行上面的代码更新配置值时, 就会被检测出死锁. 具体表现为, 其中某个进程成功更新了数据库, 其余进程全部会抛出死锁异常, 几乎100%必现 (必现的bug就是好bug~).

按一般对死锁的理解, 常见的场景是两个进程按相反顺序加锁访问两个资源, 然后卡在互相等对方释放第一个资源造成的. 然而, 上面的代码明显和这个场景完全不沾边啊?…… 百思不得其解, 只能用尽各种模拟方法尝试找到原因. 还好最后终于确认了重现的步骤:

首先惯例假设有两个进程A和B.

开始事务 开始事务 select … for update 查找name=a的行并获得结果 select … for update 查找name=a的行 (被阻塞) insert … on duplicate key update … 更新数据成功 (deadlock found, gg) 事务被强行中断并回滚 提交事务, 完成更新

然后我就 (黑人问号.jpg) . Why???? 我不就是更新了行数据, 你都被阻塞了, 等我更新完再去拿结果不就好了?

而且, 即使我将 insert ... on duplicate key update ... 替换成 insert ... , 也照样能造成B死锁, 只是A也因唯一索引冲突插入失败而已, 也就是说, 死锁和更新无关 (也许吧).

这真的超出我理解范围了. 调出死锁分析看看 (执行 show engine InnoDB status; 然后查看Status字段的 LATEST DETECTED DEADLOCK 部分)


LATEST DETECTED DEADLOCK

Original: https://www.cnblogs.com/reginald-lee/p/16697879.html
Author: Reginald-Yoeng-Lee
Title: MySQL InnoDB 锁的二三事

原创文章受到原创版权保护。转载请注明出处:https://www.johngo689.com/598800/

转载文章受原作者版权保护。转载请注明原作者出处!

(0)

大家都在看

亲爱的 Coder【最近整理,可免费获取】👉 最新必读书单  | 👏 面试题下载  | 🌎 免费的AI知识星球