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ẽ ...
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.