wandres.dev
GIT COMO CAS · el ejemplo que ya usas

Packfiles: del objeto suelto al conjunto comprimido con deltas

Guardar un fichero por objeto es inviable a escala, y Git lo resuelve empaquetando el almacén en un formato con deltas e índice que cambia espacio por coste de acceso de forma medible y reversible.

⏱ 24 min

El modelo de las lecciones anteriores es correcto y a la vez sería inviable si se implementara literalmente. Un objeto por fichero significa que un repositorio con tres millones de objetos tiene tres millones de ficheros, que cada versión de un fichero de veinte megas ocupa veinte megas comprimidos, y que enviar una historia por la red requiere tantas peticiones como objetos falten. Git resuelve las tres cosas con una única pieza, el fichero empaquetado, y lo hace sin tocar ni un ápice del modelo lógico: los objetos siguen siendo inmutables, siguen nombrándose por su contenido y siguen teniendo el mismo identificador. Solo cambia dónde y cómo están sus bytes. Esta lección explica el formato, muestra cómo se eligen las bases de los deltas, mide el resultado en un repositorio real y nombra sin adornos qué se paga a cambio, porque ese compromiso entre espacio y coste de acceso es el que reaparece en cualquier sistema local-first que crezca lo suficiente.

🎯 Al terminar esta lección sabrás
  • Entender la diferencia entre objeto suelto y objeto empaquetado y por qué ambos coexisten.
  • Comprender cómo se busca la base de un delta y qué papel juegan la ventana y la profundidad.
  • Leer con git verify-pack la longitud de las cadenas de delta de un repositorio propio.
  • Medir la diferencia entre tamaño lógico y tamaño en disco de un mismo objeto.
  • Nombrar con precisión el coste que se paga al leer un objeto que está al final de una cadena.

Del objeto suelto al conjunto empaquetado

Un objeto suelto es un fichero comprimido con el algoritmo estándar de compresión sin pérdida, guardado en la ruta que dictan los dos primeros caracteres de su identificador. Es simple, es robusto y tiene dos límites duros: no puede aprovechar el parecido entre objetos, porque cada uno se comprime aislado, y multiplica el coste del sistema de ficheros, porque cada objeto consume una entrada de directorio y al menos un bloque completo.

El fichero empaquetado resuelve ambos límites juntando muchos objetos en un solo fichero. Dentro, cada objeto aparece de una de dos formas: entero, comprimido igual que si fuera suelto, o como delta, es decir, como una lista de instrucciones para reconstruirlo a partir de otro objeto que también está en el paquete. Junto al paquete se escribe un índice que permite encontrar cualquier objeto por su identificador sin leer el resto.

cd /tmp/almacen
git count-objects -vH        # cuantos sueltos y cuantos empaquetados

git gc                       # empaqueta y limpia lo caducado
git count-objects -vH

ls .git/objects/pack/
# pack-b15a2d84fbb194e0af676c0d638546ff0a6103a6.idx
# pack-b15a2d84fbb194e0af676c0d638546ff0a6103a6.pack

Que ambos formatos coexistan no es una transición inacabada sino un diseño deliberado. Escribir un objeto suelto es barato e inmediato, y por eso todo lo nuevo nace suelto; empaquetar es caro y se amortiza, y por eso ocurre por lotes en momentos elegidos. La operación es además reversible en los dos sentidos, lo cual es una buena prueba de que el modelo lógico no se ha alterado.

# Del almacen a un paquete y de vuelta a objetos sueltos
git rev-list --objects main | git pack-objects --stdout > /tmp/prueba.pack
cd /tmp && mkdir suelto && cd suelto && git init -q
git unpack-objects < /tmp/prueba.pack
git count-objects -v
ℹ️
El identificador nunca depende del empaquetado

Conviene repetirlo porque es el punto que sostiene todo lo demás: la dirección de un objeto es el resumen de su cabecera y su contenido lógico, no de sus bytes en disco. Un blob tiene el mismo identificador estando suelto, empaquetado entero o empaquetado como delta al final de una cadena de cincuenta. Por eso el empaquetado se puede rehacer cuantas veces se quiera, con parámetros distintos, sin invalidar nada ni obligar a nadie a sincronizar de nuevo. La representación física es una decisión local y revisable; la identidad no lo es.

El delta, la ventana y la profundidad

Aquí está la parte que más contradice la intuición. Uno esperaría que el delta de una versión se calculara contra la versión anterior según la historia, porque es lo que haría un sistema orientado a diferencias. Git no usa la historia para esto en absoluto. Ordena los candidatos con una heurística basada en el tipo de objeto, el nombre del fichero en que aparecen, el tamaño y la edad, desliza una ventana sobre esa lista ordenada y, para cada objeto, prueba a expresarlo como delta de los que caen dentro de la ventana, quedándose con el que salga más pequeño.

