Rails counter_cache + foreign_key + MySQL = DEADLOCK??

quakewang · 发布于 2017年06月15日 · 最后由 hooopo 回复于 2017年06月21日 · 1301 次阅读
162
本帖已被设为精华帖!

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

共收到 11 条回复
De6df3

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

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

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

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

8

好像 https://github.com/magnusvk/counter_culture 解决了这个问题。

《高性能mysql》里有一个计数表的技巧来避免并发修改一条记录,预先生成计数表,随机选择一条去增减数量,然后再sum,Rails里可以earger load之后手动sum,不过有点麻烦...

记得这种模式也有rails gem实现了,找不到地址了...

PS. 新系统,强烈推荐postgresql +10086

162
8hooopo 回复

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

20859
8hooopo 回复

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

19766
8hooopo 回复

真是令人吃鲸的奇思妙想

9800

从来不用外键约束

377
before_create do
  article.increment!(:likes_count)
end

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

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

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

273
8hooopo 回复

10086 这个梗没有听明白。

8
273ruby_sky 回复

那+1024好啦

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