[RUBY] [Rails] Find_each fait une boucle sans fin et consomme de la mémoire en production

Méthode pratique find_each dans ActiveRecord J'ai écrit à propos de l'histoire parce que la méthode de mise en œuvre n'était pas bonne et qu'elle a bouclé à l'infini, et le phénomène selon lequel il a été arrêté de force par le tueur OOM dans l'environnement de production s'est produit.

Qu'est-ce que find_each

Ce que fait find_each n'est pas d'obtenir une grande quantité de données à la fois et de les boucler, mais de les obtenir en unités fixes (1000 valeurs par défaut) et de les boucler. Lorsque vous traitez une grande quantité de données, si vous les acquérez toutes en même temps, vous utiliserez une grande quantité de mémoire, mais vous pouvez la traiter avec une petite quantité de mémoire en la divisant à l'aide de find_each.

C'est difficile à comprendre même si vous l'écrivez avec des mots. Voici un exemple d'exécution.

#Quand il y a 10 000 utilisateurs
pry(main)> User.all.count
   (1.1ms)  SELECT COUNT(*) FROM `users`
=> 10000

#Si vous utilisez chacun d'eux, 10 000 objets seront acquis à la fois.
pry(main)> User.all.each {|user| p user.id}
  User Load (4.5ms)  SELECT `users`.* FROM `users`
1
2
3
...
10000

# find_1 avec chacun,Obtenez 000 articles à la fois
[8] pry(main)> User.all.find_each {|user| p user.id}
  User Load (3.9ms)  SELECT `users`.* FROM `users` ORDER BY `users`.`id` ASC LIMIT 1000
