Май
21
2012

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

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.

P.S Тот же самый написанный мной пост на хабре.

Оставить комментарий

CAPTCHA image


Поля, отмеченные * обязательны для заполнения


XHTML: Вы можете использовать следующие теги: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong> <pre lang="" line="" escaped="" highlight="">