Django и особенности использования транзакций в MySQL

в 16:00, , рубрики: django, innodb, mysql, orm, python, transactions, проблемы, транзакции, метки: , , , , , , ,

Django и особенности использования транзакций в MySQLНаверное всем известно, что 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


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js