WPML lento? Il backend WordPress che non risponde? Probabilmente è il database.

Nel mio caso, ogni click nel pannello admin richiedeva 15-20 secondi. Il database aveva raggiunto i 2.5GB per un sito con “solo” 18.000 articoli. Il colpevole: 550.000 stringhe Gutenberg registrate da WPML nella tabella wp_icl_strings, stringhe che non avrebbero mai dovuto esistere.

Quello che segue è il percorso completo: diagnosi SQL, pulizia del database, cronjob di prevenzione automatica e tuning MariaDB. Tutto testato su un sito in produzione.

Il setup del sito WordPress multilingua

Il cliente è uno studio legale con un portale legislativo. Circa 18.000 articoli (custom post type “legislazione”) tradotti in 4 lingue con WPML.

L’infrastruttura: Ubuntu 24.04 su VPS Keliweb (8GB RAM, 6 CPU, 200GB SSD), gestita con 1Panel. Stack completo: MariaDB 11.8.5, WordPress 6.9, Redis 8.4 per object cache, OpenResty come web server, CrowdSec + Cloudflare bouncer per la sicurezza.

La stessa VPS ha richiesto diversi interventi infrastrutturali nel tempo: limite memory per il bouncer CrowdSec (che leakava fino a saturare la RAM), network watchdog systemd per un problema di networking al reboot, ottimizzazione Redis (256MB, allkeys-lru), aumento swap a 3GB, OPcache a 256MB. Tutto questo per dire che l’infrastruttura era già ottimizzata. Il problema era nel database.

Il problema: tabella wp_icl_strings da 960MB

Ho iniziato con una diagnosi delle dimensioni delle tabelle:

-- Dimensioni tabelle WPML - il primo segnale d'allarme
SELECT
    table_name AS 'Tabella',
    ROUND(data_length / 1024 / 1024, 2) AS 'Dati (MB)',
    ROUND(index_length / 1024 / 1024, 2) AS 'Indici (MB)',
    ROUND((data_length + index_length) / 1024 / 1024, 2) AS 'Totale (MB)',
    table_rows AS 'Righe stimate'
FROM information_schema.tables
WHERE table_schema = DATABASE()
    AND table_name LIKE 'wp_icl_%'
ORDER BY (data_length + index_length) DESC;

I risultati:

TabellaDimensioneRighe
wp_icl_translate960 MB819.000
wp_icl_translation_status580 MB
wp_icl_stringsgrande550.000+

Il database totale era 2.5GB, di cui oltre 1.5GB erano solo tabelle WPML.

Il pannello WPML stesso segnalava 552.636 stringhe “non utilizzate o con dati errati”. Ma il bottone di pulizia automatica? Non funzionava. Timeout dopo timeout.

Perché WPML registra stringhe Gutenberg ridondanti

Per capire il problema serve sapere come WPML gestisce le traduzioni. Ci sono due metodi principali.

Metodo “Duplica post” (quello che usava il sito): WPML crea una copia del post per ogni lingua. Il traduttore lavora direttamente sul post duplicato, come se fosse un articolo indipendente. I contenuti tradotti vivono nella tabella wp_posts, come qualsiasi altro post WordPress.

Metodo “String Translation”: WPML estrae ogni blocco di testo dal post (titoli, paragrafi, bottoni) e lo registra come “stringa” nella tabella wp_icl_strings. Il traduttore traduce le singole stringhe da un’interfaccia dedicata, e le traduzioni vanno in wp_icl_string_translations.

Il primo metodo non ha bisogno della tabella stringhe: i contenuti sono già nei post duplicati. Il secondo sì, perché le stringhe SONO il meccanismo di traduzione.

Il bug: anche con il metodo “duplica post” attivo, WPML registrava comunque tutte le stringhe dei blocchi Gutenberg in wp_icl_strings. Doppia registrazione: i contenuti erano sia nei post duplicati (dove servivano) sia nelle stringhe (dove non servivano a nessuno). Ogni blocco Gutenberg, ogni paragrafo, ogni heading di ogni articolo, moltiplicato per 18.000 post.

Per capire la portata:

