— Artikel — № 051

051 —Magento

Magento CSV-import zonder downtime: 9.000 rijen wisselen

Een Loom van 23:41, een leverancier-CSV met 9.043 rijen en een webshop die niet plat mocht. Hoe een Nederlands bureau de import draaide zonder een minuut downtime.

Bovenaanzicht van CSV-vel, manilamap, messing sleutel, liniaal, rood potlood, indexkaarten, lakzegel op linnen.
Hero · gestileerd stilleven№ 051

De Loom van 23:41

Op een dinsdag om 23:41 viel er een Loom-link binnen. Twaalf minuten schermopname van de lead developer van een Nederlands bureau, stem strak, terwijl hij zijn terminal toelichtte. Hun grootste Magento 2.4.5-klant (ongeveer €4M per jaar in B2B-onderdelen en accessoires) had net een product-CSV van 9.043 rijen van de leverancier binnengekregen. Nieuwe prijzen, nieuwe voorraadstanden, achttien nieuwe SKU's, plus herschreven omschrijvingen op ruwweg de helft van de catalogus. De klant wilde alles live hebben vóór 06:00, want dan logden de eerste groothandelskopers in.

De vorige poging, twee weken eerder, had de webshop 38 minuten uit de lucht gehaald. Categoriepagina's gaven 502's. Cart-updates liepen op timeouts. Admin-sessies bleven hangen. Uiteindelijk hebben ze MySQL herstart, daarbij twee rijen uit de import verloren en de hele volgende dag besteed aan reconcilen. De vraag van de lead op die Loom was rechttoe: hoe vervangen we 9.000 rijen Magento-productdata zonder dat de site eruit ligt?

Dit is de post-mortem van die nacht. Namen weggelaten, elk commando en elk SQL-fragment is wat we daadwerkelijk hebben gedraaid of geadviseerd.

Waar de native importer van Magento afhaakt

De Magento_ImportExport-module van Magento 2 is voor precies dit werk gebouwd. bin/magento import:products of de admin-UI werken prima voor CSV's van bescheiden omvang. Het wordt pas vervelend als de import breed is (veel attributen per rij) en de catalogus tegelijk live URL-rewrites, multi-store, MSI-voorraad en tier prices heeft draaien. Elke opgeslagen rij triggert writes tegen:

  • catalog_product_entity en bijbehorende EAV-tabellen _int, _varchar, _text, _decimal
  • url_rewrite, bij elke wijziging van een URL-key (en Magento herschrijft de rewrite bij elke save, ook als de key feitelijk niet veranderd is)
  • cataloginventory_stock_item en de MSI-tabel inventory_source_item
  • De partities van catalog_product_index_price, één per customer group
  • De full-text search index (catalogsearch_fulltext_scope1 op MySQL, de Elasticsearch-index als die geconfigureerd is)

Op een drukke shop botsen die writes met reads van categoriepagina's, layered navigation en de cart. Het InnoDB-deadlocklog loopt vol met steeds dezelfde regel:

SQLSTATE[40001]: Serialization failure: 1213 Deadlock found when trying
to get lock; try restarting transaction, query was: UPDATE
`catalog_product_index_price` SET ...

Zodra er genoeg deadlocks opstapelen, retryt de importjob, faalt, en herstart óf vanaf rij 1 óf slaat de gefaalde rijen volledig over, afhankelijk van welke modus je hebt gekozen. Tegen die tijd hapt de frontend al naar adem.

De CSV en de importjob opknippen

De eerste fix is weinig spannend. Stop met proberen 9.000 rijen in één transactie te importeren. Magento's async bulk API bestaat precies hiervoor. We hebben de leverancier-CSV met een korte shell-loop in chunks van 300 rijen geknipt:

cd /var/www/magento/var/import
mkdir chunks
tail -n +2 supplier-2026-05.csv | split -l 300 -d - chunks/products-
head -n 1 supplier-2026-05.csv > header.csv
for f in chunks/products-*; do
  cat header.csv "$f" > "$f.csv" && rm "$f"
done
ls chunks/ | wc -l
# 31

