Pular para o conteúdo principal
Distributed Systems12 min de leitura

Read-Your-Writes é um Contrato de Sessão, Não uma Configuração de Banco

Adicionei uma réplica de leitura e, em um dia, usuários reportaram edições que "não salvaram" — elas tinham salvado, mas as leituras correram contra o stream de replicação e perderam. Estas são minhas notas sobre por que read-your-writes é um contrato de sessão em vez de uma configuração de réplica, reproduzido com 50 ms de lag fixo em Go, e as três formas de carregá-lo — roteamento sticky, um token de versão estilo GTID e uma espera de bounded-staleness — comparadas em custo.

Todos os Posts
2/4

A primeira vez que isso me mordeu, eu tinha acabado de adicionar uma réplica de leitura para aliviar a pressão de um primário que estava sendo martelado em leituras. A mudança parecia gratuita. Em um dia, um punhado de usuários reportou que suas edições de perfil "não salvaram" — exceto que as edições tinham sido salvas. Eles simplesmente não conseguiam vê-las no próximo carregamento de página.

O código era do tipo que todo engenheiro backend já escreveu:

python
id = create_resource(...)
get_resource_state(id, ...)   # às vezes: "id does not exist"

A escrita foi para o primário. A leitura, sendo read-only, foi roteada para uma réplica. A leitura correu contra o stream de replicação e perdeu. Por algumas dezenas de milissegundos, o sistema insistiu que a coisa que eu tinha acabado de criar não existia. Marc Brooker abre seu post de novembro de 2025 sobre consistência forte com exatamente este formato, e o detalhe que ficou comigo é como o código de aplicação "conserta" isso: um loop de retry com um sleep e um número mágico, que vira um loop infinito no momento em que o recurso também pode estar legitimamente ausente.

Passei um tempo procurando a configuração de banco que faria isso desaparecer. Esse era o frame errado. Não há botão na réplica que conserte isso, porque a propriedade que eu queria não é uma propriedade da réplica. É uma propriedade da sessão — minha sessão, aquela que fez a escrita e depois fez a leitura.

O que read-your-writes realmente significa

Read-your-writes é uma das quatro garantias de sessão nomeadas no trabalho Bayou por Terry e colegas em 1994. As quatro são read-your-writes, monotonic reads, writes-follow-reads e monotonic writes. O enquadramento que fez sentido para mim: cada garantia é sobre apresentar a uma sessão uma visão dos dados que é consistente com as próprias ações daquela sessão, mesmo quando as leituras e escritas aterrissam em servidores diferentes e mutuamente inconsistentes.

Read-your-writes diz: dentro de uma sessão, uma leitura reflete toda escrita que aquela sessão já fez. Nada sobre outras sessões. Nada sobre ordem global. Apenas a promessa de que eu não vejo o tempo andar para trás em relação às minhas próprias escritas.

Essa palavra — sessão — é todo o ponto. A garantia vive entre o cliente e os dados, não dentro de qualquer réplica única. Então ela tem que ser carregada em algum lugar: em como as requisições são roteadas, ou em um token que o cliente segura, ou em uma verificação de frescor que a réplica executa antes de responder. Três lugares, três implementações, três contas diferentes. O resto deste post é sobre reproduzir a falha, depois pagar cada uma dessas contas e ver qual dói menos.

Reproduzindo a leitura stale de propósito

Acho impossível raciocinar sobre bugs de consistência até conseguir fazê-los acontecer sob demanda. Lag de replicação real é o inimigo disso — ele varia, então o bug é um heisenbug. Então fixei o lag. O programa abaixo é um primário e uma réplica em um único arquivo Go. O primário mantém um contador monotônico de versão, o substituto em memória do gtid_executed do MySQL. Toda escrita incrementa o contador, envia a mudança por um channel e devolve ao chamador a versão como um token. A réplica aplica esse stream após um atraso fixo de 50 ms.

go
package main

import (
	"fmt"
	"sync"
	"time"
)

// write carrega o token de versão que um cliente mantém para garantir read-your-writes.
type write struct {
	version int64
	key     string
	value   string
}

// primary é a fonte da verdade mais um contador monotônico de versão,
// o equivalente moral do gtid_executed do MySQL.
type primary struct {
	mu      sync.Mutex
	version int64
	data    map[string]string
	stream  chan write
}

func newPrimary() *primary {
	return &primary{data: map[string]string{}, stream: make(chan write, 64)}
}

// Write aplica localmente, envia a mudança para a réplica e retorna
// o token que o chamador deve carregar para ler sua própria escrita.
func (p *primary) Write(key, value string) int64 {
	p.mu.Lock()
	p.version++
	v := p.version
	p.data[key] = value
	p.mu.Unlock()
	p.stream <- write{v, key, value}
	return v
}

// replica aplica o stream após um lag fixo e rastreia até onde alcançou.
type replica struct {
	mu      sync.Mutex
	applied int64
	data    map[string]string
	caught  *sync.Cond
}