-- Conta stringhe per tipo di contesto
-- Il campo "context" in wp_icl_strings indica da dove arriva la stringa.
-- WPML usa il pattern "gutenberg-{post_id}" per le stringhe dei blocchi editor.
SELECT
    CASE
        WHEN context LIKE 'gutenberg-%' THEN 'Gutenberg blocks'
        WHEN context LIKE 'theme%' THEN 'Theme strings'
        WHEN context LIKE 'plugin%' THEN 'Plugin strings'
        ELSE context
    END AS tipo,
    COUNT(*) AS totale
FROM wp_icl_strings
GROUP BY tipo
ORDER BY totale DESC
LIMIT 10;

Oltre il 90% delle stringhe aveva contesto gutenberg-{post_id}. Ogni blocco Gutenberg di ogni articolo generava stringhe ridondanti.

Pulizia database WPML: guida step by step

Step 1. Backup

Prima di toccare qualsiasi cosa, backup completo:

#!/bin/bash
# backup-db.sh - Dump completo prima della pulizia WPML

BACKUP_DIR="/root/backups/wpml-cleanup"
DB_NAME="wordpress_db"
DATE=$(date +%Y%m%d_%H%M%S)

mkdir -p "$BACKUP_DIR"

# Dump completo del database
# --single-transaction: fa il dump senza bloccare le tabelle (usa uno snapshot InnoDB)
# --routines: include stored procedure e function
# --triggers: include i trigger associati alle tabelle
mysqldump --single-transaction --routines --triggers \
    "$DB_NAME" > "$BACKUP_DIR/full_dump_${DATE}.sql"

# Dump separato delle sole tabelle WPML (per restore parziale)
# Se qualcosa va storto, puoi ripristinare solo queste senza toccare il resto
mysqldump --single-transaction "$DB_NAME" \
    wp_icl_strings \
    wp_icl_string_translations \
    wp_icl_translate \
    wp_icl_translation_status \
    > "$BACKUP_DIR/wpml_tables_${DATE}.sql"

# Comprimi (un dump da 2.5GB non compresso occupa spazio inutile)
gzip "$BACKUP_DIR/full_dump_${DATE}.sql"
gzip "$BACKUP_DIR/wpml_tables_${DATE}.sql"

echo "Backup completato: $BACKUP_DIR"
ls -lh "$BACKUP_DIR"

Step 2. Identificare le stringhe orfane

Prima di cancellare, verifica esattamente cosa stai per rimuovere:

-- Conta le stringhe Gutenberg che puntano a post NON esistenti
--
-- Come funziona la query:
-- 1. context LIKE 'gutenberg-%' filtra solo le stringhe dei blocchi editor
-- 2. SUBSTRING(context, 11) estrae tutto dopo "gutenberg-" (10 caratteri),
--    cioe' il post_id. Esempio: "gutenberg-12345" diventa "12345"
-- 3. CAST(... AS UNSIGNED) converte la stringa "12345" in numero intero,
--    necessario per confrontarlo con wp_posts.ID che e' un intero
-- 4. NOT IN (SELECT ID FROM wp_posts) verifica se quel post esiste ancora
--
-- Se il post e' stato cancellato, le sue stringhe sono orfane: occupano spazio
-- e non servono piu' a nessuno.

SELECT COUNT(*) AS stringhe_orfane
FROM wp_icl_strings
WHERE context LIKE 'gutenberg-%'
AND CAST(SUBSTRING(context, 11) AS UNSIGNED)
    NOT IN (SELECT ID FROM wp_posts);

-- Per confronto: conta le stringhe Gutenberg con post ancora esistente
-- (queste le teniamo, anche se ridondanti con il metodo "duplica post")
SELECT COUNT(*) AS stringhe_con_post
FROM wp_icl_strings
WHERE context LIKE 'gutenberg-%'
AND CAST(SUBSTRING(context, 11) AS UNSIGNED)
    IN (SELECT ID FROM wp_posts);

Nel nostro caso, circa 350.000 stringhe erano completamente orfane, puntavano a post che non esistevano più (cancellati, draft rimossi, revisioni eliminate).

Step 3. Pulizia chirurgica