1
2
3
...
1000
  User Load (0.8ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` > 1000 ORDER BY `users`.`id` ASC LIMIT 1000
1001
1002
1003
...
2000
  User Load (0.8ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` > 2000 ORDER BY `users`.`id` ASC LIMIT 1000
2001
...
10000

Consultez le Guide des rails pour plus de détails. https://railsguides.jp/active_record_querying.html#find-each

find_each est une boucle infinie! !!

C'est une méthode pratique find_each, mais comme je l'ai écrit au début, j'ai fait une erreur dans l'implémentation et j'ai bouclé à l'infini. Avant d'expliquer l'implémentation de la boucle infinie, voyons comment fonctionne find_each en premier lieu.

Comment fonctionne find_each

Voyons comment cela fonctionne en utilisant l'exemple d'exécution montré au début.

Jetons un coup d'œil au SQL qui est émis en premier.

SELECT `users`.* FROM `users` ORDER BY `users`.`id` ASC LIMIT 1000

1000 objets sont acquis en spécifiant la limite 1000. Ce qu'il faut noter ici, c'est qu'ils sont disposés par ordre croissant de CLÉ PRIMAIRE (id).

Alors, comment obtenez-vous les 1000 prochains cas?

SELECT `users`.* FROM `users` WHERE `users`.`id` > 1000 ORDER BY `users`.`id` ASC LIMIT 1000

Lors de l'obtention des 1000 éléments suivants à l'aide de SQL, LIMIT et OFFSET sont souvent utilisés, mais OFFSET n'est pas utilisé dans ce SQL. Au lieu de cela, vous pouvez voir que la clause where a une exigence supplémentaire de ʻusers.id> 1000`.

ʻUsers.id> 1000` 1000 est le dernier identifiant des 1000 premiers obtenus. Puisque les données cette fois sont arrangées dans l'ordre croissant d'id, les 1000 éléments suivants peuvent être acquis sans utiliser OFFSET en spécifiant «users.id> 1000», ce qui signifie acquérir des données plus grandes que le dernier identifiant. Faire.

Implémentation qui devient une boucle infinie

Find_each où la boucle infinie s'est produite a été implémenté comme suit. Que va-t-il se passer?

# users.id et livres.Puisque seul le titre est utilisé, seules les données nécessaires sont acquises par select.
Book.joins(:user).select('users.id, books.title').find_each do |book|
  p "user_id: #{book.id}, title: #{book.title}"
end

Au début, le SQL suivant est émis.

SELECT users.id, books.title FROM `books` INNER JOIN `users` ON `users`.`id` = `books`.`user_id` ORDER BY `books`.`id` ASC LIMIT 1000

Il n'y a pas de problème particulier avec le premier SQL. Alors qu'en est-il du SQL qui obtient les 1000 prochains?

SELECT users.id, books.title FROM `books` INNER JOIN `users` ON `users`.`id` = `books`.`user_id` WHERE `books`.`id` > 1000 ORDER BY `books`.`id` ASC LIMIT 1000

La condition «books.id> 1000» a été ajoutée. La condition 1000 est l'identifiant des 1000 dernières données obtenues en premier. Il est difficile de remarquer si vous regardez uniquement SQL, mais l'identifiant que vous obtenez avec ce SQL est ʻusers.id au lieu de books.id. Par conséquent, 1000, qui est défini sur books.id> 1000`, spécifie le users.id des dernières données.

Dans ce SQL, l'ordre de books.id est croissant et l'ordre de users.id n'est pas particulièrement contrôlé. Par conséquent, il est possible que la dernière donnée du 1000ème élément suivant soit «books.id: 2000, users.id: 1». Dans ce cas, le SQL à émettre ensuite sera le suivant.

SELECT users.id, books.title FROM `books` INNER JOIN `users` ON `users`.`id` = `books`.`user_id` WHERE `books`.`id` > 1 ORDER BY `books`.`id` ASC LIMIT 1000

La condition sera books.id> 1, et les données avant le SQL précédent ( books.id> 1000) seront récupérées. En incluant users.id dont la commande n'est pas contrôlée dans l'état de books.id de cette manière, les données à acquérir seront mélangées, et dans le pire des cas, les mêmes données seront acquises plusieurs fois et une boucle infinie se produira. Je vais.

La partie gênante de ce problème n'est pas toujours une boucle infinie, et selon les données, books.id> # {last users.id} peut arriver à être spécifié correctement et complet comme ça. Il y a. Dans ce cas, ce ne sera pas une erreur, mais ce sera un bogue dont il est difficile de remarquer que les données sont subtilement étranges, il peut donc être préférable d'avoir une boucle infinie.

Comment réparer

Dans le cas de l'exemple ci-dessus, si vous ne rétrécissez pas la colonne d'acquisition avec select, books.id sera également acquis, donc il fonctionnera correctement. Même si vous réduisez la colonne d'acquisition avec select, cela fonctionnera correctement si vous acquérez également books.id correctement comme indiqué ci-dessous.

Book.joins(:user).select('books.id AS id, users.id AS user_id, books.title').find_each do |book|
  p "user_id: #{book.user_id}, title: #{book.title}"
end

Si vous le corrigez comme ci-dessus, le correctif est terminé, mais je pense que le problème cette fois-ci était qu'il n'y avait pas de test automatisé. Il y a eu un test qui a réussi le processus correspondant, mais je n'ai pas écrit de test qui boucle find_each plus de 2 fois. Si vous avez un test, vous avez probablement remarqué le bogue car il boucle indéfiniment ou les résultats seront étranges. Avec cela comme déclencheur, j'ai également ajouté un test dans lequel find_each boucle plus de 2 fois.

Résumé

Même si vous comprenez correctement le mécanisme de find_each, il est difficile de remarquer ce bogue simplement en le vérifiant sur le bureau tel que la révision de code. De plus, c'est un processus rare que le nombre de cas dépasse 1000, et l'unité de 1000 n'est qu'une question de programme, elle a donc été cachée comme un bogue potentiel pendant un moment sans être remarquée même dans le test de fonctionnement de la boîte noire.

En réfléchissant à la façon dont j'aurais dû remarquer cela à l'avance, j'ai pensé que je devais faire un test dans lequel find_each a bouclé 2 dans le test de la boîte blanche. C'est un gaspillage d'exécuter le test de la boîte blanche manuellement pendant une seule fois, c'est donc une bonne idée d'écrire un test automatisé afin qu'il puisse être vérifié en continu.

Recommended Posts

[Rails] Find_each fait une boucle sans fin et consomme de la mémoire en production
Supprimez les "actifs" et les "turbolinks" dans "Rails6".
Fonction CRUD et MVC dans Rails
[Ruby] Distinction et utilisation des boucles dans Ruby
[Rails] Réinitialisez la base de données dans l'environnement de production