func newReplica(p *primary, lag time.Duration) *replica {
	r := &replica{data: map[string]string{}}
	r.caught = sync.NewCond(&r.mu)
	go func() {
		for w := range p.stream {
			time.Sleep(lag) // lag de replicação
			r.mu.Lock()
			r.data[w.key] = w.value
			r.applied = w.version
			r.caught.Broadcast()
			r.mu.Unlock()
		}
	}()
	return r
}

// Read é o caminho eventualmente consistente: o que a réplica tiver agora.
func (r *replica) Read(key string) string {
	r.mu.Lock()
	defer r.mu.Unlock()
	return r.data[key]
}

// ReadYourWrites bloqueia até a réplica ter aplicado o token do chamador,
// o análogo em memória de WAIT_FOR_EXECUTED_GTID_SET.
func (r *replica) ReadYourWrites(key string, token int64) string {
	r.mu.Lock()
	defer r.mu.Unlock()
	for r.applied < token {
		r.caught.Wait()
	}
	return r.data[key]
}

func main() {
	p := newPrimary()
	r := newReplica(p, 50*time.Millisecond)

	token := p.Write("profile:42", "name=Tiare")

	// Ingênuo: lê a réplica imediatamente após a escrita.
	fmt.Printf("stale read: %q\n", r.Read("profile:42"))

	// Contrato de sessão: carregue o token, espere por ele, então leia.
	fmt.Printf("fresh read: %q\n", r.ReadYourWrites("profile:42", token))
}

Execute com:

go run main.go

Toda execução imprime as mesmas duas linhas:

stale read: "" fresh read: "name=Tiare"

A primeira leitura acontece microssegundos depois que a escrita retorna, enquanto a réplica ainda está a 50 ms de aplicá-la, então vê uma string vazia — o "does not exist" da produção, reproduzido deterministicamente. A segunda leitura é o contrato em sua forma mais minimalista possível. A linha não óbvia é o loop for r.applied < token em ReadYourWrites: a leitura não pergunta "você tem minha chave", ela pergunta "você já alcançou a versão 7", e bloqueia em uma variável de condição até a resposta ser sim. Essa única comparação é a ideia inteira. O cliente carregou um token; a réplica o honrou.

O diagrama abaixo traça a corrida em uma linha do tempo para que a ordenação seja inequívoca: a escrita commita no primário em v=7, a leitura ingênua atinge a réplica enquanto ela ainda está em v=6 e retorna stale, e a leitura carregando o token estaciona até a réplica alcançar v=7.

O mesmo contrato, três formas de carregá-lo, três contas

O arquivo Go implementa a abordagem de token porque é a que generaliza. Mas não é a única forma, e nem sempre é a certa. Aqui está como as três se alinham no que realmente custam.

Janela sticky-to-primary. Depois que uma sessão escreve, roteie as leituras dessa sessão para o primário por uma janela fixa — alguns segundos, digamos. Simples de raciocinar, sem token para enfiar nas chamadas, e a leitura nunca é stale porque está lendo a fonte da verdade. A conta: toda sessão "recém-escreveu" agora é carga no primário, que é exatamente a carga que você adicionou a réplica para descarregar. E a janela é um chute. Configure mais curta que seu pior caso de lag e a corrida volta; configure mais longa e você está pagando custo de primário por leituras que teriam sido seguras numa réplica.

Token de versão (GTID ou LSN). A escrita retorna uma posição — um GTID do MySQL, um LSN do Postgres, ou o contador no programa acima. O cliente o carrega e a leitura espera até a réplica alcançá-lo. Esta é a implementação mais fiel do contrato e a única que sobrevive a roteamento complexo, porque o token viaja com a requisição em vez de depender de onde ela aterrissa. A conta vem em duas partes. Primeiro, latência de cauda: uma leitura agora pode bloquear por até o lag de replicação, então seu p99 de leitura herda seu p99 de replicação. Segundo, e essa é a que doeu na prática, você tem que enfiar o token através de cada hop. No momento em que um serviço esquece de encaminhá-lo — uma camada de cache, um segundo microsserviço, um job em background — o contrato silenciosamente se degrada para uma leitura stale normal sem nenhum erro para te avisar que aconteceu.

Espera de bounded-staleness. A réplica se recusa a responder leituras mais antigas que algum limiar, ou bloqueia até estar fresca o suficiente. Ela limita quão errada uma resposta pode estar sem bookkeeping por sessão. A conta: não é realmente read-your-writes. É "leia algo com no máximo X de staleness", que é uma promessa diferente e mais fraca. Sob um pico de lag ou rejeita leituras ou serve dados bem na borda do staleness, e nenhum desses é "você vai ver sua própria escrita".

abordagemonde o contrato vivecusto principalfalha silenciosa
sticky-to-primaryroteamento de requisiçãocarga no primário que você tentou descarregarjanela mais curta que pior caso de lag
token de versãoum token que o cliente carregap99 de leitura herda p99 de replicaçãoum hop perde o token, nenhum erro
bounded stalenessverificação de frescor na réplicarejeita ou serve na borda sob lagmais fraca que read-your-writes

O que os bancos reais te entregam

