← Назад к вопросам

Был ли сложный баг

1.7 Middle🔥 162 комментариев
#Процессы и методологии разработки

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

🐱
deepseek-v3.2PrepBro AI5 апр. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Сложный баг в практике QA-инженера

Да, в моей практике был один особенно сложный баг, связанный с тайминг-атакой (timing attack) в микросервисной архитектуре. Он проявлялся в системе обработки финансовых транзакций, где требовалась высокая консистентность данных и безопасность.

Контекст и симптомы

Система состояла из:

  • Сервис A — отвечал за валидацию и инициирование платежей.
  • Сервис B — отвечал за проверку лимитов и балансов пользователя.
  • Базы данных — каждый сервис использовал свою изолированную БД (PostgreSQL).

Симптом: При почти одновременных запросах (разница в ~50-100 мс) на выполнение двух крупных транзакций от одного пользователя система иногда пропускала проверку общего лимита, позволяя превысить допустимую сумму. Воспроизвести проблему удавалось редко и нестабильно, обычно при высокой нагрузке.

Процесс исследования и изоляции

  1. Логи и метрики показали, что оба запроса проходили все стадии валидации как успешные. Не было очевидных ошибок или таймаутов.
  2. Анализ кода сервисов не выявил явных race condition в пределах одного сервиса. Логика каждого из них была потокобезопасна.
  3. Гипотеза о состоятельности привела к подозрению на расовое условие (race condition) на уровне взаимодействия сервисов и их баз данных.

Чтобы подтвердить гипотезу, я создал нагрузочный тест, который эмулировал «атаку» двумя запросами с минимальной задержкой. Ключевым был анализ не только результатов, но и временных меток (timestamp) в логах каждого микросервиса и в самих БД.

import concurrent.futures
import requests
import time

def make_transaction(user_id, amount):
    payload = {"user_id": user_id, "amount": amount}
    # Записываем точное время отправки
    start = time.time_ns()
    response = requests.post("http://service-a/transaction", json=payload)
    return {
        "response": response,
        "start_ns": start,
        "user_id": user_id,
        "amount": amount
    }

# Симулируем два почти одновременных запроса для одного пользователя
with concurrent.futures.ThreadPoolExecutor() as executor:
    future1 = executor.submit(make_transaction, user_id=123, amount=9000)
    future2 = executor.submit(make_transaction, user_id=123, amount=9000)
    result1 = future1.result()
    result2 = future2.result()

# Анализируем разницу во времени истарта и ответы
time_diff = abs(result1['start_ns'] - result2['start_ns']) / 1_000_000
print(f"Разница во времени отправки запросов: {time_diff:.2f} мс")
print(f"Ответ 1: {result1['response'].status_code}, {result1['response'].json()}")
print(f"Ответ 2: {result2['response'].status_code}, {result2['response'].json()}")

Запуская подобный скрипт сотни раз, удалось стабильно воспроизвести проблему. Логический анализ временных меток выявил следующую последовательность при ошибке:

  1. Запрос 1 поступает в Сервис A, который читает текущий баланс из своей БД (старое значение).
  2. Запрос 2 поступает почти сразу. Сервис A, обрабатывая его, также читает то же самое старое значение баланса, так как Запрос 1 ещё не commit-ил обновление в БД Сервиса A.
  3. Оба запроса, имея идентичные (устаревшие) данные о балансе, независимо проходят проверку лимита в Сервисе B и выполняются.

Корень проблемы и решение

Проблема не была банальной. Она заключалась в недостаточном уровне изоляции транзакций и архитектурном просчёте:

  • Сервис A использовал транзакцию только для финального обновления своего статуса платежа.
  • Критическая операция чтения баланса перед проверкой выполнялась ВНЕ этой транзакции (или в транзакции с уровнем изоляции READ COMMITTED). Это создавало окно в несколько миллисекунд, когда второй запрос мог прочитать неактуальные данные.
  • Сервисы не использовали пессимистичные блокировки или оптимистичные блокировки с управлением версиями для сущности «пользовательский баланс» на уровне источника истины (чаще всего, это была БД Сервиса B).

Решение было многоуровневым:

  1. Горячий фикс: В Сервисе B была добавлена пессимистичная блокировка строки (SELECT ... FOR UPDATE) таблицы балансов на время проверки и обновления лимита для данного пользователя. Это гарантировало последовательную обработку.
  2. Архитектурное изменение: Внедрили единый паттерн idempotency key для транзакций и более строгий уровень изоляции транзакций (REPEATABLE READ) для критических операций.
  3. Добавление интеграционных тестов, которые явно проверяли систему на подобные race condition с помощью инструментов вроде toxiproxy для симуляции задержек между микросервисами.

Выводы и уроки

Этот баг научил меня нескольким ключевым вещам:

  • Микросервисы не отменяют race condition, а лишь переносят их в распределённую среду, делая сложнее для обнаружения.
  • Важность анализа временных меток и последовательности событий в логах разных систем для понимания распределённых процессов.
  • Недостаточно тестировать бизнес-логику в изоляции. Необходимы интеграционные и нагрузочные тесты, которые эмулируют реальное конкурентное поведение пользователей.
  • Проектируя распределённые системы, необходимо с первого дня думать о консистентности данных и выбирать соответствующие механизмы: блокировки, паттерны саги (Saga) или событийная модель с компенсирующими действиями.

Этот случай наглядно показал, что роль QA-инженера выходит далеко за рамки проверки функциональности. Она включает глубокий анализ архитектуры, понимание принципов работы СУБД и умение моделировать экстремальные сценарии поведения системы.

Был ли сложный баг | PrepBro