Когда несколько асинхронных операций состязаются за общие ресурсы, возникает инженерная проблема на уровне ядра системы. Невидимые состояния гонки порождают плавающие баги, которые паразитируют в production-средах. Речь не об элементарных данных в UI-компонентах – я о фундаментальных схватках за индексные блоки СУБД, файловые дескрипторы или кэши.
Механизм коллизии
Представьте микросервис, обновляющий статус заказа при одновременных запросах от клиента и фоновой джобы:
async function updateOrderStatus(orderId, newStatus) {
const order = await OrderModel.findById(orderId);
order.status = newStatus;
await order.save();
}
Кажется безопасным? При конкурентных вызовах возникнет мутация устаревшей версии сущности. Транзакции SELECT…UPDATE в PostgreSQL выполняются как:
- Чтение версии из индекса по
order_id
- Изменение данных в memory
- Запись новой версии
Если два процесса выполняют шаг 1 одновременно, оба получат идентичные данные. Результат второго save()
перетрет изменения первого. Неделя работы инвойсистемы может исчезнуть за 200 мс.
Инженерные паттерны блокировок
Оптимистичные блокировки через контроль версий
UPDATE orders
SET status = 'processed', version = new_version
WHERE id = :id AND version = :current_version;
В коде реализуется реакция на конфликт:
async function safeUpdate(orderId, newStatus) {
let updated = false;
while (!updated) {
const order = await Order.findTransactional(orderId);
const prevVersion = order.version;
order.status = newStatus;
const result = await Order.update({
status: newStatus,
version: prevVersion + 1
}, {
where: { id: orderId, version: prevVersion }
});
updated = result.affectedRows > 0;
}
}
Цикл повторяет попытку, пока не будет поймана актуальная версия. Метод эффективен для систем с низкой конкуренцией.
Пессимистичные блокировки на уровне строк
BEGIN;
SELECT * FROM orders WHERE id = :id FOR UPDATE;
-- Критическая секция
UPDATE orders SET status = 'shipped' WHERE id = :id;
COMMIT;
В приложении:
await sequelize.transaction(async (tx) => {
const order = await Order.findByPk(orderId, {
lock: tx.LOCK.UPDATE,
transaction: tx
});
await order.update({ status: 'shipped' }, { transaction: tx });
});
Блокировка FOR UPDATE
монополизирует строку до завершения транзакции. Требует эксплицитного управления соединениями в ORM.
Нюансы реализации
-
Deadlocks: Двунаправленные блокировки вызывают взаимоблокировки RDBMS. Добавляйте
lock_timeout
:sqlSET lock_timeout = '500ms';
-
Ведение журнала конфликтов: Фиксируйте коллизии в Prometheus/Grafana:
javascriptconst conflictCounter = new prometheus.Counter({ name: 'db_update_conflicts_total' }); async function conflictSafeUpdate() { try { // ... } catch (err) { if (err instanceof OptimisticLockError) { conflictCounter.inc(); } } }
-
Статусные автоматы: Запрещайте невалидные переходы через enum с защитой на уровне БД:
sqlALTER TABLE orders ADD CONSTRAINT valid_transition CHECK (status IN ('new','processed','shipped'))
Когда блокировки не работают
- Уровень кэширования: Redis-слой ничего не знает о MVCC БД. Решение:
redis.call('WATCH', orderKey)
local data = redis.call('GET', orderKey)
-- Проверка версии
redis.call('MULTI')
redis.call('SET', orderKey, newData)
redis.exec()
-
Shared-nothing архитектуры: В Kafka потребители обрабатывают партиции по-отдельности. Глобальная блокировка требуется только при смене партиционирования.
-
Бессерверные функции: Lambda/Azure Functions не сохраняют состояние. Используйте серийные номера запросов:
POST /order/update
Content-Type: application/json
If-Unmodified-Since: 2023-05-17T12:00:00Z
X-Request-Id: 84e0e136-3241-4d50-8dfa
Предел производительности
Блокировки создают очередь из ожидающих процессов. При нагрузке > 100 RPS коллизии превращаются в лавинообразный обвал. Без раннего троттлинга система уйдет в thrashing. Рецепты:
- Переходите на event sourcing с персистентным логом состояний
- Перепроектируйте запросы на изменение только по первичному ключу
- Применяйте паттерн CQRS для разделения запросов и изменений
Инструментарий ревизий
- PostgreSQL
log_lock_waits
:
ALTER SYSTEM SET log_lock_waits = on;
SELECT pg_reload_conf();
- Журнал медленных транзакций:
# postgresql.conf
log_min_duration_statement = 500
- Тестирование с Toxiproxy:
toxiproxy-cli toxic add mysql_db -t latency -a latency=500
Состояния гонки в распределённых системах неизбежны, как атмосферное трение в аэродинамике. Ключевая мысль в инженерной практике: блокировочные схемы проще спроектировать при первичном конструировании, чем реинжинирить post-mortem на перегретой кластерной системе. Настройка явного ограничения параллелизма для критических участков – не оптимизация, а страховка от критических отказов. Мониторинг блокировок в PostgreSQL или Redis Slowlog должен стать таким же рутинным действием, как проверка дискового пространства.
Проектировки, которые сегодня кажутся устойчивыми при unit-тестировании, могут породить каскадный коллапс при реальном сетевом пакете нагрузки. Стратегия: запирать ресурсы на минимально необходимый срок, всегда информировать пользователей о коллизиях и вести детальные логи операций исключений. Чем выше параллельность в инфраструктуре, тем существеннее роль атомарных команд и контрольных сумм.