Python / PHP / JavaScript developer
0,0
рейтинг
20 мая 2012 в 20:00

Разработка → 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
карма
48,0
рейтинг 0,0
Python / PHP / JavaScript developer
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (21)

  • +3
    В случае использования метода get_or_create() — написать свой метод, выполняющий принудительный COMMIT перед повторным чтением данных
    тут следует учесть, что коммит приводит к глобальным изменениям. если клиентский код, использующий данный метод, помимо получения объекта, проводит с базой ещё какие-то манипуляции, то преждевременное подтверждение транзакции для него может стать чревато. в общем случае, коммитить транзакцию лучше на том же уровне, где её отрывали.
    • +2
      Согласен, но на самом деле в Django ORM любая операция изменения данных (create / update / delete) завершается принудительным COMMIT-ом, поэтому если программист использует в коде метод get_or_create(), то он должен быть готовым к тому, что в любом случае может быть произведен COMMIT для фиксации новосозданной записи, и все произведенные до этого незакоммиченные манипуляции будут тоже закоммичены. Поэтому в данном случае COMMIT перед SELECT-ом оправдан.
      Но соглашусь, что перед использованием подобного рода методов, стоит осознавать что происходит в системе, и действовать НЕ наобум.
      • 0
        в Django ORM любая операция изменения данных (create / update / delete) завершается принудительным COMMIT-ом
        Хм, странное поведение. В любом случае хардкод коммита после getorcreate это наихудшее из того, что можно придумать. Второй способ тоже не очень хорош, поскольку меняет глобальные настройки mysql. А если переезжать на другой сервер, настройки автокоммита перенести кто-нибудь да обязательно забудет. Так что единственным приемлемым решением является третий способ.
        • 0
          На дефолтном конфиге многое не вытянуть по производительности.
      • 0
        Это по-умолчанию. Но настроить commit только когда запрос (view) обработан успешно можно добавлением всего одного middleware. И если нужно ручное управление транзакциями — так же есть средства.
  • +1
    Не так давно налетели на эти грабли, помимо джанги БД использовалась rails приложением, долго понять не могли почему джанга не видет новые данные добавленные через рельсы. Изначально подумал что где то сидит кэш, но оказалось что проблема совсем иная, после чего наш гуру дал вводный курс по уровням изоляций транзакций =) Так что наматываем на ус и стараемся быть ниндзями, попутно хоть изучая базовые вещи использованных технологий, не всё же кирпичи ставить.
    • 0
      >Изначально подумал что где то сидит кэш
      Да, я тоже когда первый раз столкнулся с подобной трабблой грешил на кэш. Особенно странно все это кажется после перехода с других языков, где для доступа к MySQL используются драйверы с AUTOCOMMIT=1, и все транзакции управляются вручную по мере необходимости.
      • 0
        А в чем приемущество использовать подход с ручным автокоммитом по-умолчанию?
        • +2
          С ручным автокоммитом — в смысле с AUTOCOMMIT=1 по умолчанию?
          На мой взгляд транзакции нужны далеко не всегда — там где нужны, проще самому взять на себя управлением коммитами/роллбеками, а во всех остальных случаях использовать коммит по-умолчанию. Ошибочных ситуаций будет меньше — на подобии «забыл сделать коммит после какого-то select-а» и т.п.
          Но это лишь мое мнение и оно не претендует на истину.
          • 0
            Вы пишите
            1. В Django в качестве интерфейса к MySQL используется расширение MySQLdb, а оно в свою очередь при каждом подключении к базе устанавливает:
            AUTOCOMMIT=0


            Вот это я и имею ввиду под ручным автокоммитом. Хотел узнать какой в этом смысл?
            • +1
              Ммм… Это принято за аксиому в PEP 249 — Python Database API Specification v2.0:
              Commit any pending transaction to the database. Note that if the database supports an auto-commit feature, this must be initially off. An interface method may be provided to turn it back on.

              А вот почему — для меня тоже загадка.
              • 0
                Я также знаю, что Django ORM само заботится о внешних связях, т. е. делает это не на уровне БД как Doctrine ORM, а на уровне приложения.
                Мне кажется это поведение неоправдонным в контексте целостности данных. Поправьте если я не прав.
                • 0
                  Ммм… по поводу внешних связей — не могу сказать ничего плохого, т.к. багов на этом не ловил и сильно далеко в код Django не вкапывался на эту тему. Лично у меня syncdb при создании базы проставлял верно все внешние ключи. Но если у вас были с этим какие-то трабблы, то было бы интересно послушать.
                  • 0
                    Багов не ловил. Мало работал с Django.

                    Вот какая особенность. Миграции в БД создатели Django всецело переложили на плечи разработчиков. syncdb — лишь отслеживает новые таблицы, что годится для установки модулей (приложений в терминах Джанго) и соотв-но никак не годится для процесса разработки с изменением структуры БД. Но в тоже время они сами заботятся о целостности данных на уровне приложения (внешние связи, запросы с ручным автокоммитом). Вообщем, мне непонятна такая изберательность в подходе взаимодействия приложения с БД.
                  • 0
                    Для сравенения в Doctrine ORM (особенно во 2-й версии) отлично работает синхронизация структуры БД, описанной в конфиге/нотациях с реальной структурой БД. Также она не вмешивается в процесс целостности данных. Такой подход мне кажется более оправднонным и интуитивно понятным.
                    • 0
                      Если я правильно понял, мейнстрим-штуку для миграции данных South не включают в Джангу что бы не привязывать обновления South к обновлениям самой Джанги, т.к. первые могут быть гораздо чаще. А так эту батарейку используют многие другие батарейки.
  • 0
    Что-то я не пойму зачем коммитить перед чтением в обработке ошибки — если в таблице значение уже есть, то оно создавалось в другом месте или потоке и следовательно коммитить надо там.
    в общем такие коммиты череваты волшебными багами, если есть управление транзакциями. А если коммитим каждую запись, то проще поднять автокоммит.
    • 0
      Второе чтение в последнем блоке try-except происходит только в случае если не удалось прочитать и создать данные, на случай если данные были уже созданы во временном промежутке между первоначальным чтением-созданием. В этом случае второй get будет всегда завершаться эррором, т.к. из-за AUTOCOMMIT=0 и REPEATABLE-READ новые данные не будут считаны из БД.
      • 0
        и как это исправит коммит в текущем потоке?
        • 0
          Самый true-way — в settings.py указать другой уровень изоляции транзакций при коннекте к БД, а именно READ COMMITTED
          • 0
            Извените, я подумал что это все в рамках READ COMMITED.
            для REPEATABLE READ перед чтением надо делать коммит, согласен.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.