Sumário
Algumas semanas atrás, fui tomado pelo tédio em relação às coisas que eu precisava estudar. Atualmente, no ano de 2026 (para você que está lendo isso no futuro), estamos vivendo o hype absoluto dos agentes de IA. Eu confesso que, apesar de também estar empolgado, testando e usando agentes de IA para trabalhar e tudo mais, eu estava à procura de outra coisa para estudar, algo que fugisse um pouco de ficar testando harnesses e ferramentas de IA, pensando em como posso usar novos agentes para escrever código, economizar tokens e blá-blá-blá.
Dito isso, juntando o útil ao agradável, já que é uma tecnologia que vou utilizar no meu trabalho, comecei a estudar sobre o Cloud Spanner, o serviço de banco de dados relacional distribuído e globalmente escalável desenvolvido pelo Google.
Por que o Spanner existe...
Lendo os artigos publicados no Google Schollar eu entendo que o Cloud Spanner nasce para responder a seguinte pergunta:
Como construir um banco de dados que funcione em vários datacenters espalhados pelo mundo sem abrir mão da consistência das transações?
Quando precisamos escolher um banco de dados para um projeto, além de considerar custos, a afinidade da equipe e os objetivos do software que sera construido, normalmente precisamos escolher entre os benefícios oferecidos por um banco de dados relacional e as características de escalabilidade e distribuição presentes em muitos bancos NoSQL.
Bancos relacionais
Os bancos de dados relacionais, como MySQL e PostgreSQL, tendem a priorizar estrutura, relacionamentos e consistência e, com isso, trazem um pacote como por exemplo as transações ACID
Transações ACID
Transações ACID são um conjunto de propriedades que garantem que as operações em um banco de dados sejam executadas de forma confiável:
- O A vem de Atomicidade: ou todas as operações da transação acontecem ou nenhuma delas acontece.
- O C vem de Consistência: a transação mantém o banco de dados em um estado válido, respeitando suas regras e restrições.
- O I vem de Isolamento: transações concorrentes são executadas de forma isolada, evitando que uma transação interfira incorretamente no resultado de outra.
- E, por fim, o D vem de Durabilidade: depois que uma transação é confirmada (
COMMIT) seus dados permanecem persistidos mesmo em caso de falha.
Consistência e escalabilidade global
Em um banco de dados que oferece consistência forte, quando um dado é alterado e a transação é confirmada as leituras que vão ser executadas devem refletir essa alteração de acordo com as garantias de consistência oferecidas pelo banco de dados.
Consequentemente, a escalabilidade global de bancos relacionais tradicionais pode se tornar complicada. Isso acontece porque em um banco de dados distribuído manter a consistência forte entre servidores espalhados pelo mundo é caro e complexo.
Imagine dois servidores: um no Brasil e outro nos Estados Unidos. Se uma transação altera um dado no Brasil, o banco precisa coordenar essa alteração com os demais servidores para garantir que as réplicas mantenham as garantias de consistência esperadas. Isso pode exigir:
- comunicação entre regiões, aumentando a latência;
- coordenação entre servidores;
- replicação síncrona, que pode reduzir o desempenho;
- maior complexidade para lidar com falhas de rede e indisponibilidade de regiões.
Bancos NoSQL
E então temos os bancos de dados NoSQL. Particularmente, eu usei poucos bancos de dados desse tipo (Redis conta? kkk). Mas bancos como Bigtable, Cassandra e DynamoDB garantem uma enorme escalabilidade, pois não dependem da mesma estrutura de relacionamentos e joins entre tabelas presente nos bancos relacionais, o que pode facilitar a distribuição dos dados e a escalabilidade horizontal.
Em muitos sistemas NoSQL distribuídos, após uma alteração diferentes réplicas podem temporariamente apresentar valores diferentes, mas eventualmente convergem para o mesmo estado. Porém, essa não é uma característica obrigatória de todos os bancos NoSQL, já que alguns oferecem consistência forte ou diferentes níveis de consistência.
Distribuição pelo mundo
O Spanner foi criado justamente para tentar eliminar essa escolha.
Ele é um banco de dados realmente global e permite que um único banco esteja distribuído entre diversos datacenters ao redor do mundo, sem que a aplicação precise lidar manualmente com a replicação dos dados.
O Spanner oferece consistência forte em escala global, eu acho que esse talvez seja o maior diferencial apontado nos artigos, pois o Spanner oferece serializabilidade externa, garantindo que as transações distribuídas sejam executadas com propriedades ACID e que exista uma ordem global consistente entre as transações. Em outras palavras, mesmo com servidores em continentes diferentes, o sistema consegue manter uma visão consistente sobre a ordem em que as transações foram confirmadas.
Além disso, temos a escalabilidade automática (ou até o seu cartão de crédito aguentar $$$$$). O sistema consegue dividir os dados (sharding), mover os dados automaticamente, rebalancear a carga e distribuir e replicar os dados entre diferentes datacenters sem intervenção humana. Sim, sem intervenção humana. Isso me lembra o assunto das IAs que eu estou tentando fugir...
Como ele resolve esses problemas?
Essa imagem mostra a arquitetura de software do Spanner, mais especificamente como o sistema organiza a replicação, consenso e transações distribuídas entre diferentes datacenters.
A arquitetura é baseada em dados particionados em tablets, que são replicados por meio de grupos Paxos distribuídos entre diferentes datacenters.
Sobre essa infraestrutura, o Spanner implementa mecanismos para executar transações distribuídas e, por meio do TrueTime, consegue atribuir timestamps globalmente significativos às transações. Essas ideias trabalham em conjunto para fornecer não apenas tolerância a falhas e replicação, mas também consistência externa, fazendo com que a ordem de serialização das transações respeite a ordem em que elas realmente ocorreram.
1. Replicação utilizando Paxos
O primeiro problema é garantir que os dados continuem disponíveis e consistentes mesmo quando estão distribuídos entre várias máquinas e datacenters. O Spanner resolve isso particionando os dados em tablets e organizando cada conjunto de dados em um grupo Paxos.
Um grupo Paxos é um conjunto de réplicas que mantém uma cópia do mesmo conjunto de dados e usa o algoritmo de consenso Paxos para concordar sobre quais operações devem ser aplicadas e em qual ordem.
Cada grupo possui várias réplicas, que podem estar distribuídas geograficamente. O Paxos é responsável por fazer com que essas réplicas concordem sobre a ordem das operações realizadas naquele grupo. Dessa forma, uma falha de uma máquina ou de uma réplica não significa necessariamente a perda dos dados ou a indisponibilidade do sistema, desde que o grupo ainda consiga operar com as réplicas necessárias.
É importante falar que Paxos não resolve sozinho o problema das transações distribuídas. Ele garante consenso dentro de cada grupo de replicação. Quando uma transação precisa modificar dados pertencentes a mais de um grupo Paxos, é necessário um mecanismo adicional para coordenar esses grupos.
É aí que entram as transações distribuídas.
2. Transações distribuídas
Uma transação que acessa apenas um grupo Paxos é relativamente simples pois o próprio mecanismo de Paxos combinado com a lock table, já fornece os mecanismos necessários para a execução transacional. Porém, quando uma única transação envolve dados pertencentes a vários grupos Paxos, é necessário coordenar todos esses grupos para que a operação seja concluída de forma atômica.
Nesse cenário, o Spanner utiliza Two-Phase Commit (2PC) para coordenar os diferentes participantes da transação. Um dos grupos participantes é escolhido como coordenador, e seu Participant Leader atua como Coordinator Leader. Os demais grupos possuem seus próprios Participant Leaders, que participam do protocolo de commit.
Cada participante precisa preparar sua parte da transação. Os participantes que não são o coordenador adquirem os locks de escrita e registram seu estado de preparação utilizando Paxos. Depois eles informam ao coordenador o timestamp de preparação. O coordenador aguarda as respostas dos participantes e escolhe um timestamp de commit que respeite as restrições necessárias para preservar a ordem da transação. O resultado do commit também é registrado por meio do Paxos, garantindo que o estado do Transaction Manager seja replicado.
3. TrueTime
Mesmo resolvendo replicação e transações distribuídas, ainda existe outro problema que é como determinar a ordem global das transações?
Em um banco distribuído globalmente, diferentes máquinas possuem relógios diferentes. Mesmo utilizando sincronização de relógios, sempre existe algum nível de incerteza. Se cada máquina simplesmente utilizasse seu próprio relógio local para definir o timestamp de uma transação, poderia acontecer de uma transação que ocorreu posteriormente receber um timestamp menor que uma transação anterior.
O Spanner resolve esse problema com o TrueTime.
Em vez de fornecer um único valor afirmando "agora são exatamente X horas", a API retorna um intervalo:
TT.now() = [earliest, latest]
A ideia é que o tempo físico real esteja garantidamente dentro desse intervalo.
O Spanner mantém essa incerteza pequena utilizando múltiplas referências de tempo, incluindo GPS e relógios atômicos. O sistema não tenta fingir que todos os relógios são perfeitamente sincronizados. Pelo contrário: ele conhece explicitamente a margem de erro dos relógios e utiliza essa informação nos algoritmos de transação.
4. Commit Wait
Mas existe uma dificuldade pois mesmo que o Spanner conheça o intervalo de incerteza do relógio, ele ainda precisa garantir que um timestamp de commit não seja interpretado como pertencente ao futuro quando a transação já estiver sendo observada como concluída.
Por isso existe o Commit Wait.
O coordenador escolhe um timestamp de commit s, baseado no limite superior fornecido pelo TrueTime. Antes de permitir que a transação seja confirmada e observada pelos clientes, o sistema aguarda até que o limite inferior do intervalo retornado pelo TrueTime seja maior que s. Nesse momento, o sistema pode garantir que o timestamp de commit s pertence definitivamente ao passado.
O ponto mais importante é que o Commit Wait não serve simplesmente para "sincronizar os relógios". O relógio continua tendo incerteza. O que o Spanner faz é usar essa incerteza a seu favor.
Se uma transação recebe um timestamps, o sistema espera até que tenha certeza de que o tempo real já ultrapassou. Somente então o commit pode ser observado externamente. Dessa maneira, se uma segunda transação começar depois que a primeira já foi confirmada e observada pelo cliente, o TrueTime garante que a segunda transação não receberá um timestamp que viole essa ordem.
Resumo da ópera (Sim, foi gerado por IA Sherlock)
Após esse resumo sobre as principais features implementadas no Spanner que permitem trabalhar com transações distribuídas, dados replicados em datacenters ao redor do mundo e toda a consistência que torna o banco confiável, vamos partir para a mão na massa e emular o Spanner localmente e dar uma brincada.
Emulando o Cloud Spanner com cloud-spanner-emulator
É importante lembrar que o Spanner Emulator possui algumas limitações quando comparado ao Google Cloud Spanner disponibilizado pelo gcloud. O emulador é voltado principalmente para desenvolvimento e testes locais e não reproduz toda a infraestrutura distribuída do Spanner em produção. Por isso, ele não simula recursos como a replicação real entre regiões e datacenters, a distribuição física dos dados, a alta disponibilidade proporcionada pela infraestrutura global do Google Cloud, nem representa fielmente os comportamentos de latência e falhas de uma arquitetura distribuída real.
Para seguir, é necessário ter Git, Ruby, Docker e Docker Compose instalados na sua máquina. Eu uso Ubuntu como sistema operacional, então os comandos utilizados aqui são voltados para ele.
Primeiro, faça o clone do projeto na sua máquina clicando aqui.
O Spanner Emulator será executado via Docker Compose. A configuração desse serviço está definida no arquivo docker-compose.yml dentro do projeto.
services:
spanner:
image: gcr.io/cloud-spanner-emulator/emulator
ports:
- "9010:9010"
- "9020:9020"
Essa é uma configuração simples do Spanner Emulator, executando localmente em um container Docker, sem a necessidade de utilizar o gcloud para subir ou gerenciar a instância localmente.
Também não estamos configurando aqui um cenário com múltiplas réplicas ou uma topologia distribuída. A ideia é apenas ter uma instância local do emulator para podermos realizar nossos testes
Para subir o serviço em segundo plano, o comando é o:
docker compose up -d
Ele vai criar e executar o container do Spanner Emulator. Para garantir que ele está funcionando corretamente, você pode verificar os containers em execução com o comando:
docker ps
Você tera um retorno parecido com esse
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
7e5383d98efd gcr.io/cloud-spanner-emulator/emulator "./gateway_main --ho…" 11 days ago Up 8 seconds 0.0.0.0:9010->9010/tcp, [::]:9010->9010/tcp, 0.0.0.0:9020->9020/tcp, [::]:9020->9020/tcp spanner-spanner-1
Agora que temos o Spanner rodando, vou explicar como o código funciona passo a passo. Se quiser se aventurar sozinho pelo código, o README do projeto vai te ajudar a se situar.
Se você não conhece Ruby, nós usamos o Bundler para gerenciar e instalar nossas bibliotecas, que em Ruby chamamos de gems. As gems utilizadas pelo projeto, assim como suas respectivas versões, estão definidas no arquivo Gemfile.
# frozen_string_literal: true
source 'https://rubygems.org'
gem 'dotenv', '>= 3.2'
gem 'google-cloud-spanner', '>= 2.36'
gem 'mutex_m', '>= 0.3.0'
A gem principal para o nosso estudo é a google-cloud-spanner. Ela vai nos permitir utilizar a API do Spanner, conectar ao banco de dados e executar transações. O legal é que, nessa versão da gem, não precisamos adicionar uma gem secundária para gerenciar um pool de conexões, pois o próprio cliente do Spanner já trabalha com o gerenciamento de sessões.
A gem dotenv é responsável por carregar nossas variáveis de ambiente a partir do arquivo .env. Também temos a mutex_m, que foi adicionada por causa da versão do Ruby utilizada no projeto. Basicamente, ela é uma dependência necessária para o funcionamento da google-cloud-spanner nessa versão do Ruby.
Dito isso, podemos instalar as gems utilizando o comando:
bundle install
No contexto da gem google-cloud-spanner, um client é o objeto que representa sua conexão lógica com o serviço do Cloud Spanner. Ele é o ponto de entrada para acessar instâncias, bancos de dados e outros recursos.
Então temos um wrapper para criar nosso client em client.rb
# frozen_string_literal: true
require 'google/cloud/spanner'
require 'dotenv/load'
Dotenv.load
# Wraps the Google::Cloud::Spanner project client.
# Provides a configured Spanner client connected to the emulator.
class Client
def create
Google::Cloud::Spanner.new(
project_id: 'localhost'
)
end
end
Em instance.rb temos uma classe para a criação de uma instancia do Spanner basicamente essa instancia é o nível mais alto de organização é basicamente um container que agrupa bancos de dados. A instance define onde os dados moram e quanto recurso eles têm (no emulador isso é irrelevante mas em produção, cada instance tem N nós com processamento e armazenamento dedicados).
class Instance
def initialize
@client = Client.new.create # cria o client do projeto Spanner (Google::Cloud::Spanner)
end
attr_reader :client # expõe o client pra outros usarem (ex: Database#client)
def find
@client.instance('test-instance') # busca a instance existente
end
def create
@client.create_instance( # tenta criar a instance
'test-instance', # ID da instance
name: 'Test Instance', # nome amigável
config: 'emulator-config', # região/emulador
nodes: 1 # quantidade de nós
).wait_until_done! # aguarda a criação terminar (é async)
find # depois busca e retorna a instance criada
rescue Google::Cloud::AlreadyExistsError
find # se já existia, só retorna a existente
end
end
Para concluir a configuração, agora precisamos criar um banco de dados e fornecer um client conectado a esse banco para executar nossas transações, isso acontece na classe database.rb
# Importa a classe responsável por gerenciar a instância do Spanner.
require_relative 'instance'
# Gerencia o ciclo de vida e o acesso ao banco de dados Spanner.
class Database
# Define o identificador da instância do Spanner.
INSTANCE_ID = 'test-instance'
# Define o identificador do banco de dados.
DATABASE_ID = 'test-db'
# Inicializa e armazena a instância do Spanner.
def initialize
@instance = Instance.new.create
end
# Cria o banco ou retorna o banco existente.
def create
# Cria o banco e aguarda a conclusão da operação.
@instance.create_database(DATABASE_ID).wait_until_done!
# Retorna uma referência ao banco criado.
@instance.database(DATABASE_ID)
rescue Google::Cloud::AlreadyExistsError
# Retorna o banco caso ele já exista.
@instance.database(DATABASE_ID)
end
# Retorna um cliente reutilizável para acessar o banco.
def client
# Cria o cliente apenas uma vez e reutiliza nas próximas chamadas.
@client ||= Instance.new.client.client(INSTANCE_ID, DATABASE_ID)
end
end
Agora que temos toda a configuração explicada, precisamos de tabelas, pois estamos lidando com um banco de dados, afinal. Para isso, é uma prática legal criar migrations, que são scripts pré-definidos utilizados para criar e alterar tabelas, índices e outros recursos do banco de dados de forma versionada.
No projeto, temos a pasta migrations, que contém as migrações responsáveis por criar as tabelas Users e Accounts.
Exemplo em migrations/create_accounts.rb
# frozen_string_literal: true
require_relative 'base'
# Migration that creates the Accounts table.
class CreateAccounts < Migration
DDL = <<~SQL
CREATE TABLE Accounts (
AccountId INT64 NOT NULL,
Owner STRING(MAX) NOT NULL,
Balance NUMERIC NOT NULL,
UpdatedAt TIMESTAMP OPTIONS (allow_commit_timestamp=true)
) PRIMARY KEY (AccountId)
SQL
end
Para executar as migrações, temos um Rakefile que contém tarefas automatizadas escritas em Ruby. O comando para executar a tarefa responsável por rodar as migrações é:
bundle exec rake db:migrate
Se tudo ocorrer como o esperado nos teremos duas tabelas criadas no Emulador de Spanner. Para testar, vamos criar alguns registros e listar os dados de User e Account. Para isso temos os models, que basicamente representam as nossas tabelas criadas no Spanner e facilitam a interação com os dados por meio do código Ruby.
Exempo em models/user.rb
# frozen_string_literal: true
require_relative 'base'
# Represents a user record in the Users table.
class User < Base
TABLE_NAME = 'Users'
COLUMNS = %w[UserId Name UpdatedAt].freeze
def create(user_id:, name:)
commit_timestamp = Google::Cloud::Spanner::ColumnValue.commit_timestamp
super({ UserId: user_id, Name: name, UpdatedAt: commit_timestamp })
end
end
Para criar um registro, precisamos acessar o irb, que é o console interativo do Ruby. Com ele, podemos carregar nossas classes, instanciar objetos e executar as operações que precisamos diretamente pelo terminal.
Comando para acessar o irb
irb
Bem facil você não acha?
Para criar um registro
require_relative 'models/user'
user = User.new
user.create(user_id: 123, name: "John")
Ainda no mesmo console para conferir se o registro foi feito com sucesso, execute o find
user.find(123)
Você deve receber uma resposta assim
=> {UserId: 123, Name: "John", UpdatedAt: 2026-08-02 21:51:49.196166 UTC}
Isso significa que conseguimos criar e listar registros no Cloud Spanner. O próximo passo agora é executar os examples que estão no código e que permitem explorar algumas particularidades desse banco de dados.
O primeiro é o examples/transfer.rb. Esse código serve para demonstrar o conceito de ACID, mais especificamente a atomicidade das transações no Cloud Spanner.
# frozen_string_literal: true
require_relative '../models/account'
require_relative '../config/logger'
db = Database.new
db.create
client = db.client
# Garante dados limpos (upsert é idempotente)
client.commit { |c| c.upsert 'Accounts', [{ AccountId: 1, Owner: 'Alice', Balance: 1000 }] }
client.commit { |c| c.upsert 'Accounts', [{ AccountId: 2, Owner: 'Bob', Balance: 0 }] }
LOGGER.info 'Initial balance'
Account.new.all.each { |a| LOGGER.info " ##{a[:AccountId]} #{a[:Owner]}: $#{a[:Balance].to_f}" }
# =============================================================================
# Successful transfer: read, validate, debit, credit inside a transaction
# =============================================================================
client.transaction do |tx|
alice = tx.execute_query(
'SELECT AccountId, Balance FROM Accounts WHERE AccountId = @id',
params: { id: 1 }
).rows.first
raise 'Insufficient funds' if alice[:Balance] < 700
tx.execute_update 'UPDATE Accounts SET Balance = Balance - @amount WHERE AccountId = @id',
params: { amount: 700, id: 1 }
tx.execute_update 'UPDATE Accounts SET Balance = Balance + @amount WHERE AccountId = @id',
params: { amount: 700, id: 2 }
end
LOGGER.info 'After transfer'
Account.new.all.each { |a| LOGGER.info " ##{a[:AccountId]} #{a[:Owner]}: $#{a[:Balance].to_f}" }
# =============================================================================
# Rollback: an exception inside the block prevents commit
# =============================================================================
begin
client.transaction do |tx|
alice = tx.execute_query(
'SELECT Balance FROM Accounts WHERE AccountId = @id',
params: { id: 1 }
).rows.first
raise "Balance: $#{alice[:Balance]}" if alice[:Balance] < 500
tx.execute_update 'UPDATE Accounts SET Balance = Balance - @amount WHERE AccountId = @id',
params: { amount: 500, id: 1 }
end
rescue RuntimeError => e
LOGGER.info "Transfer denied: #{e.message}"
end
LOGGER.info 'Balances unchanged'
Account.new.all.each { |a| LOGGER.info " ##{a[:AccountId]} #{a[:Owner]}: $#{a[:Balance].to_f}" }
O que ele faz?
-
Cria o banco e garante que as contas existam:
- Alice: $1000
- Bob: $0
-
Executa uma transferência de $700 dentro de uma transação:
- Verifica o saldo de Alice.
- Retira $700 de Alice.
- Adiciona $700 a Bob.
- Se tudo der certo, a transação faz commit.
-
Testa um rollback:
- Tenta retirar $500 de Alice.
- Como o saldo é insuficiente, uma exceção é lançada.
- A transação não é confirmada e a alteração é desfeita.
Isso demonstra que o Spanner consegue manter a consistência dos dados durante operações que envolvem múltiplas alterações. Nesse caso, a transferência é atômica: ou as duas operações que são retirar o dinheiro de Alice e adicionar o dinheiro a Bob são confirmadas, ou nenhuma delas é aplicada.
Para rodar basta executar (fora do console irb)
ruby examples/transfer.rb
Exemplo de resposta
Initial balance
#1 Alice: $1000.0
#2 Bob: $0.0
After transfer
#1 Alice: $300.0
#2 Bob: $700.0
Transfer denied: Balance: $0.3e3
Balances unchanged
#1 Alice: $300.0
#2 Bob: $700.0
Outro exemplo adicionado é o examples/concurrency.rb Esse código testa a concorrência e o isolamento transacional do Cloud Spanner. Ele cria 10 threads simultâneas. Cada uma tenta transferir $100 da Alice para o Bob
Como as 10 transações acontecem ao mesmo tempo, várias podem tentar alterar o mesmo registro da Alice.
O que o Spanner faz?
O Spanner detecta que existem transações concorrentes conflitantes e algumas podem ser abortadas para preservar a consistência.
A Gem então pode tentar novamente automaticamente a transação abortada.
O exemplo em examples/concurrency.rb
# frozen_string_literal: true
require_relative '../models/account'
require_relative '../config/logger'
# =============================================================================
# Concurrency test: multiple threads racing to debit the same account.
#
# Spanner detects conflicts and aborts all but one transaction.
# The client automatically retries aborted transactions.
# =============================================================================
db = Database.new
db.create
client = db.client
client.commit { |c| c.upsert 'Accounts', [{ AccountId: 1, Owner: 'Alice', Balance: 1000 }] }
client.commit { |c| c.upsert 'Accounts', [{ AccountId: 2, Owner: 'Bob', Balance: 0 }] }
THREADS = 10
AMOUNT = 100
successful = Queue.new
failed = Queue.new
threads = (1..THREADS).map do |i|
Thread.new do
attempt = 0
begin
client.transaction do |tx|
attempt += 1
LOGGER.info "[T#{i}] attempt ##{attempt}"
alice = tx.execute_query(
'SELECT Balance FROM Accounts WHERE AccountId = @id', params: { id: 1 }
).rows.first
raise 'Insufficient funds' if alice[:Balance] < AMOUNT
tx.execute_update(
'UPDATE Accounts SET Balance = Balance - @amount WHERE AccountId = @id', params: { amount: AMOUNT, id: 1 }
)
tx.execute_update(
'UPDATE Accounts SET Balance = Balance + @amount WHERE AccountId = @id', params: { amount: AMOUNT, id: 2 }
)
end
successful << i
LOGGER.info "[T#{i}] committed after #{attempt} attempt(s)"
rescue RuntimeError
failed << i
LOGGER.info "[T#{i}] denied after #{attempt} attempt(s)"
end
end
end
threads.each(&:join)
alice = client.read('Accounts', %w[Balance], keys: 1).rows.first
bob = client.read('Accounts', %w[Balance], keys: 2).rows.first
LOGGER.info '=' * 50
LOGGER.info "Successful: #{successful.length}, Denied: #{failed.length}"
LOGGER.info "Alice: $#{alice[:Balance].to_f}, Bob: $#{bob[:Balance].to_f}"
if alice[:Balance].zero? && bob[:Balance] == 1000
LOGGER.info 'Correct'
else
LOGGER.info 'Unexpected balance'
end
Para executar
ruby examples/concurrency.rb
Exemplo de resposta
[T1] attempt #1
[T2] attempt #1
[T3] attempt #1
[T4] attempt #1
[T5] attempt #1
[T6] attempt #1
[T7] attempt #1
[T8] attempt #1
[T9] attempt #1
[T10] attempt #1
[T6] committed after 1 attempt(s)
[T2] attempt #2
[T3] attempt #2
[T4] attempt #2
[T7] attempt #2
[T8] attempt #2
[T9] attempt #2
[T5] attempt #2
[T10] attempt #2
[T1] attempt #2
[T7] committed after 2 attempt(s)
[T4] attempt #3
[T8] attempt #3
[T2] attempt #3
[T9] attempt #3
[T3] attempt #3
[T5] attempt #3
[T10] attempt #3
[T1] attempt #3
[T10] committed after 3 attempt(s)
[T8] attempt #4
[T2] attempt #4
[T9] attempt #4
[T3] attempt #4
[T5] attempt #4
[T1] attempt #4
[T4] attempt #4
[T1] committed after 4 attempt(s)
[T9] attempt #5
[T8] attempt #5
[T4] attempt #5
[T2] attempt #5
[T3] attempt #5
[T5] attempt #5
[T3] committed after 5 attempt(s)
[T2] attempt #6
[T9] attempt #6
[T5] attempt #6
[T8] attempt #6
[T4] attempt #6
[T2] committed after 6 attempt(s)
[T5] attempt #7
[T9] attempt #7
[T8] attempt #7
[T4] attempt #7
[T5] committed after 7 attempt(s)
[T8] attempt #8
[T9] attempt #8
[T4] attempt #8
[T9] committed after 8 attempt(s)
[T4] attempt #9
[T8] attempt #9
[T4] committed after 9 attempt(s)
[T8] attempt #10
[T8] committed after 10 attempt(s)
==================================================
Successful: 10, Denied: 0
Alice: $0.0, Bob: $1000.0
Correct
Resumidamente, o resultado mostra que:
- As 10 threads começaram simultaneamente, tentando transferir $100.
- Como todas alteram o saldo da mesma conta (Alice), ocorreram conflitos entre as transações.
- O Spanner abortou algumas transações conflitantes, e o cliente reexecutou automaticamente essas transações.
-
Por isso, algumas transações precisaram de várias tentativas:
- T1 → 4 tentativas
- T8 → 10 tentativas
No final, todas as 10 transações foram concluídas com sucesso.
O saldo final está correto:
- Alice: $0
- Bob: $1000
Isso demonstra que o Spanner consegue controlar a concorrência e manter a consistência dos dados mesmo com várias transações sendo executadas simultaneamente.
Esse é um código simples, criado apenas para testar o Spanner Emulator. É importante lembrar que o emulador não consegue reproduzir todas as principais funcionalidades e benefícios de executar o Spanner em uma infraestrutura real do Google Cloud, como a replicação de dados entre múltiplas regiões e a distribuição dos dados entre diferentes datacenters.
Conector do Spanner para Active Record
O código que mostrei foi desenvolvido em Ruby. Porém, para facilitar a integração do Cloud Spanner com aplicações desenvolvidas em Ruby on Rails, existe a gem activerecord-spanner-adapter, que funciona como um adaptador entre o Active Record e o Spanner.
Ferramenta para testes de carga
Para avaliar o desempenho do Cloud Spanner sob diferentes níveis de carga e concorrência, podemos utilizar o YCSB (Yahoo! Cloud Serving Benchmark). O próprio Google Cloud utiliza o YCSB em benchmarks do Spanner para avaliar workloads de chave-valor, simulando diferentes padrões de leitura e escrita.
Conclusão
No fim das contas, o que mais me chamou atenção no Spanner não foi uma única tecnologia isolada, mas a forma como várias ideias diferentes trabalham juntas para um problema extremamente chato e dificil de se resolver.
O Spanner tenta unir coisas que durante muito tempo pareciam exigir escolhas diferentes entre o modelo relacional e as transações ACID dos bancos tradicionais com a distribuição global e a escalabilidade horizontal esperadas de sistemas distribuídos.
Para isso, ele combina replicação e consenso com Paxos, transações distribuídas com Two-Phase Commit, controle de concorrência, e utiliza o TrueTime e o Commit Wait para garantir uma ordem global consistente entre as transações. Cada uma dessas peças resolve um problema específico, mas é a junção desse "Lego" de ideias que permite ao Spanner oferecer consistência forte em uma arquitetura distribuída globalmente.
No nosso pequeno laboratório local (que obviamente não conseguimos reproduzir toda essa infraestrutura) por mais simples que tenha sido, foi suficiente para brincar com algumas das ideias que vimos ao longo do artigo: criar tabelas, executar transações, testar atomicidade, provocar concorrência e observar o banco lidando com conflitos entre transações.
E acho que esse é o ponto principal deste artigo. O objetivo aqui não era explicar absolutamente tudo sobre o Spanner, muito menos substituir a documentação oficial. A ideia era simplesmente entender por que ele existe, quais problemas ele tenta resolver, como suas principais peças se encaixam e colocar a mão no código para ver um pouco disso funcionando na prática.
No final, talvez a melhor forma de resumir o Spanner seja justamente aquela pergunta do começo, que é como construir um banco de dados que funcione em vários datacenters espalhados pelo mundo sem abrir mão da consistência das transações?
Para mim o Cloud Spanner é uma boa resposta.
Referências
https://static.googleusercontent.com/media/research.google.com/pt-BR//archive/spanner-osdi2012.pdf
https://static.googleusercontent.com/media/research.google.com/pt-BR//pubs/archive/45855.pdf
https://dl.acm.org/doi/epdf/10.1145/3035918.3056103
https://medium.com/@sylvain.tiset/two-phase-commit-the-good-the-bad-and-the-blocking-eee29e1f5a84
https://cloud.google.com/blog/products/databases/benchmarking-spanner-for-key-value-workloads



Top comments (0)