django

Django: Как быстро получить ненужные дубликаты в простом QuerySet

  • среда, 9 декабря 2015 г. в 02:10:44
http://habrahabr.ru/post/272559/

Только что обнаружил интересный баг (баг с точки зрения человеческой логики, но не машины), и решил им поделиться с сообществом. Программирую на django уже довольно долго, но с таким поведением столкнулся впервые, так что, думаю, кому-нибудь да пригодится. Что ж, к делу!

Пусть у нас в коде есть такой примитивный кусок:

# views.py
ids = [5201, 5230, 5183, 5219, 5217, 5209, 5246, 5252, 5164, 5248, ...<и т.д.>...]
products = Product.objects.filter(id__in=ids)

Полученные товары про помощи пагинации выводятся на соответствующей страничке по 20 штук. Однажды звонит менеджер и говорит, что товар «прыгает» по страницам — сначала он был замечен на второй странице, а потом внезапно повторяется на пятой.

«Ха» — заявляем мы, ставим брейкпоинт после указанного блока кода и делаем print(products). Визуально и, для верности, циклом проверяем вывод — а там дубликатов нет!

Сделаем вот что: попробуем отловить дублированный товар индексированием и слайсами. Через некоторое время обнаруживаем негодяев: products[3] == products[20]. Так, нашли их. 3 и 20. Товар name.

Выводим: print(products), смотрим на позиции 3 и 20… а там разные товары! Да как так?

Пробуем print(products[0:10]) — товар в позиции 3 есть — name. Пробуем print(products[10:21]) — товар в позиции 20 тоже есть, и он такой же — name. @#! Ну что ж, видимо, django как-то по-разному делает итерацию и взятие по индексу (штоа?), проверим.

Лезим в QuerySet класс, там смотрим __getitem__ метод, вот он в кратком пересказе:

qs = self._clone()
qs.query.set_limits(k, k + 1)
return list(qs)[0]

То есть взятие по индексу — это просто установка set_limits для запроса, поэтому я решил проверить, как же это выглядит в SQL — может, туда закралась ошибка?

qs1 = products._clone()
qs1.query.set_limits(3, 4)
print(qs1.query)

qs2 = products._clone()
qs2.query.set_limits(20, 21)
print(qs2.query)

И когда я получил

SELECT "shop_product"."id", ... FROM "shop_product" WHERE ("shop_product"."id" IN (5201, 5230, 5183, 5219, 5217, 5209, 5246, 5252, 5164, 5248)) LIMIT 1 OFFSET 3

и

SELECT "shop_product"."id", ... FROM "shop_product" WHERE ("shop_product"."id" IN (5201, 5230, 5183, 5219, 5217, 5209, 5246, 5252, 5164, 5248)) LIMIT 1 OFFSET 20,

я понял, что ничего не понял. По разному смещению в базе находится одна и та же запись? Но там же constraint на id, дубликатов быть не может…

В общем, когда я выполнил ручками оба запроса прямо в Postgresql и получил одинаковые записи, я начал гуглить по postgres limit offset duplicates и нашёл ответ на stackoverflow. А штука вот какая:

Когда не указан порядок сортировки строк в запросе (ORDER_BY), то Postgres может применять любую сортировку, которая ему по душе — и я, в общем-то, не против, я же так и написал: Product.objects.filter(...), без всяких order_by(). Когда я только писал этот код, пагинации не было, и все товары выводились разом на страницу — тут Postgres сортировал все эти товары произвольно, но зато все сразу.

А потом, когда появилась разбивка на страницы, бд получала команду навроде «отсортируй строчки как тебе удобнее и дай мне строчки с 20 по 40», и вот при разных диапазонах (0-20 или 20-40) сортировка была разная — это зависило от оптимизаций postgres — и получается, что на вывод шли указанные строки из случайного списка.

А вот и цитата с сайта postgres:
The query optimizer takes LIMIT into account when generating query plans, so you are very likely to get different plans (yielding different row orders) depending on what you give for LIMIT and OFFSET. Thus, using different LIMIT/OFFSET values to select different subsets of a query result will give inconsistent results unless you enforce a predictable result ordering with ORDER BY. This is not a bug; it is an inherent consequence of the fact that SQL does not promise to deliver the results of a query in any particular order unless ORDER BY is used to constrain the order.

Что ж, будем знать!

products = Product.objects.filter(...).order_by('price')

Да? НЕТ! Проверив всё, я опять обнаружил дубли — но на этот раз я уже попался на то, что order_by использовал параметр, который может быть одинаков — и теперь у всех товаров с одинаковой ценой порядок сортировки опять был неопределён. Так что:

products = Products.objects.filter(...).order_by('price', 'id')

Вот теперь точно всё.

P.S.: Лично для меня это было ещё одним наглядным подтверждением «Закона дырявых абстракций» — ты вроде пишешь ORM простой запрос «дай мне строки 20-40», и вроде даже необязательно знать SQL и Postgres, но в итоге в один прекрасный момент эта абстракция течёт, и вот ты уже изучаешь основы.