Database Internals - Pages e Storage Layout
Introdução
O store de brinquedo do Essenciais endereçava os dados por offset em bytes. Cada registro era uma linha num arquivo, o índice guardava a posição exata de cada linha, e a leitura puxava exatamente aqueles bytes. Engines de verdade não trabalham nessa granularidade. Eles leem e escrevem em páginas: blocos de tamanho fixo, 8 KB no PostgreSQL e 4 KB por padrão no SQLite, e toda linha, toda entrada de índice e todo controle de espaço livre mora dentro de uma.
Este artigo é sobre a página em si: por que o tamanho é fixo, como linhas de tamanho variável cabem dentro dela, como é um arquivo de tabela do PostgreSQL quando você abre com pageinspect, e como o buffer pool decide quais páginas ficam na memória. A B-tree do próximo artigo é construída inteiramente com essas páginas, então vale conhecer bem.
Por que o storage é cortado em páginas
Disco nunca te entrega um único byte. Um HD lê setores inteiros, um SSD lê flash pages inteiras (normalmente de 4 a 16 KB), e o sistema operacional faz cache dos arquivos em páginas próprias de 4 KB. Pedir 30 bytes no offset 1.000.003 custa mais ou menos o mesmo que pedir os 4 KB em volta, porque é isso que realmente se move.
Então os engines param de fingir e escolhem uma unidade. Quando toda página tem o mesmo tamanho, a página n de um arquivo começa no byte n * page_size, e o resto do engine pode falar de páginas pelo número. O endereço de uma linha vira "página 7, slot 3" em vez de um offset cru, o cache vira "mantém essas N páginas na memória", e a B-tree vira "esta página aponta para aquelas páginas".
O tamanho é um trade-off, e cada engine para num lugar diferente:
- PostgreSQL usa 8 KB (
BLCKSZ), definido na compilação do servidor. Não dá para mudar por tabela. - SQLite usa 4 KB por padrão e aceita qualquer potência de dois de 512 a 65.536, configurada com
PRAGMA page_sizeantes do banco ter qualquer conteúdo. - MySQL InnoDB usa 16 KB por padrão.
Páginas maiores significam menos I/O num scan e nós de B-tree mais largos. Páginas menores significam que um lookup pontual lê menos dado que não precisa, e alterar uma linha suja menos memória. Nenhum dos dois ganha sempre. O PostgreSQL sai com 8 KB há décadas, e pouquíssima gente recompila com outro tamanho.
A slotted page
Páginas de tamanho fixo e linhas de tamanho variável não combinam naturalmente. Uma linha com um nome de 3 caracteres e uma linha com uma bio de 3.000 caracteres precisam caber numa página, e linhas são apagadas e atualizadas depois de escritas.
Quase todo engine resolve isso com uma slotted page. A página começa com um header pequeno, depois vem um array de slots (o PostgreSQL chama de line pointers), e os dados das linhas são empacotados do fim da página para trás. O espaço livre é o que sobra no meio.
0 8192
+--------+-----+-----+-----+------------------+-------+-------+-------+
| header | lp1 | lp2 | lp3 | -> free space <- | row 3 | row 2 | row 1 |
+--------+-----+-----+-----+------------------+-------+-------+-------+
^ ^
lower upperDois números no header, lower e upper, marcam onde termina o array de slots e onde começam os dados das linhas. Inserir uma linha move o upper para baixo pelo tamanho da linha e o lower para cima em um slot. A página está cheia quando os dois iriam se cruzar.
O array de slots existe para que nada fora da página precise saber onde a linha está lá dentro. Índices e outras páginas se referem a uma linha como "página 7, slot 3", e o slot 3 guarda o offset real em bytes. Se o engine quiser mover as linhas dentro da página, por exemplo para fechar os buracos deixados por deletes, ele move os bytes e atualiza os slots, e toda referência externa continua válida.
Aqui está uma slotted page em Go, com header de 4 bytes e slots de 4 bytes:
package page
import (
"encoding/binary"
"errors"
)
const (
PageSize = 8192
headerSize = 4 // lower (2 bytes) + upper (2 bytes)
slotSize = 4 // offset (2 bytes) + length (2 bytes)
)
var ErrPageFull = errors.New("page full")
// Page is a slotted page: header, slot array growing forward,
// free space, then tuples growing backward from the end.
type Page [PageSize]byte
func NewPage() *Page {
p := &Page{}
p.setLower(headerSize)
p.setUpper(PageSize)
return p
}
func (p *Page) lower() uint16 { return binary.LittleEndian.Uint16(p[0:2]) }
func (p *Page) upper() uint16 { return binary.LittleEndian.Uint16(p[2:4]) }
func (p *Page) setLower(v uint16) { binary.LittleEndian.PutUint16(p[0:2], v) }
func (p *Page) setUpper(v uint16) { binary.LittleEndian.PutUint16(p[2:4], v) }
func (p *Page) NumSlots() int { return int(p.lower()-headerSize) / slotSize }
func (p *Page) FreeSpace() int { return int(p.upper()) - int(p.lower()) }
func (p *Page) slot(i int) (off, length uint16) {
base := headerSize + i*slotSize
return binary.LittleEndian.Uint16(p[base:]), binary.LittleEndian.Uint16(p[base+2:])
}
func (p *Page) setSlot(i int, off, length uint16) {
base := headerSize + i*slotSize
binary.LittleEndian.PutUint16(p[base:], off)
binary.LittleEndian.PutUint16(p[base+2:], length)
}
// Insert copies tuple into the page and returns its slot number.
func (p *Page) Insert(tuple []byte) (int, error) {
if len(tuple)+slotSize > p.FreeSpace() {
return 0, ErrPageFull
}
upper := p.upper() - uint16(len(tuple))
copy(p[upper:], tuple)
slot := p.NumSlots()
p.setSlot(slot, upper, uint16(len(tuple)))
p.setUpper(upper)
p.setLower(p.lower() + slotSize)
return slot, nil
}
func (p *Page) Get(slot int) ([]byte, bool) {
if slot < 0 || slot >= p.NumSlots() {
return nil, false
}
off, length := p.slot(slot)
if off == 0 { // dead slot
return nil, false
}
return p[off : off+length], true
}
// Delete marks the slot dead. The tuple bytes stay until Compact runs.
func (p *Page) Delete(slot int) {
if slot >= 0 && slot < p.NumSlots() {
p.setSlot(slot, 0, 0)
}
}
// Compact packs live tuples against the end of the page again.
// Slot numbers don't change, so (page, slot) addresses stay valid.
func (p *Page) Compact() {
type live struct {
slot int
data []byte
}
var tuples []live
for i := 0; i < p.NumSlots(); i++ {
if t, ok := p.Get(i); ok {
tuples = append(tuples, live{i, append([]byte(nil), t...)})
}
}
upper := uint16(PageSize)
for _, t := range tuples {
upper -= uint16(len(t.data))
copy(p[upper:], t.data)
p.setSlot(t.slot, upper, uint16(len(t.data)))
}
p.setUpper(upper)
}O comportamento que importa é o intervalo entre Delete e Compact:
p := page.NewPage()
a, _ := p.Insert([]byte("ana"))
b, _ := p.Insert([]byte("bruno"))
p.Delete(a)
fmt.Println(p.FreeSpace()) // 8172: deleting freed nothing yet
p.Compact()
fmt.Println(p.FreeSpace()) // 8175: the 3 bytes of "ana" are back
v, _ := p.Get(b)
fmt.Println(string(v)) // bruno, same slot as beforeApagar só marca o slot. Os bytes de "ana" ficam onde estavam até alguém compactar a página, e o slot morto em si nunca é reaproveitado nesta versão. O PostgreSQL funciona do mesmo jeito. A PageRepairFragmentation em src/backend/storage/page/bufpage.c faz o que o Compact faz, e ela só roda durante o pruning ou o VACUUM, nunca como parte de um DELETE. Diferente deste brinquedo, o PostgreSQL reaproveita line pointers mortos depois que o VACUUM confirma que nada mais aponta para eles.
Uma página heap do PostgreSQL, byte a byte
O PostgreSQL guarda as linhas das tabelas num heap: uma pilha sem ordem de páginas, onde uma linha nova vai para qualquer página que tenha espaço. O header real da página é o PageHeaderData em src/include/storage/bufpage.h, com 24 bytes:
typedef struct PageHeaderData
{
PageXLogRecPtr pd_lsn; /* LSN of the last WAL record that changed this page */
uint16 pd_checksum;
uint16 pd_flags;
LocationIndex pd_lower; /* offset to start of free space */
LocationIndex pd_upper; /* offset to end of free space */
LocationIndex pd_special; /* offset to start of special space */
uint16 pd_pagesize_version;
TransactionId pd_prune_xid;
ItemIdData pd_linp[FLEXIBLE_ARRAY_MEMBER]; /* line pointers */
} PageHeaderData;pd_lower e pd_upper são os mesmos dois números da versão em Go. pd_special é o início de uma área bem no fim da página que as páginas de índice usam para metadados próprios (uma página de B-tree guarda os links para as irmãs ali). Páginas heap não usam, então ele aponta para 8192. pd_lsn liga a página ao write-ahead log, e isso vai importar no artigo de WAL.
Cada line pointer é um ItemIdData empacotado em 4 bytes: 15 bits de offset, 2 bits de estado (unused, normal, redirect, dead) e 15 bits de tamanho. Quinze bits bastam para endereçar qualquer byte numa página de 32 KB, que é o maior BLCKSZ que o PostgreSQL suporta.
Dá para ver tudo isso numa tabela de verdade com o pageinspect:
CREATE EXTENSION pageinspect;
CREATE TABLE users (id int, name text);
INSERT INTO users VALUES (1, 'ana'), (2, 'bruno');
SELECT lower, upper, special, pagesize
FROM page_header(get_raw_page('users', 0)); lower | upper | special | pagesize
-------+-------+---------+----------
32 | 8120 | 8192 | 8192lower é 32: os 24 bytes do header mais dois line pointers de 4 bytes. upper é 8120 porque as duas linhas ocuparam 72 bytes no fim da página. Os line pointers mostram onde:
SELECT lp, lp_off, lp_len, t_ctid
FROM heap_page_items(get_raw_page('users', 0)); lp | lp_off | lp_len | t_ctid
----+--------+--------+--------
1 | 8160 | 32 | (0,1)
2 | 8120 | 34 | (0,2)ana ocupa 32 bytes: um header de tupla de 23 bytes com padding até 24, 4 bytes do int e 4 do texto (1 byte de tamanho mais 3 caracteres). bruno ocupa 34, mas começa 40 bytes abaixo de ana, porque toda tupla começa num offset alinhado em 8 bytes. Uma tabela de linhas minúsculas gasta a maior parte de cada página com headers e padding, e esse é um dos motivos de uma tabela estreita no PostgreSQL ocupar mais disco do que os tipos das colunas fariam você imaginar.
t_ctid é o endereço da própria linha, (bloco, line pointer). O PostgreSQL expõe isso como a coluna oculta ctid, e é isso que todo índice guarda para apontar de volta para o heap.
Um update escreve uma linha nova
Agora muda um nome:
UPDATE users SET name = 'ana maria' WHERE id = 1;
SELECT ctid, * FROM users; ctid | id | name
-------+----+-----------
(0,2) | 2 | bruno
(0,3) | 1 | ana mariaA linha 1 saiu de (0,1) para (0,3). O PostgreSQL nunca sobrescreve uma linha no lugar. O UPDATE escreveu uma versão nova completa no espaço livre, e a versão antiga continua na página com o t_xmax apontando para a transação que fez o update e o t_ctid apontando para (0,3). Leitores cujo snapshot começou antes do update continuam vendo ana; o resto segue a cadeia até ana maria. Isso é MVCC, e vai ter um artigo próprio. O que importa para a página é que todo update custa uma tupla nova inteira de espaço livre, e a antiga fica lá até passar um pruning ou um vacuum.
Isso também explica o parâmetro de storage fillfactor. Com o padrão de 100, as páginas são enchidas por completo no insert, e um update normalmente precisa colocar a versão nova em outra página. Uma tabela muito atualizada pode usar fillfactor = 90 ou menos para que os updates caibam na mesma página, o que deixa o PostgreSQL fazer updates HOT (heap-only tuple) e não mexer nos índices.
Quantas linhas cabem
Um jeito rápido de ver a densidade é agrupar as linhas pela parte de bloco do ctid:
CREATE TABLE big (id int, name text);
INSERT INTO big SELECT i, md5(i::text) FROM generate_series(1, 100000) i;
SELECT (ctid::text::point)[0]::int AS block, count(*)
FROM big GROUP BY 1 ORDER BY 1 LIMIT 3; block | count
-------+-------
0 | 120
1 | 120
2 | 120Cada linha tem um header de 24 bytes, 4 bytes de int, 33 bytes de texto, arredondado para 64, mais um line pointer de 4 bytes: 68 bytes. (8192 - 24) / 68 dá 120. As 100.000 linhas precisam de 834 páginas, cerca de 6,8 MB, para mais ou menos 3,6 MB de valores de fato.
Valores que não cabem com folga vão para outro lugar. Quando uma linha passa de uns 2 KB (TOAST_TUPLE_THRESHOLD), o PostgreSQL primeiro tenta comprimir as colunas grandes e depois move elas para uma tabela TOAST separada, deixando um ponteiro pequeno na tupla do heap. É por isso que um SELECT id FROM docs pode ser rápido mesmo quando cada linha carrega um body de 1 MB: as páginas do heap só guardam os ponteiros.
Arquivos, forks e segmentos
Uma tabela no PostgreSQL é mais de um arquivo:
SELECT pg_relation_filepath('users');
-- base/5/16430Dentro do diretório de dados, base/5 é o banco e 16430 é o relfilenode da tabela. Do lado dele você encontra:
16430, o main fork, com as páginas do heap16430_fsm, o free space map, que registra mais ou menos quanto espaço cada página tem, para um insert achar uma página sem ler todas16430_vm, o visibility map, dois bits por página dizendo se todas as linhas dela são visíveis para todo mundo e se estão todas congeladas; oVACUUMusa isso para pular páginas, e index-only scans usam para pular o heap16430.1,16430.2e assim por diante quando o main fork passa de 1 GB, já que o PostgreSQL divide as relações em segmentos de 1 GB
Uma consequência que pega muita gente: o VACUUM marca o espaço dentro das páginas como reutilizável, mas quase nunca devolve esse espaço para o sistema operacional. Ele só consegue truncar páginas vazias bem no fim do arquivo. Uma tabela que cresceu até 50 GB e depois teve 90% das linhas apagadas continua com 50 GB, cheia de espaço livre que linhas novas vão ocupar. Encolher o arquivo exige VACUUM FULL ou pg_repack, que reescrevem a tabela.
SQLite: a mesma página, organizada por chave
O SQLite usa o mesmo layout de slots com outros nomes. Toda página do arquivo pertence a uma B-tree, a uma freelist ou a uma cadeia de overflow, e uma página de B-tree tem um header de 8 ou 12 bytes, um array de cell pointers de 2 bytes e o conteúdo das células empacotado a partir do fim da página. A página 1 também carrega o header de 100 bytes do banco, que começa com SQLite format 3\0.
A diferença maior é onde as linhas moram. O SQLite não tem heap. Uma tabela é uma B-tree com chave rowid, e as linhas ficam nas folhas dela, em ordem de chave. O InnoDB faz o mesmo com a primary key. Isso se chama tabela clustered (ou index-organized), e muda os trade-offs:
- Um lookup pela primary key chega na linha direto no fundo da árvore. No PostgreSQL, o lookup no índice termina num
ctid, e buscar a linha é mais uma leitura de página no heap. - Um range scan pela primary key lê linhas que estão fisicamente lado a lado. Um heap do PostgreSQL só mantém essa ordem se você inserir em ordem de chave ou rodar
CLUSTER, que é uma reescrita pontual. - Um índice secundário não pode apontar para uma posição física, porque as linhas se movem quando as folhas fazem split. Os índices secundários do SQLite e do InnoDB guardam a primary key, então um lookup percorre duas árvores. Os índices do PostgreSQL guardam o
ctid, que vai direto na página, mas precisa ser atualizado sempre que uma versão da linha vai para outra página. - Primary keys aleatórias, como UUIDv4, espalham os inserts pela árvore clustered inteira e fazem split de páginas para todo lado. Um heap não liga para a ordem da chave e só faz append.
Se o seu sqlite3 foi compilado com a virtual table dbstat, dá para ver as páginas:
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);
INSERT INTO users VALUES (1, 'ana'), (2, 'bruno');
SELECT name, pageno, pagetype, ncell, unused FROM dbstat;sqlite_schema|1|leaf|1|3896
users|2|leaf|2|4054A tabela é uma página folha com duas células. A tabela de schema fica na página 1. Ela tem menos espaço livre porque essa página também guarda o header de 100 bytes do arquivo, e a única célula dela armazena o texto completo do CREATE TABLE.
O buffer pool
Ler uma página do disco a cada acesso faria até um workload todo em cache se arrastar, então todo engine mantém um pool de páginas na memória. No PostgreSQL isso é o shared_buffers, 128 MB por padrão, dividido em slots de 8 KB. Cada slot tem um descritor (BufferDesc em src/include/storage/buf_internals.h) com a identidade da página (relação, fork, número do bloco), um contador de pins, um usage count e uma flag de dirty.
Um pin diz "alguém está usando esta página agora, não tira ela daqui". A flag dirty diz que a página na memória está diferente da que está no disco. Nem o commit nem o UPDATE escrevem a página de dados em si. Páginas sujas são escritas depois, pelo background writer, pelo checkpointer ou por um backend que precisa do slot. A durabilidade no commit vem do WAL, não das páginas de dados.
Quando o pool está cheio, o PostgreSQL escolhe uma vítima com um clock sweep (StrategyGetBuffer em src/backend/storage/buffer/freelist.c). Um ponteiro anda em círculo pelos buffers. Todo acesso aumenta o usage count de um buffer, até 5. O ponteiro decrementa o contador de cada buffer sem pin por onde passa e despeja o primeiro que chegar a zero. Uma página muito usada sobrevive a várias voltas do ponteiro; uma página tocada uma vez vai embora na próxima. É uma aproximação de LRU sem precisar reordenar uma lista a cada acesso.
Uma versão pequena em Go, usando a Page de antes:
type frame struct {
pageID uint32
page Page
pins int
usage int
dirty bool
valid bool
}
type BufferPool struct {
file *os.File
frames []frame
table map[uint32]int // page id -> frame index
hand int
}
func NewBufferPool(file *os.File, size int) *BufferPool {
return &BufferPool{file: file, frames: make([]frame, size), table: map[uint32]int{}}
}
func (bp *BufferPool) Fetch(id uint32) (*Page, error) {
if i, ok := bp.table[id]; ok {
f := &bp.frames[i]
f.pins++
f.usage = min(f.usage+1, 5)
return &f.page, nil
}
i, err := bp.victim()
if err != nil {
return nil, err
}
f := &bp.frames[i]
if f.valid {
if f.dirty {
if _, err := bp.file.WriteAt(f.page[:], int64(f.pageID)*PageSize); err != nil {
return nil, err
}
}
delete(bp.table, f.pageID)
}
f.page = Page{}
if _, err := bp.file.ReadAt(f.page[:], int64(id)*PageSize); err != nil && err != io.EOF {
return nil, err
}
f.pageID, f.pins, f.usage, f.dirty, f.valid = id, 1, 1, false, true
bp.table[id] = i
return &f.page, nil
}
func (bp *BufferPool) Unpin(id uint32, dirty bool) {
f := &bp.frames[bp.table[id]]
f.pins--
f.dirty = f.dirty || dirty
}
// victim runs the clock: skip pinned frames, decrement usage counts,
// and take the first frame that reaches zero.
func (bp *BufferPool) victim() (int, error) {
for range len(bp.frames) * 6 {
i := bp.hand
bp.hand = (bp.hand + 1) % len(bp.frames)
f := &bp.frames[i]
switch {
case !f.valid:
return i, nil
case f.pins > 0:
continue
case f.usage == 0:
return i, nil
}
f.usage--
}
return 0, errors.New("no unpinned buffers available")
}O loop dá seis voltas no anel porque um usage count chega no máximo a 5. Se todo frame sem pin foi decrementado cinco vezes e ainda nada ficou livre, está tudo pinado. O PostgreSQL levanta a mesma mensagem de erro nesse caso. Quem chama precisa fazer Unpin do que fez Fetch, e uma página só sai do pool depois que o último pin some. Esquecer um Unpin aqui vaza um frame para sempre, e é por isso que o PostgreSQL rastreia os pins por backend e reclama alto de pins vazados no fim da transação.
O que esta versão deixa de fora é a maior parte do trabalho de verdade: concorrência. O PostgreSQL divide a tabela de mapeamento em 128 partições com locks separados, empacota o pin count, o usage count e as flags num único inteiro atômico para que um pin não precise de lock, e tem um content lock separado por buffer para que leitores e escritores da mesma página não se atropelem.
Scans grandes usam um anel
O clock sweep tem um ponto fraco. Um sequential scan numa tabela de 20 GB tocaria cada página uma vez, subiria cada usage count para 1 e empurraria as páginas quentes para fora do pool no caminho. O PostgreSQL evita isso com buffer access strategies: quando um sequential scan lê uma tabela maior que um quarto do shared_buffers, ele usa um anel privado pequeno de 256 KB e fica reaproveitando esses buffers em vez de tomar conta do pool. VACUUM e COPY têm anéis próprios. Um relatório noturno que varre uma tabela grande não esvazia o seu cache.
Dá para olhar dentro do pool com o pg_buffercache:
CREATE EXTENSION pg_buffercache;
SELECT c.relname, count(*) AS buffers, sum(b.isdirty::int) AS dirty
FROM pg_buffercache b
JOIN pg_class c ON b.relfilenode = pg_relation_filenode(c.oid)
WHERE b.reldatabase = (SELECT oid FROM pg_database WHERE datname = current_database())
GROUP BY c.relname
ORDER BY buffers DESC
LIMIT 5;É isso também que o EXPLAIN (ANALYZE, BUFFERS) reporta. shared hit é uma página encontrada no pool, e read é uma que precisou vir do sistema operacional. O PostgreSQL 18 inclui os contadores de buffer no EXPLAIN ANALYZE sem você pedir.
Dois caches
O PostgreSQL lê passando pelo sistema operacional, então uma página que não está no shared_buffers ainda pode estar no page cache do kernel. Por isso a recomendação de sempre é dar uns 25% da RAM para o shared_buffers e deixar o resto para o SO, em vez dos 80% que você poderia esperar. Um read no EXPLAIN não significa necessariamente uma leitura de disco, só que o PostgreSQL precisou pedir ao kernel. O PostgreSQL 18 também trouxe I/O assíncrono (io_method, com worker como padrão e io_uring no Linux), então sequential scans, bitmap heap scans e o VACUUM podem ter várias leituras em andamento ao mesmo tempo em vez de esperar uma por uma.
O SQLite tem a mesma divisão em escala menor. O pager dele mantém um cache de páginas por conexão, dimensionado com PRAGMA cache_size, uns 2 MB numa build padrão, e conta com o SO para o resto.
Quando a escrita de uma página dá meio errado
Uma página de 8 KB são duas páginas de 4 KB do SO, e o disco só garante que um setor ou uma flash page é escrito de forma atômica. Se a energia cai no meio da escrita de uma página de dados, o arquivo pode ficar com a primeira metade nova e a segunda antiga: uma torn page. Nenhuma das duas versões está correta, e nada na página avisa isso.
O PostgreSQL ataca isso por dois lados. Com full_page_writes ligado (o padrão), a primeira alteração numa página depois de cada checkpoint escreve a imagem inteira da página no WAL, para o recovery poder restaurar uma cópia limpa antes de reaplicar as alterações. Os data checksums pegam a corrupção quando a página é lida de volta; desde o PostgreSQL 18, o initdb liga eles por padrão, e SHOW data_checksums mostra como está o seu cluster. Os modos rollback journal e WAL do SQLite resolvem o mesmo problema com cópias de página próprias. Tudo isso depende do log, então a história completa fica para o artigo de WAL.
Escolhendo entre layouts
Se você está escolhendo um banco, o formato da página raramente é o motivo. Mesmo assim ele decide muito do comportamento do dia a dia.
Um heap como o do PostgreSQL combina com tabelas com muita escrita, vários índices secundários e chaves que chegam em ordem aleatória. Inserts vão para onde tiver espaço, e os índices apontam direto para uma página. Você paga com uma visita extra ao heap em todo lookup por índice, versões de linha que se acumulam até o vacuum, e arquivos que não encolhem sozinhos.
Um layout clustered como o do SQLite ou do InnoDB combina com tabelas lidas principalmente pela primary key ou por faixas dela, como uma tabela orders lida por cliente através de uma chave (customer_id, order_id). A linha está no fim da descida pelo índice e as vizinhas estão fisicamente perto. Você paga com splits de página em chaves aleatórias e lookups secundários que percorrem duas árvores.
O tamanho da página normalmente é um fato fixo do engine. O SQLite é o caso em que você escolhe, e o padrão de 4 KB bate com a página do SO e com a maioria dos SSDs; só aumente se você medir leituras sequenciais grandes que se beneficiem disso.
Conclusão
Toda linha mora num slot de uma página de tamanho fixo, toda página é endereçada pelo número, e o buffer pool decide quais dessas páginas estão na memória. A maior parte do comportamento de storage que surpreende as pessoas vem desse layout: o ctid que muda no update, a tabela que não encolhe depois de um DELETE, o contador de shared hit no EXPLAIN.
O próximo é B-Trees: como essas páginas são ligadas numa árvore que continua rasa com milhões de linhas, o que acontece quando uma página folha enche, e como a nbtree do PostgreSQL faz splits sem parar os leitores.