Eenendertig chunks van ongeveer 300 rijen. Elke chunk duurt 40 tot 90 seconden om te importeren op de bak van deze klant (8 vCPU, 16 GB RAM, RDS MySQL 8.0). Sequentieel draaien met een kleine sleep ertussen, en de frontend blijft responsief.

Voor de eigenlijke import gebruikten we de async bulk REST endpoints in plaats van de CLI. De async-route zet jobs in RabbitMQ en een consumer verwerkt ze één voor één, precies wat we wilden. De setup is twee blokken in app/etc/env.php:

'queue' => [
    'consumers_wait_for_messages' => 0,
],
'cron_consumers_runner' => [
    'cron_run' => true,
    'max_messages' => 100,
    'consumers' => ['async.operations.all'],
],

Start de consumer daarna één keer met een enkele worker, zodat de chunks op volgorde worden afgewerkt:

bin/magento queue:consumers:start async.operations.all \
  --single-thread --max-messages=10000 &

Indexer-modi en het schuifwerk

De grootste winst kwam uit het op manual zetten van élke indexer vóór de import begon. Standaard staan Magento's indexers op Update on Save, wat betekent dat elke rij-write een partiële reindex triggert van price, stock, EAV en search. Vermenigvuldig dat met 9.000 en de rekensom wordt lelijk. De indexer-documentatie dekt de modi maar zwijgt over de operationele consequenties.

bin/magento indexer:set-mode manual
bin/magento indexer:status
# Design Config Grid           Manual Updates
# Customer Grid                Manual Updates
# Category Products            Manual Updates
# Product Categories           Manual Updates
# Product Price                Manual Updates
# Product EAV                  Manual Updates
# Stock                        Manual Updates
# Catalog Rule Product         Manual Updates
# Catalog Product Rule         Manual Updates
# Catalog Search               Manual Updates
# Inventory                    Manual Updates

Met de indexers uit, schrijft de import alleen naar de brontabellen. Reads vanuit de storefront blijven de eerder opgebouwde indextabellen raken, en die houden nog steeds de data van gisterenavond vast. De bezoeker ziet voor de duur van de import de prijzen van gisteren. Voor deze klant, die expliciet zei dat 30 minuten oude prijzen prima zijn maar downtime niet, was dat de juiste afweging.

Reindexen zonder de catalogus offline te halen

Toen de laatste chunk binnen was, hadden we 9.043 rijen bijgewerkt, indexers onaangeroerd, frontend nog steeds netjes serverend. Het volgende probleem was reindexen zonder dezelfde outage van 38 minuten te reproduceren.

De default bin/magento indexer:reindex draait elke indexer op volgorde en bouwt ze from scratch op. Op een catalogus van deze omvang neemt de price-reindex alleen al 6 tot 9 minuten en houdt catalog_product_index_price het grootste deel van die tijd op slot. Queries van categoriepagina's die daartegen joinen schuiven netjes in de wachtrij achter de rebuild.

In plaats daarvan hebben we selectief gereindexed, in volgorde van afhankelijkheid, en elke indexer apart aangeroepen zodat we ertussen konden pauzeren:

bin/magento indexer:reindex catalog_product_attribute
bin/magento indexer:reindex catalog_product_price
bin/magento indexer:reindex inventory
bin/magento indexer:reindex cataloginventory_stock
bin/magento indexer:reindex catalogrule_product
bin/magento indexer:reindex catalog_category_product
bin/magento indexer:reindex catalogsearch_fulltext

Voor de price-indexer, de zwaarste, hebben we een partiële reindex tegen alleen de gewijzigde SKU's gedraaid. Magento heeft per indexer een _cl-changelogtabel (catalog_product_price_cl), normaal alleen gebruikt in scheduled mode, maar die kun je ook handmatig vullen:

INSERT INTO catalog_product_price_cl (entity_id)
SELECT entity_id FROM catalog_product_entity
WHERE sku IN (SELECT sku FROM tmp_changed_skus);