Due query, in quest’ordine. L’ordine conta: le traduzioni nella tabella wp_icl_string_translations hanno un campo string_id che punta alla riga corrispondente in wp_icl_strings. Se cancelli prima le stringhe, le traduzioni restano orfane (nessun vincolo foreign key le rimuove automaticamente, WPML non usa foreign key). Quindi: prima le traduzioni, poi le stringhe.

-- 1. Elimina le TRADUZIONI delle stringhe Gutenberg orfane
-- La sintassi "DELETE st FROM ... INNER JOIN ..." e' specifica di MySQL/MariaDB.
-- Funziona cosi': il JOIN collega ogni traduzione alla sua stringa madre,
-- il WHERE filtra solo le stringhe orfane, e "DELETE st" cancella solo
-- le righe dalla tabella delle traduzioni (non dalle stringhe).
DELETE st FROM wp_icl_string_translations st
INNER JOIN wp_icl_strings s ON st.string_id = s.id
WHERE s.context LIKE 'gutenberg-%'
AND CAST(SUBSTRING(s.context, 11) AS UNSIGNED)
    NOT IN (SELECT ID FROM wp_posts);

-- 2. Ora elimina le STRINGHE Gutenberg orfane
-- Questa e' una DELETE semplice: cancella dalla tabella principale
-- le righe il cui contesto punta a post non piu' esistenti.
DELETE FROM wp_icl_strings
WHERE context LIKE 'gutenberg-%'
AND CAST(SUBSTRING(context, 11) AS UNSIGNED)
    NOT IN (SELECT ID FROM wp_posts);

Attenzione: su database grandi queste query possono impiegare diversi minuti. Se hai più di 500k righe da cancellare, valuta di procedere a batch. Ecco uno script bash che lo automatizza:

#!/bin/bash
# wpml-batch-delete.sh - Cancellazione a batch per database grandi
# Cancella 50.000 righe alla volta per evitare lock prolungati
# e permettere al server di respirare tra un batch e l'altro.

DB_NAME="wordpress_db"
BATCH=50000

