Комментарии (2)
Ответ сгенерирован нейросетью и может содержать ошибки
Сложный баг в практике QA-инженера
Да, в моей практике был один особенно сложный баг, связанный с тайминг-атакой (timing attack) в микросервисной архитектуре. Он проявлялся в системе обработки финансовых транзакций, где требовалась высокая консистентность данных и безопасность.
Контекст и симптомы
Система состояла из:
- Сервис A — отвечал за валидацию и инициирование платежей.
- Сервис B — отвечал за проверку лимитов и балансов пользователя.
- Базы данных — каждый сервис использовал свою изолированную БД (PostgreSQL).
Симптом: При почти одновременных запросах (разница в ~50-100 мс) на выполнение двух крупных транзакций от одного пользователя система иногда пропускала проверку общего лимита, позволяя превысить допустимую сумму. Воспроизвести проблему удавалось редко и нестабильно, обычно при высокой нагрузке.
Процесс исследования и изоляции
- Логи и метрики показали, что оба запроса проходили все стадии валидации как успешные. Не было очевидных ошибок или таймаутов.
- Анализ кода сервисов не выявил явных race condition в пределах одного сервиса. Логика каждого из них была потокобезопасна.
- Гипотеза о состоятельности привела к подозрению на расовое условие (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 поступает в Сервис A, который читает текущий баланс из своей БД (старое значение).
- Запрос 2 поступает почти сразу. Сервис A, обрабатывая его, также читает то же самое старое значение баланса, так как Запрос 1 ещё не commit-ил обновление в БД Сервиса A.
- Оба запроса, имея идентичные (устаревшие) данные о балансе, независимо проходят проверку лимита в Сервисе B и выполняются.
Корень проблемы и решение
Проблема не была банальной. Она заключалась в недостаточном уровне изоляции транзакций и архитектурном просчёте:
- Сервис A использовал транзакцию только для финального обновления своего статуса платежа.
- Критическая операция чтения баланса перед проверкой выполнялась ВНЕ этой транзакции (или в транзакции с уровнем изоляции
READ COMMITTED). Это создавало окно в несколько миллисекунд, когда второй запрос мог прочитать неактуальные данные. - Сервисы не использовали пессимистичные блокировки или оптимистичные блокировки с управлением версиями для сущности «пользовательский баланс» на уровне источника истины (чаще всего, это была БД Сервиса B).
Решение было многоуровневым:
- Горячий фикс: В Сервисе B была добавлена пессимистичная блокировка строки (
SELECT ... FOR UPDATE) таблицы балансов на время проверки и обновления лимита для данного пользователя. Это гарантировало последовательную обработку. - Архитектурное изменение: Внедрили единый паттерн idempotency key для транзакций и более строгий уровень изоляции транзакций (
REPEATABLE READ) для критических операций. - Добавление интеграционных тестов, которые явно проверяли систему на подобные race condition с помощью инструментов вроде
toxiproxyдля симуляции задержек между микросервисами.
Выводы и уроки
Этот баг научил меня нескольким ключевым вещам:
- Микросервисы не отменяют race condition, а лишь переносят их в распределённую среду, делая сложнее для обнаружения.
- Важность анализа временных меток и последовательности событий в логах разных систем для понимания распределённых процессов.
- Недостаточно тестировать бизнес-логику в изоляции. Необходимы интеграционные и нагрузочные тесты, которые эмулируют реальное конкурентное поведение пользователей.
- Проектируя распределённые системы, необходимо с первого дня думать о консистентности данных и выбирать соответствующие механизмы: блокировки, паттерны саги (Saga) или событийная модель с компенсирующими действиями.
Этот случай наглядно показал, что роль QA-инженера выходит далеко за рамки проверки функциональности. Она включает глубокий анализ архитектуры, понимание принципов работы СУБД и умение моделировать экстремальные сценарии поведения системы.