Bluesky migra para SQLite de tenant único
(github.com/bluesky-social)- O PR #1705 de refatoração do PDS do atproto da Bluesky altera o PDS para usar um datastore SQLite de tenant único e passa a armazenar o repo de cada usuário e o estado privado da conta em seus próprios arquivos SQLite
- Os bancos de dados de usuário são armazenados na estrutura de caminho
/${dbDirectory}/${sha256Hex(did).slice(0,2)}/${did}, e a chave de assinatura de cada repo é mantida junto ao respectivo arquivo SQLite - A abstração existente de acesso a dados de usuário é substituída pelo ActorStore e, como o SQLite não oferece suporte a transações concorrentes, operações de escrita precisam estabelecer explicitamente uma transação com a store
- Handles de arquivos de banco de dados abertos e chaves de assinatura são gerenciados por LRUCache, mantendo em memória até 30 mil handles de arquivos abertos e 30 mil chaves; quando um DB é expulso do cache, o handle do arquivo é fechado
- Para gerenciamento do estado do serviço, são introduzidos 3 DBs SQLite separados, executados em modo WAL para possibilitar leituras concorrentes e replicação por streaming; a distribuição do PDS deve incluir Litestream ou uma ferramenta semelhante
Principais mudanças do PR
- O PR #1705 refatora o PDS para ser baseado em um datastore SQLite de tenant único
- Cada usuário tem seu próprio arquivo SQLite dedicado, que armazena o repo e o estado privado da conta desse usuário
- O DB de usuário é armazenado em um caminho hierárquico usando o hash do DID
- Formato do caminho:
/${dbDirectory}/${sha256Hex(did).slice(0,2)}/${did}
- Formato do caminho:
- A repo signing key de cada repo é armazenada no mesmo local do arquivo SQLite
ActorStore e modelo de transações
- A abstração de acesso a dados de usuário muda dos “services” existentes para ActorStore
- A principal diferença do ActorStore é que as classes para leitura e escrita são separadas
- Como o SQLite não oferece suporte a transações concorrentes, para realizar uma operação de escrita é necessário estabelecer claramente uma transação com a store
- O log de commits inclui retrabalho de reader e transactor, tratamento de race em transações do actor store e limpeza da interface da store
Gerenciamento de cache e handles de arquivos
- Um LRUCache é mantido para chaves de assinatura e bancos de dados
- Os limites configurados são os seguintes
- Máximo de 30 mil handles de arquivos abertos
- Máximo de 30 mil chaves mantidas em memória
- Quando um banco de dados é expulso do cache, o handle do arquivo é fechado
- Commits relacionados incluem
actor store in lru cacheefix open handles
3 DBs SQLite para estado do serviço
- Além dos DBs por usuário, são introduzidos 3 bancos de dados SQLite separados para gerenciar o estado do serviço
- service DB: gerencia informações de conta, códigos de convite, refresh tokens etc.
- did cache DB: contém apenas uma única tabela para cache de DID resolution
- sequencer DB: contém apenas uma única tabela que gerencia a ordem de atualização de todos os repos de um serviço
- Cada arquivo SQLite é executado em WAL mode
- O objetivo do WAL mode é permitir leituras concorrentes e replicação por streaming
- Há um plano de incluir Litestream ou uma ferramenta semelhante na distribuição do PDS
Revisão e status do merge
- Este PR é composto por um total de 143 commits e foi mesclado do branch
pds-sqlite-refactorpara o branchpds-v2 - A data do merge foi 1º de novembro de 2023, e o commit de merge é
8449ceb - O revisor devinivy aprovou a mudança após deixar várias notas e comentários
- devinivy avaliou que a refatoração traz “muitas simplificações excelentes” e, no geral, passa uma sensação de organização
- Após o merge, o branch
pds-sqlite-refactorfoi excluído
Pergunta posterior
- Em 28 de fevereiro de 2025, npetrangelo analisou a escala das mudanças deste PR e pediu um resumo dos trade-offs entre a arquitetura Postgres anterior e a arquitetura SQLite introduzida por este PR
- O texto fornecido não inclui uma resposta da Bluesky a essa pergunta
1 comentários
Comentários no Hacker News
Gosto de SQLite, mas a abordagem de ter um schema ou banco de dados separado por tenant geralmente traz muitas dificuldades
Em uma instância compartilhada, usando segurança em nível de linha (RLS), mesmo que uma migração falhe é possível fazer rollback de tudo, mas em schemas por tenant, se uma migração de dados falha por causa de dados inesperados, os usuários acabam presos em versões diferentes de schema até que a causa seja encontrada
Quando se chega à escala de sharding, algo parecido pode acontecer de qualquer forma, mas até lá, um único banco de dados é o mais simples, e depois pode até ser necessário juntar os dados ou transferir a propriedade de recursos de forma atômica
Não sou contra essa configuração e ela tem seus casos de uso, mas na empresa estamos fugindo com tudo de schema por tenant. Se você não investir direito, os problemas são numerosos demais, e raramente se está preparado para isso quando a ideia surge pela primeira vez
O curioso é que, uns 10 anos atrás, o app começou com SQLite por tenant, depois migrou para schemas por tenant no PostgreSQL, e agora está indo para um schema único com RLS, ou seja, foi exatamente na direção oposta
Tendo lidado com bancos de dados gigantes em produção, não quero passar por isso de novo
Quando a carga fica grande o suficiente, toda mudança vira um risco, porque não dá para testar completamente todos os casos extremos de performance
Também é um padrão comum um usuário do plano gratuito encontrar um caminho de código sem índice e derrubar a produção
Alguns usuários ficarem em versões diferentes de schema por causa de falha em migração de dados talvez não seja um grande problema
Em um serviço grande e complexo nesse nível, normalmente as atualizações de schema são feitas em etapas: 1. tornar o código compatível com o schema futuro, 2. migrar os dados, 3. remover o suporte ao schema antigo
Então, em geral, deveria ser seguro operar por bastante tempo no estado entre as etapas 1 e 2. Claro, bugs novos são exceção, mas do ponto de vista operacional, um sistema que volta para um estado intermediário de migração também pode ser aceitável quando se usa esse procedimento
Se o produto tem menos de 100 clientes, pode até ser melhor que cada usuário esteja em uma versão diferente de schema
Cada cliente pode ter cronogramas e exigências de upgrade diferentes, e conheço negócios que fazem trabalho sob medida para alguns clientes e, na prática, nem rodam o mesmo código
No fim, depende da estrutura do negócio
Sendo justo, 10 anos atrás RLS ainda não existia. Surgiu no PostgreSQL 9.5 em 2016
https://blog.turso.tech/introducing-embedded-replicas-deploy...
https://electric-sql.com/
Não entendo o que querem dizer com “SQLite não suporta transações simultâneas”
Pelo que eu sei, suporta, desde que o arquivo
.dbnão seja acessado por compartilhamento de arquivos como UNC ou NFS: https://www.sqlite.org/wal.htmlJá usei para ler e atualizar o banco de dados a partir de várias threads/processos na mesma máquina, e se for preciso uma visão consistente ou se não quiser manter a transação aberta por muito tempo, também dá para fazer snapshot com a sqlite backup API
Posso estar deixando passar algo, e faz alguns anos que não mexo com SQLite, então não tenho certeza
Não, eu estava enganado. Na prática, é mais algo como múltiplas leituras, uma única escrita
Acho que eu vinha assumindo isso sem conferir com cuidado suficiente. Ainda assim, a maioria dos bancos de dados que fiz com SQLite era muito mais voltada para leitura do que para escrita
Correção feita
Se esperar um pouco, o hctree [1] deve estabilizar, e então será possível escolher entre os mecanismos de backend tradicionais e o backend com suporte a concorrência recém-implementado
[1] https://sqlite.org/hctree/doc/hctree/doc/hctree/index.html
Segundo a documentação, o escritor apenas acrescenta novo conteúdo ao fim do arquivo WAL, então leitura e escrita podem acontecer ao mesmo tempo, mas como existe apenas um arquivo WAL, só pode haver um escritor por vez
Acho que o texto original queria dizer que as operações de atualização precisam ser executadas de forma sequencial
Com tráfego baixo funciona, mas quando as transações crescem ou aumenta o número de escritas simultâneas, em algum momento aparecem problemas de database locked, mesmo com WAL ativado
Dá para contornar isso em certa medida no nível da aplicação, mas em geral, se você chegou nesse ponto, deveria considerar seriamente outro backend de banco de dados
Pelo menos da última vez que verifiquei, isso provavelmente quer dizer que não há bloqueio em nível de linha, e que até o bloqueio em nível de tabela é bem limitado
Pela documentação, o escritor ainda bloqueia o banco de dados inteiro
Interessante, e gostei da estratégia de manter uma relação 1:1 entre usuário e banco de dados
Mas fiquei curioso sobre como eles tratam dados que exigem agregação entre usuários. Se eu sigo outro usuário e ele publica algo, como meu banco de dados é atualizado com a nova postagem? Ou essa arquitetura vale apenas para dados persistentes como dados de perfil e relações de follow, enquanto dados interativos como feed são tratados separadamente?
Também é legal que o “pooling de conexões” seja basicamente só limitar o número de handles abertos com um cache LRU. Como cada conexão de DB é single-threaded, também é interessante que a concorrência seja tratada no nível de tenancy, e não no nível da conexão
Parece que também seria fácil colocar limitação de taxa por banco de dados em cima disso para bloquear abuso de usuários específicos
Também fiquei curioso se existe uma forma simples de configurar o Litestream para um número arbitrário de bancos de dados
Sempre fico feliz de ver a adoção de SQLite/Litestream crescendo no servidor. Nós também usamos ao criar apps novos
SQLite + Litestream é uma escolha melhor para bancos de dados por tenant, e replicar/fazer backup em S3/R2 sai muito mais barato do que um banco de dados gerenciado caro na nuvem [1]
Até 3900% mais barato que o SQLServer da Azure
[1] https://docs.servicestack.net/ormlite/litestream
Não entendi o que significa 3900% mais barato
Num antigo emprego em fintech, a empresa armazenava as contas dos clientes como arquivos sqlite3 criptografados em armazenamento de blobs, e isso combinava bem com o padrão de acesso
À primeira vista, isso parece uma combinação do pior com o terrível
Eu adoraria que alguém escrevesse um bom texto explicando as vantagens com números reais e analisando os defeitos esperados. Pode ser um tema realmente interessante se for aprendido direito
À primeira vista, parece uma escolha bem razoável, especialmente se a ideia for construir um sistema distribuído que será executado e implantado por muitos usuários que não são administradores de sistemas profissionais
E parece que esse deve ser o objetivo aqui, então eu esperaria que uma meta de projeto fosse evitar a necessidade de configurar, provisionar e administrar um banco de dados adicional ou outro servidor
Queria que alguém que conhece melhor o Bluesky explicasse quais dados vão para o SQLite e quais não vão
Estou assumindo que coisas como mensagens entre usuários não vão
Pense em email. Se você envia um email e coloca cinco pessoas em cópia, sete pessoas acabam armazenando a mesma cópia do email em seus próprios servidores de email
Ou seja, não é uma estrutura com um banco de dados central contendo um único email ao qual os outros fazem referência
O sharding de banco de dados relacional basicamente funciona assim também
Esse tipo de desnormalização de dados é quase indispensável à medida que a aplicação escala, especialmente em aplicações muitos-para-muitos com alta proporção de leituras em relação a escritas
Se a proporção de leituras em relação a escritas for baixa, uma estrutura de banco de dados relacional com um único master e vários slaves ainda consegue lidar com uma quantidade surpreendente de requisições e dados
Hoje, o Bluesky praticamente hospeda sozinho o único PDS, mas o objetivo final é que todo usuário final tenha seu próprio PDS
A Inrupt/SOLID chama esse conceito de “pod”
Na prática, eles integraram ontem o segundo PDS de produção, então há progresso
Só existem mensagens públicas transmitidas para o mundo inteiro
Não fui pesquisar se existem planos para mensagens diretas
Qual seria o motivo de fazer hash do usuário com sha256 e separá-lo em um diretório de destino de dois caracteres?
md5 não seria muito mais rápido e resolveria o mesmo problema?
Também evita ter que responder à pergunta “por que você usou um hash inseguro?” e tem mais valor eliminar ou minimizar uma possível classe de problemas de segurança
Ou talvez, como eu, estejam soterrados pelas ferramentas de segurança da empresa e não queiram criar exceções separadas para cada uso de md5
Se você não precisa de um hash seguro, há muitos hashes rápidos não criptográficos
O Bluesky ainda é só por convite?
É uma forma de limitar o crescimento enquanto eles escalam o sistema do lado do backend e da prevenção de abusos
Há uma fila dedicada para desenvolvedores, e dá para conseguir acesso bem rápido: https://atproto.com/blog/call-for-developers