Có nên sử dụng counter cache cho quan hệ many to many trong Rails ???
Bài đăng này đã không được cập nhật trong 6 năm
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