Veröffentlicht am Schreib einen Kommentar

Speicherverbrauch von wp_insert_post beim Massenimport umgehen

Für ein kleines Plugin war es erforderlich, eine CSV-Liste in einen individuellen Inhaltstyp (Custom Post Type, CPT) zu importieren. Dabei stieß ich auf ein paar Performance-Probleme bei der Verwendung von wp_insert_post(), die ich loswerden wollte.

Auf das Problem stieß ich, indem ich die CSV-Datei Zeile für Zeile einlas und dann die Daten jeder Zeile per wp_insert_post() in die Datenbank importierte. Nicht nur war diese Methode sehr langsam, sodass möglicherweise eine Zeitüberschreitung stattfinden würde – immerhin beinhaltet die CSV-Datei knapp 30.000 Zeilen – sondern der Arbeitsspeicher-Verbrauch war zu hoch. Während ich mit 512 MB memory_limit in PHP noch hinkam, waren 256 MB (was der Wert auf dem verwendeten Produktivsystem ist) zu wenig.

Overhead in wp_insert_post()

wp_insert_post() tut noch einige Dinge mehr als nur Inhalte in die Datenbank zu schreiben, weshalb ich mich erst einmal darauf konzentrierte, genau diese Funktion nachzubilden, ohne den für mich in diesem Zusammenhang unnötigen Überhang von wp_insert_post() zu verwenden.

Erster Versuch: $wpdb->insert

Letztendlich werden die Daten via $wpdb->insert in die Datenbank geschrieben. Also verwendete ich dieselbe Funktion direkt. Zum Vergleich: Ein normaler Aufruf der Seite im Backend ohne einen Import braucht 63,0577 MiB Arbeitsspeicher. Mit $wpdb->insert sind es nun mit Import 228,0297 MiB. So würde es zwar nach aktuellem Maßstab im Produktivsystem auch funktionieren, allerdings war mir dieses Limit dennoch zu knapp am Maximum von 256 MiB. Ebenso kann ich nicht sagen, inwieweit die CSV-Datei, die regelmäßig aktualisiert werden soll, noch in Zukunft wächst.

Es scheint demnach so, als ob auch $wpdb->insert einige Aktionen vornimmt, die viel Arbeitsspeicher benötigen. Als Alternative fand ich das noch direktere $wpdb->query in Verbindung mit $wpdb->prepare. Das brachte mich auf einen Arbeitsspeicherverbrauch von 186,1205 MiB, was für mich für den Import aller Daten auf einmal als in Ordnung erschien.

Die Lösung: $wpdb->query

Allerdings war die Ausführungszeit hier ziemlich lang, ca. 25 – 30 Sekunden. Zu diesem Zeitpunkt wurde jede Zeile in einer einzelnen Abfrage in die Datenbank geschrieben. Also spielte ich mit den Werten, wie viele Daten ich über eine einzelne Abfrage in die Datenbank schrieb. Zusätzlich zur besseren Performance wurde auch der Arbeitsspeicherverbrauch drastisch reduziert.

100 Einträge pro Insert: 82,7393 MiB Arbeitsspeicher; 3,19194 s Importdauer
1000 Einträge pro Insert: 82,7328 MiB Arbeitsspeicher; 2,8335 s – 3,02511 s Importdauer

Noch mehr Einträge pro Insert in die Datenbank zu schreiben, brachte keine weiteren Vorteile.

Um noch das letzte Bisschen herauszuholen, verwendete ich SET autocommit = 0;, um die Daten wirklich erst am Ende in die Datenbank zu schreiben. Dadurch spart man sich noch etwas I/O auf der Festplatte, was dann folgendermaßen aussah:

1000 Einträge pro Insert: 82,7378 MiB Arbeitsspeicher; 2,65201 s Importdauer

Der Code

Am Ende sieht die Funktion bei mir so aus:

