Die erste Zeile der CSV begann mit einer Zahl, die ich nicht einordnen konnte:

block_height,tx_id,version,input_count,output_count,value_sat
812904,4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b,2,1,1,625000000

625.000.000 Satoshi, das sind 6,25 Bitcoin, das ist die Coinbase-Transaktion eines Blocks. Ich hatte vor, „mal kurz“ alle Transaktionen von 2022 bis 2024 zu laden und ein paar Verteilungen zu zeichnen. Zwanzig Minuten später hatte ich 500.000 Zeilen im Speicher, einen Kernel, der mich ansah, und eine Erkenntnis darüber, was „mal kurz“ bei Daten bedeutet.

Was in einer Blockzeile steht

Ein Block ist keine Tabelle, er ist eine serialisierte Struktur mit einer Länge davor. Jede Transaktion hat Inputs, jeder Input verweist auf einen früheren Output, jeder Output hat einen Wert und ein Skript. Wer das parst, arbeitet auf Byte-Ebene: Magic Bytes, Blockgröße, Header mit 80 Bytes, dann eine VarInt, die sagt, wie viele Transaktionen folgen.

Das erklärt die Zahl, die mich am längsten verwirrt hat: Warum 500.000 Zeilen für drei Jahre? Weil eine Zeile nicht ein Block ist, sondern eine Transaktion, und ein Block im Schnitt 2.500 davon enthält. Meine erste Schätzung lag um Faktor 2.500 daneben, weil ich „Block“ und „Zeile“ gleichgesetzt hatte.

Drei Wege, dieselben Daten zu lesen

Ich habe alle drei gebaut, weil ich den Unterschied messen wollte und nicht glauben wollte, dass er so groß ist.

VarianteZeitSpeicherBemerkung
csv-Modul, streaming4 min 10 s90 MBeine Zeile zur Zeit, kein Index
Pandas, alles laden1 min 40 s2,4 GBschnell, dann kein Platz mehr
DuckDB über Parquet22 s310 MBQuery statt Skript
Terminal mit laufendem Parser und einer wachsenden Zeilenzahl
Der Lauf, der funktioniert hat: 500.000 Zeilen, 4 Minuten, 90 MB Speicher. Die Variante davor hatte 2,4 GB belegt und den Rechner in den Swap gedrückt.

Die zwanzig Minuten aus dem Titel sind übrigens nicht die Laufzeit, sondern die Zeit bis zum ersten brauchbaren Ergebnis. Vier Minuten Parser, sechs Minuten Debugging eines Off-by-one in der VarInt-Leseroutine, zehn Minuten für die Frage, warum die Summe aller Outputs nicht zur Blockbelohnung passt. (Sie passt nicht, weil Gebühren dazukommen. Ich habe es dreimal nachgerechnet, bevor ich es geglaubt habe.)

Wo die zwanzig Minuten herkommen

Der Parser selbst ist kurz, wenn man die VarInt einmal versteht. Das Format ist little-endian, also liest man die Bytes rückwärts, und die Länge ist selbst variabel kodiert: kleine Werte in einem Byte, größere mit einem Präfix-Byte.

def read_varint(buf, i):
    prefix = buf[i]
    if prefix < 0xfd:
        return prefix, i + 1
    if prefix == 0xfd:
        return int.from_bytes(buf[i+1:i+3], "little"), i + 3
    if prefix == 0xfe:
        return int.from_bytes(buf[i+1:i+5], "little"), i + 5
    return int.from_bytes(buf[i+1:i+9], "little"), i + 9

def read_tx(buf, i):
    version = int.from_bytes(buf[i:i+4], "little")
    n_in, i = read_varint(buf, i + 4)
    for _ in range(n_in):
        i += 36                      # outpoint: 32 Byte Hash + 4 Byte Index
        script_len, i = read_varint(buf, i)
        i += script_len + 4          # Script + Sequenz
    n_out, i = read_varint(buf, i)
    value = 0
    for _ in range(n_out):
        v = int.from_bytes(buf[i:i+8], "little")
        value += v
        i += 8
        script_len, i = read_varint(buf, i)
        i += script_len
    return {"version": version, "value": value}, i

Der Fehler, der mich sechs Minuten gekostet hat, war ein i + 1 statt i + 9 im letzten Zweig. Danach las der Parser jede Transaktion um acht Bytes versetzt und produzierte Werte, die aussahen wie Beträge und keine waren. Das ist die unangenehmste Art von Fehler: kein Absturz, nur Unsinn mit plausibler Größenordnung.

Was ich über Daten gelernt habe

Erstens: Beträge gehören in Ganzzahlen. 0,00000001 Bitcoin als float ist eine Rundung, die sich über 500.000 Zeilen zu einem falschen Gesamtwert addiert, und niemand findet es später. Satoshi als int, alles andere ist Anzeige.

Zweitens: Kleine Dateien lügen. Meine ersten 5.000 Zeilen waren vollständig, konsistent und hatten eine Verteilung, die ich fast als Ergebnis präsentiert hätte. Erst bei 500.000 Zeilen kamen die Blöcke mit 12.000 Transaktionen und die Ausreißer, die die Verteilung kippten. Stichproben sind ein Anfang, kein Beweis.

Und ein vierter Punkt, der mich länger beschäftigt hat als die Technik: Eine Verteilung über 500.000 Transaktionen ist keine Aussage über Nutzer. Sie ist eine Aussage über Blöcke. Wer aus „die meisten Transaktionen haben zwei Outputs“ schließt, dass Nutzer zwei Ziele haben, hat den Gegenstand der Messung gewechselt, ohne es zu merken. Ich habe mir deshalb angewöhnt, bei jeder Zahl dazuzuschreiben, was die Einheit ist – hier: eine Transaktion, nicht eine Person.

Drittens: Der schnellste Weg ist oft der, der am wenigsten Code braucht. DuckDB hat 22 Sekunden gebraucht, ohne dass ich einen Parser geschrieben habe – weil jemand anders das Format schon kannte. Ich habe den Parser trotzdem fertig gebaut, weil ich das Format verstehen wollte, und das ist ein anderes Ziel als ein Ergebnis.

Fazit

500.000 Zeilen in zwanzig Minuten ist keine beeindruckende Zahl, sie ist eine ehrliche: vier Minuten Arbeit, sechzehn Minuten Irrtum. Was übrig bleibt, ist ein Parser, dem ich traue, und eine Verteilung, die ich erklären kann, ohne auf ein Histogramm zu zeigen.

Meine Frage an dich: Wenn du ein Binärformat zum ersten Mal parst – liest du die Spezifikation, oder schaust du dir die ersten Bytes im Hex-Editor an und rätst?