-- Then trigger a partial reindex via the MView
UPDATE mview_state SET status = 'idle', mode = 'enabled'
WHERE view_id = 'catalog_product_price';

De partiële reindex deed er 41 seconden over tegen 9.043 SKU's, versus 7 minuten voor een volledige rebuild. Het grootste deel van die 41 seconden zat in de customer-group fan-out: deze klant heeft vier groepen (Guest, Retail, Wholesale, B2B-Tier), waardoor de indextabel bij elke cyclus aangroeit tot circa 36.000 rijen.

Tot slot de cache. De verleiding is bin/magento cache:flush, wat álles platlegt inclusief de cart- en session-cache. Doe het niet. Gerichte invalidatie per tag is genoeg:

bin/magento cache:clean config block_html full_page
# leave layout, collections, db_ddl, eav, integration alone

Wat we volgende keer anders doen

Het werkte. De hele klus liep van 02:18 tot 03:04. De p95 response time van de storefront bleef de hele tijd onder 800ms. De leverancier-CSV stond binnen, prijzen en voorraad waren om 03:10 live, de lead developer sliep door tot 11:00.

Twee dingen zouden we anders doen. Eén, het URL-rewrite-probleem. Magento herschreef de hele url_rewrite-tabel voor producten waarvan de URL-key feitelijk niet was veranderd, omdat de importer de kolom in de CSV ziet staan en hem klakkeloos wegschrijft. De fix is om de url_key-kolom volledig uit de import-CSV te slopen als de keys niet wijzigen. Dat hebben we gedaan op chunks 4 tot en met 31 en daarmee circa 80.000 rij-writes op url_rewrite uitgespaard.

Twee, de leverancier zou niet elke maand de hele catalogus moeten sturen als er maar een fractie van de rijen verandert. Een pre-import diff tegen de CSV van de vorige maand bracht de daadwerkelijke import de cyclus erna terug tot 1.247 rijen. awk is daar genoeg voor:

awk -F',' 'NR==FNR{a[$1]=$0; next} a[$1]!=$0' \
  prev-month.csv supplier-2026-06.csv > delta.csv
wc -l delta.csv
# 1248 (header + 1247 changes)

Toen we Pier bouwden, kwamen we precies dit soort werk telkens tegen: kleine bureaus die in een live Magento- of WordPress-database moeten poken via een MySQL editor zonder hem op slot te zetten, en die een betrouwbare version history willen wanneer iets om 02:00 alsnog scheefloopt. We hebben het uiteindelijk opgelost door elke wijziging in een snapshot per change te wikkelen die met één klik terugrolt. Dat is wat het bureau hierboven nu gebruikt om de maandelijkse delta-CSV en de onvermijdelijke losse SQL-tweaks te stagen, zonder ooit nog phpMyAdmin te openen.

Het kleinste dat je morgen kunt proberen

Open een staging-Magento, draai bin/magento indexer:set-mode manual, importeer een kleine CSV en kijk wat er met de response time van de storefront gebeurt. Heb je de indexer-modus nog nooit live zien omklappen, dan is het verschil het soort ding dat je je herinnert zodra iemand je een leverancier-dump van 9.000 rijen aanreikt.

— Vragen —

Kan dit ook met bin/magento import:products in plaats van de async API?

Ja, maar de CLI draait synchroon en een import van 9.000 rijen in één proces loopt al snel in deadlocks. Door de CSV op te knippen en het async bulk endpoint te gebruiken, kun je tussen chunks in elk geval even pauzeren.

Verlies je data als je indexers op manual zet?

Nee. Wijzigingen op entities worden bijgehouden in de mview changelog-tabellen (de _cl-tabellen). Bij een reindex worden ze in partial mode opnieuw afgespeeld. Vergeet niet om de indexers daarna terug te zetten op Update by Schedule.

Hoe zit het met de reindex-tijd van Elasticsearch op een grote catalogus?

catalogsearch_fulltext kan tegen een grote index meerdere minuten duren. Draai hem als laatste, accepteer kortstondig verouderde zoekresultaten, of doe een partiële reindex tegen alleen de gewijzigde SKU's.