Django's select_for_update(skip_locked=True) не совместим с spanning
Я пытался заблокировать самый старый элемент в наборе запросов и ломал голову над тем, почему это не работает.
Каждый раз, когда я использовал этот первый фрагмент кода, весь набор запросов блокировался.
with transaction.atomic():
locked_entry = Entry.objects.select_for_update(skip_locked=True).filter(
event__date=distribution_date(),
status='pending',
entry_type__name='Premium'
).order_by('-created_at').first()
print(locked_entry)
sleep(4)
Однако при передаче точных экземпляров FK блокировка стала корректно блокировать только самый старый экземпляр. Я уверен, что это как-то связано с тем, как вызывается SQL, но надеялся на объяснение, если есть эксперты по Django :)
with transaction.atomic():
locked_entry = Entry.objects.select_for_update(skip_locked=True).filter(
event=Event.objects.get(date=distribution_date()),
status='pending',
entry_type=EntryType.objects.get(name='Premium')
).order_by('-created_at').first()
print(locked_entry)
sleep(4)
Это в некоторой степени меньше о Django ORM и немного больше о собственном поведении Postgres.
select_for_update()
работает с наборами запросов только , а не с результатами, состоящими из одного экземпляра. По этой причине вторая версия вашего кода может заблокировать не более одной строки, только если сам набор запросов (т. е. набор запросов, как он выглядит до вызова .first()
) содержит только эту одну строку. Это означает, что skip_locked
практически ничего не делает в вашем конкретном случае использования, как показано на данный момент, поскольку блокировка всего набора запросов и последующий запрос всего набора запросов приведут к блокирующему поведению.
Вы можете подтвердить это, проверив следующую модификацию вашего запроса:
Entry.objects.filter(
event__date = default_date,
status = 'pending',
entry_type__name = 'Premium'
).order_by('-created_at')[:1].select_for_update()
<<<Ключевым моментом модификации является отказ от , так .first()
как это Django sugar для получения instance, а не кверисета. Нарезка, с другой стороны, сохраняет кверисет. Не думаю, что это имеет значение, но для ясности я также решил применять .selected_for_update()
после того, как мы сгенерировали нарезанный queryset.
В результате модифицированный запрос блокирует набор запросов, содержащий только одну строку, в отличие от вашего оригинального запроса, в котором блокировка приходится на полный результат .filter()
.
Я проверил это и для вас.
Тестовая установка
# models.py
from django.db import models
from django.utils import timezone
default_date = timezone.datetime(2024, 12, 1).date()
class EntryType(models.Model):
name = models.CharField(max_length=100, default="Premium")
class Event(models.Model):
date = models.DateField(default=default_date)
class Entry(models.Model):
status = models.CharField(max_length=100, default="pending")
created_at = models.DateTimeField(auto_now_add=True)
entry_type = models.ForeignKey(
EntryType,
on_delete=models.CASCADE,
null=True
)
event = models.ForeignKey(
Event,
on_delete=models.CASCADE,
null=True
)
В тестовых сценариях ниже вы увидите, что я разбил ваш запрос на части - это сделано для того, чтобы лучше показать, когда придет время увидеть результаты, где возникло блокирующее поведение. Они должны быть функционально эквивалентны.
«Базовые» результаты (без skip_locked
)
# tty1:
In [1]: from apps.test.script import test_runner_chaining, retrieve_nonblocked_queryset
In [2]: test_runner_chaining(1, 5)
Self sleep: 1
Query sleep: 5
Database: default: django_tenants.postgresql_backend
-----------
04:33:51.076197: Starting run (1)
New transaction, using sleep interval value: 5
04:33:51.109168: Full queryset: <QuerySet [<Entry: Entry object (17)>, <Entry: Entry object (16)>]>
04:33:51.116166: Narrowed result: <QuerySet [<Entry: Entry object (17)>]>
04:33:56.118673: Completed run (1), sleeping for (1)
04:33:57.119963: Starting run (2)
New transaction, using sleep interval value: 5
04:33:57.120957: Full queryset: <QuerySet [<Entry: Entry object (17)>, <Entry: Entry object (16)>]>
04:34:01.122894: Narrowed result: <QuerySet [<Entry: Entry object (17)>]>
04:34:06.125476: Completed run (2), sleeping for (1)
# tty2:
In [1]: from apps.test.script import test_runner_chaining, retrieve_nonblocked_queryset
In [2]: test_runner_chaining(1, 5)
Self sleep: 1
Query sleep: 5
Database: default: django_tenants.postgresql_backend
-----------
04:33:52.410499: Starting run (1)
New transaction, using sleep interval value: 5
04:33:52.443820: Full queryset: <QuerySet [<Entry: Entry object (17)>, <Entry: Entry object (16)>]>
04:33:56.118673: Narrowed result: <QuerySet [<Entry: Entry object (17)>]>
04:34:01.122894: Completed run (1), sleeping for (1)
04:34:02.123438: Starting run (2)
New transaction, using sleep interval value: 5
04:34:02.124424: Full queryset: <QuerySet [<Entry: Entry object (17)>, <Entry: Entry object (16)>]>
04:34:06.126336: Narrowed result: <QuerySet [<Entry: Entry object (17)>]>
04:34:11.128978: Completed run (2), sleeping for (1)
Взглянув на временные метки, можно увидеть, что tty1
начинает выполняться, а затем tty2
запускается примерно через 1 секунду. Для выполнения функции потребуется не менее 5 секунд, что оставляет достаточно времени для временных меток, чтобы продемонстрировать поведение блокировки.
Сравните общее время выполнения прогона #1, и вы сразу увидите, что это так. Во время выполнения №1 tty1
тратит ~5 секунд, тогда как tty2
тратит ~9 секунд.
В частности, интерес представляют эти две строки (проверьте временные метки):
# tty1
04:33:56.118673: Completed run (1), sleeping for (1)
# tty2
04:33:56.118673: Narrowed result: <QuerySet [<Entry: Entry object (17)>]>
В tty2
, когда Django выполняет фрагмент (что происходит во время print
), он вынужден ждать - потому что транзакция tty1
не завершена, поэтому она все еще удерживает блокировку. Отсюда и задержка - Postgres удерживает соединение, но не дает ответа, ожидая, пока блокировка освободится. Именно это и показывают временные метки выше: <<<транзакция tty1
завершается, и сразу же после этого tty2
получает ответ от теперь уже незаблокированной таблицы.
Посмотрев на полный вывод терминала, вы также увидите, что tty1
тратит некоторое время на ожидание результата во время своего второго запуска - и что временная метка совпадает с временной меткой завершения первой транзакции tty2
. Причина точно такая же, как и выше, только роли теперь поменялись местами.
Результаты с skip_locked
# tty1
04:50:46.995004: Starting run (1)
New transaction, using sleep interval value: 5
04:50:47.028377: Full queryset: <QuerySet [<Entry: Entry object (17)>, <Entry: Entry object (16)>]>
04:50:47.035377: Narrowed result: <QuerySet [<Entry: Entry object (17)>]>
04:50:52.038783: Completed run (1), sleeping for (1)
# tty2
04:50:48.223853: Starting run (1)
New transaction, using sleep interval value: 5
04:50:48.257852: Full queryset: <QuerySet []>
04:50:48.263286: Narrowed result: <QuerySet []>
04:50:53.266371: Completed run (1), sleeping for (1)
На этот раз ждать не придется, и этого следовало ожидать, поскольку действует skip_locked
.
Вы заметите, что в этой ситуации мы не можем определить даже родительский набор запросов, и это именно потому, что tty1
заблокировал весь набор запросов, а поскольку мы пропускаем то, что заблокировано, список результатов возвращается из Postgres в Django как пустое множество.
Почему мы уверены, что причиной этого является размещение .select_for_update()
на «неправильном» кверисете?
Я нашел некоторые результаты, используя инструменты pg
, но, честно говоря, мне кажется, что с ними трудно работать. Вместо этого я придумал более практичный тест в функции retrieve_nonblocked_queryset()
, где мы будем запрашивать не один и тот же набор запросов, а конкретную строку, которая, как мы знаем по результатам наших предыдущих запусков, не будет заблокирована. Это, в свою очередь, означает, что если мы получим задержку результата в неблокирующей функции, мы можем сделать вывод, что это означает, что запрос тест-раннера в итоге передал либо блокировку таблицы, либо, по крайней мере, блокировку строки, которая охватывает весь набор запросов, а не только объект, который мы в итоге выбрали.
Установка:
Выполните тестовый прогон на tty1
, а неблокируемую функцию на tty2
.
Результаты:
# tty1
05:00:47.718589: Starting run (1)
New transaction, using sleep interval value: 5
05:00:47.719588: Full queryset: <QuerySet [<Entry: Entry object (17)>, <Entry: Entry object (16)>]>
05:00:47.720587: Narrowed result: <QuerySet [<Entry: Entry object (17)>]>
05:00:52.722550: Completed run (1), sleeping for (1)
# tty2
In [3]: retrieve_nonblocked_queryset()
05:00:48.727061: Requesting row, awaiting DB
<QuerySet [<Entry: Entry object (16)>]>
05:00:52.723543: DB returned
И снова мы видим, что временные метки совпадают, что указывает на то, что транзакция tty2
должна была подождать транзакцию tty1
.
Если бы мы использовали skip_locked
в неблокирующей функции, то получили бы пустой кверисет, как и раньше.
Модифицированный запрос
Изменения в коде (перемещение .select_for_update()
с первоначального места на место после среза):
def queryset_runner(qset: models.QuerySet, sleep_interval):
with transaction.atomic():
locked_entry = qset[:1].select_for_update() # <--- Moved here
print(f"{timezone.now().time()}: Full queryset: {qset}")
print(f"{timezone.now().time()}: Narrowed result: {locked_entry}")
sleep(sleep_interval)
def test_with_chaining(sleep_interval = 4):
qset: models.QuerySet = Entry.objects.filter(
event__date = default_date,
status = 'pending',
entry_type__name = 'Premium'
).order_by('-created_at')
queryset_runner(qset, sleep_interval)
Результаты:
# tty1
In [2]: test_runner_chaining(1, 5)
Self sleep: 1
Query sleep: 5
Database: default: django_tenants.postgresql_backend
-----------
05:10:57.943099: Starting run (1)
New transaction, using sleep interval value: 5
05:10:57.977099: Full queryset: <QuerySet [<Entry: Entry object (17)>, <Entry: Entry object (16)>]>
05:10:57.984099: Narrowed result: <QuerySet [<Entry: Entry object (17)>]>
05:11:02.986970: Completed run (1), sleeping for (1)
# tty2
In [2]: retrieve_nonblocked_queryset()
05:10:58.621391: Requesting row, awaiting DB
<QuerySet [<Entry: Entry object (16)>]>
05:10:58.626064: DB returned
Транзакция tty2
возвращается немедленно, не дожидаясь транзакции tty1
. Это означает, что какая бы блокировка ни была получена tty1
, она не повлияла на запрос tty2
. Это убедительно свидетельствует о том, что при измененном запросе tty1
заблокировал только Entry(17)
(а не весь отфильтрованный набор запросов), оставив Entry(16)
незаблокированным для tty2
.
Summary
Не включены прогоны для проверки того, есть ли существенные различия при выполнении запроса с явными FK вместо обхода отношений. Если вы хотите сделать это, то код есть (test_runner_explicit()
), но я прогнал их несколько раз и обнаружил, что поведение идентично другому тестовому прогону.
Я не знаю, что является причиной описанного вами поведения, когда явные FK имеют значение, это либо какой-то важный компонент или код, который вы не указали в своем вопросе, либо старая версия Django, либо старая или «нестандартная» конфигурация Postgres.
Что касается SQL, вы можете откомментировать соответствующую строку в queryset runner, если хотите сравнить SQL-запросы - но на Django 5.0 они немного отличались, хотя только в том, как он запрашивал определенные поля в JOIN (то есть это не приведет к разнице в поведении блокировки). Поэтому я уверен, что переведенный SQL не имеет значения.
В итоге вы можете решить проблему гораздо более чистым способом, построив значительно более узкий набор запросов с помощью среза, а затем применив блокировку к измененному запросу. Это работает независимо от того, строите ли вы запрос с явными FK или путем обхода отношений.