A abordagem de token não é algo que você constrói do zero no MySQL. Desde o 5.7.5, o MySQL vem com WAIT_FOR_EXECUTED_GTID_SET(gtid_set [, timeout]). Depois de uma escrita no primário, você lê o GTID da transação de @@global.gtid_executed, carrega para a réplica e chama a função antes da sua leitura. Ela bloqueia até esse conjunto de GTID ser um subconjunto do conjunto executado da réplica, então retorna 0; em timeout retorna 1. Esse valor de retorno importa: é a costura onde você decide se faz fallback para o primário, levanta um erro, ou serve stale de propósito. O loop for e a variável de condição no meu arquivo Go são exatamente essa função com a rede removida.

O Aurora MySQL expõe a mesma ideia como uma variável de sessão. Configure aurora_replica_read_consistency para SESSION e as leituras através de write forwarding esperam que as escritas encaminhadas dessa sessão repliquem antes de responder; os outros valores são EVENTUAL e GLOBAL. A AWS nota que o lag do leitor é geralmente bem abaixo de 100 ms depois de uma escrita, o que te diz o tamanho da conta de latência de cauda em que a abordagem de token te inscreve — milissegundos de um dígito a dois dígitos de bloqueio ocasional, não segundos.

Onde isso para de ser suficiente

Quero ser honesto sobre o teto aqui, porque eu bati nele. Read-your-writes por réplica não te dá monotonic reads entre réplicas. Imagine duas leituras na mesma sessão: a primeira aterrissa em uma réplica que alcançou e vê sua escrita; a segunda aterrissa em uma réplica mais atrasada e não vê. Você leu sua escrita, depois des-leu. O conjunto de escritas que você observou andou para trás. Brooker faz esse ponto precisamente — o padrão clássico de réplica de leitura não oferece monotonic reads, então a dica "vai e volta" conforme as requisições saltam entre réplicas. Consertar isso significa fixar uma sessão em uma réplica ou empurrar a marca d'água alta monotonicamente, o que é mais bookkeeping em cima do token que você já está carregando.

Há uma segunda rachadura. Todo o esquema assume que "suas escritas" é um conjunto bem definido que o cliente pode nomear com um token. Para uma única edição de perfil, é. Para infrastructure-as-code, um workflow multi-serviço, ou qualquer read-modify-write onde a leitura em uma entidade gateia uma escrita em outra, a fronteira de "suas escritas" embaça, e o token para de ser uma alça limpa. Este é o argumento para pular garantias de sessão inteiramente e fazer todas as leituras fortemente consistentes, do jeito que o Aurora DSQL faz — toda leitura escolhe um timestamp e qualquer réplica bloqueia até poder responder daquele momento. Nesse ponto você para de carregar o contrato através do seu código porque o banco o carrega para você. O trade que você está pesando é real: complexidade por requisição no seu stack versus pagar por um banco que faz o problema desaparecer.

Para a maioria dos serviços que construí, a resposta honesta fica no meio. Sticky-to-primary para a janela curta logo após uma escrita cobre a esmagadora maioria dos reports de "não consigo ver minha própria edição" com quase nenhum código. O token de versão vale a pena enfiar apenas nos caminhos de leitura específicos onde uma leitura stale é visível ao usuário e a janela sticky é muito grosseira. E buscar leituras totalmente fortes é uma chamada que você faz quando "suas escritas" parou de ser algo que um único token consegue descrever.

Takeaways

  • Trate read-your-writes como uma propriedade da sessão, não uma flag na réplica. Se você está procurando na configuração da réplica pelo conserto, está olhando na camada errada.
  • Reproduza a leitura stale com um lag fixo antes de consertá-la. Um 50 ms fixado transforma um heisenbug em uma asserção de uma linha.
  • Escolha onde o contrato vive deliberadamente: roteamento (sticky), um token carregado (GTID/LSN) ou uma verificação de frescor (bounded staleness). Cada um tem uma conta diferente.
  • Um token carregado se degrada silenciosamente na primeira vez que um hop esquece de encaminhá-lo. Se você escolhe tokens, trate "o token ainda está anexado" como algo que você testa, não que assume.
  • Read-your-writes por si só não compra monotonic reads entre réplicas. Se leituras saltando entre réplicas podem andar para trás, você precisa de afinidade de sessão ou de uma marca d'água alta monotônica também.

Busque sticky-to-primary quando a janela visível ao usuário é curta e você prefere gastar um pouco de carga no primário do que enfiar estado através do seu stack. Busque tokens de versão quando leituras stale são visíveis em caminhos específicos e você controla cada hop pelo qual o token deve sobreviver. Busque leituras totalmente fortes quando "suas escritas" cresceu além do que um token consegue nomear — workflows multi-entidade, IaC, read-modify-write entre serviços — e você prefere comprar a garantia a mantê-la.

Continue lendo

Curtindo? Talvez goste disso aqui.

Nada parecido — quer tentar outro ângulo?

Isso foi útil?

Deixe uma avaliação ou uma nota rápida — me ajuda a melhorar.