/**
 * Import CSV data as custom post.
 * 
 * @param	string	$file Path to the CSV file
 * @return	string 'success' or an empty string
 */
private function import_csv( string $file ): string {
	ini_set( 'auto_detect_line_endings', true );
	
	$fields = [];
	$handle = fopen( $file, 'r' );
	
	if ( $handle === false ) {
		return '';
	}
	
	// data that will be stored later in the database
	$time = current_time( 'mysql' );
	$time_gmt = current_time( 'mysql', 1 );
	$user_id = get_current_user_id();
	
	global $wpdb;
	
	$wpdb->query( "SET autocommit = 0;" );
	// delete all posts before a new import
	// do it with a manual query to speed up deletion
	$wpdb->query(
		"DELETE		post,
					term,
					meta
		FROM		$wpdb->posts post
		LEFT JOIN	$wpdb->term_relationships term
		ON			post.ID = term.object_id
		LEFT JOIN	$wpdb->postmeta meta
		ON			post.ID = meta.post_id
		WHERE		post.post_type = 'custom_post_type';"
	);
	
	$count = 0;
	$sql_data = [];
	$sql_prepare_values = '';
	
	while ( ( $data = fgetcsv( $handle ) ) !== false ) {
		$count++;
		
		// we have data - store them
		if ( ! empty( $data ) ) {
			$post_name = sanitize_title( $row_data['zip'] );
			
			$sql_data[] = $user_id; // post_author
			$sql_data[] = serialize( $data ); // post_content
			$sql_data[] = ''; // post_content_filtered
			$sql_data[] = $post_name; // post_name
			$sql_data[] = $data['zip']; // post_title
			$sql_data[] = ''; // post_excerpt
			$sql_data[] = 'publish'; // post_status
			$sql_data[] = 'custom_post_type'; // post_type
			$sql_data[] = 'closed'; // comment_status
			$sql_data[] = 'closed'; // ping_status
			$sql_data[] = ''; // post_password
			$sql_data[] = ''; // to_ping
			$sql_data[] = ''; // pinged
			$sql_data[] = 0; // post_parent
			$sql_data[] = home_url( '/' . $post_name ); // guid
			$sql_data[] = 0; // menu_order
			$sql_data[] = $time; // post_date
			$sql_data[] = $time_gmt; // post_date_gmt
			$sql_data[] = $time; // post_modified
			$sql_data[] = $time_gmt; // post_modified_gmt
			$sql_prepare_values .= '(%d, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %d, %s, %d, %s, %s, %s, %s),';
		}
		
		// after getting data of 1000 entries, send query
		if ( $count === 1000 ) {
			// using $wpdb directly is much faster than wp_post_insert()
			// and $wpdb->query() alongside $wpdb->prepare() uses nearly
			// 20% less memory
			$wpdb->query( $wpdb->prepare(
				"INSERT INTO	$wpdb->posts (
									post_author,
									post_content,
									post_content_filtered,
									post_name,
									post_title,
									post_excerpt,
									post_status,
									post_type,
									comment_status,
									ping_status,
									post_password,
									to_ping,
									pinged,
									post_parent,
									guid,
									menu_order,
									post_date,
									post_date_gmt,
									post_modified,
									post_modified_gmt
								)
				VALUES			" . rtrim( $sql_prepare_values, ',' ),
				...$sql_data
			) );
			
			// reset data to prepare a new query
			$count = 0;
			$sql_data = [];
			$sql_prepare_values = '';
		}
	}
	
	$wpdb->query( "COMMIT;" );
	$wpdb->query( "SET autocommit = 1;" );
	
	fclose( $handle );
	ini_set( 'auto_detect_line_endings', false );
	
	return 'success';
}

Noch ein Hinweis am Ende: Durch die Verwendung von $wpdb->query bist du selbst dafür verantwortlich, die Daten zu maskieren. Deshalb sollte es immer nur in Verbindung mit $wpdb->prepare verwendet werden.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.