De esa mecánica salen dos consecuencias importantes. La primera es que la base de un delta puede ser cualquier objeto del paquete, incluso uno más nuevo o de una rama distinta, siempre que se parezca. La segunda es que la heurística prefiere que los objetos grandes sean bases y los pequeños deltas, lo que en la práctica hace que las versiones recientes tiendan a estar enteras y las antiguas a reconstruirse, que es justo lo que conviene porque lo reciente se lee más.

# Dos parametros gobiernan la busqueda
git repack -a -d --window=50 --depth=50

# Persistirlos para este repositorio
git config pack.window 50
git config pack.depth 50

Que la historia no intervenga en la elección de la base tiene una consecuencia que conviene subrayar, porque es donde este diseño se separa definitivamente de los sistemas orientados a diferencias. En aquellos, la cadena de reconstrucción sigue la cadena de revisiones, así que el coste de leer una versión antigua crece con cuántas revisiones vinieron después y el formato queda atado a la forma de la historia. Aquí la cadena de deltas es una estructura independiente que solo persigue el parecido, se puede rehacer con otro criterio en cualquier momento y no cambia nada de lo que el usuario ve. La compresión dejó de ser una propiedad del modelo de datos para ser una propiedad del fichero, que es donde debe estar.

La ventana controla cuántos candidatos se prueban y por tanto cuánto tiempo de proceso se invierte en buscar; la profundidad limita cuántos deltas encadenados se permiten y por tanto cuánto trabajo costará después reconstruir el objeto más desfavorecido. Subir ambos comprime más y hace más lenta tanto la compresión como la lectura. Es un mando de dos posiciones sobre el mismo compromiso.

flowchart LR
B[objeto base entero] --> D1[delta uno]
D1 --> D2[delta dos]
D2 --> D3[delta tres]
D3 --> L[lectura reconstruye la cadena entera]
I[fichero indice] --> B
I --> D3
style B fill:#a6e3a1,color:#11111b
style L fill:#f38ba8,color:#11111b
style I fill:#89b4fa,color:#11111b

Medirlo en un repositorio de verdad

La teoría se comprueba en dos comandos, y merece la pena hacerlo sobre datos propios en lugar de creer los números de nadie. El primero informa de las cadenas de delta del paquete; el segundo compara, objeto a objeto, el tamaño lógico con el tamaño realmente ocupado y revela cuál es su base.

