1300 words
7 minutes
Django ORMでdistinct()が効いてるはずなのに重複する。犯人はMySQLだった話

開発中に、

あ?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.sort
fjs_attributegroup.sort
fjs_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()

に見えていても、

実際には、

id
value
attribute_group_id
itempfatrgroup.sort
attributegroup.sort
attribute.sort

まで含めてDISTINCT判定されていた。


本当に起きていたこと#

例えばこんなデータ。

idvaluepf_sort
1030001
1030002

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句に見覚えのないカラムが紛れ込んでいたら、

たぶん半沢が始まっている。