Наверное всем известно, что Django является одним из самых популярных фреймворков для web-разработки на python-е. И даже если в основе web-проекта лежит сторонний код, то зачастую при разработке используют отдельные части этого фреймворка — например ORM. В данной статье я хотел бы рассказать об особенностях использования Django ORM при работе с базой данных MySQL, а именно про транзакции и подводные камни, связанные с ними. Так, например, если в какой-то момент вы осознаёте, что вместо ожидаемых данных, возвращается совершенно другой результат, то возможно, данная статья поможет разобраться что к чему.
Далее речь пойдет про InnoDB, поскольку это единственный движок, идущий в составе MySQL и полноценно поддерживающий транзакции (BDB не в счёт, так как давно уже не поддерживается).
Стоит отметить ряд особенностей:
1. В Django в качестве интерфейса к MySQL используется расширение MySQLdb, а оно в свою очередь при каждом подключении к базе устанавливает:
AUTOCOMMIT=0
То есть каждая операция изменения данных должна завершаться COMMIT/ROLLBACK для фиксации или отката изменений. Если вы раньше использовали расширения PHP (PDO, Mysqli) или Ruby для доступа к MySQL, то наверное будете немного удивлены, поскольку практически во всех драйверах доступа к БД при подключении значение AUTOCOMMIT не меняется (а по умолчанию в MySQL оно задано как AUTOCOMMIT=1).
2. MySQL использует уровень изоляции транзакций REPEATABLE-READ, в отличии, например, от PosgreSQL или Oracle, в которых уровень изоляции транзакий по умолчанию READ-COMMITTED.
Что это значит? Рассмотрим на конкретном примере:
CREATE TABLE IF NOT EXISTS `test` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`value` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO `test` VALUES (NULL, 'a');
REPEATABLE-READ
1ая транзакция: | 2ая транзакция: |
---|---|
SET AUTOCOMMIT=0; | SET AUTOCOMMIT=0; |
SELECT * FROM `test`;
+----+-------+ | id | value | +----+-------+ | 1 | a | +----+-------+
|
|
INSERT INTO `test` VALUES (NULL, 'b'); SELECT * FROM `test`; +----+-------+ | id | value | +----+-------+ | 1 | a | | 2 | b | +----+-------+ COMMIT; |
|
INSERT INTO `test` VALUES (NULL, 'c'); SELECT * FROM `test`; +----+-------+ | id | value | +----+-------+ | 1 | a | | 3 | c | +----+-------+ COMMIT; |
Как видно из примера, после первого чтения данных все последующие чтения будут возвращать точно такой же результат до того момента, пока не произойдет COMMIT, независимо от того что происходит в других транзакциях.
READ-COMMITTED
1ая транзакция: | 2ая транзакция: |
---|---|
SET SESSION tx_isolation='READ-COMMITTED'; SET AUTOCOMMIT=0; |
SET SESSION tx_isolation='READ-COMMITTED'; SET AUTOCOMMIT=0; |
SELECT * FROM `test`;
+----+-------+ | id | value | +----+-------+ | 1 | a | +----+-------+
|
|
INSERT INTO `test` VALUES (NULL, 'b'); | |
SELECT * FROM `test`;
+----+-------+ | id | value | +----+-------+ | 1 | a | +----+-------+ |
|
SELECT * FROM `test`;
+----+-------+ | id | value | +----+-------+ | 1 | a | | 2 | b | +----+-------+ COMMIT; |
|
INSERT INTO `test` VALUES (NULL, 'c'); SELECT * FROM `test`; +----+-------+ | id | value | +----+-------+ | 1 | a | | 2 | b | | 3 | c | +----+-------+ COMMIT; |
В случае READ-COMMITTED выборка SELECT всегда возвращает последнюю закоммиченную версию данных.
Возвращаясь к теме Django — подвох в использовании Django ORM состоит в том, что судя по всему READ-COMMITTED единственный уровень изоляции транзакций, на который ориентировались разработчики. Так, например, если мы обратимся к коду Django, а именно к реализации метода get_or_create() в классе QuerySet:
def get_or_create(self, **kwargs):
"""
Looks up an object with the given kwargs, creating one if necessary.
Returns a tuple of (object, created), where created is a boolean
specifying whether an object was created.
"""
assert kwargs,
'get_or_create() must be passed at least one keyword argument'
defaults = kwargs.pop('defaults', {})
lookup = kwargs.copy()
for f in self.model._meta.fields:
if f.attname in lookup:
lookup[f.name] = lookup.pop(f.attname)
try:
self._for_write = True
return self.get(**lookup), False
except self.model.DoesNotExist:
try:
params = dict([(k, v) for k, v in kwargs.items() if '__' not in k])
params.update(defaults)
obj = self.model(**params)
sid = transaction.savepoint(using=self.db)
obj.save(force_insert=True, using=self.db)
transaction.savepoint_commit(sid, using=self.db)
return obj, True
except IntegrityError, e:
transaction.savepoint_rollback(sid, using=self.db)
exc_info = sys.exc_info()
try:
return self.get(**lookup), False
except self.model.DoesNotExist:
# Re-raise the IntegrityError with its original traceback.
raise exc_info[1], None, exc_info[2]
то вторая попытка извлечения объекта:
return self.get(**lookup), False
всегда будет завершаться с ошибкой.
Попытаюсь пояснить — вот, например, два процесса одновременно вызывают метод get_or_create() некой модели. 1ый процесс пытается прочитать данные — данных нет, генерируется исключение DoesNotExist. 2ой процесс аналогично пытается прочитать данные и аналогично генерирует исключение DoesNotExist. Далее, поскольку в рамках соединения используется AUTOCOMMIT=0 и уровень изоляции транзакций REPEATABLE-READ, оба процесса «замораживают» прочитанные данные. Допустим, пусть первый процесс успешно создает запись и возвращает объект созданной записи. Но при этом второй процесс не может ничего создать, так как это будет нарушать ограничение уникальности. Забавно то, что он не видит объект, созданный в первом процессе, в связи с тем, что при повторном чтении данных возвращается «замороженный» результат.
Конечно, в экспериментальных условиях данную ошибку воспроизвести довольно проблематично, но при многочисленных конкурентных запросах, данный код будет работать нестабильно, периодически генерируя исключение DoesNotExist.
Как с этим бороться?
1. В случае использования метода get_or_create() — написать свой метод, выполняющий принудительный COMMIT перед повторным чтением данных:
@transaction.commit_manually()
def custom_get_or_create(...):
try:
obj = SomeModel.objects.create(...)
except IntegrityError:
transaction.commit()
obj = SomeModel.objects.get(...)
return obj
2. В настройках MySQL ( /etc/mysql/my.cnf ) использовать принудительно уровень изоляции транзакций READ-COMMITTED:
transaction-isolation = READ-COMMITTED
3. При использовании Django >= 1.2 версии использовать в settings.py в опциях подключения к базе следующий код:
DATABASE_OPTIONS = {
"init_command": "SET storage_engine=INNODB, SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED",
}
Данный баг опубликован на багтрекере Django достаточно давно, но до сих пор тикет не закрыт и проблема по-прежнему актуальна.
Или вот ещё пример — допустим Django ORM используется отдельно от веб-сервера, в рамках некоего демона, который постоянно висит в памяти, и периодически считывает новые данные из таблицы MySQL. Данный эксперимент можно провести используя встроенный shell в Django:
python manage.py shell
>>> from test_module.models import *
>>> len(SomeModel.objects.all())
10
Далее используя второй терминал добавим несколько записей:
>>> SomeModel(name='test1').save()
>>> SomeModel(name='test2').save()
>>> len(SomeModel.objects.all())
12
И хотя во втором терминале изменения налицо, но эти новодобавленные записи в первом терминале по прежнему будут недоступны, т.к. начатая транзакция не завершена, и после первого чтения данных все последующие чтения будут возвращать один и и тот же результат до тех пор, пока принудительно не будет вызван COMMIT.
Что с этим делать? Менять уровень изоляции транзакций в настройках mysql (my.cnf) или же в параметрах подключения к базе данных в settings.py в Django. Ну или же принудительно коммитить данные после каждого чтения:
>>> from django.db import connection, transaction
>>> len(Param_Type.objects.all())
10
>>> transaction.commit_unless_managed()
>>> len(Param_Type.objects.all())
12
Почему все так происходит? Возможно это связано с тем, что Django изначально проектировался для работы с использованием PostgreSQL в качестве базы данных, в котором, как писалось выше, «из коробки» используется READ-COMMITTED. В общем, данное не совсем стандартное поведение Django ORM применительно к MySQL InnoDB может привести к довольно-таки трудноотлавливаемым багам. Поэтому в большинстве мест, где обсуждается описанная проблема (в различных блогах и на stackoverflow), настойчиво рекомендуют использовать READ-COMMITTED как уровень изоляции транзакций по умолчанию, аргументируя этот выбор ещё и тем, что READ-COMMITTED «производительнее» чем REPEATABLE-READ.
Автор: StraNNikk