開発中に、
あ?distinct入れてんねんけど。
いやいや。JOINで増えてるのは分かる。
だからdistinct入れてんねん。きみdistinctの意味わかってる?みたいなことをひとり心の中で言ったことがある人は、たぶんこの記事の読者である。
今回遭遇したのは、
「distinct()してるのに同じデータが返ってくる」
という謎現象。
最終的にはDjangoのバグでもなく、自分のコードの単純ミスでもなく、MySQLの仕様に綺麗にハマっていた。
しかもエラーは出ない。
静かに壊れる。
こういうやつが一番怖い。
現象
事例データ周りを触っていて、属性一覧を取得していた。
attrs_qs = ( Attribute.objects .filter( attribute_group__in=[g['id'] for g in groups], active=True ) .order_by( 'attribute_group__item_pf_atr_group__sort', 'attribute_group__sort', 'sort', 'id' ) .values( 'id', 'value', 'attribute_group_id' ) .distinct())見た感じ特に問題なさそう。
実際、
JOINで増えるかもしれんし、とりあえずdistinct()
みたいなコードはよく見る。
ところが結果を見ると、
[ { "id": 10, "value": "3000" }, { "id": 10, "value": "3000" }]みたいな状態になる。
いや、なんでやねん。
distinctどこ行った。
最初に疑ったこと
まず疑ったのはJOIN。
- JOIN先が増えてる?
- 中間テーブルがおかしい?
- related_nameミス?
- LEFT JOINのせい?
この辺を順番に見ていく。
実際、JOINで件数は増えていた。
でも、
JOINで増えてる↓distinctがある↓だから潰れるはず↓なんで残ってる?になる。
ここで少しずつ嫌な予感がしてきた。
SQLを見たら違和感があった
こういう時はDebug Toolbar。
SQLを開く。
SELECT DISTINCT fjs_attribute.id, fjs_attribute.value, fjs_attribute.attribute_group_id, fjs_itempfatrgroup.sort, fjs_attributegroup.sort, fjs_attribute.sort・・・
・・・
・・・
ん?
お前誰やねん
values()で指定したのはこれだけ。
.values( 'id', 'value', 'attribute_group_id')なのにSQLを見ると、
fjs_itempfatrgroup.sortfjs_attributegroup.sortfjs_attribute.sortがSELECTされている。
いや、お前誰やねん。
呼んでないやろ。
最初はDebug Toolbarの表示の問題かと思った。
でもSQL全文を見てもちゃんとSELECTされている。
勝手に増えている。
ここでだいぶ嫌な予感がしてきた。
犯人はMySQLだった
調べていくと原因はMySQLの仕様だった。
MySQLでは、
SELECT DISTINCT ...ORDER BY ...を使う場合、
ORDER BYに使用するカラムをSELECT対象に含める必要がある。
そのためDjangoが内部的にORDER BY用のカラムを追加していた。
つまりORM上は、
.values( 'id', 'value', 'attribute_group_id').distinct()に見えていても、
実際には、
idvalueattribute_group_iditempfatrgroup.sortattributegroup.sortattribute.sortまで含めてDISTINCT判定されていた。
本当に起きていたこと
例えばこんなデータ。
| id | value | pf_sort |
|---|---|---|
| 10 | 3000 | 1 |
| 10 | 3000 | 2 |
Python側から見ると、
{ "id": 10, "value": "3000"}しか見えない。
だから同じデータに見える。
でもMySQLから見ると、
(10, 3000, 1)(10, 3000, 2)なので別行。
distinct()は壊れていない。
ちゃんと働いている。
ただ、自分が思っていた対象に対してではなかった。
倍になってる
ここで全てが繋がった。
JOINも正常。
Djangoも正常。
MySQLも正常。
distinct()も正常。
なのに結果だけがおかしい。
返ってきたデータを見る。
[ {"id": 10, "value": "3000"}, {"id": 10, "value": "3000"},]・・・
倍やん。
完全に倍やん。
倍返しである
distinctしたはずなのに、
データが倍になって返ってくる。
しかもエラーも出ない。
静かに。
ログにも残らず。
倍返しされる。
ということで現場ではこの現象をこう呼ぶこととする。
半沢バグ
Django + MySQL において、 values().distinct().order_by() の組み合わせにより、 ORDER BY用カラムがDISTINCT判定へ巻き込まれ、 見た目上同じデータが倍返しされる現象。
危険なのは、
エラーにならないこと。
件数集計。
価格計算。
組み合わせ生成。
こういうところで起きると結構しんどい。
対策
今回のケースではGROUP BY寄りに寄せた方が安全そうだった。
from django.db.models import Min
attrs_qs = ( Attribute.objects .filter(...) .values( 'id', 'value', 'attribute_group_id' ) .annotate( pf_sort=Min( 'attribute_group__item_pf_atr_group__sort' ) ) .order_by( 'pf_sort', 'id' ))あるいは、
distinct()+values外のorder_byを見たら一度警戒する。
これだけでもだいぶ違うと思う。
まとめ
今回学んだことはシンプルだった。
distinct()を見たら安心してしまう。
でも本当に見るべきなのは、
何に対してDISTINCTが掛かっているか。
次また似た現象を見たら、
まずSQLを見る。
そして知らんカラムがおったら疑う。
今後これを
黒崎チェック
と呼ぶことにした。
もしSELECT句に見覚えのないカラムが紛れ込んでいたら、
まず黒崎チェック。
☑ values()に書いたの?☑ annotateしたの?☑ order_byで使ってないわよね?☑ じゃあお前誰?もしSELECT句に見覚えのないカラムが紛れ込んでいたら、
たぶん半沢が始まっている。