- Há 3 anos, a Notion melhorou com sucesso a velocidade dos apps da Notion para Mac e Windows ao usar um banco de dados SQLite para armazenar dados em cache no cliente
- Desta vez, conseguiu levar a mesma melhoria também para os usuários que acessam a Notion pelo navegador
- Este artigo é uma análise aprofundada de como a Notion melhorou o desempenho no navegador usando a implementação
sqlite3 em WebAssembly (WASM)
- Com o uso de SQLite, o tempo de navegação entre páginas melhorou 20% em todos os navegadores modernos
- A diferença foi ainda mais evidente para usuários cujo tempo de resposta da API era especialmente lento devido a fatores externos, como a conexão com a internet
- Por exemplo, para usuários na Austrália o tempo de navegação entre páginas ficou 28% mais rápido, na China 31% e na Índia 33%
Tecnologias principais: OPFS e Web Workers
- A biblioteca WASM SQLite usa a API moderna do navegador chamada Origin Private File System (OPFS) para manter os dados entre sessões
- O OPFS só pode ser usado em Web Workers
- Um Web Worker pode ser entendido como um código que roda em uma thread separada da thread principal, onde a maior parte do JavaScript do navegador é executada
- A Notion é empacotada com Webpack, que oferece uma sintaxe fácil de usar para carregar Web Workers
- Foi configurado um Web Worker para criar ou carregar um arquivo de banco de dados SQLite existente usando OPFS, e o código de cache já existente passou a rodar nesse Web Worker
- A biblioteca Comlink foi usada para facilitar o gerenciamento da troca de mensagens entre a thread principal e o Worker
Abordagem baseada em SharedWorker
- A arquitetura final foi baseada em uma nova solução apresentada por Roy Hashimoto em uma discussão no GitHub
- Uma abordagem que permite que apenas uma aba por vez acesse o SQLite, ao mesmo tempo em que outras abas também podem executar consultas SQLite
- Como essa nova arquitetura funciona?
- Em resumo, cada aba tem um Web Worker dedicado que pode escrever no SQLite
- Mas, na prática, apenas uma aba pode usar de fato o Web Worker
- O SharedWorker é responsável por gerenciar qual é a "aba ativa"
- Quando a aba ativa é fechada, o SharedWorker sabe que precisa escolher uma nova aba ativa
- Para executar uma consulta SQLite, a thread principal de cada aba envia a consulta ao SharedWorker, e o SharedWorker a redireciona para o Worker dedicado da aba ativa
- As abas podem executar quantas consultas SQLite quiserem ao mesmo tempo, e tudo é sempre roteado para uma única aba ativa
- Cada Web Worker acessa o banco de dados SQLite usando a implementação OPFS SyncAccessHandle Pool VFS, que funciona em todos os principais navegadores
Por que a abordagem simples não funcionou
- Antes de construir a arquitetura descrita acima, foi feita uma tentativa de rodar o WASM SQLite de uma forma mais simples, com um Web Worker dedicado por aba e cada Web Worker escrevendo no banco de dados SQLite
- No entanto, nenhuma delas era suficiente para atender às necessidades da Notion sem adaptações
Obstáculo #1: isolamento entre origens
- Para usar OPFS via
sqlite3_vfs, o site precisava estar em estado de "isolamento entre origens"
- Para adicionar o isolamento entre origens à página, era necessário configurar alguns cabeçalhos de segurança que restringem quais scripts podem ser carregados
- Configurar esses cabeçalhos poderia dar bastante trabalho
- A Notion depende de muitos scripts de terceiros para várias funções da infraestrutura web, então alcançar isolamento total entre origens exigiria pedir a cada fornecedor que configurasse novos cabeçalhos e alterasse a forma como os iframes funcionam — uma exigência difícil de viabilizar na prática
- Nos testes, foi possível obter dados importantes de desempenho oferecendo essa variante a um subconjunto de usuários por meio de Origin Trials para
SharedArrayBuffer, disponíveis nos navegadores Chrome e Edge
- Com esses Origin Trials, foi possível contornar temporariamente a exigência de isolamento entre origens
Obstáculo #2: problemas de corrupção
- Quando o OPFS via
sqlite3_vfs foi disponibilizado para um pequeno grupo de usuários, começaram a surgir bugs graves para alguns deles
- Esses usuários passaram a ver dados incorretos nas páginas
- Por exemplo, comentários atribuídos à pessoa errada ou links para novas páginas cuja prévia era de uma página completamente diferente
- Ao examinar os arquivos de banco de dados dos usuários afetados, apareceu um padrão indicando que o banco SQLite estava corrompido de alguma forma
- Ao selecionar linhas de certas tabelas, surgiam erros; ao inspecionar as próprias linhas, foram encontrados problemas de consistência, como múltiplas linhas com o mesmo ID, mas com conteúdos diferentes
- Sobre como o banco SQLite chegou a esse estado, a hipótese foi de que isso ocorreu por causa de problemas de concorrência
- Havia várias abas abertas, e cada aba tinha um Web Worker dedicado com uma conexão ativa com o banco de dados SQLite
- O aplicativo da Notion escreve com frequência no cache sempre que recebe atualizações do servidor, ou seja, as abas acabavam escrevendo no mesmo arquivo ao mesmo tempo
- Já estava sendo usada uma abordagem com transações para agrupar consultas SQLite em lote, mas havia forte suspeita de que a corrupção ocorria por falta de tratamento de concorrência do lado da API OPFS
- Por isso, começaram a registrar logs dos erros de corrupção e tentaram algumas soluções paliativas, como adicionar Web Locks e permitir que apenas a aba em foco escrevesse no SQLite
- Esses ajustes reduziram a taxa de corrupção, mas não o suficiente para reativar o recurso no tráfego de produção
- Ainda assim, foi possível confirmar que os problemas de concorrência contribuíam significativamente para a corrupção
- Esse problema não ocorria no app desktop da Notion
- Nessas plataformas, apenas um único processo pai escreve no SQLite
- É possível abrir quantas abas quiser no app, mas sempre apenas uma thread acessa o arquivo do banco de dados
Obstáculo #3: a alternativa só pode rodar em uma aba por vez
- A variante OPFS SyncAccessHandle Pool VFS também foi avaliada
- Essa variante não exige
SharedArrayBuffer, então pode ser usada em Safari, Firefox e outros navegadores sem Origin Trial para SharedArrayBuffer
- A desvantagem dessa variante é que ela só pode rodar em uma aba por vez
- Se uma aba posterior tentar abrir o banco de dados SQLite, simplesmente ocorre um erro
- Por um lado, isso significa que o OPFS SyncAccessHandle Pool VFS não sofre dos problemas de concorrência da variante OPFS via
sqlite3_vfs
- Isso foi confirmado porque não foram encontrados problemas de corrupção ao disponibilizá-la para um pequeno grupo de usuários
- Por outro lado, essa variante não podia ser lançada como estava, porque a intenção era que todas as abas dos usuários se beneficiassem do cache
Resolvendo o problema
- O fato de nenhuma variante poder ser usada como estava levou à construção da arquitetura com SharedWorker descrita acima
- Essa arquitetura é compatível com uma dessas variantes de SQLite
- Ao usar a variante OPFS via
sqlite3_vfs, como apenas uma aba escreve por vez, é possível evitar os problemas de corrupção
- Ao usar a variante OPFS SyncAccessHandle Pool VFS, o SharedWorker permite que o cache funcione em todas as abas
- Depois de confirmar que essa arquitetura funcionava com ambas as variantes, que a melhora de desempenho era visível nas métricas e que não havia problemas de corrupção, chegou o momento de escolher qual variante seria usada em definitivo
- Foi escolhido o OPFS SyncAccessHandle Pool VFS, porque ele não exige isolamento entre origens e, portanto, não impede o lançamento em navegadores além de Chrome e Edge
Mitigando regressões de desempenho
- Quando essa melhoria começou a ser disponibilizada aos usuários, foram encontrados alguns problemas de regressão de desempenho que precisavam ser corrigidos, como tempos de carregamento mais lentos
O carregamento da página ficou mais lento
- A primeira descoberta foi que a movimentação entre páginas da Notion ficou mais rápida, mas o carregamento inicial da página ficou mais lento
- A análise de profiling mostrou que, em geral, o carregamento da página não fica limitado pela busca de dados
- O código de inicialização do app da Notion executa outras tarefas enquanto espera a conclusão das chamadas de API, como parsing de JS e configuração do app, então ele não se beneficia do cache SQLite tanto quanto a navegação
- A lentidão aconteceu porque o usuário precisava baixar e processar a biblioteca WASM SQLite
- Isso bloqueava o processo de carregamento da página e impedia que outras tarefas de carregamento acontecessem ao mesmo tempo
- Como essa biblioteca tem algumas centenas de kilobytes, o tempo adicional apareceu de forma perceptível nas métricas
- Para resolver isso, a forma de carregar a biblioteca foi ajustada levemente
- O WASM SQLite passou a ser carregado de forma totalmente assíncrona, sem bloquear o carregamento da página
- Isso significava que os dados da página inicial quase nunca seriam carregados a partir do SQLite
- Isso foi considerado aceitável, porque foi avaliado de forma objetiva que o ganho de velocidade ao carregar a página inicial a partir do SQLite não compensava a perda causada pelo download da biblioteca
- Após aplicar a mudança, as métricas de carregamento inicial da página ficaram iguais entre o grupo de teste e o grupo de controle do experimento
Dispositivos lentos não se beneficiavam do cache
- Outro comportamento encontrado nas métricas foi que, embora o tempo mediano de navegação entre páginas da Notion tenha melhorado, o tempo no percentil 95 ficou pior
- Certos dispositivos, como celulares com navegador apontando para a Notion, não se beneficiavam do cache e na verdade apresentavam piora
- A resposta para esse enigma veio de uma investigação anterior conduzida pela equipe mobile
- Quando esse cache foi implementado no aplicativo mobile nativo, alguns dispositivos, como celulares Android antigos, tinham leitura de disco muito lenta
- Portanto, não era possível assumir que carregar dados do cache em disco seria mais rápido do que carregar os mesmos dados via API
- Como resultado dessa investigação mobile, já existia na navegação de páginas uma lógica que fazia duas requisições assíncronas (SQLite e API) "competirem" entre si
- Essa lógica foi simplesmente reimplementada no caminho de código dos cliques de navegação
- Isso fez com que o percentil 95 do tempo de navegação ficasse igual entre os dois grupos do experimento
Conclusão
- Levar ao navegador as melhorias de desempenho do SQLite para a Notion teve seus próprios desafios
- Em especial, surgiram várias incógnitas ligadas a tecnologias novas, e algumas lições foram aprendidas ao longo do processo:
- O OPFS, por padrão, não lida com concorrência de forma elegante. Os desenvolvedores precisam estar atentos a isso e projetar suas soluções levando esse fator em conta
- Web Workers e SharedWorkers (e seus parentes Service Workers, não abordados neste artigo) têm capacidades diferentes, e pode ser útil combiná-los quando necessário
- Até a primavera de 2024, implementar totalmente o isolamento entre origens em uma aplicação web sofisticada não é algo simples, especialmente quando há uso de scripts de terceiros
- Como resultado do cache de dados com SQLite no navegador para os usuários, foi observada a melhora de 20% no tempo de navegação mencionada anteriormente, sem piora em outras métricas
- O mais importante é que não foram observados problemas causados por corrupção do SQLite
- Considera-se que o sucesso e a estabilidade dessa abordagem final se devem à equipe responsável pela implementação oficial em WASM do SQLite, assim como a Roy Hashimoto por disponibilizar ao público essa abordagem experimental
6 comentários
É por isso que serviços que precisam colaborar com terceiros já deveriam sair com isolamento entre origens ativado desde o primeiro lançamento...
Opa, cometkim, que bom te ver por aqui
Quando abro uma página do Notion no Firefox, ela trava e fica impossível de usar. Será que é por causa disso..? (O app do Notion funciona bem, então por enquanto estou usando ele)
Provavelmente é isso. O Enda também só oferece suporte à gravação de arquivos locais no Chrome e no Edge.
Aconteceu comigo em um laptop Linux antigo; bastava abrir no modo privado.