git verify-pack -v .git/objects/pack/*.idx | grep 'chain length'
# chain length = 1: 39 objects

git cat-file --batch-all-objects \
  --batch-check='%(objecttype) %(objectsize) %(objectsize:disk) %(deltabase)' | grep blob
# blob 13974 18 33a3edd472cbf72982e64a649b0435816864087f
# blob 14037 18 8e50eefa8284175187f4c9e2eb82897597d0bf9b

Esos números salen de un experimento reproducible en un minuto: un fichero de tres mil líneas y cuarenta commits que le añaden una línea cada uno. Cada blob mide unos catorce mil bytes lógicos y ocupa dieciocho bytes en disco, porque la línea añadida es lo único que el delta necesita describir. El paquete entero, con las cuarenta y una revisiones completas del fichero más sus árboles y sus commits, pesa unos veinticinco kilobytes, menos del doble de una sola revisión.

💡
Cuando el paquete no comprime, casi siempre es por el contenido

Si repites el experimento con contenido aleatorio o con ficheros ya comprimidos, como imágenes o archivos binarios de ofimática, verás que no aparece ninguna cadena de delta y el paquete pesa casi lo mismo que los objetos sueltos. No es un fallo de configuración: los deltas viven del parecido byte a byte, y dos versiones de un fichero comprimido no se parecen aunque su contenido original sí lo haga. Ese es el motivo real de que los repositorios con muchos binarios crezcan tan mal, y también la razón de que exista core.bigFileThreshold, que a partir de cierto tamaño desactiva el intento y ahorra el tiempo de buscar en vano.

Qué se paga y qué estructuras lo compensan

💾

Se gana espacio y entradas de directorio

Un fichero en lugar de millones, y solo se guardan de verdad las diferencias entre objetos parecidos.

⏱️

Se paga en lectura aleatoria

Leer un objeto al final de una cadena obliga a reconstruir toda su cadena antes de poder devolverlo.

🧠

Hay caché para amortiguarlo

Las bases reconstruidas se guardan en memoria, y core.deltaBaseCacheLimit fija cuánta se dedica a ello.

🗺️

Hay índices auxiliares

El índice múltiple de paquetes y el grafo de commits evitan recorrer objetos para responder preguntas frecuentes.

Conviene además situar cuándo ocurre el empaquetado, porque su coste es visible y a veces molesto. Git lo dispara automáticamente cuando el número de objetos sueltos supera cierto umbral, típicamente al terminar una operación que creó muchos, y en repositorios grandes eso puede significar una espera inesperada en el momento menos oportuno. La alternativa moderna es programarlo en segundo plano en lugar de sufrirlo de forma reactiva, que es exactamente lo que hace la orden de mantenimiento, y es una decisión que cualquier sistema local-first con compactación tendrá que tomar en los mismos términos: nadie discute que haya que compactar, se discute cuándo y a costa de la latencia de quién.

El coste de la segunda tarjeta no es teórico y se nota en operaciones que tocan muchos objetos antiguos dispersos. Por eso el formato incorpora ayudas que atacan cada síntoma por separado y que conviene conocer, porque en repositorios grandes marcan una diferencia enorme.

git repack -a -d -b                    # incluye mapas de bits de alcanzabilidad
git multi-pack-index write             # un indice unico sobre varios paquetes
git commit-graph write --reachable     # topologia precalculada de los commits
git maintenance run                    # ejecuta el mantenimiento habitual

El índice que acompaña al paquete merece una frase propia porque resuelve el problema que el formato crea. Un paquete es un fichero enorme sin orden útil para buscar, así que junto a él se escribe una lista de todos los identificadores que contiene, ordenada, con la posición de cada objeto dentro del paquete y una tabla auxiliar que permite saltar directamente a la zona que corresponde a los primeros bits del identificador. Con eso, localizar un objeto entre varios millones son unas pocas lecturas y no un recorrido. Es la misma idea que cualquier índice de base de datos, aplicada a un almacén donde la clave ya venía dada por el contenido.

Merece la pena señalar que el mismo formato sirve para el disco y para la red, y que esa reutilización es una de las decisiones más rentables del diseño. Cuando dos repositorios sincronizan, el emisor construye un paquete a medida que puede referirse como base a objetos que el receptor ya tiene y que por tanto no viaja; el receptor lo completa al recibirlo. Un paquete así se llama delgado y se maneja con las mismas herramientas.

git index-pack --stdin --fix-thin < /tmp/prueba.pack
git verify-pack -v .git/objects/pack/*.idx | tail -2
La representación física es una decisión revisable solo si la identidad no depende de ella

Aquí está la lección de ingeniería que trasciende por completo a Git y que rara vez se enuncia al enseñar este tema. El formato empaquetado es agresivo: comprime, encadena deltas, reordena, elige bases con heurísticas que han cambiado varias veces en veinte años y que volverán a cambiar. Nada de eso ha roto jamás un repositorio existente ni ha obligado a nadie a migrar datos, y la razón es una sola y hay que verla con claridad: el identificador de un objeto se calcula sobre su contenido lógico y nunca sobre su representación física. Esa frontera, trazada al principio y respetada sin excepciones, es lo que convierte la capa de almacenamiento en un espacio de experimentación libre. Se puede reempaquetar con otros parámetros, cambiar el algoritmo de compresión, añadir mapas de bits, inventar un índice nuevo o soportar un formato futuro, y todo ello es una decisión local que no requiere que nadie más se entere, porque desde fuera el almacén sigue respondiendo lo mismo a las mismas preguntas. Compáralo con lo que ocurre cuando esa frontera se cruza: en cuanto la identidad de un dato depende de cómo se guardó —su posición en un fichero, su clave autoincremental, el orden en que llegó, la versión del serializador— cualquier cambio de representación se convierte en una migración coordinada entre todas las réplicas, y a partir de ahí el formato queda congelado por miedo. El precio de esa rigidez rara vez se ve al principio y siempre se paga al final. Para quien diseña un sistema local-first la regla operativa es corta y vale más que muchas optimizaciones: decide primero qué es la identidad de tus datos y asegúrate de que se puede calcular sin saber nada de cómo están guardados; si lo consigues, la compactación, la deduplicación, el cambio de motor de almacenamiento y hasta la migración a otro dispositivo se vuelven problemas locales y reversibles, y si no lo consigues, cada uno de ellos será un evento de coordinación global. Los paquetes de Git no son una optimización inteligente, son la prueba de que esa frontera se trazó bien.

⚔️ Reproduce el experimento y observa tus propias cadenas
  1. Crea un fichero de miles de líneas y cuarenta commits que añadan una línea cada uno, y empaqueta con git repack -a -d.
  2. Compara el tamaño lógico y el de disco de sus blobs con git cat-file --batch-all-objects --batch-check.
  3. Repite el experimento con contenido aleatorio y explica por qué desaparecen los deltas.
  4. Reempaqueta el mismo repositorio con profundidad uno y con profundidad doscientos y compara tamaño y tiempo.
  5. Lee las longitudes de cadena de un repositorio grande que uses con git verify-pack -v y busca el peor caso.
  6. Desempaqueta un paquete con git unpack-objects en un repositorio vacío y comprueba que los identificadores no cambian.