Rails counter_cache + foreign_key + MySQL = DEADLOCK??

quakewang · 2017年06月15日 · 最后由 kafei 回复于 2017年12月21日 · 8056 次阅读
本帖已被管理员设置为精华贴

最近在检查一个项目的日志时,发现了一些奇怪的 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 次再同步到数据库。

counter_cache 字段还有另外个问题是会锁定行,也需要注意。

例如 User 有个 photos_count,如果要批量上传照片,同一个用户,批量提交 10 个照片,实际上写入会因为更新 photos_count 字段,导致 User 的某一行被锁定,于是效率上不去。

huacnlee 将本帖设为了精华贴。 06月15日 15:52

一致性也有问题...3.2 时代 我曾经搞了个 PR 的,从框架层面做这个事情超难,后来没合并 4 官方做重构了,但是问题依旧,之后 Shopify 的人接了我的工作继续搞,也还是有问题

hooopo 回复

看了一下 README,counter_culture 是通过把更新计数器移到 after_commit 解决的,也算一种绕过去的方法吧

hooopo 回复

很棒啊,请教一下,如果想学习 postgresql 的优化,有没有推荐的书或者资料。

hooopo 回复

真是令人吃鲸的奇思妙想

从来不用外键约束

before_create do
  article.increment!(:likes_count)
end

这个已经改变了业务逻辑:先累加赞,再添加赞记录。

一个思路是把这类计数器累加操作可以放入队列里异步、顺序执行。

另外我的使用 counter_culture+pg 经验中发在多线程高并发时,累加值容易互相覆盖,最好还是定时或在某些 callback 再 call 一个 update_counter_by_counts

hooopo 回复

10086 这个梗没有听明白。

ruby_sky 回复

那 +1024 好啦

你这 SQL 写的就是错的。你这么干,得先 SELECT FOR UPDATE,好不好

我们也遇到过 counter cache 的瓶颈,后来用 redis 代替,周期性写回,就没问题了。苦于老项目只能留在 MySQL。

16 楼 已删除

已经全面切换到 PostgreSQL 上了,虽然用了好久 MySQL,但是从 2015 年年底开始接触 PostgreSQL,就将自己做的小东西的后端全部向 PostgreSQL 上了,到今天为止,主要使用 PostgreSQL 和 CouchDB 了。

超级数据库 PostgreSQL 创业初期如何使用 PostgreSQL 代替多种后端存储

TTalkIM 点评版本

需要 登录 后方可回复, 如果你还没有账号请 注册新账号