Die Admin-Seite soll die letzten fünfzig Zeilen der Logdatei zeigen, und die Datei ist inzwischen mehrere hundert Megabyte groß. Mit file() lädt PHP jede einzelne Zeile in den Speicher, um am Ende fast alle wieder wegzuwerfen. Schneller geht es, wenn das Skript direkt ans Ende springt. Wie man eine Datei öffnet, liest und schreibt, zeigt der Artikel Dateihandling mit PHP. Der Dateizeiger selbst kommt dort nur am Rand vor, obwohl an ihm hängt, ob PHP fseek() ans Ende springen kann und wann das scheitert.
Die Beispiele liefen unter Windows und Linux mit PHP 8.4. Meist verhalten sich beide gleich, beim Textmodus nicht.
Was PHP fseek() mit dem Dateizeiger macht
Jeder geöffnete Stream merkt sich eine Position, den Dateizeiger (englisch file pointer). Lesen und Schreiben beginnen dort und schieben ihn um die verarbeiteten Bytes weiter. Gezählt wird ab 0 und in Bytes, nicht in Zeichen. Ein Umlaut in UTF-8 belegt also zwei Positionen. PHP fseek() setzt diese Position neu. Vorbild ist die gleichnamige Funktion aus der C-Standardbibliothek. So lässt sich ein Kopfbereich überspringen:
<?php
$fp = fopen(__DIR__ . '/export.dat', 'rb');
/* die ersten 512 Bytes sind der Kopf */
fseek($fp, 512);
$daten = fread($fp, 1024);
Ob ein Aufruf gelingt, hängt an zwei Fragen: Kann der Stream überhaupt springen, und landet das Ziel vor dem Dateianfang?
flowchart TD
A[fseek aufrufen] --> B{Stream suchbar?}
B -->|nein| C[Ergebnis -1 mit Warnung]
B -->|ja| D{Bezugspunkt}
D -->|SEEK_SET| E[Anfang plus Offset]
D -->|SEEK_CUR| F[Position plus Offset]
D -->|SEEK_END| G[Dateiende plus Offset]
E --> H{Ziel kleiner 0?}
F --> H
G --> H
H -->|ja| I[Ergebnis -1, Zeiger bleibt]
H -->|nein| J[Ergebnis 0, Zeiger steht dort]
Die Signatur im Original: Parameter und Rückgabewert
Zum Zeiger gehören drei Funktionen:
fseek(
resource $stream,
int $offset,
int $whence = SEEK_SET
): int
ftell(resource $stream): int|false
rewind(resource $stream): bool
/* Sets the file position indicator for the file
referenced by stream. The new position, measured
in bytes from the beginning of the file, is
obtained by adding offset to the position
specified by whence.
offset - the number of bytes; a negative value
is allowed with SEEK_CUR and SEEK_END
whence - SEEK_SET: from the beginning of the file
SEEK_CUR: from the current position
SEEK_END: from the end of the file
return - the function returns 0 on success, -1
on failure. Seeking past the end of
the file is allowed; if data is then
written, the gap reads as bytes with
value 0.
In append mode (a, a+) data is always written
at the end of the file. Not all streams support
seeking: forward seeking is done by reading and
discarding data, other forms of seeking fail. */
Der Rückgabetyp von PHP fseek() ist int, nicht bool. ftell() liefert die aktuelle Position oder false, rewind() dagegen true oder false. Drei Funktionen, drei verschiedene Arten, Erfolg zu melden.
SEEK_SET, SEEK_CUR und SEEK_END
Der dritte Parameter legt fest, von wo aus der Offset zählt. An einer Datei mit dem Inhalt Hallo Welt, zehn Bytes lang, ergaben sich unter Windows und Linux dieselben Werte:
<?php
file_put_contents('gruss.txt', 'Hallo Welt');
$fp = fopen('gruss.txt', 'r+b');
fseek($fp, 6);
echo fread($fp, 3);
/* Wel, der Zeiger steht danach bei 9 */
fseek($fp, -2, SEEK_CUR);
echo ftell($fp);
/* 7 */
fseek($fp, -4, SEEK_END);
echo fread($fp, 10);
/* Welt */
| Bezugspunkt | Neue Position | Negativer Offset |
SEEK_SET | genau der Offset, gezählt ab dem Dateianfang | immer -1, der Zeiger bleibt stehen |
SEEK_CUR | aktuelle Position plus Offset | erlaubt, solange das Ziel nicht vor 0 liegt |
SEEK_END | Dateigröße plus Offset | der Normalfall, um vom Ende aus zu lesen |
Die Größe einer Datei lässt sich zwar mit SEEK_END und ftell() ermitteln. Direkter sind filesize() für einen Pfad und fstat() für einen offenen Stream.
Warum PHP fseek() 0 und -1 liefert
Weil 0 Erfolg bedeutet, führen gleich zwei naheliegende Prüfungen in die Irre. if (fseek($fp, $pos) === false) trifft nie zu, und if (!fseek($fp, $pos)) ist ausgerechnet bei Erfolg wahr. Richtig ist der Vergleich mit 0:
<?php
if (fseek($fp, $pos) !== 0) {
throw new RuntimeException(
"Sprung auf Byte $pos misslungen"
);
}
Die Fehlerfälle verhielten sich im Test leiser als erwartet. Ein Ziel vor dem Dateianfang, etwa fseek($fp, -1) oder fseek($fp, -20, SEEK_END) bei zehn Bytes, lieferte -1 ohne jede Warnung, und der Zeiger blieb, wo er war. Ein unbekannter Bezugspunkt wie 5 ergab ebenfalls still -1. Laut wurde PHP fseek() erst bei falschen Typen: Ein String als Offset warf TypeError: fseek(): Argument #2 ($offset) must be of type int, string given, ein geschlossenes Handle TypeError: fseek(): supplied resource is not a valid stream resource. Ein Float wie 2.5 wurde mit einer Deprecated-Meldung auf 2 abgeschnitten.
Ein erfolgreicher Sprung hat außerdem einen Nebeneffekt: Nach dem Lesen bis zum Ende meldet feof() true, und der Sprung setzt diese Markierung zurück. Nach fseek($fp, 0) stand feof() wieder auf false, ein zweiter Durchlauf funktioniert also ohne neues Öffnen.
ftell() und rewind(): Position lesen und zurücksetzen
ftell() verrät, wo der Zeiger gerade steht. Nach jedem fgets() zeigt er auf das erste Byte hinter dem Zeilenumbruch. Bei der Datei erste, zweite, dritte mit gemischten Zeilenenden waren das 6 und 14. Wer sich diese Werte merkt, kann jede Zeile später mit PHP fseek() direkt wieder anspringen. rewind($fp) ist die Kurzform von fseek($fp, 0) und liefert true. Bei einem Stream, der nicht springen kann, kam false mit der Warnung rewind(): Stream does not support seeking.
Unter Windows gibt es dabei eine Falle, die nur im Textmodus auftritt. Wer eine Datei mit 'rt' öffnet, bekommt Zeilen ohne das Wagenrücklaufzeichen, und ftell() zählt dann nicht mehr die Bytes in der Datei:
<?php
$inhalt = "eins\r\nzwei\r\ndrei\r\n";
file_put_contents('liste.txt', $inhalt);
/* t: Textmodus */
$fp = fopen('liste.txt', 'rt');
fgets($fp);
$pos = ftell($fp);
/* Windows 5, Linux 6 */
fseek($fp, $pos);
var_dump(fgets($fp));
/* Windows "\n", Linux "zwei\r\n" */
Unter Windows meldete ftell() vor den drei Zeilen 0, 5 und 10, die echten Byte-Positionen sind 0, 6 und 12. Der Sprung auf 5 landete deshalb zwischen Wagenrücklauf und Zeilenumbruch, und fgets() lieferte eine leere Zeile. Unter Linux hat t keine Wirkung. Wer Positionen speichert oder anspringt, öffnet Dateien deshalb immer mit b, also 'rb', 'r+b' oder 'c+b'.
Hinter das Dateiende und im Modus a
Ein Sprung über das Ende hinaus ist für PHP fseek() kein Fehler. Bei der Datei mit zehn Bytes lieferte fseek($fp, 100) eine 0, ftell() meldete 100, fread() einen leeren String und feof() true. Erst ein fwrite() von einem Byte an dieser Stelle verändert die Datei: Sie war danach 101 Bytes groß, und die Lücke dazwischen bestand aus Nullbytes. Was das für den Platz auf der Festplatte bedeutet, erklärt das Tutorial zu ftruncate().
Im Anhängemodus, typisch für Logdateien, meldet PHP fseek() Erfolg und bewirkt beim Schreiben trotzdem nichts:
<?php
file_put_contents('log.txt', 'ABCDEFGHIJ');
$fp = fopen('log.txt', 'a+');
fseek($fp, 2);
echo fread($fp, 3);
/* CDE, das Lesen folgt dem Zeiger */
fwrite($fp, 'xy');
echo ftell($fp);
/* 7, tatsächlich steht er bei 12 */
fclose($fp);
echo file_get_contents('log.txt');
/* ABCDEFGHIJxy, angehängt statt eingefügt */
Im Modus a meldete ftell() direkt nach dem Öffnen sogar 0, obwohl die Datei zehn Bytes hatte. Auf die Position ist dort also kein Verlass. Mit 'r+' oder 'c+' landet der Text dagegen an der gewählten Stelle. Aus ABCDEFGHIJ wurde dabei ABxyEFGHIJ: Die beiden Buchstaben ersetzen, was dort stand. Einfügen kann kein Modus. Wer einen neuen Eintrag an den Anfang eines Gästebuchs setzen will, schreibt Eintrag und alten Inhalt in eine temporäre Datei und benennt sie danach um.
Nicht jeder Stream ist suchbar
PHP fseek() funktioniert mit Dateien, aber nicht mit jedem Stream. Ob ein Stream springen kann, verrät stream_get_meta_data($h)['seekable']. Gemessen auf beiden Systemen:
| Stream | suchbar | Sprung an den Anfang |
Datei, php://memory, php://temp, tmpfile() | ja | 0 |
php://stdin aus einer Pipe | nein | -1 mit Warnung |
php://stdin aus einer umgeleiteten Datei | ja | 0 |
php://output, HTTP-Stream | nein | -1 mit Warnung |
compress.zlib:// | ja | 0, aber kein SEEK_END |
Die Warnung lautet fseek(): Stream does not support seeking. Beim gepackten Stream hieß es SEEK_END is not supported. Beim HTTP-Stream gelang ein Sprung nach vorn auf Byte 20 zwar, aber nur, weil diese Bytes schon im Lesepuffer standen. Bei einer Datei mit 1,8 MB scheiterte fseek($h, 100000) mit -1 und derselben Warnung. Verlässlich vorwärts ging es nur relativ zur aktuellen Stelle: fseek($h, 100000, SEEK_CUR) lieferte 0 und die richtigen Bytes, denn dabei liest PHP und verwirft, was dazwischen liegt. So steht es auch im Handbuch. Zurück ging es nie.
Darauf bauen sollte man nicht. Wer einen fremden Stream mehrfach lesen muss, kopiert ihn in einen temporären Stream, der immer springen kann. Derselbe Weg hilft, wenn ein fseek() für Strings gesucht ist: Auch ein Text in einer Variablen wird so zum suchbaren Stream:
<?php
$h = fopen('https://example.com/liste.csv', 'rb');
if (!stream_get_meta_data($h)['seekable']) {
$kopie = fopen('php://temp', 'w+b');
stream_copy_to_stream($h, $kopie);
fclose($h);
$h = $kopie;
}
rewind($h);
/* ein String als suchbarer Stream */
$s = fopen('php://memory', 'w+b');
fwrite($s, $text);
fseek($s, 0);
Praxis: die letzten Zeilen einer großen Datei
Zurück zur Logdatei aus der Einleitung. Statt alles zu laden, springt die Funktion ans Ende und liest rückwärts in Blöcken von 4 KiB, bis genug Zeilenumbrüche beisammen sind:
<?php
function letzte_zeilen(string $pfad, int $n): array
{
$fp = fopen($pfad, 'rb');
fseek($fp, 0, SEEK_END);
$pos = ftell($fp);
$puffer = '';
while ($pos > 0
&& substr_count($puffer, "\n") <= $n) {
$lesen = min(4096, $pos);
$pos -= $lesen;
fseek($fp, $pos);
$puffer = fread($fp, $lesen) . $puffer;
}
fclose($fp);
$rest = rtrim($puffer, "\r\n");
if ($rest === '') {
return [];
}
$zeilen = preg_split('/\r?\n/', $rest);
return array_slice($zeilen, -$n);
}
Gemessen an einer Datei mit 500.000 Zeilen und 31 MiB, jeweils für die letzten zehn Zeilen:
| Weg | Windows | Linux |
file() und array_slice() | 48,5 ms, rund 88 MiB | 111,0 ms, rund 85 MiB |
letzte_zeilen() | 0,23 ms, rund 0,02 MiB | 0,11 ms, rund 0,02 MiB |
Beide Wege lieferten dieselben Zeilen. Eine leere Datei ergibt ein leeres Array, und Windows-Zeilenenden schneidet preg_split() mit ab. Bei einem memory_limit von 128 MiB fehlt file() nicht mehr viel bis zum Abbruch, während die Variante mit PHP fseek() von der Dateigröße unabhängig bleibt. Soll die Seite eine wachsende Datei fortlaufend mitlesen wie tail -f, zeigt das Tutorial zu fstat() das passende Muster.
Praxis: Zeilenindex und Import fortsetzen mit PHP fseek()
Eine Oberfläche blättert seitenweise durch ein großes Anrufprotokoll. Für Zeile 450.000 müsste das Skript jedes Mal alle Zeilen davor lesen. Ein Index aus den Positionen, einmal als Array aufgebaut, erspart das:
<?php
$fp = fopen('anrufe.log', 'rb');
$index = [];
while (true) {
$pos = ftell($fp);
if (fgets($fp) === false) {
break;
}
$index[] = $pos;
}
/* Zeile 450.000, der Index zählt ab 0 */
fseek($fp, $index[449999]);
echo fgets($fp);
Der Aufbau dauerte für 500.000 Zeilen 75,9 ms unter Windows und 41,5 ms unter Linux. Danach brauchte jeder Sprung mit PHP fseek() unter 0,04 ms, ohne Index waren es 53,9 und 25,8 ms je Zugriff. Der Index lohnt sich also schon ab dem zweiten Sprung. Er gehört in eine Datei oder einen Cache und muss neu entstehen, sobald sich die Protokolldatei ändert.
Nach demselben Prinzip setzt ein abgebrochener Import an der richtigen Stelle wieder ein. Das Skript merkt sich regelmäßig die Position hinter der zuletzt verarbeiteten Zeile, beim Neustart springt es dorthin:
<?php
$stand = __DIR__ . '/import.pos';
$fp = fopen(__DIR__ . '/lieferung.csv', 'rb');
$start = (int) @file_get_contents($stand);
fseek($fp, $start);
$n = 0;
while (($zeile = fgets($fp)) !== false) {
importiere($zeile);
if (++$n % 1000 === 0) {
file_put_contents($stand, ftell($fp));
}
}
file_put_contents($stand, ftell($fp));
Die Position nach jeder Zeile zu schreiben kostet: 10.000 Aufrufe von file_put_contents() brauchten im Test 2,3 Sekunden unter Windows und 11,5 im Linux-Container. Alle 1000 Zeilen genügt, wenn importiere() eine doppelt gelesene Zeile verträgt. Die gespeicherte Position gilt nur für genau diese Datei. Ersetzt der Lieferant sie in der Nacht, zeigt der Wert mitten in eine fremde Zeile. Eine Prüfung auf Größe und Änderungszeit gehört deshalb dazu.
Praxis: Binärdaten und Datensätze fester Länge
Viele Dateiformate legen Informationen an festen Stellen ab. Schon der Dateityp steht an einer solchen Stelle: Ein PNG beginnt mit acht festen Bytes, die sich mit PHP strncmp() gegen die Signatur prüfen lassen. In einer PNG-Datei stehen Breite und Höhe ab Byte 16 als zwei 32-Bit-Zahlen. PHP fseek() springt direkt dorthin, unpack() liest die Werte:
<?php
$fp = fopen(__DIR__ . '/bild.png', 'rb');
fseek($fp, 16);
$kopf = unpack('Nbreite/Nhoehe', fread($fp, 8));
echo $kopf['breite'], ' x ', $kopf['hoehe'];
/* 550 x 310 */
Im Test stimmte das Ergebnis mit getimagesize() überein. Dasselbe Prinzip trägt Dateien mit Datensätzen fester Länge. Satz Nummer $nr beginnt bei $nr * 20, und ändern lässt er sich an Ort und Stelle:
<?php
const LAENGE = 20;
$fp = fopen(__DIR__ . '/kunden.dat', 'c+b');
flock($fp, LOCK_EX);
/* zweiter Satz, gezählt ab 0 */
$nr = 1;
fseek($fp, $nr * LAENGE);
echo fread($fp, LAENGE);
fseek($fp, $nr * LAENGE);
$satz = str_pad('A002;Schulze-Wagner', LAENGE);
fwrite($fp, substr($satz, 0, LAENGE));
flock($fp, LOCK_UN);
fclose($fp);
Das str_pad() ist kein Schmuck. Ohne es überschrieb fwrite($fp, 'XYZ') im Test nur drei Bytes, und hinter dem neuen Text stand weiter der Rest des alten Satzes. Umgekehrt kürzt str_pad() nie: Ein Wert mit 33 Bytes überschrieb im Test den folgenden Satz. Das substr() schneidet deshalb auf die Satzlänge zu. Arbeiten mehrere Prozesse an derselben Datei, gehören Sprung und Schreiben unter eine gemeinsame Sperre, wie sie das Tutorial zu flock() beschreibt.
Große Dateien über 2 GB
In älteren Forenbeiträgen heißt es, PHP fseek() scheitere ab 2 GB. Das lag an 32-Bit-Ganzzahlen, in die keine größere Position passte. Auf beiden Testsystemen meldete PHP_INT_SIZE den Wert 8, die Positionen sind dort 64-Bit-Zahlen, und die Grenze spielt keine Rolle. Wer auf einem Altsystem PHP_INT_SIZE = 4 sieht, hat ein 32-Bit-PHP und die alte Grenze noch.
Fazit
PHP fseek() bewegt den Dateizeiger in Bytes, ab dem Anfang, ab der aktuellen Stelle oder ab dem Ende, und meldet 0 oder -1. Geprüft wird deshalb auf !== 0, nie auf false. Ein Ziel vor dem Anfang scheitert still, eines hinter dem Ende gelingt und hinterlässt beim Schreiben Nullbytes.
Drei Situationen verdienen Misstrauen, obwohl kein Fehler gemeldet wird: der Anhängemodus, in dem jedes Schreiben am Ende landet und ftell() falsche Werte liefert, der Textmodus unter Windows mit verschobenen Positionen und fremde Streams, bei denen vor dem Sprung ein Blick auf seekable gehört. Mit Dateien im Binärmodus trägt PHP fseek() dann genau die Muster, die bei großen Dateien zählen: die letzten Zeilen ohne Volllast, einen Index für beliebige Zeilen und Datensätze, die sich an Ort und Stelle ändern lassen.