Esqueça o cache do contador para associações muitos-para-muitos no Rails

Eu gosto de cache de contador no Active Record.

A contagem de associações representa uma melhoria de desempenho ao evitar o carregamento rápido e consultas N + 1 para listagens – por exemplo, a página de índice de Postagens que mostra quantos Comentários cada Postagem tem.

No entanto, tive dificuldade em tentar usar o cache de contador em associações muitos para muitos, com has_many throughe has_and_belongs_to_many. Depois de tentar por um tempo, cheguei à conclusão de que é melhor não usar o cache de contador para esses tipos de associações. É péssimo para o desempenho porque gera várias consultas SQL que podem ser reduzidas a apenas 4 ou menos consultas.

Vou explicar com exemplos.

O cenário

Digamos que temos posts com tags por meio de taggings. As postagens mantêm a contagem de suas tags:

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

Observe que dependent: :destroyisso destruirá marcações, não tags ou postagens. Agora o tags_countcampo será atualizado automaticamente sempre que uma postagem for criada, atualizada ou excluída.

Más notícias: várias atualizações de SQL

O cache do contador funciona executando uma atualização SQL sempre que uma nova associação é criada ou excluída.

Quando uma nova postagem é criada com algumas tags (na verdade, 99 tags para fins de exemplo), o Active Record fará várias atualizações:

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
-- ======================

Ao destruir uma única tag que está associada a muitas (99) postagens:

SELECT * FROM posts INNER JOIN posts_tags ON posts.id = posts_tags.post_id WHERE posts_tags.tag_id = 1

DELETE FROM posts_tags WHERE posts_tags
.tag_id = 1 AND posts_tags.post_id = 1
UPDATE posts SET tags_count
= tags_count - 1 WHERE posts.id = 1;

DELETE FROM posts_tags WHERE posts_tags
.tag_id = 1 AND posts_tags.post_id = 2
UPDATE posts SET tags_count
= tags_count - 1 WHERE posts.id = 2;

...

DELETE FROM posts_tags WHERE posts_tags
.tag_id = 1 AND posts_tags.post_id = 99
UPDATE posts SET tags_count
= tags_count - 1 WHERE posts.id = 99;

DELETE FROM tags WHERE tags
.id = 1

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

Isso é claramente prejudicial para objetos com dezenas ou centenas de associações.

Substitua o cache do contador por chamadas de retorno

Decidi encerrar o cache do contador automático para associações muitos para muitos. Vamos usar callbacks em seu lugar:

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

Observe que substituí a taggingstabela por uma posts_tagstabela de junção. Também substituí tags_countpor total_tagspara evitar o cache automático do contador devido às convenções de nomenclatura . Felizmente, este comportamento automático está obsoleto e terá desaparecido no Rails 5.

Ao usar before_save, certificamo-nos de definir a contagem total antes da instrução INSERT (evitando assim um UPDATE tardio). Ao contar tag_ids, evitamos uma COUNT consulta adicional nas tags. Vamos ver como os mesmos exemplos apresentados antes funcionam agora.

Quando o usuário cria uma postagem com muitas tags:

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
-- ======================

MELHORAR ALERTA: isso poderia ser reduzido ainda mais para apenas 2 consultas inserindo em massa em posts_tags. Se alguém por aí souber como fazer isso no Active Record, por favor, comente.

Ao destruir uma única tag associada a muitas postagens:

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
-- ======================

Conclusão

Use o cache do contador apenas para associações um-para-muitos. É perfeito para essas situações!

Mas depois de tudo isso, parece bastante óbvio que o recurso de cache de contador embutido não é adequado para associações muitos para muitos (talvez isso já fosse óbvio para muitos desenvolvedores por aí). Decidi escrever este post de qualquer maneira para aconselhar outras pessoas que estão pensando em seguir esse caminho. Espero ter convencido vocês do contrário 🙂