📚 RM.Loyalty.docs
➕ Новая статья
Редактирование: дублирование-кошельков-rca
Путь:
11_CRM_и_процессинг/дублирование-кошельков-rca.md
Содержимое (Markdown)
# Дублирование кошельков (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 Сначала устранить существующие дубли (оставить кошелёк с наибольшим балансом), затем: ```sql -- Убедиться, что дублей нет (должно вернуть 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`); ``` После объединения кошельков пересчитать агрегированный баланс гостей: ```sql 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 — последний рубеж защиты: даже при гонках в приложении БД не даст создать второй кошелёк того же типа.
💾 Сохранить
Отмена