echo "=== Pulizia traduzioni stringhe orfane ==="
while true; do
    AFFECTED=$(mysql -N -e "
        DELETE st FROM ${DB_NAME}.wp_icl_string_translations st
        INNER JOIN ${DB_NAME}.wp_icl_strings s ON st.string_id = s.id
        WHERE s.context LIKE 'gutenberg-%'
        AND CAST(SUBSTRING(s.context, 11) AS UNSIGNED)
            NOT IN (SELECT ID FROM ${DB_NAME}.wp_posts)
        LIMIT ${BATCH};
        SELECT ROW_COUNT();
    ")
    echo "  Traduzioni rimosse in questo batch: $AFFECTED"
    # Se il batch ha cancellato meno righe del limite, abbiamo finito
    [ "$AFFECTED" -lt "$BATCH" ] && break
    # Pausa di 2 secondi tra i batch per non saturare I/O
    sleep 2
done

echo "=== Pulizia stringhe orfane ==="
while true; do
    AFFECTED=$(mysql -N -e "
        DELETE FROM ${DB_NAME}.wp_icl_strings
        WHERE context LIKE 'gutenberg-%'
        AND CAST(SUBSTRING(context, 11) AS UNSIGNED)
            NOT IN (SELECT ID FROM ${DB_NAME}.wp_posts)
        LIMIT ${BATCH};
        SELECT ROW_COUNT();
    ")
    echo "  Stringhe rimosse in questo batch: $AFFECTED"
    [ "$AFFECTED" -lt "$BATCH" ] && break
    sleep 2
done

echo "=== Completato ==="

Step 4. Ottimizzazione tabelle

Dopo una DELETE massiva, MariaDB non libera automaticamente lo spazio su disco. I dati sono stati cancellati logicamente (le righe non esistono più), ma il file fisico della tabella mantiene la stessa dimensione, pieno di “buchi” dove c’erano le righe eliminate. OPTIMIZE TABLE ricostruisce la tabella da zero, compattando i dati e riscrivendo gli indici.

-- OPTIMIZE crea una copia temporanea della tabella, la riempie
-- con i dati compattati, poi sostituisce l'originale. Durante l'operazione
-- la tabella e' bloccata in scrittura: le SELECT funzionano ma INSERT/UPDATE no.
-- Su una tabella da 960MB puo' impiegare 5-15 minuti.
-- Eseguilo in un orario di basso traffico (notte, weekend).

OPTIMIZE TABLE wp_icl_strings;
OPTIMIZE TABLE wp_icl_string_translations;
OPTIMIZE TABLE wp_icl_translate;
OPTIMIZE TABLE wp_icl_translation_status;

Step 5. Cronjob settimanale

Il problema si ripresenta. Ogni nuovo articolo genera nuove stringhe ridondanti. Ho creato uno script di pulizia automatica:

#!/bin/bash
# /usr/local/bin/wpml-cleanup.sh
# Pulizia settimanale stringhe Gutenberg orfane + OPTIMIZE
# Eseguito via cron ogni domenica alle 05:00

LOG="/var/log/wpml-cleanup.log"
DB_NAME="wordpress_db"
DATE=$(date '+%Y-%m-%d %H:%M:%S')

echo "[$DATE] Inizio pulizia WPML" >> "$LOG"

# Conta orfane prima della pulizia
ORPHANS=$(mysql -N -e "
    SELECT COUNT(*) FROM ${DB_NAME}.wp_icl_strings
    WHERE context LIKE 'gutenberg-%'
    AND CAST(SUBSTRING(context, 11) AS UNSIGNED)
        NOT IN (SELECT ID FROM ${DB_NAME}.wp_posts);
")
echo "[$DATE] Stringhe orfane trovate: $ORPHANS" >> "$LOG"

if [ "$ORPHANS" -gt 0 ]; then
    # Pulizia traduzioni
    mysql -e "
        DELETE st FROM ${DB_NAME}.wp_icl_string_translations st
        INNER JOIN ${DB_NAME}.wp_icl_strings s ON st.string_id = s.id
        WHERE s.context LIKE 'gutenberg-%'
        AND CAST(SUBSTRING(s.context, 11) AS UNSIGNED)
            NOT IN (SELECT ID FROM ${DB_NAME}.wp_posts);
    "

    # Pulizia stringhe
    mysql -e "
        DELETE FROM ${DB_NAME}.wp_icl_strings
        WHERE context LIKE 'gutenberg-%'
        AND CAST(SUBSTRING(context, 11) AS UNSIGNED)
            NOT IN (SELECT ID FROM ${DB_NAME}.wp_posts);
    "

    # Optimize
    mysql -e "
        OPTIMIZE TABLE ${DB_NAME}.wp_icl_strings;
        OPTIMIZE TABLE ${DB_NAME}.wp_icl_string_translations;
    "

    echo "[$DATE] Pulizia completata. Rimossi $ORPHANS orfani." >> "$LOG"
else
    echo "[$DATE] Nessuna stringa orfana trovata." >> "$LOG"
fi

Per programmarlo, aggiungi una riga al crontab del server:

# Rendi eseguibile lo script
chmod +x /usr/local/bin/wpml-cleanup.sh

# Apri il crontab dell'utente root
crontab -e

# Aggiungi questa riga:
# ┌───── minuto (0)
# │ ┌───── ora (5 = le 5 di mattina)
# │ │ ┌───── giorno del mese (* = tutti)
# │ │ │ ┌───── mese (* = tutti)
# │ │ │ │ ┌───── giorno della settimana (0 = domenica)
# │ │ │ │ │
  0 5 * * 0 /usr/local/bin/wpml-cleanup.sh

Step 6. Prevenzione

Nelle impostazioni WPML, disattiva quello che non serve:

  1. WPML → Impostazioni → Traduzione stringhe: disattiva “Registra automaticamente le stringhe per la traduzione”
  2. WPML → Media Translation: disattiva se non traduci gli alt text delle immagini
  3. WPML → Impostazioni avanzate: verifica che il metodo di traduzione sia solo “Duplica post” senza String Translation attivo in parallelo

Step 7. Tuning MariaDB

Con un database di queste dimensioni, i default di MariaDB non bastano. Ecco i parametri che ho modificato e perché:

# /etc/mysql/mariadb.conf.d/99-custom.cnf
# Crea questo file (il "99-" garantisce che venga caricato per ultimo
# e sovrascriva i default)

[mysqld]
# Buffer pool: e' la cache in RAM dove MariaDB tiene le pagine di dati
# e indici piu' usate. Piu' e' grande, meno deve leggere da disco.
# Regola pratica: 25-30% della RAM totale del server.
# Con 8GB di RAM e altri servizi attivi (Redis, OpenResty, PHP),
# 2GB e' il massimo ragionevole.
innodb_buffer_pool_size = 2G

# Log file: MariaDB scrive qui le modifiche prima di applicarle
# alle tabelle (write-ahead log). Un file piu' grande riduce la
# frequenza di flush su disco, utile durante operazioni massive
# come le nostre DELETE da 350k righe.
innodb_log_file_size = 512M

# Join buffer: memoria allocata per ogni JOIN che non usa un indice.
# Le query WPML con JOIN tra stringhe e traduzioni ne beneficiano.
# Default: 256KB, troppo poco per JOIN su tabelle da 500k+ righe.
join_buffer_size = 8M

# Query cache: in teoria memorizza i risultati delle SELECT ripetute.
# In pratica, con WordPress (che fa molte scritture) + Redis (che gia'
# fa da cache a livello applicativo), la query cache di MariaDB
# diventa un collo di bottiglia. Meglio disabilitarla.
query_cache_type = 0
query_cache_size = 0

# Temp table: dimensione massima delle tabelle temporanee in RAM.
# Le query con GROUP BY (come la nostra diagnostica per contesto)
# creano tabelle temporanee. Se superano questo limite, MariaDB
# le scrive su disco, molto piu' lento.
tmp_table_size = 64M
max_heap_table_size = 64M
# Restart MariaDB dopo le modifiche
systemctl restart mariadb

# Verifica che i parametri siano stati applicati
mysql -e "SHOW VARIABLES LIKE 'innodb_buffer_pool_size';"

Risultati: da 2.5GB a 900MB

MetricaPrimaDopo
Database totale2.5 GB~900 MB
wp_icl_strings550.000 righe~180.000 righe
wp_icl_translate960 MB~350 MB
Tempo caricamento admin15-20s2-3s
Crescita settimanale DB~50 MB~5 MB (con cronjob)

Il backend WordPress è tornato utilizzabile. Non veloce come vorremmo (è pur sempre un WP con 18.000 articoli), ma gestibile.

Proiezione di scalabilità

Questi numeri li ho stimati basandomi sul caso reale. Utili per chi sta valutando WPML su cataloghi grandi:

Articoli (×4 lingue)Stringhe stimateDimensione DB stimata
1.000~500k~2 GB
5.000~2.5M~8 GB
10.000~5M~15 GB+
20.000~10M~30 GB+

Sì, scala linearmente. No, non è sostenibile.

Limiti di WPML e alternative per siti multilingua grandi

WPML non scala bene oltre i 10k articoli multilingua. È un dato di fatto. Se il progetto parte con l’obiettivo di avere 10.000+ contenuti tradotti in 3+ lingue, valuta alternative fin dall’inizio: MultilingualPress (un’installazione WordPress per lingua, multisite), Polylang (più leggero di WPML per setup semplici), Weglot (SaaS, zero impatto database), o un approccio headless con i18n nativo del framework frontend.

Le query di pulizia funzionano solo per il pattern gutenberg-{post_id}. Se WPML ha registrato stringhe con altri contesti (theme, plugin, custom), vanno gestite separatamente. Verifica sempre con la query di diagnosi per contesto prima di cancellare.

Il cronjob è un cerotto, non una soluzione. La vera soluzione sarebbe che WPML non registrasse stringhe ridondanti quando il metodo di traduzione è “duplica post”. Finché non fixano questo comportamento, il cronjob è necessario.

Se stai gestendo un caso simile e ti chiedi perché per il mio blog personale ho scelto uno stack completamente diverso, ne parlo in Astro, Ghost o WordPress per un blog tecnico? Come (e perché) ho scelto Markdown. Spoiler: niente WordPress, niente database, niente problemi.