Краткое резюме расследования (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 читает с реплики и не видит свежевставленный кошелёк).
Сначала устранить существующие дубли (оставить кошелёк с наибольшим балансом), затем:
-- Убедиться, что дублей нет (должно вернуть 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;
Wallet::create — заменить Check-Then-Act на атомарный INSERT ... ON DUPLICATE / INSERT IGNORE (после добавления UNIQUE KEY).AbstractJobCommand::createWallet — добавить проверку существования кошелька перед вставкой.GuestLoader (ветка CREATE) — добавить проверку типов кошельков при повторной синхронизации.UNIQUE INDEX — последний рубеж защиты: даже при гонках в приложении БД не даст создать второй кошелёк того же типа.