Добавить в избранное
Комментарии

Почему тесты на SQLite врут про гонки (select_for_update и компания)

Чтение ≈ 3 мин

select_for_update на SQLite молча ничего не блокирует, InMemory-провайдер EF не знает транзакций, и зелёные тесты не гарантируют, что деньги не спишутся дважды. Как тестировать гонки на настоящей СУБД.

картинка к записи Почему тесты на SQLite врут про гонки (select_for_update и компания)

Локальные тесты на SQLite удобны: быстрые, без сервера, база создаётся в памяти за миллисекунды. Поэтому многие проекты гоняют весь сьют на SQLite, а прод работает на PostgreSQL или MySQL. Для большинства тестов это нормально. Но целый класс самых дорогих ошибок — гонки при работе с деньгами, остатками и слотами — на SQLite не проверяется вообще. Тесты зелёные, а на проде два параллельных запроса списывают баланс дважды.

Что именно не работает

select_for_update() в Django на SQLite игнорируется. SQLite не поддерживает SELECT ... FOR UPDATE, и Django просто не добавляет эту часть к запросу. Ошибки нет, предупреждения нет. Код с блокировкой и без неё ведёт себя в тестах одинаково.

SQLite блокирует всю базу на запись, а не строки. Параллельные транзакции сериализуются грубо (или падают с database is locked), поэтому сценарий «два запроса прочитали один и тот же баланс» воспроизводится не так, как на проде, если воспроизводится вообще.

Django TestCase выполняет каждый тест внутри одной транзакции. Даже на Postgres параллельные потоки внутри TestCase не увидят данных друг друга. Для тестов конкурентности нужен TransactionTestCase.

EF Core InMemory-провайдер — не база данных. Он не поддерживает транзакции (по умолчанию выдаёт предупреждение, которое часто подавляют), не проверяет внешние ключи и ограничения уникальности, не выполняет SQL. ExecuteUpdate и FromSql на нём не работают. SQLite-провайдер EF ближе к реальности, но с теми же ограничениями блокировок.

Отличия на этом не заканчиваются: регистр в LIKE и уникальных индексах, типы (decimal в SQLite хранится как число с плавающей точкой или текст), даты и часовые пояса, ON CONFLICT, JSON-операторы, порядок сортировки строк.

Как выглядит пропущенный баг

def withdraw(wallet_id, amount):
    with transaction.atomic():
        w = Wallet.objects.select_for_update().get(pk=wallet_id)   # на SQLite — обычный SELECT
        if w.balance < amount:
            raise InsufficientFunds
        w.balance -= amount
        w.save()

Если кто-то уберёт select_for_update() или перенесёт проверку за пределы транзакции, все тесты на SQLite останутся зелёными. На проде два одновременных запроса на вывод 1000 при балансе 1000 пройдут оба.

Тест конкурентности, который действительно что-то проверяет

Django, только на PostgreSQL:

import threading
from django.db import connection, connections
from django.test import TransactionTestCase, skipUnlessDBFeature

class WithdrawRaceTest(TransactionTestCase):
    @skipUnlessDBFeature("has_select_for_update")
    def test_parallel_withdraw_does_not_overdraw(self):
        w = Wallet.objects.create(balance=1000)
        barrier = threading.Barrier(2)
        results = []

        def worker():
            try:
                barrier.wait()                       # стартуем одновременно
                withdraw(w.pk, 1000)
                results.append("ok")
            except InsufficientFunds:
                results.append("rejected")
            finally:
                connections.close_all()              # у каждого потока своё соединение

        threads = [threading.Thread(target=worker) for _ in range(2)]
        for t in threads: t.start()
        for t in threads: t.join()

        w.refresh_from_db()
        self.assertEqual(sorted(results), ["ok", "rejected"])
        self.assertEqual(w.balance, 0)

skipUnlessDBFeature("has_select_for_update") честно пропускает тест на SQLite, а не делает вид, что он прошёл. В отчёте видно: «пропущено 3 теста конкурентности». Это сигнал, что полную проверку должен сделать CI.

ASP.NET Core + EF Core, на настоящем Postgres (Testcontainers или сервис в CI):

[Fact]
public async Task Parallel_withdraw_does_not_overdraw()
{
    var walletId = await SeedWallet(balance: 1000m);
    var tasks = Enumerable.Range(0, 2).Select(async _ =>
    {
        await using var db = CreateContext();               // отдельный контекст и соединение
        return await db.Wallets
            .Where(w => w.Id == walletId && w.Balance >= 1000m)
            .ExecuteUpdateAsync(s => s.SetProperty(w => w.Balance, w => w.Balance - 1000m));
    });
    var rows = await Task.WhenAll(tasks);

    Assert.Equal(1, rows.Sum());                            // списал ровно один
    await using var check = CreateContext();
    Assert.Equal(0m, (await check.Wallets.FindAsync(walletId))!.Balance);
}

Практическая схема

  • Локально можно оставить SQLite для быстрого слоя: юнит-тесты, формы, шаблоны, валидация. Этот слой работает как pre-push гейт за секунды.
  • В CI весь сьют, включая тесты конкурентности, идёт на той же СУБД и версии, что и прод (сервис postgres:16 в GitHub Actions, см. урок про CI).
  • Тесты, которым нужна настоящая БД, помечайте явно (@tag("postgres"), [Trait("Category","Integration")]) и пропускайте с понятной причиной, если БД не та.
  • Локальный Postgres поднимается за минуту (docker run -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:16), и при работе с деньгами стоит гонять его и локально.
  • Ревью кода с деньгами: каждая операция «прочитать, проверить, записать» либо выполняется под select_for_update в транзакции, либо является одним атомарным UPDATE ... WHERE balance >= x, либо защищена concurrency token. Счётчики меняются через F("count") + 1 или ExecuteUpdate, а не через obj.count += 1.

Короткий вывод

Зелёный тест на SQLite говорит «логика верна в однопоточном мире». О том, что произойдёт при двух одновременных запросах, он не говорит ничего. Гонки проверяются только на той СУБД, которая работает в проде, и только тестом, который действительно запускает запросы параллельно.

Поделиться
Понравился материал?
Проголосовало пользователей: 0Пожалуйста проголосуйте!

Комментарии

Свернуть форму

Проверяем авторизацию…