Eine Mitgliederliste soll zweimal auf derselben Seite erscheinen, oben als Auswahlfeld und weiter unten als Tabelle. Die Abfrage steht, die erste Schleife liefert brav alle Namen, und die zweite gibt nichts aus. Kein Fehler, keine Warnung, einfach nichts. PHP mysqli_data_seek löst das mit einer einzigen Zeile, sobald klar ist, woran es liegt. Wie die Abfrage selbst entsteht, steht im Tutorial zu Prepared Statements.
Der Grund steckt in einem Bauteil, das man nie zu Gesicht bekommt und trotzdem ständig bewegt: dem Ergebniszeiger.
Warum die zweite Schleife in PHP leer bleibt
Eine Ergebnismenge ist kein Array mit Index, sondern ein Band mit einem Lesekopf. Jeder Abruf schiebt den Kopf eine Zeile weiter. Die Ergebnismenge selbst entsteht mit mysqli_query() oder, wenn die Abfrage mit mysqli_real_query() abgesetzt wurde, mit mysqli_store_result(); beide holen alle Zeilen auf einen Schlag zu PHP herüber, und nur deshalb gibt es überhaupt etwas, worin ein Zeiger springen kann. Ist die letzte Zeile gelesen, bleibt er dort stehen und jeder weitere Abruf liefert null. Genau das beendet die erste while-Schleife, und genau deshalb hat die zweite nichts mehr zu tun. mysqli_data_seek() bewegt diesen Lesekopf wieder.
flowchart TD
A[Abfrage senden] --> B[Zeiger auf Zeile 0]
B --> C{Noch eine Zeile?}
C -- ja --> D[Zeile lesen]
D --> C
C -- nein --> E[null, Zeiger am Ende]
E --> F[data_seek 0]
F --> B
<?php
$sql = 'SELECT id, name FROM mitglieder'
. ' ORDER BY id';
$r = $db->query($sql);
while ($z = $r->fetch_assoc()) {
echo '<option>' . $z['name'] . '</option>';
}
/* zweiter Durchlauf, ohne Rücksprung */
while ($z = $r->fetch_assoc()) {
echo '<tr><td>' . $z['name'] . '</td></tr>';
}
/* gibt nichts aus */
Die Reparatur ist eine Zeile. PHP mysqli_data_seek() setzt den Lesekopf zurück auf den Anfang, und der zweite Durchlauf liefert dieselben Zeilen noch einmal. Ohne eine weitere Abfrage an den Server, denn die Daten liegen längst in PHP.
<?php
mysqli_data_seek($r, 0);
while ($z = $r->fetch_assoc()) {
echo '<tr><td>' . $z['name'] . '</td></tr>';
}
/* liefert wieder alle Zeilen */
Syntax, Parameter und Rückgabewert im Original
<?php
mysqli_data_seek(
mysqli_result $result,
int $offset
): bool
/* result: a buffered result set
offset: the row number to move to,
counting from zero, must be 0 or more
and below mysqli_num_rows()
returns true on success, false when the
row does not exist
object style: $r->data_seek($offset)
for prepared statements, no return value:
mysqli_stmt_data_seek($stmt, $offset) */
Das Wort buffered ist eine Bedingung und keine Beschreibung: Bei einer ungepufferten Abfrage gibt es nichts, worin man springen könnte. Und der Rückgabetyp bool ist keine Formsache, denn PHP mysqli_data_seek() liefert in zwei ganz verschiedenen Lagen false, die man auseinanderhalten muss.
PHP mysqli_data_seek(): an jede beliebige Zeile springen
Die Zählung beginnt bei null. Das ist der häufigste Abstandsfehler bei diesem Thema und deshalb einen eigenen Absatz wert. An einem nach id sortierten Ergebnis mit zehn Zeilen gemessen: Nach einem Sprung auf Versatz 5 liefert der nächste Abruf die Zeile mit der id 6. Wer die letzte Zeile will, übergibt den Wert aus mysqli_num_rows() minus eins.
<?php
$r = $db->query($sql); /* 10 Zeilen, ORDER BY id */
mysqli_data_seek($r, 5);
$z = $r->fetch_assoc();
echo $z['id']; /* 6, nicht 5 */
mysqli_data_seek($r, mysqli_num_rows($r) - 1);
$letzte = $r->fetch_assoc();
Warum der Sprung ein MySQL ORDER BY braucht
Das ORDER BY in der Abfrage ist kein Schmuck. Ohne feste Reihenfolge ist ein Sprung auf Versatz 5 nicht reproduzierbar, weil der Server die Zeilen in beliebiger Ordnung liefern darf. Wer im Ergebnis springt, sortiert also in SQL.
Praxis 1: erste Zeile als Aufmacher, danach die Liste
Der Klassiker auf Nachrichtenseiten. Der jüngste Beitrag steht groß oben, darunter folgen alle Beiträge als Liste, den ersten eingeschlossen. Ohne PHP mysqli_data_seek() bräuchte das entweder zwei Abfragen oder ein Zwischenarray.
<?php
$r = $db->query($sql);
$erste = $r->fetch_assoc();
echo '<h2>' . $erste['name'] . '</h2>';
mysqli_data_seek($r, 0);
while ($z = $r->fetch_assoc()) {
echo '<li>' . $z['name'] . '</li>';
}
Praxis 2: Summe berechnen, bevor die Posten erscheinen
Eine Rechnung soll den Gesamtbetrag in der Kopfzeile zeigen, die Einzelposten aber darunter. Die Summe steht erst fest, wenn alle Zeilen gelesen sind, und dann ist der Zeiger am Ende. PHP mysqli_data_seek() macht aus dem Henne-Ei-Problem zwei Durchläufe über dieselben Daten.
<?php
$summe = 0.0;
while ($z = $r->fetch_assoc()) {
$summe += (float) $z['betrag'];
}
echo 'Gesamt: ' . number_format($summe, 2);
mysqli_data_seek($r, 0);
while ($z = $r->fetch_assoc()) {
echo $z['name'] . ': ' . $z['betrag'];
}
Praxis 3: innerhalb einer Ergebnismenge blättern
Liegt die Liste ohnehin schon vollständig in PHP, lässt sich die Seitenanzeige daraus bedienen, ohne für jede Seite ein neues LIMIT an den Server zu schicken. Das lohnt sich bei überschaubaren Mengen, etwa einer Auswertung, die sowieso am Stück geholt wird. Ein ähnlicher Umgang mit mysqli_num_rows() findet sich im Tutorial zum CSV-Export aus MySQL.
<?php
$proSeite = 20;
$gesamt = mysqli_num_rows($r);
$seiten = (int) ceil($gesamt / $proSeite);
$versatz = ($seite - 1) * $proSeite;
if ($versatz < $gesamt) {
mysqli_data_seek($r, $versatz);
for ($i = 0; $i < $proSeite; $i++) {
$z = $r->fetch_assoc();
if ($z === null) {
break;
}
echo $z['name'];
}
}
Die Prüfung auf $versatz < $gesamt ist Absicht. Ohne sie läuft PHP mysqli_data_seek() bei einer zu hohen Seitenzahl in den ersten Randfall, und der verhält sich anders, als die meisten erwarten.
Die vier Randfälle
Alle vier sind an PHP 8.4 gegen MySQL 8.0.46 nachgemessen. Der erste enthält eine Überraschung, die in vielen Beiträgen falsch steht.
<?php
/* 1: hinter das Ende, 10 Zeilen im Ergebnis */
var_dump(mysqli_data_seek($r, 10)); /* false */
/* der Zeiger bleibt stehen, wo er war */
/* 2: leeres Ergebnis */
$leer = $db->query('SELECT id FROM x WHERE 0');
var_dump(mysqli_data_seek($leer, 0)); /* false */
/* 3: negativer Versatz */
try {
mysqli_data_seek($r, -1);
} catch (ValueError $e) {
echo $e->getMessage();
/* Argument #2 ($offset) must be greater
than or equal to 0 */
}
/* 4: nach fetch_all steht der Zeiger am Ende */
$alle = $r->fetch_all(MYSQLI_ASSOC);
var_dump($r->fetch_assoc()); /* NULL */
mysqli_data_seek($r, 0); /* wieder lesbar */
| Lage | Ergebnis | Was danach gilt |
| Versatz hinter der letzten Zeile | false | Der Zeiger wird nicht bewegt. Der nächste Abruf liefert die Zeile, auf der er ohnehin stand, und nicht null. |
| Leere Ergebnismenge, Versatz 0 | false | Kein Fehler, sondern der Normalfall einer Suche ohne Treffer. Vorher mysqli_num_rows() abfragen. |
| Negativer Versatz | ValueError | Wird geworfen, nicht zurückgegeben. Eine Prüfung auf false kommt gar nicht erst zum Zug. |
Sprung nach fetch_all() | true | Das Auslesen hat den Zeiger ans Ende geschoben. Nach dem Sprung ist die Menge wieder von vorn lesbar. |
Ein misslungener Sprung ändert nichts, er meldet nur, dass er nicht ging. Wer nach einem false weiterliest, bekommt Daten von einer Stelle, die er nicht angesteuert hat. Die zweite Zeile ist die gemeinste Falle des Themas: Eine Fehlerbehandlung der Form if (!mysqli_data_seek($r, 0)) { fehler(); } schlägt bei jeder leeren Suche an, obwohl alles in Ordnung ist.
Eine Beobachtung, die viele Beiträge falsch wiedergeben, gehört noch dazu. Zwei foreach-Schleifen hintereinander über dasselbe gepufferte Ergebnis liefern beide alle Zeilen, ganz ohne Zeigersprung. Der Iterator des Ergebnisobjekts setzt den Zeiger beim Betreten selbst zurück. Danach steht er allerdings am Ende, sodass ein anschließendes while wieder leer bleibt. PHP mysqli_data_seek() braucht man also nicht zwischen zwei foreach, sehr wohl aber beim Wechsel der Schleifenform.
<?php
$r = $db->query($sql);
foreach ($r as $z) { echo $z['id']; } /* 1 bis 10 */
foreach ($r as $z) { echo $z['id']; } /* 1 bis 10 */
while ($z = $r->fetch_assoc()) {
echo $z['id'];
}
/* keine Ausgabe, der Zeiger steht am Ende */
Warum es bei ungepufferten Abfragen nicht geht
Bei MYSQLI_USE_RESULT bleiben die Zeilen auf dem Server, PHP hält immer nur eine im Speicher. Es gibt also nichts, worin ein Zeiger springen könnte. PHP mysqli_data_seek() liefert dort auch kein false, sondern wirft einen harten Error. Ältere Beiträge behaupten das Gegenteil.
<?php
$u = $db->query($sql, MYSQLI_USE_RESULT);
try {
mysqli_data_seek($u, 0);
} catch (Error $e) {
echo $e->getMessage();
/* mysqli_data_seek() cannot be used in
MYSQLI_USE_RESULT mode */
}
Ein gemessener Unterschied lohnt die Erwähnung, weil die Meldungen gleich aussehen. mysqli_num_rows() wirft im selben Modus ebenfalls, liefert aber nach dem vollständigen Durchlauf plötzlich die richtige Zahl. Der Sprung wirft auch dann noch. Die beiden verhalten sich also nicht gleich.
Was der ungepufferte Betrieb dafür an Speicher spart und wie man ihn sauber wieder abräumt, zeigt das Tutorial zu mysqli_free_result() mit gemessenen Zahlen.
Springen oder alles in ein Array holen?
Die Alternative zum Zeigersprung ist ein einmaliges mysqli_fetch_all() und danach beliebig viele foreach über das Array. Beide Wege wurden an 2000 Zeilen mit 200 Durchläufen gemessen, ohne geladenes Xdebug, weil dessen Aufzeichnung solche Werte um ein Vielfaches verzerrt. Der Rücksprung vor fetch_all() gehört dazu: Nach der ersten Messung steht der Zeiger am Ende, und ohne ihn bleibt das Array leer.
<?php
$summe = 0;
$t = microtime(true);
for ($i = 0; $i < 200; $i++) {
mysqli_data_seek($r, 0);
while ($z = $r->fetch_assoc()) {
$summe += (int) $z['id'];
}
}
printf("%.1f ms\n", (microtime(true) - $t) * 1000);
/* 33,1 ms bei 2000 Zeilen */
mysqli_data_seek($r, 0);
$alle = $r->fetch_all(MYSQLI_ASSOC);
$t = microtime(true);
for ($i = 0; $i < 200; $i++) {
foreach ($alle as $z) {
$summe += (int) $z['id'];
}
}
printf("%.1f ms\n", (microtime(true) - $t) * 1000);
/* 7,3 ms, dafür 0,905 MiB im Array */
| Kriterium | Sprung im Ergebnis | Array aus fetch_all() |
| 200 Durchläufe, 2000 Zeilen | 33,1 ms | 7,3 ms |
| Speicher | 0,094 MiB für die Ergebnismenge | 0,905 MiB obendrauf, etwa das Zehnfache |
| Wahlfreier Zugriff | Sprung und Abruf, zwei Schritte | direkt über den Index |
| Passt, wenn | zwei oder drei Durchläufe und der Speicher knapp ist | oft durchlaufen oder nach Index zugegriffen wird |
Das Array ist also etwa viermal schneller und kostet ungefähr das Zehnfache an Speicher. Bei zwei Durchläufen geht es um Millisekunden, und dann gewinnt der Sprung. Wer dagegen in einer Schleife immer wieder auf einzelne Zeilen zugreift, holt einmal ins Array. Noch ein Hinweis zur Abgrenzung: PHP mysqli_data_seek() bewegt den Zeilenzeiger, mysqli_field_seek() den Feldzeiger, und die beiden sind voneinander unabhängig. Gemessen lieferte mysqli_fetch_field() nach einem Sprung auf Zeile 1 weiterhin das erste Feld. Was in diesen Feldobjekten steckt, steht im Tutorial zu mysqli_fetch_field().
Vorbereitete Anweisungen: zwei Wege, eine Vorbedingung
Über mysqli_stmt_get_result() entsteht ein gewöhnliches Ergebnisobjekt, auf dem alles Bisherige unverändert gilt. Der zweite Weg über gebundene Variablen ist der unangenehmere. Dort heißt die Funktion mysqli_stmt_data_seek(), sie gibt nichts zurück, und sie wirkt ausschließlich nach einem mysqli_stmt_store_result(). Fehlt dieser Aufruf, passiert schlicht gar nichts: keine Warnung, kein Fehler, der nächste Abruf liest einfach an der nächsten Zeile weiter.
<?php
/* Weg 1: get_result liefert ein mysqli_result */
$stmt->execute();
$res = $stmt->get_result();
mysqli_data_seek($res, 3); /* true, dann id 4 */
/* Weg 2: gebundene Variablen */
$stmt->execute();
$stmt->store_result(); /* Pflicht, sonst nichts */
$stmt->bind_result($id, $name);
mysqli_stmt_data_seek($stmt, 5);
$stmt->fetch();
echo $id; /* 6 */
Fazit
Der Ergebniszeiger ist ein Lesekopf, kein Index, und er bleibt nach dem letzten Abruf am Ende stehen. Das erklärt die leere zweite Schleife, und PHP mysqli_data_seek() räumt sie mit einer Zeile aus dem Weg. Die Zählung beginnt bei null, das Ergebnis gehört sortiert, und vier Randfälle sollte man kennen, bevor sie einen treffen: ein misslungener Sprung bewegt gar nichts, ein leeres Ergebnis liefert ebenfalls false, ein negativer Versatz wirft, und im ungepufferten Betrieb geht der Sprung gar nicht. Bei vielen Durchläufen ist das Array schneller, bei zwei oder drei gewinnt PHP mysqli_data_seek() ohne zusätzlichen Speicher.