Có nên sử dụng counter cache cho quan hệ many to many trong Rails ???

Counter cache giúp tăng performance bằng cách tránh việc query N+1. Tuy nhiên chúng ta có nên sử dụng nó với quan hệ many to many trong rails không? Thông qua bài viết này tôi sẽ trả lời cho câu hỏi trên.

Kịch bản đưa ra chúng ta có Post và Tag có quan hệ many to many thông qua Tagging. Post sẽ lưu lại số lượng Tag của nó:

class Tagging
  # FIELDS: post_id, tag_id
  belongs_to :tag
  belongs_to :post, counter_cache: :tags_count # updates tags_count in Post
end


class Tag
  # FIELDS: title
  has_many :taggings
  has_many :posts, through: :taggings, dependent: :destroy
end

class Post
  # FIELDS: content, tags_count
  has_many :taggings
  has_many :tags, through: :taggings, dependent: :destroy
end

Chú ý. dependent: :destroy khai báo trong Post sẽ xóa hết Tagging chứ không phải Tag của Post. tags_count sẽ tự động update mỗi khi post được tạo, update hoặc delete.

Multiple SQL updates

Counter cache làm việc bằng cách chạy SQL update mỗi khi một quan hệ được tạo hoặc xóa. Khi mà một Post mới được khởi tạo với một vài Tag (ví dụ ở đây là 99 Tag), Active Record sẽ chạy các câu query như dưới đây:

INSERT INTO posts (content) VALUES ("Lorem ipsum")

INSERT INTO taggings (post_id, tag_id) VALUES (1, 1);
UPDATE posts SET tags_count = tags_count + 1 WHERE posts.id = 1;

INSERT INTO taggings (post_id, tag_id) VALUES (1, 2);
UPDATE posts SET tags_count = tags_count + 1 WHERE posts.id = 1;

...

INSERT INTO taggings (post_id, tag_id) VALUES (1, 99);
UPDATE posts SET tags_count = tags_count + 1 WHERE posts.id = 1;

-- ======================
-- TOTAL QUERIES: 2×N + 1
-- ======================

Vậy đối với những Post có hàng chục hoặc hàng trăm Tag sẽ sinh ra rất nhiều câu query khiến việc thực thi trở nên chậm đi.

Thay thế counter cache bằng callbacks

class Post
  # FIELDS: content, total_tags
  has_and_belongs_to_many :tags
  before_save :update_total_tags

  def update_total_tags
    self.total_tags = tag_ids.count
  end
end

class Tag
  # FIELDS: title
  has_and_belongs_to_many :posts
  before_destroy :update_posts

  def update_posts
    Post.where(id: post_ids).update_all('total_tags = total_tags - 1')
  end
end

Bằng cách thay thế taggings table với post_tags join table (bạn cũng có thể đổi tên tags_counts thành total_tags để tránh việc tự động sử dụng counter cache).

Từ giờ chúng ta sử dụng before_save để update giá trị của total_count. Với việc đếm tag_ids có thể tránh được count query trên Tag. Hãy xem query khi mà tạo một Post với nhiều Tag

INSERT INTO posts (content, total_tags) VALUES ("Lorem ipsum", 99)
INSERT INTO posts_tags (post_id, tag_id) VALUES (1, 1);
INSERT INTO posts_tags (post_id, tag_id) VALUES (1, 2);
...
INSERT INTO posts_tags (post_id, tag_id) VALUES (1, 99);
-- ======================
-- TOTAL QUERIES: N + 1
-- ======================

Khi xóa Post có nhiều Tag:

SELECT posts.id FROM posts INNER JOIN posts_tags ON posts.id = posts_tags.post_id WHERE posts_tags.tag_id = 1
UPDATE posts SET total_tags = total_tags - 1 WHERE posts.id IN (1, 2, ..., 99)
DELETE FROM posts_tags WHERE posts_tags.tag_id = 1
DELETE FROM tags WHERE tags.id = 1
-- ======================
-- TOTAL QUERIES: 4
-- ======================

Tổng kết

Thông qua ví dụ trên chắc hẳn các bạn đã có câu trả lời cho câu hỏi và đầu bài viết đã đưa ra. Có thể counter cache là phương án tốt nhất khi sử đụng cho quan hệ one to many nhưng có vẻ lại không thích hợp khi bạn có quan hệ many to many.


All Rights Reserved