дублирование кошельков rca

Дублирование кошельков (RCA)

Краткое резюме расследования (Root Cause Analysis) проблемы дублирования кошельков в таблице cl_wallets.

Симптом

У одного гостя (guest_id) появляется более одной записи в cl_wallets с одинаковым type_id (один тип кошелька). Это приводит к раздваиванию баланса, некорректным начислениям и ошибкам при списании.

Корень проблемы (одной строкой)

База данных не имеет ограничения, которое физически запрещает дублирование кошельков одного типа для одного гостя, а вся защита в приложении не атомарна.

Причины

# Причина Где Вероятность
1 Нет UNIQUE constraint в БД. Существующий ключ (point, provider, guest_id, external_id) не включает type_id, а external_id всегда уникален (guidv4()) — БД не защищает от дублей. схема cl_wallets Высокая
2 Race condition в checkWalletByType. Паттерн Check-Then-Act без блокировки: SELECT и INSERT не в одной транзакции. Два параллельных запроса оба проходят проверку и оба вставляют кошелёк. Wallet.php::create() Высокая
3 Прямой INSERT без проверки. createWallet() делает INSERT без проверки существования; параллельные job-воркеры получают одинаковый список гостей и создают дубли. AbstractJobCommand::createWallet Средняя
4 GuestLoader CREATE без проверки дублей при повторном запуске (телефон нормализуется иначе → создаётся второй гость + кошелёк). GuestLoader.php Средняя

Усугубляющий фактор: lag реплики чтения (selectByGuestID читает с реплики и не видит свежевставленный кошелёк).

Решение

1. БД (критичный приоритет) — добавить UNIQUE INDEX

Сначала устранить существующие дубли (оставить кошелёк с наибольшим балансом), затем:

-- Убедиться, что дублей нет (должно вернуть 0)
SELECT COUNT(*) FROM (
    SELECT guest_id, type_id, COUNT(*)
    FROM cl_wallets
    WHERE deleted = 0 AND type_id IS NOT NULL
    GROUP BY guest_id, type_id
    HAVING COUNT(*) > 1
) t;

-- Добавить уникальный ключ
ALTER TABLE cl_wallets
  ADD UNIQUE KEY `uq_guest_type_nodelete` (`guest_id`, `type_id`);

После объединения кошельков пересчитать агрегированный баланс гостей:

UPDATE cl_guests g
JOIN (
    SELECT w.guest_id,
           SUM(CASE WHEN wt.options LIKE '%"count_general":true%' THEN w.balance ELSE 0 END) AS new_bonuses
    FROM cl_wallets w
    JOIN cl_wallet_types wt ON w.type_id = wt.id
    WHERE w.deleted = 0
    GROUP BY w.guest_id
) sums ON g.id = sums.guest_id
SET g.bonuses = sums.new_bonuses;

2. Код

  • Wallet::create — заменить Check-Then-Act на атомарный INSERT ... ON DUPLICATE / INSERT IGNORE (после добавления UNIQUE KEY).
  • AbstractJobCommand::createWallet — добавить проверку существования кошелька перед вставкой.
  • GuestLoader (ветка CREATE) — добавить проверку типов кошельков при повторной синхронизации.

UNIQUE INDEX — последний рубеж защиты: даже при гонках в приложении БД не даст создать второй кошелёк того же типа.