Метрика rogue-запросов: как поймать SQL-инъекцию по аномалии плана выполнения
Классические WAF и сигнатурки пропускают инъекции, когда атакующий вшивает UNION в конец легитимного WHERE, избегая типичных паттернов. В production такие запросы незаметны до момента утечки данных. Метрика аномалии плана выполнения ловит их по поведению оптимизатора, а не по тексту.
Почему план выполнения сбоит при инъекции
Нормальный запрос даёт стабильный план: Index Scan, Nested Loop, предсказуемый cost. Инъекция вводит нестандартные операторы (OR, UNION, комментарии), которые ломают оптимизатор. Появляются неожиданные Filter, SeqScan вместо IndexScan, или cost подскакивает в 10 раз. Это выглядит как выброс в статистической выборке.
Формула детекции rogue-запроса
Я считаю аномалию так: Deviation = |Cost_actual - Median(Cost_history)| / IQR(Cost_history). Если значение больше 5 — это повод проверить запрос. Но cost — не единственный индикатор. Упрощённый пример на Python:
if rogue_plan not in normal_plans:
alert("Неизвестный тип плана — потенциальная инъекция")
else:
deviation = (450.0 - 12.4) / 0.3 # > 1000
if deviation > 5:
alert("Статистическая аномалия затрат")
Признаки аномалии и типичная ошибка
Главные признаки: смена типа сканирования (SeqScan вместо IndexScan), появление сортировки там, где её не было, резкий скачок estimated_rows в 100+ раз, необычный join (NestedLoop вместо HashJoin). Типичная ошибка: игнорировать ложные срабатывания при выкатке фичи или изменении данных. Без калибровки на контрольной выборке метрика завалит алертами.
Практический совет и trade-offs
Для production используй медиану и IQR — они устойчивы к кратковременным пикам. Cтоимость: хранение истории планов для каждого запроса и вычислительные затраты на анализ. Но trade-off оправдан: rogue-запросы выявляются до того, как инъекция выполнится. Не полагайся только на cost — сравнивай структуру plan tree, чтобы отсечь случайные колебания.
Вывод: Статистическая аномалия плана выполнения превращает оптимизатор базы данных в сенсор безопасности, ловящий инъекции по поведению, а не по тексту запроса.
Архитектурная гниль в read-оптимизированных кэшах: детекция утечки старого бизнес-логика через stale projection в CQRS-потоках
Каждый, кто работал с CQRS, знает: проекции живут своей жизнью. Со временем read-модели превращаются в «кладбище полей» — хранят то, что уже нигде не используется бизнесом. Это не баг, а stale projection — классическая архитектурная гниль, когда кеш возвращает данные, не соответствующие актуальным правилам, что приводит к скрытым дефектам в production.
Как это выглядит в production
Когда команда меняет требования, а projection продолжает агрегировать данные по старым правилам, кеш честно отдает то, что накопили. Пример: событие OrderShipped приходит в projection. В коде когда-то было поле IsDelayed — его вычисляли на основе expectedDeliveryDate и actualShipDate. Потом бизнес изменил метрики: задержка считается только после статуса «On Hold». Но projection продолжает считать IsDelayed по-старому.
func (p *OrderProjection) Handle(event OrderShipped) {
// Старая логика — уже не актуальна
p.IsDelayed = event.ShipDate.After(event.ExpectedDelivery)
// Новая логика — требует проверки статуса
// p.IsDelayed = event.ShipDate.After(event.ExpectedDelivery) && event.Status == "OnHold"
p.ShipmentId = event.ShipmentId
// ... остальные поля
}
Как детектировать гниль
* Мониторинг "мертвых полей" — заведите метрику, сколько раз поле из projection читается за период. Если 0 в течение двух недель — кандидат на удаление.
* Дата последнего изменения — храните updated_at как часть projection. Если проекция не обновлялась дольше N циклов бизнес-событий — она stale.
* Audit-лог использованных событий — проверяйте, какие события применяются к projection, а какие — нет. Если из 10 ивентов применяется только 3, остальные — legacy.
Типичная ошибка и практический совет
Ошибка: чистка проекции только при обнаружении бага в production, а не на этапе changelog. Совет: при каждом рефакториге бизнес-логики сразу обновляйте код обработчика и очищайте projection. Если бизнес позволяет простой, выполняйте тотальный пересчет (replay) после изменения логики.
Вывод: Stale projection — это не ошибка кеша, а сигнал о рассинхронизации между бизнес-требованиями и read-моделью, который можно предотвратить мониторингом использования полей и обязательным cleanup при changelog.
Sleeping-read consistency gaps: детекция stale коммитов при пониженной изоляции read-committed в production
Работаете с read-committed и считаете, что это "достаточно безопасно"? Есть нюанс, который в production вылезает неочевидными гонками. Sleeping-read consistency gap.
Проблема stale чтений
Транзакция читает данные, потом делает паузу — sleep, внешний вызов API, что угодно. После пробуждения читает снова. Read-committed не обещает snapshot между двумя SELECT. Только что первый запрос выполнился на старых данных — второй ловит свежий коммит. Для тестировщика это значит: отчёт, собранный за два запроса, может быть неконсистентным. Проверили баланс, пошли списывать — а между ними пришёл внешний платёж. Бизнес-логика ломается, хотя СУБД формально права.
Методы детекции
Как ловить в production?
* Логируйте snapshot age. В PostgreSQL — pg_current_snapshot() до и после паузы. Если снапшоты разные — gap есть.
* Реперные точки. Вставляйте timestamp начала транзакции. Если после паузы читаете строки с меткой времени больше старта — это stale-чтение.
* SQL + Python для воспроизведения:
import psycopg2, time
conn = psycopg2.connect("...")
conn.autocommit = False
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM orders")
cnt1 = cur.fetchone()
time.sleep(2)
cur.execute("SELECT COUNT(*) FROM orders")
cnt2 = cur.fetchone()
print(cnt1, cnt2) # разные — gap
Типичная ошибка
Многие QA прогоняют паузы только в идеальных условиях. В реальности задержки от внешних API или очередей могут превышать 100 мс и гарантированно вызывать stale чтения. Проверяйте с искусственными задержками — имитируйте реальное поведение, а не идеальное окружение.
Защита в production
Для транзакций с паузами переходите на REPEATABLE READ или SERIALIZABLE. Вставляйте оптимистичные блокировки, проверяйте версии строк. Это не баг PostgreSQL. Это контракт read-committed. И QA нужно проверять, что приложение терпит изменения данных между чтениями в одной транзакции. Иначе в проде начинается "а почему у нас баланс разъехался".
Вывод:
Sleeping-read gap — это не ошибка изоляции, а инженерный компромисс между производительностью и консистентностью, который QA обязано детектировать через тесты с реальными задержками и переходом на уровень repeatable read для критичных операций.
Non-idempotent retry poisoning: как повторная обработка успешных Kafka-сообщений плодит дубликаты в production
Классическая ситуация: consumer упал после того, как обработал сообщение, но до коммита offset. Kafka переотправляет то же сообщение при ребалансе. Если обработка не идемпотентна, каждый retry создаёт дублирующие side-effect записи — в БД, внешних API, логах. Это retry poisoning, который часто остаётся незамеченным до инцидента.
Как это выглядит в production
Обычный pipeline:
1. Получить сообщение.
2. Выполнить бизнес-логику — списать деньги, создать заказ, отправить письмо.
3. Записать результат в БД.
4. Закоммитить offset.
Крах на шаге 2 или 3 — offset не зафиксирован. После рестарта consumer получает то же сообщение снова. Если side-effect записи не имеют уникальных ключей — появляются дубли:
* Один и тот же заказ с разными order_id.
* Двойное списание с одного счёта.
* Повторная отправка email или SMS.
Как выявить дубликаты
Метрики и мониторинг:
* consumer_lag резко падает, но processed_messages растёт быстрее committed_offsets.
* Искать записи с одинаковым businessKey, но разными id в базе.
* Логи: повторная обработка сообщения с тем же partition/offset в течение короткого окна.
Практический совет: идемпотентный ключ
Храните уникальный event_id в сообщении. При обработке:
INSERT INTO orders (event_id, data)
VALUES (:event_id, :data)
ON CONFLICT (event_id) DO NOTHING;
Или сначала проверяйте:
SELECT 1 FROM outbox WHERE event_id = :id;
Это дешевле повторного вызова внешнего API.
Типичная ошибка
Использовать idempotent producer на стороне Kafka и думать, что этого достаточно. Он гарантирует, что сообщение не запишется дважды в partition, но не защищает от повторной обработки одного сообщения после краша consumer. Идемпотентность должна быть на уровне consumer-логики.
Trade-off: скорость vs надежность
Автокоммит enable.auto.commit=true или ручной коммит до выполнения логики ускоряет throughput, но увеличивает риск потери данных при краше. Выбирать между потерей и дублированием — риск-ориентированное решение. Для финансовых транзакций дубли критичнее.
Вывод:
Retry poisoning — не баг в коде, а архитектурная дыра, которая лечится только внедрением идемпотентности на уровне consumer с уникальными идентификаторами и upsert-операциями.
Детекция split-brain в сессионной аффинности при частичном отказе WebSocket-шлюзов
Коллеги, разберем кейс, который многие QA видели в логах, но не всегда могут воспроизвести в тестовой среде: частичный отказ WebSocket-шлюзов и расщепление сессий. Эта ситуация критична для real-time коммуникаций, где балансировщик переключает клиента на новый шлюз без синхронизации состояния.
Проблема: потеря контекста сессии
При отказе одного шлюза балансировщик переключает клиента на другой. Если session affinity настроена жестко по IP или хэшу, новый шлюз может не иметь состояния сессии. Клиент остается в полуживом состоянии: WebSocket сломан, HTTP-запросы проходят, но данные расходятся. Это классический split-brain, который сложно отловить в production без специальной детекции.
Техника: health-check с версионностью и gateway ID
Добавьте в каждый шлюз заголовок X-Gateway-Sequence при WebSocket-рукопожатии. Клиент хранит sequence и gateway ID, а при смене шлюза отправляет RECONNECT с force: true. Дополните сессионный heartbeat: ping/pong содержит ID текущего шлюза. Если клиент получает ответ от другого шлюза без предупреждения, это триггер split-brain. Пример кода на Python:
Как тестировать: симуляция отказа шлюза
Используйте Docker Compose с разными gateway_id. Выключите один шлюз (docker stop) во время активного WebSocket-соединения. Проверьте, что клиент детектирует смену gateway_id и вызывает resync_session. После рестарта старого шлюза клиент не должен переключаться обратно — sticky session должна оставаться стабильной. Типичная ошибка: health-check успешен, но состояние устарело. Решение: добавьте в health-check поле session_valid: boolean для проверки актуальности.
Торговля инженерными рисками
Split-brain в WebSocket — не баг, а архитектурная особенность при масштабировании. Быстрое обнаружение через идентификацию шлюза снижает время восстановления до миллисекунд, но добавляет complexity в клиентскую логику. Убедитесь, что resync_session не вызывает race conditions при параллельных вызовах. Для экономии ресурсов можно использовать кумулятивные heartbeat с флагами вместо полной синхронизации.
Вывод: Детекция split-brain через gateway ID и версионированный health-check позволяет предотвратить расхождение сессий за миллисекунды, что критично для любой real-time архитектуры с сессионной аффинностью.
Слепые зоны health-check'ов: zombie-потоки и silent worker-тупики
Здорово, когда продакшен не падает. Но бывает хуже — он молча гниёт. Health-check'ы по хелсу обычно отвечают 200, а задачи уже не обрабатываются. Типичная ошибка — путать "процесс жив" с "процесс полезен".
Zombie-поток: жив, но бесполезен
Это тред в deadlock'е или бесконечном цикле. Health-check стучится на порт — ответ есть. А внутри пул потоков заблокирован, очередь растёт, задачи не выполняются. Пример из production: на Java один поток захватил блокировку и ушёл в while(true). Остальные четыре ждали. Метрики CPU в норме, health-check зелёный. Бизнес заметил через пару часов по таймаутам клиентов.
Silent worker-тупик: исчез в никуда
Поток "умер" молча — OutOfMemoryError сожрал тред, но JVM не упала. Или исключение проглотили в catch блоке. Работник исчез, а health-check рапортует "всё ок". Это прямой путь к затяжным инцидентам, которые не улавливаются стандартными мониторингами.
Три инженерных подхода к детекции
* Метрики пула. Смотри на active threads, queue size, completed tasks. Если active threads упёрлись в pool size, а completed tasks застыл — алерт. Через Micrometer или Prometheus настраивается за час, но даёт надёжный сигнал.
* Health-check с осмыслением. Не просто ping эндпоинт, а опрос воркеров. Храни lastProcessedTimestamp для каждого треда. Если последняя обработка была минуту назад, а очередь не пуста — пора дёргаться. Это risk-based проверка, а не формальная.
* Deadline propagation. Если задача выполняется дольше таймаута — логируй stacktrace, даже если исключения нет. Часто тупик видно только по трейсу. Без этого вы теряете наблюдаемость и тратите часы на расследование.
Типичная ошибка при проектировании health-check'ов
Стандартный health-check проверяет "отвечает ли процесс на порт", а не "выполняет ли задачи". Разница колоссальная. В production без этих проверок zombie-потоки могут молча висеть неделями, пока кто-то случайно не заглянет в дашборд или бизнес не закричит. Trade-off: дополнительная сложность мониторинга окупается сокращением времени детекции с часов до минут.
Вывод: Health-check в production — это не проверка живости процесса, а валидация его продуктивности, иначе вы рискуете неделями работать с молча гниющим сервисом.