最近在检查一个项目的日志时,发现了一些奇怪的 deadlock 错误,经过排查之后发现和 counter_cache 以及 foreign_key 相关,记录一下相关的情况。
功能需求:用户可以对一篇文章点赞,文章需要显示共有多少个赞。为了性能考虑,在 belongs_to 里面设置了 counter_cache,这是很常见的做法。 简化后的 model 代码如下:
class Article < ApplicationRecord
has_many :likes
end
class Like < ApplicationRecord
belongs_to :article, counter_cache: true
end
migration 脚本如下,为了数据一致性考虑,利用了数据库的外键约束,设置了 foreign_key:
class CreateArticles < ActiveRecord::Migration[5.0]
def change
create_table :articles do |t|
t.string :title
t.text :content
t.integer :likes_count, default: 0
t.timestamps
end
end
end
class CreateLikes < ActiveRecord::Migration[5.0]
def change
create_table :likes, force: true do |t|
t.references :article, foreign_key: true
t.timestamps
end
end
end
日志显示,当多个用户同时对同一篇文章点赞的时候,有概率出现死锁,在 console 里面用多线程模拟一下并发点赞:
3.times.map {
Thread.new {
Like.create(article: Article.find(1))
}
}.each(&:join)
跑几次就很容易重现出这个错误:
ActiveRecord::StatementInvalid: Mysql2::Error: Deadlock found when trying to get lock; try restarting transaction: UPDATE `articles` SET `likes_count` = COALESCE(`likes_count`, 0) + 1 WHERE `articles`.`id` = 1
一开始我很不理解为什么这里会出现死锁,因为 Like.create 产生的 sql 很简单:
INSERT INTO `likes` (`article_id`, `created_at`, `updated_at`) VALUES (1, '2017-06-15 06:10:48', '2017-06-15 06:10:48')
UPDATE `articles` SET `likes_count` = COALESCE(`likes_count`, 0) + 1 WHERE `articles`.`id` = 1
按照我原先对 mysql 的理解,只有第二句执行 update 的时候,才会对 Article 表的 id 1 记录请求一个 exclusive (X) lock,每个线程都只有一个锁的情况下,只会出现 lockwait,而不是 deadlock。
经过搜索相关关键字,发现了这个 bug 报告: https://bugs.mysql.com/bug.php?id=48652
原来由于外键的存在,在执行第一句 insert 的时候,会对 Article 表的 id 1 记录请求一个 shared (S) lock:
If a FOREIGN KEY constraint is defined on a table, any insert, update, or delete that requires the constraint condition to be checked sets shared record-level locks on the records that it looks at to check the constraint. InnoDB also sets these locks in the case where the constraint fails.
2 个线程的 SQL 执行顺序按照这样的时序发生,就会产生死锁:
T1 INSERT 获得 S lock (Article id 1记录)
T2 INSERT 获得 S lock (Article id 1记录)
T1 UPDATE 升级 X lock (等待T2的S lock释放)
T2 UPDATE 升级 X lock (等待T1的S lock释放,死锁发生)
那如何解决这个问题?有几个选择:
A. 取消外键
如果能够在代码层面保证数据一致性,取消外键是最简单的选择。
B. 改用 postgresql
对于新系统,我现在都强烈推荐 postgresql,用过了你就不会想回去 mysql。这个外键导致死锁的问题在 9.3 版本之前也存在,但是很快通过新的 Lock 类型解决了,看看 mysql 的那个 bug 报告日期,我都要哭了。
C. 不用 ActiveRecord 的 counter cache callback
如果我们能够将 update counter 的语句在 insert 之前执行,也就不会有死锁的情况发生,改进一下 model 代码如下:
class Like < ApplicationRecord
belongs_to :article
before_create do
article.increment!(:likes_count)
end
end
题外话,如果相关模型的并发写很高,即使在没有外键或者 postgresql 的情况下,更新 counter cache 也会成为一个瓶颈,我们还可以选择将计数器更新用 redis 做 buffer,每 N 次再同步到数据库。