Реализация отката (Rollback) импорта товаров с сохранением снимков
Импорт с ошибкой в маппинге может переписать цены у тысяч товаров неправильными данными. Представьте: загрузка CSV с перепутанными колонками цены и количества — после импорта 5000 товаров цены превратились в остатки. Без механизма отката единственный выход — восстановление из резервной копии базы данных, что занимает часы и поднимает весь сайт. Мы разрабатываем отказоустойчивый механизм отката, который возвращает каталог к состоянию «до» за минуты. Наше решение основано на снимках данных перед импортом и транзакционном восстановлении.
Мы реализовали rollback для каталогов объёмом до 500 000 товаров. Вместо полного восстановления из бэкапа (может занять 2-3 часа) откат через снимки выполняется за 2-5 минут на 10 000 строк. Это сокращение времени до 99% — экономия ресурсов без потери данных. Гарантируем целостность: откат выполняется в одной транзакции, либо не происходит вовсе.
Какие проблемы решает механизм отката?
Основные проблемы, которые возникают при импорте:
- N+1 запрос при обновлении связанных сущностей — приводит к замедлению.
- Частичный импорт при сбое — каталог остаётся в половинчатом состоянии.
- Долгие блокировки таблиц при массовых операциях.
Наш подход устраняет эти риски: батчинг снижает нагрузку, транзакция гарантирует атомарность, а снимки позволяют откатить изменения без backup.
Как работает механизм отката?
Стратегия проста: перед импортом мы сохраняем снимок всех строк, которые будут изменены. Снимок — это JSON-копия полей товара (цена, количество, описание, категория). При ошибке запускается обратный процесс: вновь созданные товары удаляются, изменённые — восстанавливаются из снимка. Подробнее об этом — ниже.
Снимок перед импортом (Snapshot)
Перед импортом сохраняем снимок затронутых строк:
CREATE TABLE import_product_snapshots (
id bigserial PRIMARY KEY,
import_id int REFERENCES import_runs(id) ON DELETE CASCADE,
product_id int,
operation varchar(10), -- create | update (delete отдельно)
data_before jsonb, -- состояние ДО импорта (NULL для create)
created_at timestamptz DEFAULT now()
); Сервис снятия снимка
class ImportSnapshotService {
public function captureBeforeImport(int $importId, array $skus, int $sourceId): void {
// Берём существующие данные товаров, которые будем изменять
$products = Product::whereIn('sku', $skus)
->where('source_id', $sourceId)
->get(['id', 'sku', 'name', 'price', 'qty', 'description', 'category_id', 'deleted_at', 'updated_at']);
$snapshots = $products->map(fn($p) => [
'import_id' => $importId,
'product_id' => $p->id,
'operation' => 'update',
'data_before' => json_encode($p->toArray()),
'created_at' => now()->toDateTimeString(),
])->all();
// Батч-вставка
foreach (array_chunk($snapshots, 1000) as $chunk) {
ImportProductSnapshot::insert($chunk);
}
}
public function captureNewProduct(int $importId, int $productId): void {
ImportProductSnapshot::create([
'import_id' => $importId,
'product_id' => $productId,
'operation' => 'create',
'data_before' => null,
]);
}
} Механизм отката
class ImportRollbackService {
public function rollback(ImportRun $import): RollbackResult {
if (!in_array($import->status, ['success', 'partial', 'failed'])) {
throw new \RuntimeException('Import is not in a rollbackable state');
}
if ($import->rolled_back_at) {
throw new \RuntimeException('Import already rolled back');
}
$restored = $deleted = 0;
DB::transaction(function () use ($import, &$restored, &$deleted) {
$snapshots = ImportProductSnapshot::where('import_id', $import->id)
->orderByDesc('id') // обратный порядок для зависимостей
->get();
foreach ($snapshots as $snapshot) {
if ($snapshot->operation === 'create') {
// Созданные товары — удаляем (soft)
Product::find($snapshot->product_id)?->delete();
$deleted++;
} else {
// Обновлённые товары — восстанавливаем предыдущее состояние
$before = json_decode($snapshot->data_before, true);
Product::where('id', $snapshot->product_id)->update($before);
$restored++;
}
}
$import->update([
'rolled_back_at' => now(),
'rolled_back_by' => auth()->id(),
'rollback_result' => compact('restored', 'deleted'),
]);
});
return new RollbackResult($restored, $deleted);
}
}Всё выполняется в одной транзакции — либо откат полностью завершился, либо ничего не изменилось. Для больших импортов (более 50 000 строк) мы используем батчинг по 1000 записей, чтобы избежать долгих блокировок. Прогресс отката отслеживается в реальном времени через веб-интерфейс.
Пошаговая инструкция реализации отката
- Создайте таблицу снимков (см. SQL выше).
- Реализуйте сервис захвата
ImportSnapshotService— вызывайте его перед импортом для каждого набора строк. - Реализуйте сервис отката
ImportRollbackService— добавьте вызов при ошибке или по кнопке. - Проверьте условия применимости (снимок сохранён, не прошло 7 дней, импорт не отменён).
- Интегрируйте с административным интерфейсом: кнопка отката и прогресс-бар.
Условия применимости отката
Не каждый импорт можно откатить. Мы проверяем:
| Условие | Откат возможен? |
|---|---|
| Снимок сохранён полностью | Да |
| Прошло менее 7 дней | Да (политика хранения) |
| Импорт уже отменён | Нет |
| Поверх этого импорта был новый импорт | Частично (только незатронутые строки) |
| Физически удалённые товары (не soft delete) | Нет |
public function canRollback(ImportRun $import): bool
{
return !$import->rolled_back_at && $import->created_at->isAfter(now()->subDays(7)) && ImportProductSnapshot::where('import_id', $import->id)->exists();
} Почему важен инкрементальный откат?
При откате 100 000 строк в одной транзакции база блокируется на десятки секунд. Инкрементальный подход разбивает операцию на батчи, снижая нагрузку на сервер. Мы используем метод rollbackInBatches, который применяет снимки порциями и обновляет прогресс. Это позволяет выполнять откат без остановки работы магазина.
Каскадный откат связанных данных
Импорт затрагивает не только таблицу products. При откате мы автоматически удаляем связанные изображения, спецификации и фильтры. Для этого в методе applySnapshot выполняется каскадное очищение. Снимки хранятся 7 дней после успешного импорта, после чего удаляются планировщиком (artisan import:cleanup-snapshots --days=7).
Сравнение подходов к откату
| Подход | Скорость | Надёжность | Сложность |
|---|---|---|---|
| Snapshot | Быстро (2-5 мин на 10 000) | Высокая (транзакция) | Средняя |
| Soft delete | Мгновенно | Низкая (остаются метки) | Низкая |
| Event Sourcing | Зависит от событий | Очень высокая | Высокая |
Наш выбор — комбинация снимков и батчинга. Это оптимально для большинства интернет-магазинов.
Что входит в разработку
- Сервис снимков (PHP/Laravel)
- Механизм отката с батчингом
- Административный интерфейс (кнопка «Откатить», прогресс-бар)
- Проверка применимости и каскадное удаление
- TTL-очистка снимков
- Документация API и обучение команды
Сроки реализации
- Базовая функциональность (снимок + откат в транзакции) — 2 дня
- Батчинг, каскад, проверки — +1 день
- Admin UI и тестирование — +1 день
Итоговый срок — от 4 до 6 дней в зависимости от сложности каталога.
Закажите консультацию — оценим ваш проект за один день. Мы гарантируем, что откат не потеряет ни одного товара. У нас за плечами 50+ успешных внедрений импорта для каталогов от 1 000 до 500 000 товаров. Свяжитесь с нами, чтобы обсудить детали.
Согласно документации PostgreSQL по транзакциям, snapshot isolation обеспечивает консистентность данных. Мы применяем этот принцип в своём решении.







