Projete sistemas de software como um engenheiro sênior — curso completo
Autoria: freeCodeCamp.org · Assistir no YouTube
Vídeo de autoria do canal indicado, não da IANIA; todos os direitos pertencem ao criador. Transcrição, resumo e tradução gerados automaticamente; o conteúdo no idioma original é preservado na versão em idioma original.
Idioma original: inglês
Vídeo original: Design Software Systems Like a Senior Engineer – Complete Course · Canal: freeCodeCamp.org · 3:16:33 · Assistir no YouTube
Tópicos: Dados, bancos de dados e embeddings, Engenharia de software com IA, Infraestrutura e hardware de IA, Open source e ecossistemas de software
O curso apresenta um framework de cinco etapas para organizar entrevistas de system design, esclarecer requisitos e discutir decisões técnicas sob pressão. Em seguida, aplica o método a sistemas semelhantes ao Spotify, Booking.com e WhatsApp, cobrindo arquitetura, APIs, armazenamento, escalabilidade e falhas. Os exemplos destacam trade-offs como consistência e disponibilidade, além de técnicas para lidar com concorrência, conexões em tempo real e entrega confiável de mensagens. O curso também enfatiza comunicação, julgamento técnico e planejamento como competências que diferenciam engenheiros seniores.
Pontos-chave
Seção intitulada “Pontos-chave”- Entrevistas de system design avaliam comunicação, pensamento estruturado, julgamento técnico, profundidade prática e gestão do tempo.
- O framework proposto tem cinco etapas: esclarecer requisitos, projetar dados e APIs, definir a arquitetura, analisar escala e confiabilidade e concluir com trade-offs e extensões.
- O design deve começar pelos requisitos e estimativas de capacidade, em vez de partir diretamente para a escolha de tecnologias ou para o código.
- No sistema de streaming de música, arquivos de áudio dominam o armazenamento e a largura de banda; object storage e CDN ajudam a atender essa necessidade.
- No sistema de reservas, consistência forte e controle de concorrência são fundamentais para evitar reservas duplicadas.
- No app de mensagens, WebSocket, filas, armazenamento escalável e sincronização entre dispositivos sustentam a entrega em tempo real.
- Cada decisão arquitetural deve ser justificada pelas necessidades do sistema e acompanhada de seus trade-offs.
- O design precisa considerar falhas, monitoramento, recuperação, segurança e limites de uso, além dos fluxos de sucesso.
Capítulos
Seção intitulada “Capítulos”| Início | Capítulo | Resumo |
|---|---|---|
| 00:00 | Objetivos e desafios das entrevistas de system design | O curso apresenta um método para estruturar entrevistas e explica por que essas habilidades são relevantes em diferentes níveis de carreira. |
| 09:21 | Framework de cinco etapas | O instrutor detalha como esclarecer requisitos, projetar dados e APIs, definir a arquitetura, avaliar confiabilidade e concluir com trade-offs. |
| 23:51 | Projeto de uma plataforma de streaming de música | O exemplo inspirado no Spotify começa pelos requisitos e estimativas de capacidade para músicas, metadados e tráfego. |
| 35:19 | APIs e armazenamento para streaming de música | O curso define endpoints e modelos de dados para músicas, artistas e playlists, e justifica o uso de armazenamento de objetos e SQL. |
| 44:18 | Escalabilidade do sistema de música | O exemplo amplia a escala com CDN, servidores stateless, réplicas de leitura, sharding e armazenamento regional. |
| 51:08 | Requisitos de um sistema de reservas | O projeto inspirado no Booking.com esclarece requisitos de busca, pagamentos, cancelamentos, preços dinâmicos e consistência. |
| 1:04:04 | Arquitetura e serviços para reservas | O instrutor propõe serviços para hotéis, reservas, preços, pagamentos e notificações, além de discutir busca e separação entre serviços. |
| 1:17:09 | APIs e prevenção de reservas duplicadas | O curso percorre os endpoints de reservas e apresenta formas de impedir submissões duplicadas e lidar com cancelamentos. |
| 1:32:28 | Modelagem de dados e concorrência em reservas | O exemplo modela inventário e transações e compara isolamento serializável com bloqueios de linha para evitar conflitos. |
| 1:52:52 | Requisitos e arquitetura de um app de mensagens | O projeto inspirado no WhatsApp define requisitos de mensagens, presença, mídia e entrega, e apresenta os componentes da arquitetura. |
| 2:12:17 | APIs e sincronização entre dispositivos | O curso descreve os fluxos de comunicação, confirmações de leitura e sincronização de mensagens entre vários dispositivos. |
| 2:28:29 | Entrega de mensagens em grupo e envio de mídia | O exemplo explica a distribuição de mensagens em grupos e o uso de URLs pré-assinadas e CDN para transferir arquivos. |
| 2:40:32 | Processamento, criptografia e segurança | O curso aborda escrita em lote, filas de mensagens, criptografia de ponta a ponta e limites para prevenir abusos. |
| 2:52:04 | Sharding, réplicas e limpeza de dados | O instrutor compara estratégias de particionamento e replicação e descreve tarefas agendadas para remover dados expirados. |
| 3:04:28 | Distribuição global e recuperação de falhas | O curso discute regiões, falhas do Kafka e recuperação de mensagens, além de lançamentos graduais com feature flags. |
| 3:13:24 | Como aplicar o framework em entrevistas | A conclusão reforça começar por requisitos e estimativas, explicar trade-offs e verbalizar o raciocínio durante a entrevista. |
Citações
Seção intitulada “Citações”Os requisitos são muito vagos, e cabe a você esclarecê-los, definir o escopo e propor a solução. — 04:08
Você mostra que consegue pensar no que quebra sob carga pesada, não apenas nos fluxos ideais. — 16:37
O segredo é começar de forma simples, sem projetar demais para poucos usuários, e aumentar a escala gradualmente. — 48:31
Você não precisa projetar um sistema perfeito em uma ou duas horas. — 3:15:29
Conceitos e entidades
Seção intitulada “Conceitos e entidades”- System design (conceito): Processo de definir a arquitetura, os componentes e os fluxos de dados de um sistema com base em requisitos e restrições.
- CAP theorem (conceito): Teorema discutido no curso para explicar trade-offs entre consistência, disponibilidade e tolerância a partições.
- SQL (conceito): Tipo de banco de dados relacional apresentado para dados com relações, consultas complexas e requisitos de consistência.
- NoSQL (conceito): Categoria de bancos de dados apresentada como opção para workloads de alto volume de escrita e consultas previsíveis.
- WebSocket (conceito): Conexão persistente bidirecional usada para comunicação em tempo real entre clientes e servidores de chat.
- Change Data Capture (CDC) (conceito): Técnica apresentada para propagar alterações do banco relacional ao Elasticsearch e manter o índice atualizado.
- Consistent hashing (conceito): Método de distribuição de chaves entre servidores que reduz a quantidade de dados movida quando novos servidores são adicionados.
- Signal Protocol (conceito): Protocolo citado como base para criptografar mensagens de ponta a ponta em um app de mensagens.
- Spotify (empresa): Plataforma usada como referência para projetar um sistema de streaming de música.
- Booking.com (empresa): Plataforma usada como referência para projetar um sistema de busca e reserva de hotéis.
- WhatsApp (empresa): App de mensagens usado como referência para discutir entrega em tempo real, sincronização e escalabilidade.
- PostgreSQL (ferramenta): Banco de dados relacional apresentado para armazenar dados transacionais e manter a fonte de verdade das reservas.
- Cassandra (ferramenta): Banco de dados apresentado como opção para armazenar grandes volumes de mensagens com alto throughput de escrita.
- Elasticsearch (ferramenta): Mecanismo de busca apresentado para consultas textuais e filtragem rápida de hotéis.
- Kafka (ferramenta): Sistema de mensagens usado para desacoplar serviços, processar eventos e persistir mensagens de forma assíncrona.
- Redis (ferramenta): Armazenamento em memória apresentado para cache de disponibilidade, presença e conexões de usuários.
- Amazon S3 (ferramenta): Object storage citado para armazenar arquivos de áudio e mídia em grande escala.
- Hike Simon (pessoa): Pessoa identificada na introdução como responsável pelo desenvolvimento do curso.
Tradução completa (pt-BR)
Seção intitulada “Tradução completa (pt-BR)”00:00 Aprenda design de sistemas de software e passe em entrevistas. Este curso apresenta uma estrutura prática de cinco etapas para organizar seu raciocínio e lidar com requisitos vagos sob pressão. Além disso, o curso percorre exemplos do mundo real, projetando do zero sistemas semelhantes aos do Spotify, Booking.com e WhatsApp. Hike Simon desenvolveu este curso. >> Hoje, a maioria das vagas de engenharia de software exige conhecimento de design de sistemas. Seja para o próprio trabalho ou para
00:30 entrevistas, espera-se que você saiba projetar sistemas em grande escala. E, especialmente para cargos de nível sênior e staff, esse é um dos fatores decisivos para a empresa. Por isso, preparei este guia completo para entrevistas de design de sistemas, com exemplos de sistemas reais em produção que aparecem com mais frequência nessas entrevistas. Primeiro, vamos começar pela estrutura que ensina como organizar seu raciocínio, que perguntas de esclarecimento fazer e como garantir que você cubra tudo
01:00 o que o entrevistador procura, sem perder o foco. Depois, vamos aplicar essa mesma estrutura em três exemplos diferentes de design de sistemas, para que você veja como ela se aplica a sistemas diferentes em produção. Esta é a estrutura mais prática que conheço para passar em entrevistas de design de sistemas. No início da minha carreira, fui reprovado em muitas dessas entrevistas ao me candidatar a cargos seniores. E, olhando para trás agora, havia um padrão comum a todas elas: o design de sistemas
01:31 quase sempre fazia parte da conversa. E você pode argumentar que, hoje em dia, isso também é um requisito para cargos de nível intermediário, especialmente agora que todos os engenheiros de software usam IA para escrever a implementação do código. As empresas gostariam de saber se você entende o panorama geral, como os componentes se comunicam, como projetar soluções do zero e discutir as compensações. É claro que a profundidade do conhecimento varia dependendo de você estar se candidatando a cargos de nível intermediário ou
02:03 sênior, mas ainda assim você precisa estar preparado para esse tipo de entrevista, seja de nível intermediário, sênior ou acima. Isso aparece de diferentes formas. Na verdade, a mais comum que conhecemos é o formato de quadro branco, em que há uma sessão para projetar do zero um sistema completo ou uma funcionalidade. Mas, na verdade, existem mais três tipos. Outra possibilidade é discutir os gargalos da infraestrutura existente da empresa e verificar se você consegue sugerir soluções para eles e se
02:36 tem o conhecimento básico de design de sistemas. Outro tipo é percorrer seus projetos anteriores. São perguntas sobre seus projetos anteriores para verificar se você realmente entende a arquitetura do que construiu. Também pode haver perguntas conceituais que testam a profundidade do seu conhecimento sobre conceitos de design de sistemas. Mas, mesmo que você aprenda todos os conceitos de design de sistemas, ainda precisa de uma estrutura para conduzir uma discussão de 45 ou 60 minutos e aproveitar ao máximo
03:06 esse tempo. É exatamente por isso que temos esta estrutura e este guia. Outra coisa importante, antes de discutirmos a estrutura, é que a maioria dos engenheiros pensa que está sendo avaliada apenas pelo sistema que projeta, mas isso é apenas uma das cinco dimensões avaliadas. Só de saber quais são essas dimensões já vai ajudar muito mais na sua preparação para esse tipo de entrevista. Antes de chegar às dimensões, vamos primeiro entender por que isso parece difícil em comparação com outros
03:37 tipos de entrevista, como as etapas comportamentais ou de programação ao vivo. Por exemplo, se for uma etapa de programação ao vivo, geralmente é um problema no estilo LeetCode ou algum desafio do mundo real. Mas, de qualquer forma, há entradas e saídas claras, certo? Digamos que seja um desafio no estilo LeetCode: você tem uma função que aceita esse tipo de argumento. Você sabe exatamente qual é o tipo do argumento. Sabe exatamente o que se espera que a função retorne como saída. Então, fica
04:08 claro se sua solução funciona ou não, porque você pode copiar e colar esse código e tentar executá-lo. Também pode conferir o resultado executando casos de teste em um quadro branco, sem sequer rodar o código. Mas o design de sistemas é, na verdade, o oposto de tudo isso. Para começar, os requisitos são muito vagos, e cabe a você esclarecê-los, definir o escopo e também determinar a saída, que é a solução que você vai propor. Não há uma única resposta certa. E esse tipo
04:39 de entrevista testa como você pensa, não apenas o que sabe, nem se consegue encontrar a solução certa, porque não há uma única resposta certa para a pergunta que você receberá durante uma entrevista de design de sistemas. Dito isso, vamos ver o que está sendo avaliado. A maioria dos candidatos se prepara apenas memorizando algumas arquiteturas, como as do Spotify, WhatsApp e assim por diante. É claro que isso já é um avanço, se você conhece algumas variantes de sistemas e os componentes que fazem parte deles. Mas também precisa
05:11 entender as outras quatro dimensões pelas quais os entrevistadores avaliam você. Então, vamos começar pela primeira: comunicação e colaboração. Isso envolve saber se você faz perguntas de esclarecimento, valida as suposições à medida que avança ou simplesmente faz suposições e começa a construir o sistema. Isso também serve para avaliar como você trabalharia em uma situação real. Digamos que seu gerente atribua a você uma tarefa, como uma funcionalidade grande ou um serviço de sistema grande que você precisa construir do
05:41 zero. O que você faz nesse tipo de situação? Se algumas coisas não estiverem claras, você simplesmente faz suposições, constrói uma solução e só depois percebe que não era o que esperavam? Nesse caso, você acaba desperdiçando muito tempo e precisa voltar e escrever outra solução, que é, de fato, o que estavam procurando. Ou você esclarece isso desde o início? Eles também estão tentando entender se é fácil trabalhar com você. Se você tem boas habilidades de comunicação, além do
06:13 conhecimento técnico. A próxima dimensão é saber se você tem uma maneira estruturada de pensar e abordar problemas: você consegue decompor o problema vago que acabou de receber em ações específicas e definidas, ou simplesmente pula entre decisões aleatórias? Você fica indo e voltando entre diferentes fases do design e assim por diante? Os candidatos fortes geralmente mostram que conseguem organizar seus pensamentos mesmo quando o problema não está muito claro. Pelo menos, têm um esboço das etapas que
06:44 vamos seguir e deixam claro o que estamos fazendo em cada etapa e o que vem depois. A próxima dimensão é seu discernimento técnico. Quando você escolhe uma solução, digamos, um banco de dados, um cache ou uma fila de mensagens, consegue explicar sua decisão? Por exemplo, por que escolheu especificamente um banco de dados SQL, por que escolheu o mecanismo de cache ou o tipo específico de fila de mensagens? Essa parte mostra que, quando você escolhe algumas ferramentas para usar em um sistema, faz essa escolha
07:14 com base nos requisitos, e não por hábito ou simplesmente porque sabe o que é Kafka. Talvez houvesse outra fila de mensagens mais adequada para esse tipo de sistema. A quarta dimensão é sua experiência prática aprofundada. Essa parte diferencia dois candidatos que talvez tenham o mesmo conhecimento teórico, mas um deles também tem experiência na construção desse tipo de sistema. Isso fica evidente quando se avalia se você pensa nos modos de falha, na escala e nas coisas que
07:47 podem dar errado no sistema, porque, se você só tivesse aprendido a teoria, não conseguiria identificar esses tipos de falha no sistema. Essa parte serve especificamente para os entrevistadores identificarem engenheiros que realmente colocaram sistemas em produção, em vez daqueles que só aprenderam a teoria. E, por fim, há também a gestão do tempo, porque esse tipo de entrevista geralmente dura 45 ou 60 minutos, e você não pode ficar preso em uma área e
08:17 ficar sem tempo por fazer apenas o design de alto nível. Vale a pena perguntar ao entrevistador logo no início em que ele quer se concentrar, ou propor você mesmo um foco e alinhar isso com ele, para garantir que esteja gerenciando seu tempo de forma eficaz. Durante a entrevista, você precisa sempre saber que horas são, quanto tempo resta e quanto tempo passou nessa seção. Se estiver dedicando muito tempo a ela, é hora de seguir em frente. Então, você também precisa demonstrar gestão do tempo, que, mais uma vez, é uma dessas cinco
08:48 Os pilares se traduzem em como você trabalhará em um ambiente real. Se você não consegue administrar seu tempo durante uma entrevista, também não é bom em administrar seu tempo no trabalho. Se não tem profundidade prática, também não conseguirá entregar projetos com confiança se conhecer apenas a teoria. Certo? E o mesmo se aplica a todos esses cinco pilares. Agora que conhecemos esses cinco pilares, vamos nos concentrar na parte de gerenciamento do tempo e nas etapas que você precisa seguir para concluir uma solução de design de sistemas.
09:21 Há cinco etapas para qualquer problema de design de sistemas. Essa é uma estrutura repetível que você pode aplicar a qualquer entrevista de design de sistemas com quadro branco. O objetivo aqui é manter a organização e mostrar ao entrevistador como você raciocina sobre os problemas ao longo desses 45 ou 60 minutos. Na verdade, 60 minutos é mais comum, porque há muita coisa a abordar nesse tipo de entrevista. Então, supondo que sejam 60 minutos, vamos ver quais etapas seguiremos e quanto tempo dedicaremos a cada uma. A primeira é esclarecer
09:52 os requisitos. Você não deve começar a projetar nada antes de entender o escopo, e esse é o erro mais comum que os candidatos cometem. Então, vamos ver quais são essas cinco etapas em linhas gerais e, depois, discutiremos cada uma delas em mais detalhes. A primeira é esclarecer os requisitos por pelo menos 5 a 10 minutos, dependendo da duração da entrevista. Você precisa se certificar de que tem todos os requisitos funcionais e não funcionais antes de começar a projetar qualquer coisa. Depois disso, você pode começar pela camada de dados e API e
10:24 depois passar para a arquitetura de alto nível, ou começar por essa parte, que é um pouco variável. Você também pode começar pela arquitetura e passar para a camada de dados e API depois, conforme for mais fácil para você. Para cada uma dessas partes, você dedicaria de 15 a 20 minutos, não mais, e depois passaria a discutir escala e casos extremos perto do fim, quando já tivesse a arquitetura de alto nível, a camada de dados e as APIs. Aqui, você poderia discutir como escalaria
10:55 esse sistema ou, se a confiabilidade for mais importante para esse tipo de sistema, discutir outros casos extremos e como lidaria com as falhas. A última parte é encerrar e falar sobre qualquer coisa que você poderia melhorar nesse sistema, mas não teve tempo para fazer. Então, basta mencionar isso brevemente antes de encerrar a entrevista. Vamos começar pela primeira etapa, que geralmente leva até 10 minutos, ou menos: esclarecer o problema antes de partir para a solução. Você
11:26 não deve começar a projetar nada antes de esclarecer os requisitos funcionais e não funcionais. Por requisitos funcionais, queremos dizer quais são os principais casos de uso do sistema que você está criando e o que está explicitamente fora do escopo. Porque, se pedirem para você projetar algo como o Spotify, nunca será possível projetá-lo em 60 minutos. Normalmente, são necessárias várias equipes e meses de trabalho, se não anos, para criar e projetar tudo isso. Certo? Então, é claro que você
11:57 não consegue fazer isso como um único engenheiro em 60 minutos. O objetivo aqui é identificar os principais casos de uso nos quais vamos nos concentrar e o que deixaremos fora do escopo. Isso também ajuda a esclarecer quem são os usuários e que tipo de plataforma estamos criando. Depois, você também se concentra nos requisitos não funcionais, que respondem quais são as restrições desse sistema. Por exemplo, quantos usuários ativos por dia ou por mês você terá? Qual é a proporção entre leituras e gravações? Você esclareceria os requisitos de latência e
12:28 também qual é o tempo de inatividade aceitável, que se refere à disponibilidade do sistema, antes de passar para a próxima etapa. Também é uma boa ideia sempre verificar, ao fim de cada fase, se já está tudo certo para seguir em frente, porque, se você deixou passar alguma coisa nessa etapa, o entrevistador pode dar dicas. Por exemplo, talvez você não tenha esclarecido alguns dos requisitos funcionais importantes. Esse é um bom momento para perguntar algo como: “Esse escopo e esses requisitos fazem sentido antes de eu
12:58 passar para o design de alto nível?” Se disserem que sim, então você esclareceu tudo. Se derem uma dica, geralmente é porque você deixou passar algo nos requisitos funcionais e não funcionais. Então, esclareça isso antes de passar para a próxima etapa. A próxima é a etapa dois, que pode ser a camada de dados e API ou o design de alto nível, dependendo do que for mais fácil para você. Se achar que ajuda começar pelas camadas de dados para entender como as entidades se relacionam e, depois, passar para o design de alto nível, siga essa abordagem. Se achar que imaginar
13:30 o sistema primeiro em alto nível ajuda você a entender o fluxo do usuário, comece pelo design de alto nível. Pessoalmente, começo pelo design de alto nível e depois passo para cá, mas, às vezes, também começo pela camada de dados e API. Essa parte consiste em modelar o que você está armazenando e como os clientes vão interagir com o sistema. Quanto ao modelo de dados, devemos nos concentrar nas principais entidades desse tipo de sistema. Digamos que seja algo como o YouTube: as principais entidades são vídeos e usuários. Você também mapearia
14:01 a relação entre eles. Quais são as chaves primárias, as chaves estrangeiras e os índices principais que usaremos no banco de dados, e qual pode ser o tamanho de cada registro? Esse também é um bom momento para esclarecer quais dados precisam de consistência forte e onde é possível aceitar consistência eventual. A próxima parte dessa etapa também é definir os contratos de API, como os principais endpoints e os formatos de requisição e resposta. Também é bom falar sobre paginação, tratamento de erros, limitação de taxa,
14:33 versionamento e seguir as melhores práticas de REST API, caso você esteja usando APIs do tipo REST, que é o caso na maioria das vezes. É também nessa etapa que você escolherá o banco de dados, seja Postgres, MongoDB ou qualquer outro. Você também deve explicar por que escolheu essa opção específica, considerando os requisitos, e apresentar as compensações envolvidas, incluindo quais outras opções considerou, mas não escolheu. A outra parte importante
15:04 é a arquitetura de alto nível, que leva mais 15 minutos. Aqui, você passa uma boa parte do tempo projetando os componentes e mostrando como os dados fluem entre eles. Se não souber por onde começar, pode começar com estes quatro componentes, que estão presentes em quase todos os tipos de sistema: servidores de API, que recebem as requisições; um balanceador de carga, que distribui o tráfego entre eles; um banco de dados, onde os dados são persistidos; e
15:35 opcionalmente, camadas de cache, que armazenam em cache alguns dados comuns que você busca no banco de dados. Nesta etapa, geralmente ajuda mapear a jornada do usuário do frontend ao backend, até chegar ao banco de dados. Então, o que é o frontend? É algum tipo de aplicativo web, móvel ou desktop, que se conecta ao seu gateway de API e aos balanceadores de carga, que distribuem o tráfego entre os servidores de API. Você pode ter uma camada de cache entre esses
16:06 servidores. Opcionalmente, você pode ter filas de mensagens para desacoplar os serviços, e quase sempre haverá um banco de dados para armazenar os dados no backend. Então, explique ao entrevistador como uma requisição percorre o sistema, do cliente passando pelo balanceador de carga e pelo servidor de API, chegando ao cache e ao banco de dados e retornando, incluindo todas as operações de leitura e gravação. Se estiver usando cache, mencione também o TTL e o mecanismo de invalidação que você usa. E, para as diferentes partes do seu sistema, você
16:37 também deve explicar sua escolha entre consistência, disponibilidade e tolerância a partições, conceitos que vêm do teorema CAP. Isso significa que alguns componentes podem precisar de abordagens diferentes. Explicar isso mostra que você entende o teorema e também as compensações que está fazendo nesse sistema. A quarta etapa é discutir gargalos, escala e confiabilidade, o que pode levar de 5 a 10 minutos, dependendo de quanto tempo você dedicar ao design de alto nível e à camada de dados e API.
17:09 Essa é uma das partes importantes em que você mostra que consegue pensar no que falha sob carga pesada, não apenas nos caminhos ideais. Também é um dos sinais mais claros que diferenciam um candidato de nível intermediário de um candidato sênior. Aqui, você discute coisas como pontos únicos de falha. Você os identifica e propõe soluções para corrigir esses pontos únicos de falha. É também aqui que você fala sobre mecanismos de contingência e falhas. Por exemplo,
17:40 se você tem mecanismos para recorrer a um caminho mais lento, mas funcional, caso algo dê errado. Aqui também se espera que você fale sobre redundância, como réplicas em várias zonas de disponibilidade para servidores de aplicação com alta disponibilidade, escalonamento automático entre zonas de disponibilidade, cache distribuído com replicação e assim por diante. Outro ponto é verificar se você consegue identificar como vai monitorar, registrar em logs e alertar sobre problemas nesse sistema.
18:11 Essa parte costuma ser ignorada, mas é um forte sinal de que você pensa como um engenheiro sênior. Aqui, você também abordaria a latência da API, as taxas de erro e os limites, quais alertas configuraria para esse tipo de sistema e assim por diante. E, mais uma vez, é sempre uma boa ideia perguntar ao entrevistador se há cenários específicos de falha que gostaria que você abordasse. O mesmo vale para o design de alto nível: se você cobriu tudo o que esperavam, e também para o design de banco de dados
18:43 e de API. A última parte é onde você pode esperar discutir trade-offs e extensões. Pode ser uma pergunta aberta, como identificar algumas coisas que poderiam ser melhoradas no sistema. Geralmente, você conclui resumindo as principais decisões que tomou, o que priorizou para esse sistema e a que renunciou, porque o design de sistemas sempre envolve trade-offs. E você deve sempre explicar explicitamente o motivo por trás das concessões. Essa parte
19:13 também serve para ver se você vai propor possíveis extensões para o futuro. Por exemplo, se tivesse mais tempo e a carga aumentasse 10 vezes ou mais, o que faria para ampliar esse serviço ou sistema? Essa é uma oportunidade para mostrar que está pensando além do problema discutido aqui. E se a escala aumentar? E se algo der errado? Lembre-se de que não dá para propor um design perfeito em 60 minutos. Então, é claro que sempre haverá coisas que você
19:44 pode melhorar. O objetivo aqui não é verificar se o seu design foi perfeito, porque não foi. Você só precisa mostrar que conseguiria ampliá-lo e que, se tivesse mais tempo, teria mais ideias para melhorar o sistema. Esse é o processo de cinco etapas para gerenciar seu tempo. Mas vamos voltar também às cinco dimensões pelas quais você será avaliado e ver o que deve e o que não deve fazer durante a entrevista. A primeira coisa que recomendo é fazer perguntas para esclarecer os requisitos
20:15 antes de começar o design. Essa é a primeira etapa. Se você já partir para uma solução antes de entender o escopo, isso será um sinal de alerta para o entrevistador. Portanto, esse passo é sempre obrigatório antes de começar a projetar qualquer coisa. Outro problema comum que vejo é que os engenheiros ficam presos a um dos extremos: ou ficam em silêncio, desenhando diagramas sem conversar com o entrevistador, ou falam o tempo todo e tentam preencher cada momento de silêncio
20:46 sem pedir a opinião do entrevistador. Em vez disso, trate-o como um colega. É como se dois engenheiros de software da mesma empresa estivessem trabalhando juntos em uma solução. Então, depois de cada decisão importante ou de cada etapa, é uma boa ideia confirmar se era isso que esperavam dessa parte. E se você deixou passar alguma coisa, por exemplo, se estava esclarecendo os requisitos funcionais, pergunte se há mais algum aspecto
21:17 que você não perguntou, mas que ainda deveria fazer parte do sistema. Outra coisa é sempre sugerir várias abordagens antes de se comprometer com uma, especialmente quando há diferentes maneiras de resolver algo. Por exemplo, se você escolheu SQL, mesmo que seja uma escolha muito óbvia — digamos, um sistema de pagamentos, em que a consistência é o mais importante —, se escolheu SQL sem explicar o motivo,
21:49 vamos presumir que você não sabia, ou então o entrevistador terá de fazer uma pergunta complementar sobre por que escolheu SQL, mas talvez nem faça isso. Então, se não explicar, vão presumir que você não sabe. Mesmo que escolha SQL, mencione por que não optou por algo como NoSQL e quais são os trade-offs entre essas soluções. O mesmo vale para qualquer outra decisão que tomar quando houver mais de uma abordagem possível para o problema. Isso também se relaciona aos trade-offs: você deve sempre
22:20 explicar os trade-offs ao escolher qualquer tecnologia. Se simplesmente escolher sem justificar a decisão, isso será mais um sinal de alerta durante a entrevista. E esse último ponto se sobrepõe um pouco ao segundo: você precisa confirmar com o entrevistador cada etapa importante. Por exemplo, se fez o design de alto nível e simplesmente seguiu em frente sem perguntar se havia algo que esquecemos de abordar, pode deixar passar coisas nessa etapa. Então, para resumir, use essa estrutura para gerenciar seu tempo e lembre-se de sempre envolver
22:50 o entrevistador nas discussões enquanto trabalha em qualquer uma das etapas dessa entrevista. Lembre-se de que esta é uma conversa entre dois engenheiros, não um monólogo em que você fala o tempo todo, nem uma avaliação para fazer em casa, em que você simplesmente trabalha na solução sem se comunicar com o entrevistador. E, para recapitular rapidamente as cinco etapas: primeiro, esclarecer os requisitos funcionais e não funcionais e entender o que está dentro e fora do escopo. Em seguida, você
23:21 define os modelos de dados e as camadas da API. Depois, elabora o design de alto nível com todos os componentes envolvidos e o fluxo dos dados nas operações de leitura e gravação. Em seguida, discute a escala, os modos de falha e a confiabilidade do sistema. A última parte costuma ser aberta: você reflete sobre o sistema que projetou. Quais foram os trade-offs e como melhoraria o sistema se tivesse mais tempo? Criar uma plataforma de música
23:51 por streaming como o Spotify é um problema clássico de design de sistemas, porque envolve entrega de áudio, gerenciamento de metadados, armazenamento de objetos e tudo o que você precisa conhecer para projetar sistemas do zero. Isso é especialmente útil se você for um desenvolvedor de nível intermediário com dificuldade para projetar sistemas ou recursos do zero. Então, vamos ver como projetá-la em sete etapas simples. Primeiro, vamos entender os requisitos e planejar a capacidade. Depois, vamos
24:23 elaborar a arquitetura de alto nível e o fluxo do sistema para as operações de leitura e gravação. Em seguida, vamos projetar a API, o armazenamento e o banco de dados. Por fim, passaremos à escalabilidade e entenderemos como ampliar esse sistema para, digamos, 50 milhões de usuários ou mais. No final, também vou mostrar a pirâmide que diferencia engenheiros seniores de desenvolvedores de nível intermediário, para que você entenda quais habilidades precisa aprender para conseguir projetar sistemas como esses
24:53 do zero. Seja para o design de sistemas do Spotify, do Google Doc ou de qualquer outro sistema, vamos começar entendendo os requisitos, como primeiro passo, porque não podemos avançar sem saber o que estamos construindo. O objetivo é criar uma plataforma de streaming de música para cerca de 500.000 usuários, que terá mais de 30 milhões de músicas. Portanto, os requisitos principais são: os artistas precisam conseguir enviar suas músicas para
25:24 a plataforma. Por outro lado, os usuários precisam conseguir pesquisar e reproduzir as músicas enviadas por esses artistas. Também precisam conseguir criar e gerenciar playlists em suas contas. Para isso, também precisamos ter um perfil de usuário, para que cada um possa manter suas próprias playlists, além de monitoramento e observabilidade básicos, como verificações de integridade dos servidores, rastreamento de erros, métricas de desempenho e assim por diante. Para os formatos de áudio, vamos usar arquivos OG e
25:54 AAC, que são apenas formatos de arquivo de áudio que comprimem a música sem perda de qualidade e também ocupam menos espaço. Por exemplo, podemos usar 64 kilobits por segundo para economizar dados móveis e 128 para qualidade padrão. Para usuários premium, podemos oferecer 320 kilobits por segundo. Em média, também precisamos saber o tamanho desses arquivos, isto é, qual é o tamanho médio de um arquivo de música. Vamos supor que, para uma qualidade de áudio normal, que
26:25 a 128 kilobits por segundo, são 3 megabytes em média. Mas isso também é algo que você precisa esclarecer antes de avançar para a próxima etapa. Depois de termos os requisitos, o próximo passo é planejar a capacidade deste sistema, especialmente para o armazenamento de músicas, os metadados detalhados, os metadados dos usuários e a largura de banda diária com que vamos lidar. Para o armazenamento de músicas, como presumimos que uma música tem, em média, 3 megabytes e estamos trabalhando com 30 milhões de músicas,
26:56 isso corresponde a 90 terabytes, em média, de dados de áudio brutos. E isso não inclui as réplicas em diferentes regiões, que podem ser cópias da mesma música. Também não inclui a sobrecarga do versionamento, quando artistas enviam as músicas novamente por certos motivos. Digamos que queiram mudar alguma coisa e acabem enviando a mesma música de novo. Por isso, também precisaremos estimar um valor pelo menos duas ou três vezes maior do que a quantidade
27:27 que temos aqui, que é de 90 terabytes. Quanto aos metadados das músicas, digamos que cada uma precise de um título, referências ao artista, duração, URLs dos arquivos e assim por diante. Então, precisaremos de pelo menos 100 bytes por música, em média, para armazenar os metadados. Se presumirmos que são 100 bytes, precisaremos multiplicar esse valor por 30 milhões, o que dará 3 GB. Na prática, isso é praticamente nada em comparação com o armazenamento das músicas. Como você pode ver, o principal armazenamento com que vamos
27:58 lidar neste sistema é o armazenamento das músicas, que será um armazenamento de objetos. Quanto aos metadados dos usuários, novamente, isso não será algo enorme. Digamos que precisemos obter os perfis dos usuários, as preferências e os dados das playlists. Se presumirmos que os dados de cada perfil de usuário que vamos armazenar ocupam 1 kilobyte e temos 500 mil usuários, conforme esclarecemos nos primeiros requisitos, isso significa que teremos 500 vezes 1 kilobyte, ou seja, apenas meio GB de armazenamento para os perfis. E a
28:31 quarta coisa que precisamos estimar é a largura de banda diária que vamos usar. Digamos que o tempo médio de reprodução seja de 3,5 minutos. Isso também precisa ser esclarecido na etapa anterior. Se usarmos 128 a 160 kilobits por segundo, isso dá aproximadamente 3 a 4 megabytes por transmissão. E vamos supor que cada usuário transmita de 10 a 15 músicas por dia. Isso levará a custos significativos de saída de dados. Custos de saída são as taxas cobradas pelos provedores de nuvem
29:03 quando os dados saem da rede deles para chegar aos usuários. De modo geral, a principal conclusão do planejamento de capacidade é que o áudio dominará tanto os custos de armazenamento quanto os de largura de banda. Os metadados representam apenas uma pequena parte deste sistema. Com isso, podemos passar para a arquitetura de alto nível, uma arquitetura bem geral por enquanto. Precisaremos de algum tipo de aplicativo móvel ou, digamos, da interface web do aplicativo. Esse será o aplicativo voltado para o usuário, que cuidará da interface do usuário, das buscas dos usuários,
29:35 dos controles de reprodução e do gerenciamento de playlists. Ele fará chamadas HTTP para as APIs REST. Mas, antes disso, teremos um balanceador de carga que distribuirá essas solicitações recebidas entre vários servidores de API. Esses servidores serão as APIs RESTful. Como algoritmo para o balanceador de carga, podemos usar round-robin ou o algoritmo de menos conexões. O balanceador de carga também fará verificações de integridade dos servidores de API e garantirá
30:05 que eles estejam sempre disponíveis. Em seguida, temos a camada de API, onde ficam nossas APIs RESTful. Para validação, podemos usar tokens JWT e consultar no banco de dados as músicas, seus metadados e URLs. Usaremos um banco de dados SQL para armazenar os metadados, e esses servidores farão consultas a ele. Mas, para os arquivos de áudio, precisaremos de algum tipo de armazenamento de blobs, já que eles representam a maior parte do nosso sistema. Esses arquivos serão acessados por meio de
30:37 URLs assinadas geradas, que são URLs de uso único que expiram depois de algum tempo. Assim, mantemos as URLs seguras, sem que elas fiquem sempre acessíveis pelo mesmo endereço. Um exemplo desse armazenamento de blobs pode ser o S3 da AWS, uma forma de armazenar arquivos grandes, como os arquivos OGG que precisamos guardar como parte deste design do Spotify. Esse tipo de armazenamento de blobs oferece escalabilidade praticamente ilimitada, além de durabilidade integrada e custo
31:09 -benefício para arquivos grandes. Por isso, é uma boa solução para armazenar esse tipo de arquivo de áudio. Com esse design de alto nível, podemos pensar nos workflows de leitura e escrita do sistema. Vamos começar pelo workflow de leitura, que é quando os usuários reproduzem uma música. Então, o primeiro passo é o usuário tocar no botão de reprodução para tocar uma música no aplicativo móvel. Isso fará uma solicitação GET para songs/ID. Também precisamos definir isso no design da API
31:40 em breve, na próxima etapa. Depois disso, a solicitação chegará ao balanceador de carga, que a encaminhará para o servidor de API. Como primeiro passo, o servidor de API validará se o usuário está autenticado com o token JWT. Se estiver autenticado, entraremos neste bloco, onde começaremos a reproduzir a música. Primeiro, ele consultará no banco de dados SQL os metadados da música, onde armazenamos os metadados das músicas e os perfis dos usuários. O banco retornará os detalhes da música. Depois que tivermos
32:12 os metadados, também geraremos a URL assinada no servidor de API e, então, retornaremos ao aplicativo móvel os metadados e a URL assinada da música. Em seguida, o aplicativo móvel fará solicitações de intervalo para obter blocos de áudio do armazenamento de blobs usando a URL assinada, e o armazenamento de blobs transmitirá o áudio de volta para o aplicativo móvel. Digamos que também estejamos acompanhando algumas análises para cada música reproduzida. Por esse motivo, também faremos outra solicitação POST para songs/ID da música/
32:43 SLplay, para acompanhar as análises. Aqui, podemos enviar os metadados que queremos armazenar para uso posterior. Retornaremos um erro de autenticação caso o JWT seja inválido. Portanto, nenhuma das etapas acima será executada se o usuário não estiver autenticado. Em seguida, temos o workflow de escrita deste mesmo sistema: artistas enviando um arquivo de mídia, que é o arquivo de áudio. Então, o primeiro ponto também é o aplicativo móvel. Ele fará uma solicitação POST
33:14 para armazenar a música com os metadados, e ela também chegará primeiro ao balanceador de carga. Em seguida, ele encaminhará a solicitação de envio para o servidor de API. Na API, provavelmente teremos alguns limites para o tamanho dos arquivos. Então, primeiro validaremos o formato e também o tamanho do arquivo. Se for válido, continuaremos com as próximas etapas. Se for inválido, simplesmente retornaremos o payload rejeitado, indicando formato não compatível ou tamanho de arquivo não compatível.
33:45 Caso contrário, como primeiro passo, precisamos armazenar o arquivo de áudio no armazenamento de blobs. Então, vamos enviá-lo para o armazenamento de blobs que estamos usando e, depois disso, extrairemos os metadados do áudio, como duração, taxa de bits e outras informações, além dos dados que receberemos no corpo da solicitação POST, como o título da música e assim por diante. Depois que tivermos todos os metadados, como o tipo da música, o artista e o título, poderemos inseri-los no banco de dados SQL. Além disso, depois que o arquivo for enviado, também podemos
34:16 movê-lo para uma estrutura hierárquica, se quisermos. Por exemplo, se quisermos armazenar o arquivo dentro da pasta dos artistas, organizado por ID do artista, depois pelo álbum e pelo ID do álbum, também podemos fazer isso no armazenamento de objetos. Tecnicamente, os armazenamentos de objetos não têm estruturas hierárquicas. É apenas uma ilusão de que elas existem, mas isso também nos ajuda a acompanhar as coisas como se estivessem organizadas em pastas. Se formos usar algum tipo de CDN, também precisaremos de alguma invalidação de
34:46 cache da CDN. A CDN será usada para armazenar em cache cópias do conteúdo, como páginas de artistas, metadados de álbuns e detalhes das músicas, mais perto dos usuários, para agilizar a entrega. Ela também limpará os dados antigos em cache para que os usuários vejam imediatamente as músicas mais recentes e as atualizações dos artistas. Também usaremos isso nesta solicitação para garantir que tudo esteja atualizado nas CDNs. Por fim, também enviaremos a resposta ao aplicativo móvel, informando que o envio foi concluído com sucesso. O
35:19 Nosso próximo passo será projetar esta API e o banco de dados com um pouco mais de detalhe. Então, vamos começar pelo projeto da API. Para busca e descoberta, precisaremos de uma maneira de pesquisar diferentes tipos de conteúdo com paginação. Digamos que o usuário pesquise uma música específica. Precisaremos fazer uma requisição ao endpoint /search e fornecer a consulta, que será exatamente o que ele está procurando. Aqui, podemos passar mais detalhes: se soubermos que o usuário está procurando uma música, podemos passar o tipo como
35:50 song. Também passaremos o limite, que pode ser, por exemplo, 20 por padrão, e o offset, que indica de onde devemos começar a contagem das músicas. Em seguida, também obteremos as músicas em alta, que opcionalmente podem ser filtradas por gênero ou por outros critérios. Será uma requisição GET para /songs/trending, e então poderemos passar os filtros adicionais, se quisermos, e o limite de quantas músicas queremos consultar na API. Os usuários também precisarão
36:22 obter todas as músicas de um artista específico. Digamos que estejam navegando pelas músicas de um artista específico e queiram ver somente as músicas desse artista. Para isso, precisaremos de um endpoint para /artists/ID do artista/songs, que retornará apenas as músicas que pertencem a esse artista, e também do limite, como sempre, para limitar a resposta da API. Em seguida, também precisamos controlar o acesso ao conteúdo. O primeiro endpoint será para obter os metadados da música e a
36:53 URL do arquivo de áudio. Será uma requisição GET para /songs/ID da música, que retornará todos os metadados e a URL de streaming. Também precisamos ter um endpoint de streaming direto, como /songs/ID da música/stream, que será uma alternativa às URLs assinadas. E também precisamos projetar as playlists, já que os usuários podem mantê-las e consultá-las novamente, opcionalmente incluindo também a lista de músicas que está na
37:25 playlist. Então, pode ser uma requisição GET para /playlists/ID, que retornará apenas os detalhes da playlist; se também incluirmos uma consulta como include=songs, ela retornará ainda as músicas incluídas nessa playlist. Agora, vamos ver quais ações dos usuários podemos esperar. Por exemplo, usuários comuns também criarão playlists, certo? Para isso, precisamos de algum tipo de endpoint POST para /playlists, com os metadados. Podemos esperar algo simples, como apenas o nome da
37:56 playlist e se ela é pública ou está disponível apenas para esse usuário. Depois de criá-la, eles também adicionarão músicas a ela, com uma requisição PUT para a mesma playlist, incluindo o ID e depois /songs. Aqui, podemos incluir os IDs das músicas, como algumas músicas novas que estão adicionando à playlist, e a posição em que as estamos adicionando. Digamos que já tenham quatro ou cinco músicas na playlist e que estejamos adicionando uma a partir da posição cinco ou seis. Então, enviaremos a
38:28 posição junto com ela também. Da mesma forma, eles também removerão uma música da playlist. Será a mesma URL de requisição, mas, no final, também passaremos o ID da música que queremos remover dessa playlist. E eles também podem curtir ou deixar de curtir uma música, para que ela seja adicionada aos favoritos. Isso será feito com uma requisição POST para /songs/ID da música/like, que manterá os favoritos no banco de dados. E, por último, precisaremos de alguma maneira de gerenciar os usuários.
39:00 Por exemplo, precisaremos buscar as playlists do usuário atual com alguma requisição, como GET para /users/me/playlists. Também podemos limitar esse resultado com algum limite, caso o usuário tenha playlists demais. Da mesma forma, precisamos das músicas curtidas pelo usuário. Como ele as está adicionando aos favoritos, podemos usar algo como /users/me/likes ou /users/me/liked-songs. Mas, seja qual for o caminho que usarmos, também será bom usar o mesmo plural
39:30 aqui, para seguir os princípios de uma API RESTful. E então, o limite, que pode ser 50 ou outro valor, e o offset, que indica de onde começamos a recuperar os resultados. Eles também precisam poder seguir artistas para receber notificações sobre as músicas mais recentes deles. Isso será feito com uma requisição POST para /users/me/follow; então, passaremos o ID do artista para seguir aquele artista específico. Isso é tudo para o projeto básico da API. Agora podemos passar ao armazenamento de dados. Primeiro, vamos
40:03 falar do armazenamento de blobs que usaremos. Digamos que seja um S3 na AWS, ou pode ser o armazenamento de blobs do Azure. Precisaremos usá-lo para armazenar os arquivos de áudio, porque arquivos tão grandes não podem ser armazenados em SQL ou em bancos de dados simples. Então, precisamos de algum armazenamento de blobs ou de objetos para isso. Os arquivos de áudio aqui são imutáveis, ou seja, depois de enviá-los, você não pode alterá-los. Portanto, se precisar fazer uma alteração no arquivo enviado, será necessário enviá-lo novamente e remover o antigo. Também podemos
40:36 organizá-los em uma estrutura hierárquica, como vimos antes. Podemos armazená-los, por exemplo, como artist/ID do artista/album/ID do álbum/ID da música. Isso facilitará a recuperação dos arquivos mais tarde. Como banco de dados, usaremos algum tipo de banco de dados relacional, como PostgreSQL ou MySQL. E esta é a estrutura básica que teremos no banco de dados. Vamos começar pelos usuários, onde armazenaremos o ID do usuário e alguns
41:07 metadados sobre eles, como e-mail, hash da senha, created_at, que será a data de criação ou a data de cadastro desse usuário, quando ele fez login pela última vez, o tipo de assinatura e o país. Esses metadados podem ser muitos outros, mas isso é apenas o mínimo necessário. E, com o ID do usuário, teremos uma chave estrangeira vinculada ao owner_id das playlists. Como teremos playlists, também precisaremos registrar quem é o proprietário de cada playlist, ou seja, a qual usuário ela pertence,
41:40 e também teremos o ID da playlist para vinculá-la aos itens da playlist. Precisamos do nome e de created_at para essa playlist. Então, cada playlist terá algumas músicas, certo? Aqui, podemos usar uma tabela de junção. Ela pode se chamar, por exemplo, playlist_items, e conterá, antes de tudo, o ID da playlist e também o ID da música e a posição, ou seja, onde essa música está na playlist, além de quando ela foi adicionada à playlist, se
42:10 também precisarmos manter essa informação. Basicamente, essa será a tabela que relaciona as playlists e as músicas. Para as músicas, novamente teremos o ID, que é o mais importante aqui, e alguns metadados, como título, duração e assim por diante. Outra parte importante aqui é a URL do arquivo, que usaremos para consultar o arquivo de áudio e transmiti-lo a partir do armazenamento de blobs. Também teremos artistas, que podemos armazenar em outro tipo de tabela, separado dos usuários, para manter essas informações separadas. Então,
42:42 podemos ter o ID do artista e os metadados sobre ele, como nome, país e assim por diante. E precisaremos relacionar os artistas às músicas. Para isso, podemos usar outra tabela de junção, como artist_songs, na qual podemos registrar, principalmente, o ID da música que eles enviaram e o ID do artista, ou seja, a qual artista cada música pertence. Além disso, podemos armazenar mais alguns metadados aqui. Mas isso é o mínimo necessário para essa tabela. E por que escolhemos SQL aqui?
43:14 Porque precisamos de muitas junções, como você pode ver, entre playlists e músicas, e entre músicas e artistas. Então, é uma escolha natural usar um banco de dados do tipo SQL. Podemos fazer consultas ainda mais complexas, como encontrar todas as músicas de artistas que um usuário segue, ou outras consultas complexas desse tipo. Isso é possível com bancos de dados do tipo SQL. E, quando o banco de dados crescer, também precisaremos adicionar índices às propriedades importantes aqui, que serão as
43:46 colunas acessadas com frequência. E, no nosso caso, podemos, por exemplo, indexar o título na tabela de músicas. Podemos indexar o nome na tabela de artistas. E também podemos indexar os IDs das músicas nesta tabela de junção, onde faremos a junção entre as playlists e as músicas. Esses índices nos permitirão ter recursos de busca rápidos, que também lidarão com erros de digitação e correspondências parciais que o usuário espera ao procurar uma música ou uma playlist. E o índice dos itens da playlist
44:18 vai acelerar as consultas ao buscar músicas de uma playlist específica ou ao descobrir quais playlists contêm uma música específica. Isso é tudo sobre o planejamento do armazenamento de objetos em geral e também do banco de dados SQL. Agora, vamos ver como podemos escalar isso. Digamos que a base de usuários cresça para 50 milhões de usuários e que o número de músicas chegue a 200 milhões. Isso significa que nossa arquitetura atual não consegue mais dar conta. Ou seja, se calcularmos os requisitos novamente, teremos
44:48 100 bytes para 200 milhões de músicas. Para os metadados das músicas, isso será 20 GB. Para os metadados dos usuários, serão 1 KB multiplicado pela base de usuários, o que dará 50 GB. Mas, para o armazenamento de áudio, podemos estimar 3 megabytes por arquivo de áudio. E, se multiplicarmos isso pela quantidade de músicas, que agora é de 200 milhões, digamos, o total será algo como 600 terabytes. E isso nem sequer inclui as réplicas, as cópias regionais ou os uploads de vários arquivos feitos pelos
45:20 artistas. Então, na realidade, isso será duas ou três vezes mais. Vamos ver como poderia ser nossa fase de escalabilidade. Como primeiro passo, podemos adicionar pontos de presença de CDN em diferentes regiões do mundo. Por exemplo, podemos ter um na América do Norte, um na Europa e um na América do Sul. Isso vai acelerar a recuperação do conteúdo da CDN. De modo semelhante, podemos ter balanceadores de carga em diferentes regiões. Digamos que tenhamos pelo menos dois balanceadores de carga em duas regiões diferentes, que
45:51 ainda direcionam as solicitações recebidas para os servidores de API que temos. Antes de chegar aos servidores de API, no caso das CDNs, precisamos usar algum tipo de política de remoção para gerenciar o cache. Aqui, é adequado usar o método de cache LRU, que remove automaticamente o conteúdo menos acessado quando o armazenamento fica cheio, e o cache buscaria o conteúdo no armazenamento de origem em caso de cache miss. Quanto aos servidores de API, como não mantemos nenhum tipo de sessão de usuário ou
46:24 armazenamento, eles são completamente stateless, o que significa que podemos escalá-los conforme necessário para lidar com o tráfego recebido. Portanto, adicionar mais capacidade aqui é simples: basta replicar os servidores de API existentes. O principal é que, ao projetar o servidor de API, precisamos projetá-lo para que seja realmente stateless, para que possamos escalá-lo facilmente. Em seguida, também precisamos escalar o banco de dados, porque uma única instância não consegue lidar com tantos metadados armazenados. Isso significa que
46:55 precisamos separar as leituras das gravações nos bancos de dados. Para as gravações, podemos usar uma réplica líder, na qual escreveremos os novos arquivos das músicas ou as novas playlists que os usuários criarem. Mas, para as leituras, podemos ter bancos de dados secundários ou seguidores, usados apenas para leitura. Assim, eles ficarão sincronizados com o banco de dados líder. Porém, sempre que recebermos operações de leitura, como buscar os metadados de uma música ou os metadados de uma playlist,
47:27 vamos lê-los nos bancos de dados seguidores. Como próximo passo, se isso não for suficiente, também podemos considerar o sharding do banco de dados. O sharding dividirá os dados entre várias instâncias de banco de dados. Digamos que teremos um shard para os usuários dos EUA e outro para os usuários europeus. Podemos dividir os dados geograficamente. Depois, particionaremos as músicas por intervalos de IDs de artistas. Isso nos dará escalabilidade horizontal e melhorará o desempenho, mantendo os dados relacionados juntos. E
48:00 também podemos replicar o armazenamento de blobs em diferentes regiões. Por exemplo, os usuários da Europa farão upload para um armazenamento de blobs na região europeia. Os usuários da América do Norte farão upload das músicas para o armazenamento de blobs da própria região, e assim por diante, para as outras regiões geográficas do mundo. Todos eles continuarão usando URLs assinadas para acessar os arquivos de áudio e transmitir os trechos de música. É claro que há muitas coisas que podem ser melhoradas neste sistema, como
48:31 melhorar a confiabilidade ou o monitoramento. Mas o principal, como podem ver, é começar de forma simples. Não queremos projetar demais para uma quantidade pequena de usuários e, depois, aumentar a escala gradualmente. Por fim, vamos ver como também podemos projetar sistemas ou recursos desse tipo do zero e qual é a verdadeira diferença que separa engenheiros seniores de desenvolvedores de nível intermediário. Se você pensar na parte de design como uma pirâmide, a base, que é a parte mais importante, é o planejamento,
49:02 que é a fase de elaboração do projeto: o design técnico, a compreensão dos requisitos, a estimativa de capacidade, como vimos, e a escolha da arquitetura. É por aqui que todos precisam começar ao projetar sistemas de grande escala. Só então passamos à seleção das tecnologias, como escolher o banco de dados certo, seja SQL ou NoSQL; e, se for SQL, qual tipo específico de banco de dados SQL será usado; as camadas de cache, as políticas de remoção, as soluções de armazenamento e tudo o que está relacionado à tecnologia.
49:34 A seleção da stack tecnológica vem antes da última etapa, que é a menor parte do design: a programação, ou seja, começar a implementar as coisas. Mas, na verdade, essa é a atividade que exige menos capacidade mental. Especialmente se programar for a única coisa que você sabe fazer, agora temos esses modelos na nuvem ou modelos GPT, que conseguem fazer isso de forma mais rápida, melhor e mais barata. Então, por que uma empresa pagaria você se a única coisa que você sabe fazer é programar?
50:06 É aí que a maioria dos desenvolvedores de nível intermediário fica presa, porque está com tudo completamente de cabeça para baixo. Começa a desenvolver coisas sem nem sequer ter um plano ou escolher as tecnologias. Simplesmente começa a programar e vê no que dá. Já os pensadores com uma base sólida, que aprendem a analisar os problemas antes mesmo de tocar em um teclado ou começar a escrever código, são os que nunca terão dificuldade para encontrar vagas, porque todas as empresas estão sempre
50:36 precisando de desenvolvedores assim, que consigam analisar os problemas do ponto de vista dos usuários e também transformar ideias preliminares em realidade, entendendo os requisitos. Aliás, se você quiser aprender todos esses conceitos que estou usando para projetar sistemas do zero, tenho um guia completo de design de sistemas que aborda todos os conceitos necessários para entrevistas de design de sistemas no meu canal do YouTube. Você pode acessar o primeiro link na descrição para encontrar o link do meu canal. O primeiro vídeo fixado no meu
51:08 canal é esse guia de design de sistemas, que explica todos os conceitos necessários para passar nessas entrevistas de design de sistemas. Por fim, vamos projetar um sistema de reservas de hotel semelhante ao booking.com ou ao Airbnb, que é um problema típico de entrevista de design de sistemas. O erro que a maioria dos desenvolvedores comete é tratar o pedido para projetar esse tipo de sistema como se fosse uma aplicação CRUD típica, com algum cache e replicação para a disponibilidade. Mas essa não é a maneira correta de pensar sobre isso. Por exemplo, um dos
51:39 problemas que você pode enfrentar é quando dois usuários tentam reservar o último quarto disponível ao mesmo tempo e você usa uma abordagem simples de SELECT e depois INSERT, o que cria o problema de reserva duplicada. Assim, você acaba cobrando dois cartões de crédito por um único quarto e, então, precisa lidar com reembolsos, clientes irritados e um sistema em que ninguém poderá confiar da próxima vez. É por isso que sistemas de reservas de hotel são difíceis: eles também testam as habilidades específicas que separam desenvolvedores de nível intermediário dos seniores. Coisas como lidar com
52:10 concorrência sem falhar sob carga pesada, manter os dados consistentes em vários sistemas e também projetar para falhas, caso algo dê errado em alguma parte do sistema. E não se trata de memorizar esses padrões, mas de entender os problemas reais que podem ocorrer, quais são as soluções para eles e quais são as compensações entre essas soluções. Então, vamos projetar esse sistema em quatro etapas simples. Primeiro, vamos reunir os requisitos de design e estimar o armazenamento e as consultas por segundo,
52:41 transações por segundo e qualquer outra coisa de que precisamos para projetar o sistema. Depois, vamos abordar o projeto de alto nível básico, incluindo o projeto da API, o esquema do banco de dados, os fluxos de reserva e assim por diante. Na primeira etapa, vamos escalar o sistema para lidar com 1.000 consultas por segundo e 17 transações por segundo, números próximos à escala do realbook.com. Depois, vamos lidar com as falhas sob carga pesada e discutir a recuperação do sistema sempre que algo ou alguma parte do sistema falhar. Então, se você está
53:12 cansado de explicações sobre projeto de sistemas que pulam a parte difícil e só dão uma visão superficial, esta é para você. Vamos começar pela primeira etapa, que são os requisitos de projeto. Em desafios desse tipo, geralmente seguimos uma estrutura para chegar a uma solução. E o primeiro passo é sempre fazer perguntas de esclarecimento para definir o escopo do que vamos projetar e também os requisitos funcionais e não funcionais. Então, vamos começar pelas perguntas de esclarecimento que podemos ter. Por exemplo, uma das perguntas pode ser sobre os pagamentos:
53:43 vamos permitir que as pessoas paguem na plataforma ou o pagamento será feito no check-in? Isso nos dirá se oferecemos pagamentos na plataforma ou não. Neste caso, digamos que sim. Vamos permitir que paguem na plataforma, o que significa que precisamos ter algum tipo de sistema de pagamento integrado. Outra pergunta pode ser sobre os canais de reserva. O serviço será apenas um site, um web app em que as pessoas poderão reservar hotéis, ou também ofereceremos um app para dispositivos móveis, que pode ter tarifas e preços diferentes? Então, vamos
54:14 dizer que vamos projetar tanto a versão web quanto a versão para dispositivos móveis deste aplicativo. Depois, temos as questões sobre cancelamentos e overbooking. Vamos permitir o cancelamento das reservas e também o overbooking? Os hotéis normalmente permitem algum nível de overbooking, levando em conta que pode haver cancelamentos. Se, por exemplo, receberem 10 reservas por mês, duas ou três delas podem ser canceladas. Por isso, também permitem que os usuários façam reservas além da capacidade, por causa dos
54:46 cancelamentos. Quando há cancelamentos, o quarto fica disponível. Então, eles também permitem o overbooking. Depois, temos a questão dos preços dinâmicos. Os preços são fixos ou mudam de acordo com a demanda e as datas? Neste caso, digamos que teremos preços dinâmicos. Quando a demanda é maior, como, por exemplo, na época do Natal, que é mais ou menos agora, vamos aumentar os preços. Por exemplo, este quarto de onde estou gravando custou o triplo do preço normal porque
55:17 é época de Natal. Então, eles vão aumentar os valores e, da mesma forma, em outras datas populares em que as pessoas fazem reservas, vamos aumentar os preços de acordo com a demanda. Depois, podemos passar aos requisitos funcionais. Uma pergunta pode ser sobre o gerenciamento dos hotéis. Vamos oferecer um painel administrativo para adicionar, atualizar e remover hotéis ou vamos nos concentrar apenas na parte voltada aos usuários? Painéis administrativos podem fazer parte disso, mas a funcionalidade principal geralmente gira em torno dos usuários que reservam os hotéis e cancelam as reservas. Digamos que também vamos oferecer algum tipo de painel administrativo neste sistema. Depois, chegamos às perguntas sobre busca e descoberta: quais são as principais funcionalidades que vamos oferecer? Uma forma de pensar nisso é perguntar quais funcionalidades, se removidas do Booking.com, fariam com que ele deixasse de ser o Booking.com. Essa pode ser uma boa abordagem para identificar quais são as principais
55:47 funcionalidades. Por exemplo, se os usuários não puderem buscar hotéis por localização, o aplicativo se tornará meio inútil, certo? Então, claro, uma das principais funcionalidades que vamos oferecer é a possibilidade de os usuários fazerem buscas por localização, datas, número de hóspedes, faixa de preço e outros filtros que definirmos. Depois, temos o fluxo de reserva. Normalmente, os usuários reservam os quartos. Em seguida, visualizam as reservas e devem poder cancelar as reservas, porque
56:18 também esclarecemos isso acima. O sistema também deve impedir reservas duplicadas. Esse é um dos desafios que precisamos identificar aqui para resolvê-lo nas próximas etapas. Depois, temos o processamento de pagamentos. Como esclarecemos que vamos oferecer pagamentos no app, precisamos integrar algum tipo de processamento de pagamentos, como Stripe, PayPal ou outro serviço. Opcionalmente, também podemos incluir notificações, como o envio de confirmações de reservas, cancelamentos ou pagamentos tanto para o usuário
56:50 quanto para os administradores e corretores que cadastram esses hotéis. Depois, chegamos aos requisitos não funcionais, que dizem respeito à consistência, à carga e à latência que vamos suportar. Vamos começar pela consistência. Teremos consistência forte, porque dois usuários não devem poder reservar o mesmo quarto para as mesmas datas. Isso não é negociável. Mas, quando estiverem buscando ou descobrindo quartos, a consistência eventual será suficiente, e você
57:22 vai entender o motivo quando chegarmos à fase de projeto. Depois, temos a concorrência. Precisamos lidar com milhares de usuários tentando reservar o mesmo hotel simultaneamente, sem que isso faça o sistema parar de funcionar. Também precisamos oferecer baixa latência. Os resultados da busca devem aparecer em menos de 500 milissegundos, e tudo bem se as confirmações de reserva levarem de dois a três segundos. Também devemos esclarecer a disponibilidade e a escalabilidade. Digamos que, em termos de disponibilidade, vamos oferecer 99,9% de uptime. Quanto à escala, precisamos poder escalar esse sistema horizontalmente sem ter de reprojetá-lo do zero no futuro. Depois de reunirmos os requisitos de projeto, podemos passar à estimativa de dados e capacidade e, em seguida, começar a projetar o sistema. Agora, para estimar os dados, as coisas mais importantes que nos interessam são as consultas por segundo que ocorrerão no sistema e também as transações por segundo
57:53 que correspondem às gravações no banco de dados quando os usuários reservam hotéis. Nesse tipo de sistema, você pode pensar nisso como este funil. Os usuários que chegam à plataforma ficam no topo. São as pessoas que estão visualizando os quartos dos hotéis. Isso representa 100% do tráfego nesta parte. Depois, entre esses usuários, provavelmente apenas 10% vão navegar pelos hotéis, buscar boas
58:25 opções e reservar um hotel. Esse será o segundo nível. E, no terceiro, teremos as reservas. Desses usuários, apenas 10% vão de fato reservar alguma coisa, porque a maioria dos visitantes está só olhando. Eles não pretendem reservar nada; estão apenas navegando. Mas ainda precisamos projetar os sistemas para eles, porque também vão aumentar o número de consultas por segundo no sistema. Com base nisso, há duas maneiras de
58:55 calcular a carga. Uma abordagem é, por exemplo, analisar o Booking.com e usar algum tipo de extensão, como a Similarweb, para ver o tráfego mensal que ele recebe. Por exemplo, neste mês, o site recebeu 500 milhões de visitantes. Isso significa que 500 milhões de usuários navegaram pelo sistema neste mês. Esse é o topo do funil; depois, você pode começar a fazer os cálculos para baixo. Com base nisso, também dá para descobrir o número de transações por segundo e o número de consultas por segundo
59:26 com base nessa carga. A outra maneira é usar uma abordagem de baixo para cima: obter, por exemplo, o número de reservas por ano. No caso do Booking.com, são 1,1 bilhão de reservas por ano. Então, podemos calcular os valores para cima, começando daqui: quantas visitas recebemos por mês ou por segundo na plataforma. Também poderemos calcular as transações por segundo, porque temos o número de reservas do ano. Qualquer um desses números serve. Você só
59:56 calcular a carga. Uma abordagem é, digamos, dar uma olhada no booking.com e usar algum tipo de extensão, como Similarweb, para ver o tráfego que o site recebe por mês. Por exemplo, neste mês, ele recebeu 500 milhões de visitantes. Isso significa que 500 milhões de usuários navegaram pelo sistema neste mês. Então, começando pelo topo, você pode calcular os números para baixo. Com base nisso, também é possível ver as transações por segundo e as consultas por segundo de acordo com essa carga.
1:00:28 E a outra maneira é a abordagem de baixo para cima: obter, digamos, o número de reservas por ano. No caso do booking.com, são 1,1 bilhão de reservas por ano. Então, podemos calcular os números de baixo para cima, começando por aqui: quantas visitas recebemos na plataforma por mês ou por segundo. Também poderemos calcular as transações por segundo, porque temos o número de reservas do ano. Portanto, qualquer um desses números serve. Você só
1:00:59 precisa obter um desses dados com o entrevistador, e então você pode começar os cálculos. Também leve em conta que esses números são estimativas aproximadas. Talvez você obtenha exatamente esses números para fazer os cálculos, ou talvez obtenha apenas as visitas por ano e as reservas por ano; depois disso, você deve conseguir calculá-los. A outra métrica importante de que precisamos é a duração média da estadia. Qual é a duração média das estadias que os usuários reservam nos hotéis? Digamos que sejam duas ou três noites por reserva. Então, vamos considerar
1:01:30 duas noites por reserva, e assim poderemos calcular o restante. Vamos começar com as reservas diárias. Temos o número de diárias reservadas por ano, que é de 1,1 bilhão de noites em quartos. Vamos começar por essa abordagem. Então, podemos dividir esse valor pelo número de noites por reserva, chegando a 550 milhões de reservas por ano, porque esse é o total de diárias em quartos. Esse não é o número de reservas. Portanto, precisamos dividir esse valor pelo número de noites por reserva para obter o número real de reservas feitas
1:02:02 por ano. Então, podemos dividir esse valor por 365, que é o número de dias no ano, e obteremos o número de reservas por dia, que é 1,5 milhão. Depois disso, podemos dividir 1,5 milhão pelo número de segundos em um dia, que é 86.400. Isso significa que recebemos 17 reservas por segundo, que é o número de transações por segundo que teremos de suportar no banco de dados. Já na parte do funil de tráfego
1:02:32 e das leituras, como vocês viram no Similarweb, o Booking.com recebe cerca de 500 milhões de visitas por mês, com aproximadamente oito páginas por visita. Isso significa que, em cada visita, os usuários navegam por cerca de oito páginas antes de sair do site. Com base nisso, primeiro podemos calcular as visitas por dia, dividindo o tráfego mensal por 30, que é o número de dias no mês, e chegaremos a 17 milhões de visitas por dia. Depois disso, podemos calcular as visitas por
1:03:03 segundo. Então, se dividirmos esse valor pelos segundos do dia, teremos 200 visitas por segundo ao nosso site. E, se presumirmos que os usuários vão navegar por cerca de oito páginas por visita antes de sair, digamos que isso corresponda a cinco leituras no backend por visita, como consultas de busca e verificações de disponibilidade. Então, basta multiplicar esse número por cinco, e chegaremos a cerca de 1.000 solicitações de leitura por segundo. E, com base
1:03:34 nesses cálculos, podemos ver claramente que este será um sistema com muito mais leituras do que gravações, porque precisamos suportar 1.000 QPS, ou consultas por segundo, e apenas 17 TPS, que são as transações por segundo que teremos. Portanto, nosso desafio será otimizar a arquitetura para as leituras, mantendo, ao mesmo tempo, a consistência das gravações para evitar o problema de reservas duplicadas. Esses são os números principais de que precisamos para começar a projetar o sistema. O restante podemos analisar enquanto
1:04:04 projetamos a API e o banco de dados. Se houver mais algum dado de que precisarmos, tudo bem calculá-lo nessa etapa, mas, antes de começar o design de alto nível, precisamos ao menos obter os dados principais. Depois dos cálculos de escala e volume de dados, podemos projetar uma arquitetura que se ajuste a esses números. Podemos começar usando uma arquitetura orientada a serviços, com limites bem definidos, mas mantendo os dados fortemente relacionados no mesmo banco de dados. Então, como ponto de partida,
1:04:34 digamos que temos o aplicativo móvel ou o aplicativo web, onde os usuários pesquisam esses hotéis e fazem reservas. Vamos usar uma CDN na frente para os recursos estáticos, como imagens, arquivos de mídia, conteúdo JavaScript e imagens dos hotéis. Em seguida, temos um gateway de API, que será o único ponto de entrada para a nossa aplicação. É nele que vamos tratar da autenticação, da limitação de taxa e também do roteamento das solicitações
1:05:05 entre todos os serviços que temos. Ele também pode funcionar como balanceador de carga entre as instâncias dos serviços. Por exemplo, se tivermos várias instâncias do serviço de hotéis, esse gateway de API pode atuar como balanceador de carga e distribuir o tráfego entre as instâncias um e dois. Quanto aos serviços, podemos separá-los em limites definidos. O serviço de hotéis é onde teremos as informações sobre hotéis e quartos, que são, em sua maioria, estáticas e podem ser muito bem armazenadas em cache. Um
1:05:37 serviço importante aqui é o serviço de reservas. Ele cuidará da lógica de reservas, das verificações de inventário, da criação e do cancelamento de reservas. Também é nele que a concorrência será tratada na aplicação. Em seguida, temos um serviço de tarifas. Ele cuidará dos preços dos quartos por data, porque o preço será diferente dependendo da data e do momento em que você reservar o hotel. Assim, ele aplicará preços dinâmicos com base na demanda pelos quartos. Para integrar os
1:06:08 gateways de pagamento, também precisaremos de um serviço de pagamentos que implemente, por exemplo, pagamentos com Stripe, PayPal, Apple Pay, Google Pay e Adyen. Também temos um serviço de notificações, que se comunica com o serviço de reservas e com o serviço de pagamentos. É ele que envia os e-mails e as notificações push. Para a camada de dados, inicialmente podemos começar usando algum tipo de banco de dados SQL, como o PostgreSQL, principalmente porque precisaremos das garantias ACID
1:06:39 e isso também será útil para o armazenamento de transações, já que usaremos muitas transações. Portanto, precisamos da consistência dos bancos de dados SQL. É por isso que, neste caso, optamos por SQL. Também precisamos de algum tipo de cache, como Redis, para os dados que não mudam com tanta frequência. Mas também podemos aprimorar um pouco mais essa solução. Primeiro, se pensarmos na escala de leituras que teremos, de cerca de mil consultas por segundo, o banco de dados PostgreSQL
1:07:12 não conseguirá lidar com essa carga. Então, precisamos encontrar algum tipo de solução para isso. Em situações assim, ferramentas como Elasticsearch são uma boa solução para lidar com a escala massiva das buscas. Então, vamos introduzir um serviço de busca, responsável por recuperar os hotéis com base nos filtros definidos no frontend. E o Elasticsearch cuidará da busca de texto completo nos nomes e nas descrições dos hotéis. Além disso, ele poderá
1:07:44 filtrar hotéis, por exemplo, a até 5 quilômetros de um local ou dentro de algum raio em torno do local definido, e poderá filtrar por faixa de preço, avaliações e quaisquer outros filtros de que precisarmos. O Elasticsearch é muito mais rápido do que o banco de dados PostgreSQL que usaremos, por exemplo, para listar os hotéis. Além disso, ele é eventualmente consistente, o que significa que acaba sendo atualizado com os dados do banco de dados e recebe os dados mais recentes no
1:08:16 Elasticsearch, indexando tudo corretamente para permitir buscas rápidas por localização, faixa de preço e quaisquer outros filtros. Também podemos desacoplar um pouco os serviços. Outra abordagem é usar bancos de dados diferentes para cada microsserviço. Por exemplo, podemos ter um banco de dados de pagamentos para o serviço de pagamentos e um banco de dados de hotéis para o serviço de hotéis. Vamos discutir os prós e os contras disso. Mas, para desacoplá-los também, podemos introduzir uma
1:08:48 fila de mensagens, que será responsável, por exemplo, pelos pagamentos. Sempre que um pagamento for processado no serviço de pagamentos, vamos processá-lo e publicar uma mensagem no Kafka, RabbitMQ ou em alguma outra fila de mensagens. Primeiro, isso enviará ao usuário ou ao proprietário um e-mail e uma notificação push, além de uma notificação no aplicativo informando que o pagamento foi bem-sucedido e que o hotel está reservado. Também atualizará a disponibilidade,
1:09:20 ou seja, o status do hotel. Assim, a mensagem será enviada ao serviço de busca, que atualizará essa informação no Elasticsearch, mantendo a consistência eventual. Então, logo após o pagamento, se os usuários fizerem uma busca, talvez ainda vejam aquele hotel como disponível, mas não conseguirão reservá-lo, porque usaremos o banco de dados de hotéis para verificar novamente se ele realmente está disponível ou não. E, depois de algum tempo, essa informação será persistida no banco de dados e
1:09:51 terá os mesmos dados que o banco de dados PostgreSQL. Em seguida, ao decidir se todos esses serviços serão microsserviços, um único monólito ou também poderão ser microsserviços pragmáticos. Por exemplo, no nosso caso, um único monólito simplificaria as transações, mas seria difícil escalar e fazer deploy de forma independente, porque, se muitos desenvolvedores — digamos, centenas de desenvolvedores — estiverem trabalhando nesse único monólito, ficará muito difícil
1:10:22 fazer deploy de forma independente e trabalhar nas coisas sem atrapalhar uns aos outros. Outra abordagem é usar microsserviços completos, como neste exemplo, em que temos o serviço de tarifas, o serviço de notificações e assim por diante, com bancos de dados separados, o que introduzirá transações distribuídas e problemas de consistência que não existem no nível do monólito. Isso acontece porque cada serviço, como você pode ver, por exemplo, o serviço de hotéis, tem seu próprio banco de dados, e o serviço de reservas tem seu próprio banco de dados. Outra abordagem, então, é manter
1:10:55 os dados fortemente acoplados juntos em um único banco de dados e compartilhá-los entre os serviços. Por exemplo, no nosso caso, os bancos de dados de reservas e hotéis podem ficar juntos. Isso nos permitirá usar transações ACID locais em vez de transações distribuídas. E teremos separação no nível dos serviços na camada da aplicação, mas ainda teremos os dados compartilhados para entidades fortemente acopladas. Depois de concordar com o design de alto
1:11:26 nível, a próxima etapa é o design da API e do banco de dados, e você pode começar pelo design da API e depois pelo banco de dados, ou vice-versa, da maneira que for mais fácil para você conversar sobre o fluxo. No nosso caso, vamos começar pelo design da API. Usaremos APIs RESTful para todas as operações, porque isso faz sentido nesse tipo de sistema. Embora, como você pode ver aqui, o frontend faça chamadas diretamente para a API de hotéis e também para outras APIs que
1:11:59 temos. Talvez, às vezes, faça chamadas para a API de reservas. Outra abordagem, muito comum, é usar uma camada GraphQL na frente do frontend quando há vários microsserviços. Assim, o frontend fará solicitações apenas ao GraphQL e também conhecerá o esquema com base no esquema GraphQL. Então, a camada GraphQL será responsável por fazer chamadas a todos esses serviços que você tem nos bastidores. Dessa forma, ela abstrai a complexidade e o roteamento entre esses serviços. Mas, para
1:12:32 manter o diagrama simples, vamos apenas supor que o frontend faça as chamadas diretamente. Temos aqui administradores que estão listando as propriedades. Temos usuários que estão navegando pelos hotéis e fazendo reservas. E temos o app do frontend, que pode ser um app para dispositivos móveis ou a versão web. O primeiro passo para os administradores é poderem fazer login para usar os recursos destinados a eles, enquanto os usuários podem navegar pelos hotéis sem fazer login. Mas, quando chega a hora da
1:13:03 reserva, eles também precisam criar uma conta ou fazer login. Para listar os hotéis disponíveis, faremos uma solicitação GET para /hotels com os filtros necessários definidos pelo usuário e obteremos os hotéis disponíveis. Depois, se quiserem ver um hotel específico, faremos uma solicitação GET para /hotel/ID específico do hotel, que nos retornará o hotel com esse ID. Agora, nos bastidores, isso acontecerá da seguinte maneira: a API de hotéis
1:13:35 buscará os detalhes do hotel no banco de dados. Para um hotel específico, também podemos ler esses dados do banco de dados PostgreSQL. Mas, na chamada inicial, quando fazemos uma solicitação para /hotels, é aí que usaremos o Elasticsearch para filtrar rapidamente com base nos filtros definidos no frontend. Nesta etapa, para um único hotel, basicamente apenas encaminhamos os detalhes do hotel para o frontend, e então o frontend fica responsável por processar e exibir os
1:14:06 detalhes na página. Os administradores que quiserem adicionar um novo hotel precisarão fazer uma solicitação POST ao endpoint /hotels. É aí que primeiro o inseriremos no SQL e, depois que a inserção for concluída com sucesso, simplesmente encaminharemos o status 201, indicando que o hotel foi criado. Mais tarde, ele será persistido no banco de dados do Elasticsearch. Mas, nesta etapa, mostraremos a confirmação ao administrador
1:14:36 de que o hotel foi criado. Da mesma forma, se quiserem atualizar as informações do hotel e fizerem algumas alterações, podemos fazer uma solicitação PUT ou PATCH para hotels/ID desse hotel e, novamente, atualizá-lo no banco de dados, obter o resultado da atualização, enviar a confirmação e exibi-la ao administrador. O fluxo para remover hotéis é parecido. Mais uma vez, uma solicitação DELETE, neste caso para /hotel/ID desse hotel. Removemos do
1:15:08 banco de dados, mostramos isso ao usuário e, mais tarde, o hotel será removido do Elasticsearch. E, dentro dos hotéis, também teremos quartos, porque um hotel tem muitos quartos disponíveis. Vamos tratá-los como um sub-recurso. Assim, em hotéis e no ID específico do hotel, teremos primeiro /rooms, que fornecerá todos os quartos disponíveis nesse hotel. Para buscar um quarto específico do hotel, você pode fazer uma
1:15:38 solicitação para /ID e obter mais detalhes desse hotel, que serão buscados novamente no banco de dados. Depois, encaminharemos as informações ao cliente. Basicamente, aqui estamos apenas repetindo para os quartos o que já temos para os hotéis. Então, se quiser adicionar um quarto, você fará uma solicitação POST para /ID do hotel/rooms. Isso segue os princípios gerais de REST. Inseriremos o quarto no banco de dados e forneceremos uma resposta de sucesso. Claro, você também precisa
1:16:09 tratar as falhas, exibindo um erro que explique por que algo deu errado na API. Mas, ao falar dos fluxos bem-sucedidos, ou pelo menos dos endpoints disponíveis, é isso que teremos. Também teremos um endpoint PUT para editar o tipo de quarto, novamente em /rooms. Neste caso, precisamos fornecer um ID, porque não estamos criando um novo quarto, mas editando um existente. Então, precisamos especificar qual é o ID do quarto que estamos
1:16:39 editando. O mesmo vale para a exclusão: usamos a mesma URL do PUT, mas, neste caso, o método será DELETE, que removerá o anúncio do hotel. Em seguida, para as reservas, faremos solicitações ao serviço de reservas. Digamos que um usuário queira ver o histórico de reservas. Precisamos fornecer um endpoint para buscar as reservas. Então, uma solicitação GET para /reservations, que as lerá do
1:17:09 banco de dados SQL e encaminhará a resposta ao cliente. Depois, a pessoa quer ver, digamos, os detalhes de uma reserva e a fatura que pagou. Para isso, teremos o endpoint GET /reservations/ID de uma única reserva, que buscará os detalhes no banco de dados. Obteremos os detalhes e os encaminharemos ao frontend, onde serão processados e exibidos ao cliente. Em seguida, ao criar novas reservas, eles
1:17:40 preencherão o formulário de reserva no frontend. Então, enviaremos os detalhes desse formulário em uma solicitação POST para reservations. Um ponto importante aqui é que, nesta etapa, já podemos evitar reservas duplicadas feitas por um único usuário. Há dois cenários: um usuário pode tentar clicar duas vezes, ou clicar duas vezes na mesma reserva de quarto. Esse é um cenário que podemos tratar aqui. O outro é quando dois usuários tentam reservar o mesmo quarto ao mesmo tempo. Vamos tratar
1:18:14 disso na etapa seguinte. Mas, nesta etapa, por exemplo, uma solução é incluir uma chave, como um ID de reserva, que será gerado no frontend e usada para evitar reservas duplicadas quando o usuário clicar em enviar várias vezes. Então, se tivermos o ID do hotel e o ID do quarto que esse usuário está tentando reservar, basicamente geramos um ID de reserva uma vez e, se a pessoa tentar fazer uma reserva duplicada, como reservar novamente, nós
1:18:45 será possível detectar isso no banco de dados e ver que o ID da reserva é o mesmo. Então, vamos impedi-los de fazer uma solicitação, ou podemos até fazer isso no frontend. Outra solução é fazer isso também no backend. Por exemplo, quando fizerem uma solicitação para reservar algo, vocês podem combinar o ID do hotel com o ID do quarto, o ID do usuário que está tentando fazer a reserva e também a data ou algum tipo de carimbo de data e hora.
1:19:17 Por quê? Porque, no futuro, esse usuário pode tentar reservar novamente. E, se vocês usarem esses dados em conjunto, basicamente estarão impedindo que ele faça uma reserva futura no mesmo hotel. Então, também podem usar esses dados em conjunto para gerar algum tipo de hash e, sempre que o hash corresponder ao do banco de dados, impedir que façam a reserva. Mas uma solução bem mais simples no frontend é simplesmente gerar algum tipo de UUID para o
1:19:48 ID da reserva. Se vocês estiverem falando apenas de um único usuário e de impedi-lo de reservar novamente, esta é uma solução bem leve e muito mais simples do que inserir esse hash no banco de dados e verificá-lo toda vez no backend. Então, aqui, primeiro vamos verificar se já existe um ID de reserva. Se não for encontrado, vamos inserir a nova reserva. Então, supondo que não seja uma reserva duplicada e que recebamos a confirmação da reserva, vamos simplesmente mostrar a confirmação ao usuário e, nos bastidores,
1:20:19 a mensagem do Kafka será publicada e enviará as notificações ao usuário e ao proprietário do hotel. Mas, se for uma tentativa de reserva duplicada, o usuário fará uma solicitação que conterá esse ID de reserva. Então, vamos verificar se já existe um ID de reserva e, se uma transação como essa já tiver ocorrido e a reserva já existir, vamos retornar a reserva existente e mostrar ao usuário que ela já está reservada.
1:20:52 Da mesma forma, talvez queiram cancelar a reserva ou alterar as datas dela. Então, também precisamos fornecer endpoints para isso. No caso do cancelamento, será uma solicitação DELETE para reservations/ID da reserva. No banco de dados, só precisamos verificar se o cancelamento é permitido, porque, se tentarem cancelar uma semana antes do compromisso, será permitido; mas, se tentarem cancelar em cima da hora, provavelmente não será permitido.
1:21:23 Então, se for permitido, simplesmente enviaremos a confirmação. Caso contrário, enviaremos uma resposta dizendo que não podemos excluir a reserva, ou seja, que não podemos cancelá-la, e mostraremos as opções ao usuário. De forma semelhante, vocês também podem ter a edição da reserva com uma solicitação PUT, que atualizará as datas. Por exemplo, se os planos mudaram e vocês quiserem atualizar as datas, farão uma solicitação PUT para o ID da reserva. Vamos ver também como será feita a busca. Então, outro serviço
1:21:53 que teremos para isso é o serviço de busca, que será conectado ao Elasticsearch. Digamos que o usuário defina filtros, como o local onde quer que o hotel fique, as datas de início e de término, entre outros, e façamos uma solicitação GET para /search. Esses filtros incluirão o local, os horários de check-in e check-out, o número de hóspedes, quantas camas querem, a faixa de preço e quaisquer outros filtros necessários. É aqui que consultaremos os hotéis disponíveis
1:22:24 e os tipos de quarto. Mas um ponto importante é que faremos essa consulta no Elasticsearch. Nesse caso, não consultaremos o banco de dados, porque o Elasticsearch é muito mais rápido, e esse é o objetivo de configurá-lo: retornar rapidamente os resultados da busca. Isso retornará os hotéis disponíveis naquela região, dentro daquela faixa de preço e para aqueles horários de check-in e check-out, e o frontend processará os dados e os exibirá aos usuários.
1:22:56 Em seguida, vamos passar aos modelos de dados e ao design do banco de dados. Um ponto importante é que vocês reservam o tipo de quarto, não um quarto específico. Vocês reservam um tipo específico de quarto, como um quarto com duas camas queen-size ou algo diferente, e não um quarto específico, porque os números dos quartos são atribuídos no check-in, não aqui. Mas vamos começar pelos hotéis, porque assim fará mais sentido. Então, teremos, é claro, o
1:23:27 ID do hotel, que é o identificador único desse hotel. Ele pode ter nome, endereço, localização, descrição e assim por diante. Também pode haver hotéis pertencentes à mesma marca. Nesse caso, vocês podem usar o mesmo nome de hotel, mas IDs de hotel diferentes. Assim, será uma LLC de hotel diferente nos EUA. Digamos que será uma LLC de hotel diferente na Europa. O nome será o mesmo, mas o ID será
1:23:58 diferente. Ou vocês podem usar uma tabela auxiliar, como grupos de hotéis, e agrupá-los por nome de marca, caso haja marcas com vários hotéis em locais diferentes. Depois, a parte importante: precisamos ter o tipo de quarto. Então, o ID do tipo de quarto, seu nome, descrição, quantas pessoas pode acomodar e o ID do hotel, que faz a ligação com o hotel. Assim, um hotel pode ter vários tipos de quarto, e essa é
1:24:31 uma relação de um para muitos. Depois, cada tipo de quarto também se relaciona com tarifas de tipo de quarto, porque cada um desses quartos terá uma tarifa diferente. Digamos que temos um quarto do pacote básico. Como exemplo básico, digamos que custe 100 dólares por dia. Esse é o pacote básico. Depois, vocês podem ter um quarto com uma vista melhor. Nesse caso, custará algo como 150 por dia. Também podem ter um quarto de hotel com café da manhã incluído, e
1:25:05 isso custará algo como 200, e assim por diante. É por isso que temos essas tarifas por tipo de quarto: para cada tipo de quarto, teremos uma tarifa diferente, que será multiplicada pelo número base que temos aqui. E essa também é uma relação de um para muitos, porque um tipo de quarto pode ter várias tarifas. Depois, temos os quartos propriamente ditos. Qual é o ID do quarto? É o quarto exato que vamos atribuir no check-in.
1:25:37 Depois, ele terá outros metadados, como o número do andar e se está disponível. Essa é a parte importante que acompanhará o status. É isso também que consultamos ao verificar a disponibilidade, além do ID do tipo de quarto, que novamente aponta para o tipo de quarto. Assim, um tipo de quarto pode incluir vários quartos, o que significa que vocês podem ter um hotel e, dentro dele, vários tipos de quarto, como, por exemplo, o tipo básico; e, nesse tipo básico, podem ter três
1:26:08 quartos disponíveis, todos quartos básicos. Isso também se relaciona com o ID do hotel, para que vocês possam determinar a qual hotel específico esse quarto, que vamos atribuir no check-in, pertence. Também poderiam fazer uma junção com os tipos de quarto e obter o ID do hotel dessa forma. Mas será útil ter o ID do hotel aqui. Então, esse campo indicará a qual hotel pertence o quarto que estamos atribuindo no check-in. Em seguida, teremos o inventário por tipo de quarto. O inventário é controlado por hotel, por tipo de quarto e por
1:26:41 dia, não por quarto físico. Isso nos permitirá fazer atualizações em massa da disponibilidade, além de oferecer preços escaláveis e buscas; a lógica de overbooking também será simples, porque ofereceremos overbooking considerando os cancelamentos de quartos que ocorrerão. Então, aqui acompanharemos o ID do tipo de quarto, o ID do hotel — e, novamente, há relações de um para muitos entre o tipo de quarto e o hotel — e outras informações, como a data, o inventário total,
1:27:13 total de reservas e assim por diante. Outra tabela importante é a de reservas, porque, quando o usuário seleciona um quarto específico do hotel, precisamos reservá-lo. Então, acompanharemos o ID da reserva, que depois vincularemos às transações. Teremos o ID do tipo de quarto, que aponta para a tabela de tipos de quarto. Teremos o ID exato do hotel onde a reserva foi feita, que aponta para a tabela de hotéis. E teremos o ID do hóspede, que corresponde ao usuário que fez a reserva. Para isso, então, vamos
1:27:45 ter uma tabela de hóspedes. E também podemos ter, digamos, uma tabela de administradores ou corretores, que serão os usuários que publicam esses quartos. Então, o mais importante para nós aqui é o ID do hóspede, o nome, o sobrenome, o e-mail e assim por diante. Vamos conectá-la usando uma relação de um para muitos, porque um usuário, um hóspede, pode reservar vários hotéis. Por isso, é uma relação de um para muitos. Depois, alguns metadados, como a data de início,
1:28:17 a data de término, o status, se está reservado, se foi cancelado e assim por diante. E outra parte importante aqui é que vamos separar as transações em uma tabela própria. O motivo é que, digamos, você reserva o hotel primeiro. Então, inicialmente, o status é reservado. Depois, o usuário pode mudar de ideia e cancelar a reserva ou solicitar uma alteração na data. Então, pode solicitar a alteração das datas de início e de término.
1:28:48 No segundo caso, haverá uma diferença de preço que o usuário terá de pagar ou que teremos de reembolsar. Isso será outra transação, certo? E é por isso que uma reserva pode ter várias transações. A primeira transação acontece quando o usuário reserva; a segunda, quando decide alterar as datas, digamos, e atualizar a reserva. Nesse caso, ele faz outro pagamento, que é uma segunda transação. Ou, se decidir cancelar, isso será outra transação, em que o
1:29:20 valor, nesse caso, será negativo, pois estamos reembolsando o pagamento. Ainda assim, é uma transação separada. Por isso, teremos uma tabela de transações separada, na qual registraremos o ID da transação, a qual reserva ela pertence, o tipo de transação — como reembolso, alteração ou nova transação — e o valor, que pode ser positivo ou negativo. Com esse esquema, a tabela de inventário de tipos de quarto tem uma linha para cada combinação de quarto de hotel
1:29:51 e data. Vamos preenchê-la antecipadamente para os próximos dois anos, e uma tarefa cron diária poderá adicionar novas datas conforme o tempo passar. Também podemos ver alguns dados de exemplo aqui. Teremos o ID do hotel ao qual esses tipos de quarto pertencem. Depois, o ID do tipo de quarto. Digamos que esses três quartos sejam do mesmo tipo, como o tipo básico. As datas para as quais estamos verificando, como 15, 16 de dezembro,
1:30:23 17 e assim por diante. Depois, veremos o inventário total e também o total de reservas, o que nos dará uma ideia de quantos quartos temos disponíveis. Ao verificar a disponibilidade, se estivermos usando um banco de dados SQL, digamos, para o período de 23 a 27 de dezembro, vamos selecionar a data, o inventário total e o total de reservas, que é esta coluna aqui. O inventário total menos o total de reservas nos dará a disponibilidade. Vamos selecionar esses dados da tabela de inventário de tipos de quarto
1:30:53 em que o ID do hotel e o ID do tipo de quarto correspondam. E definiremos a data para ficar dentro do intervalo desejado. Isso nos dará o total de reservas e o inventário total. Com esses dados, podemos verificar se o total de reservas mais a quantidade de quartos a reservar ainda é menor ou igual ao inventário total. Se for, permitimos a reserva. Isso significa que, se aqui temos 100 e aqui temos 92, ainda temos oito quartos disponíveis. E se
1:31:23 o usuário estiver tentando reservar apenas um quarto, teremos 92 + 1 e compararemos esse valor com 100. A condição será verdadeira, então permitiremos a reserva. Voltando aos requisitos iniciais, como permitimos, digamos, 10% de overbooking — ou qualquer que seja esse número —, podemos incluir esse fator de overbooking aqui. Digamos que permitimos 10% de overbooking; então, definimos esse valor como 10% aqui. Isso significa que, se tivermos 100
1:31:56 quartos no inventário total, permitiremos que reservem até 110 quartos, porque sabemos que os outros 10 provavelmente serão cancelados até a data da reserva. Por fim, vamos discutir também por que escolhemos um banco de dados relacional. Neste caso, já tocamos nesse assunto, mas vamos nos aprofundar um pouco mais no motivo dessa escolha. Primeiro, o mais importante para nós são as garantias ACID e, especificamente, a consistência dos bancos de dados SQL. Isso é
1:32:28 importante para evitar problemas de reservas duplicadas, que vamos abordar em breve. Pelos cálculos, também podemos ver que este é um sistema com muitas leituras. Bancos de dados relacionais lidam bem com esse tipo de leitura, e sempre podemos combiná-los com o Elasticsearch para escalar. Mas, com 1.000 consultas de leitura por segundo, contra 17 operações de escrita por segundo, fica claro que precisamos usar um banco de dados SQL aqui, combinado com a consistência
1:32:59 que precisamos do ACID. Além disso, as relações ficam mais claras dessa forma, por exemplo, entre quartos, hotéis, tipos de quarto, reservas, transações e assim por diante. Também precisaremos fazer consultas complexas para verificar a disponibilidade dos quartos ou fazer vários joins entre o inventário e os preços, filtrar por datas, agregar totais e assim por diante. O SQL também lidará bem com isso. Também precisamos lidar com a concorrência. Já
1:33:30 discutimos o primeiro cenário, em que o mesmo usuário tenta reservar o mesmo quarto duas vezes. A solução é simplesmente ter essa chave de reserva. Assim, qualquer segunda solicitação receberá um erro de chave duplicada, e retornaremos a reserva existente. Esse é o caso simples que podemos resolver. Mas o segundo cenário envolve dois usuários diferentes tentando reservar o mesmo tipo de quarto, do qual resta apenas uma vaga naquele hotel. Se você conhece os níveis de isolamento do SQL, por padrão, o nível será read committed. Ainda podem ocorrer bugs de concorrência nesse nível de isolamento, o que significa que você só vê os dados depois que outras transações são confirmadas. Mas há um intervalo entre a leitura — quando verificamos a disponibilidade — e a escrita, quando reservamos o quarto. Nesse intervalo, podem ocorrer duas transações diferentes: o usuário A lê o total de reservas e vê que há um quarto disponível, pois o total é 100. O usuário B também lê e
1:34:02 vê que há 99 quartos reservados e 100 disponíveis, o que significa que resta um disponível. Então, para os dois, a verificação é bem-sucedida: a condição é verdadeira, o que significa que permitimos que prossigam com a reserva. Portanto, o read committed, por si só, não é seguro para reservas, porque duas transações podem ler dados válidos, passar nas verificações e depois sobrescrever uma à outra. É exatamente assim que acontecem as reservas duplicadas. Para resolver isso, temos duas abordagens principais
1:34:33 neste caso. Uma delas é usar o nível de isolamento serializable. Isso significa que, ao iniciar a transação no código, podemos definir o nível de isolamento como serializable. Por padrão, ele é read committed. É simples de implementar e funciona bem com um TPS baixo, ou seja, poucas transações por segundo, porque elas não vão bloquear umas às outras se o volume não for muito alto. Então, nesse caso, usar serializable é adequado. As desvantagens são que
1:35:04 as transações vão bloquear umas às outras. Portanto, se o TPS for alto, isso não vai escalar bem, e também haverá a possibilidade de deadlocks se vários recursos forem bloqueados em ordens diferentes. No entanto, você pode, por exemplo, usar timeouts de bloqueio e defini-los com um valor para evitar esperas indefinidas, além de manter as transações curtas. Ainda assim, há uma solução melhor neste caso: usar o nível de isolamento read committed, que continua sendo o padrão, em combinação
1:35:36 com o bloqueio de linhas. Isso é feito usando SELECT FOR UPDATE, que bloqueia as linhas do inventário enquanto verificamos e reservamos os quartos. Assim, os outros usuários precisam esperar a vez deles. Se duas transações tentarem bloquear a mesma linha, a segunda terá de esperar a conclusão da primeira. Mas, se houver outras transações que não tentem escrever nessa mesma linha, elas podem prosseguir livremente e, basicamente, podem ser executadas em
1:36:07 paralelo, ao contrário do serializable, em que as transações precisam ser executadas uma de cada vez. Portanto, o FOR UPDATE evita a condição de corrida ao serializar o acesso a linhas específicas neste caso. O read committed continua sendo o padrão do PostgreSQL e funciona bem aqui. O serializable também pode ser usado se o TPS for baixo e não houver muito tráfego escrevendo no banco de dados a cada segundo. A próxima etapa é falar sobre
1:36:39 em paralelo, em vez de serializá-las, quando as transações precisam ser executadas uma por vez. Portanto, o FOR UPDATE aqui evita a condição de corrida ao serializar o acesso a linhas específicas neste caso. O READ COMMITTED ainda é o padrão do PostgreSQL e funciona bem aqui. E o nível SERIALIZABLE pode ser usado se você tiver um TPS baixo e não houver tanto tráfego gravando no banco de dados a cada segundo. A próxima etapa é falar sobre
1:37:11 a escalabilidade do sistema. Coisas como fragmentação do banco de dados, se ela é necessária, estratégia de cache, como vamos otimizar as consultas do Elasticsearch. Então, vamos começar pela fragmentação do banco de dados. Como vamos lidar com 1.000 consultas por segundo e 17 transações por segundo, eventualmente precisaremos fragmentar o banco, e isso ficará evidente pelo tempo que as consultas típicas levam para ser executadas. E há tabelas aqui que podemos dividir. Quando se trata de fragmentação, a decisão importante
1:37:41 é qual será a chave de fragmentação e por qual campo vamos fragmentar. Um exemplo aqui é fragmentar pelo ID do hotel. Por quê? Porque a maioria das consultas primeiro filtra pelo hotel para verificar a localização e se ela corresponde ao filtro da consulta de pesquisa; depois, elas buscam os tipos de quarto disponíveis nesse hotel. Todos os dados de um hotel continuarão no mesmo fragmento. Assim, não faremos junções entre fragmentos, e
1:38:13 isso também permitirá transações locais dentro do fragmento. Para a implantação inicial, podemos usar a fragmentação por módulo, que é a fórmula para fragmentar. Então, o número do fragmento será igual a — inicialmente, não será hash consistente. Será apenas o hash do ID do hotel combinado com o número de fragmentos que teremos. Isso funcionará bem quando você souber que a quantidade de fragmentos não mudará com frequência. Então, se decidirmos, por exemplo, dividir essas 1.000 consultas de leitura por
1:38:44 segundo entre 16 fragmentos, teremos cerca de 62 leituras, ou seja, consultas por segundo, e uma transação de escrita por segundo em cada fragmento. Nesse caso, a fragmentação por módulo funcionará bem e, mais tarde, poderemos integrar a fragmentação consistente, se necessário, para minimizar a movimentação de dados. O problema da fragmentação por módulo é que, se decidirmos adicionar outro banco de dados, digamos, o número 17, isso nos obrigará a
1:39:15 redistribuir quase todos os dados dos fragmentos de 1 a 16. Já se usarmos hash consistente, isso move apenas cerca de um dezessete avos dos dados, que são os dados que serão armazenados no fragmento 17. Você pode conferir o vídeo do curso de design de sistemas em que falo sobre hash consistente. Basicamente, ele usa um anel de hash, no qual tanto os servidores quanto as chaves são mapeados para pontos em um círculo, e cada
1:39:47 chave vai para o próximo servidor no sentido horário no anel. Então, se você tem um anel como este, calcula a chave, que acaba aqui, e o servidor mais próximo dela está aqui. Então, ela será armazenada nesse servidor. Com o hash consistente, adicionar um novo servidor afeta apenas as chaves entre ele e o servidor anterior, que é o número 16. Já com a fragmentação por módulo, é preciso basicamente redistribuir todos os dados. Portanto, com um número pequeno de fragmentos, a fragmentação por módulo
1:40:18 é o caminho a seguir; e, se você sabe que normalmente adicionará novos fragmentos ao banco de dados, o hash consistente é uma solução melhor. Agora, vamos ver a estratégia de cache que podemos usar para os hotéis. Precisaremos armazenar em cache os dados de inventário mais acessados no Redis ou no Memcached, para facilitar a invalidação. Esta é a estrutura de cache que podemos usar: como chave, armazenaremos o inventário, que será a
1:40:48 combinação do ID do hotel, do ID do tipo de quarto e da data; como valor, armazenaremos o inventário disponível. Digamos que, nesse hotel, novamente, haja 100 unidades disponíveis e o total reservado seja algo como 87. Também teremos, associado a isso, a taxa de overbooking, que será de 10%, mas, é claro, não armazenaremos isso no cache. Teremos esse valor fixo em algum lugar e, no cálculo, vamos usá-lo em conjunto com o inventário total e o
1:41:20 total reservado. E atribuiremos ao cache um tempo de vida, que pode ser, por exemplo, de 5 minutos para cada item do inventário. O fluxo do cache será mais ou menos assim. Primeiro, o usuário pesquisa hotéis, e é aí que verificamos primeiro o cache do Redis para consultar o inventário do hotel. Se houver um acerto no cache, ou seja, se tivermos armazenado esses dados no cache, mostraremos imediatamente ao usuário o inventário do hotel, lendo os dados do cache. Mas, caso ele
1:41:52 não esteja no cache, temos uma falha de cache. Isso significa que precisamos lê-lo do banco de dados. Depois da leitura, também vamos preencher o cache do Redis, para que, da próxima vez, haja um acerto no cache. E, se lermos os dados do cache, ou seja, houver um acerto, mas o TTL tiver expirado, basicamente repetiremos o mesmo fluxo: lemos do banco de dados, armazenamos no cache e, então, mostramos ao usuário. Depois de mostrarmos isso ao usuário, digamos que o hotel esteja disponível, o usuário
1:42:23 pode clicar em reservar. Para reservar o hotel, precisaremos validar a reserva em relação ao banco de dados e atualizar o banco com a transação da reserva. Novamente, é nesse momento que invalidaremos as chaves de cache afetadas. Se o usuário decidir reservar e a reserva for concluída com sucesso, precisaremos basicamente invalidar o cache, atualizar os campos e, na próxima pesquisa do usuário, consultar novamente o Redis. Assim, os dados estarão atualizados com o que acabou de
1:42:55 acontecer quando esse usuário, digamos, reserva o hotel. Então, na próxima vez, não mostraremos que ele está disponível; mostraremos que esse tipo de quarto não está mais disponível, caso tenha sido reservado após a pesquisa anterior. Vamos também discutir a otimização da pesquisa com Elasticsearch e explicar um pouco mais detalhadamente para que serve o Elasticsearch. O Elasticsearch é um mecanismo de pesquisa e análise, um banco de dados especializado especificamente em operações de pesquisa, e não em cargas de trabalho transacionais
1:43:25 como as do PostgreSQL ou do MySQL. PostgreSQL ou MySQL ainda são ótimos para consultas como “me dê a linha exata em que o ID é igual a 1 2 3” ou “insira isto no banco de dados em que o ID do hotel é igual a determinado valor”. Por outro lado, o Elasticsearch é ótimo para encontrar resultados com filtros complexos. Por exemplo, encontrar todos os documentos que correspondam, de alguma forma, a este texto e classificá-los por relevância. Filtrar
1:43:55 por vários critérios e fazer isso rapidamente em milhões de documentos. Por exemplo, no nosso caso, encontrar todos os quartos de hotel em que o hotel fica na Geórgia. Digamos que a faixa de preço seja de 80 a 180 dólares por dia, que a avaliação seja superior a 8 e assim por diante. E, novamente, fazer isso rapidamente. É por isso que usamos o Elasticsearch também para
1:44:26 reservas. Se usássemos apenas PostgreSQL para pesquisa, teríamos de fazer algo como selecionar tudo da tabela de hotéis em que a localização seja Paris, digamos, em que as comodidades incluam Wi-Fi, em que o preço-base seja até 200 e em que a descrição contenha algo como “luxo”, porque isso faz parte da pesquisa. Basicamente, sempre que você usa a palavra-chave LIKE, o desempenho do SQL despenca, porque essa operação é muito lenta. O problema aqui é que LIKE e
1:44:59 algum texto fazem uma varredura completa da tabela no SQL, e nem os índices nos ajudam nesse caso, porque, com LIKE, basicamente se faz uma varredura completa da tabela. E dá para imaginar que, com tantos hotéis ou tipos de quarto, teríamos de percorrer a tabela inteira só para encontrar os hotéis disponíveis em Paris, que provavelmente representam menos de 10% da tabela inteira. Além disso, o SQL não consegue classificar por relevância. Então, não consegue determinar qual
1:45:29 hotel é o mais relevante. Também não consegue lidar com erros de digitação. Se o usuário esquecer o S final de Paris, por exemplo, novamente será preciso usar a palavra-chave LIKE; caso contrário, se a pesquisa exigir exatamente “party”, nenhum resultado será encontrado. O SQL também é muito ruim para calcular distâncias geográficas, e as agregações são caras, pois agrupamos dados. Em vez disso, se usarmos o Elasticsearch, teremos pesquisa de texto completo com classificação por
1:46:02 relevância. Então, quando o usuário pesquisa, por exemplo, hotéis de luxo em Paris, primeiro dividimos “hotéis de luxo” e “Paris” em palavras-chave. Encontramos todos os documentos que contêm esses termos. Depois, também os classificamos por relevância usando um algoritmo. Um dos algoritmos usados pelo Elasticsearch. Ele também consegue detectar erros de digitação. Então, se o usuário esquecer o “i” de “Paris”, ele consegue perceber que a pessoa estava pesquisando por Paris e corrigi-lo.
1:46:33 distância de um. E também é ótimo para hotéis com classificação. Assim, ele pode mostrar hotéis a até 5 quilômetros de coordenadas próximas a Paris. Talvez você não se importe em reservar um hotel perto de Paris, em uma cidade menor e com preço mais baixo. O Elasticsearch tem índices otimizados para isso. Mas como armazenamos esses dados no Elasticsearch? Duplicamos os dados ou o que fazemos? Sim, de certa forma duplicamos os dados. Digamos que você tenha os hotéis
1:47:05 na tabela SQL. Você tem o ID do hotel, o nome, o endereço e, depois, os tipos de quarto desse hotel que pertencem especificamente a ele. Você tem as comodidades desse hotel e todo o resto. Basicamente, o Elasticsearch armazena esses dados à sua maneira, o que significa que os converte para um formato parecido com JSON, com o ID do hotel, o nome, o endereço e, depois, aninha nele a localização e os tipos de quarto. Então, como você pode ver, aqui não há junções.
1:47:36 Basicamente, ele condensa tudo em um único documento, que também pode ser usado para fazer buscas rapidamente. E como mantemos o Elasticsearch sincronizado com o PostgreSQL, já que nossa única fonte da verdade continua sendo o PostgreSQL? É aqui que verificamos se o quarto está disponível ou não, se podemos fazer overbooking ou qualquer outra coisa. Basicamente, verificamos isso no PostgreSQL, que é a única fonte da verdade. Por isso, quando fazemos
1:48:07 transações, fazemos isso verificando o PostgreSQL, e não o Elasticsearch. Mas, quando fazemos buscas, usamos o Elasticsearch. Assim, podemos usar a captura de dados de alteração (CDC) para manter o Elasticsearch eventualmente consistente com o PostgreSQL. É assim que funciona. Temos o PostgreSQL, que continua sendo a única fonte da verdade. Depois, ele usa o log de gravação antecipada para enviar os dados ao conector do Debezium, que lê esse log de gravação antecipada.
1:48:39 Ele publica os dados no Kafka e inicia o fluxo de eventos. Em seguida, o consumidor do Elasticsearch processa os eventos. Então, teremos um consumidor que processa esses eventos, atualiza o índice no Elasticsearch e o torna eventualmente consistente com o banco de dados PostgreSQL. Isso também serve para a recuperação após falhas. Assim, mesmo que o PostgreSQL falhe, você pode ler os dados do log de transações. Você tem o Debezium, que é uma
1:49:10 ferramenta de CDC open source. Ela se conecta ao PostgreSQL e lê os logs em tempo real. Depois, publica as alterações. Sempre que ocorrem alterações, ela as publica em um tópico do Kafka, e cada tabela recebe seu próprio tópico. Então, o consumidor lê os eventos do Kafka de cada tópico, um para cada tabela, e atualiza o Elasticsearch. Esse é o panorama geral do caso de uso do Elasticsearch, por que o usamos e como o mantemos atualizado com nosso banco de dados SQL.
1:49:41 Em seguida, esse sistema também precisará de replicação de banco de dados, porque estamos lidando com mil consultas por segundo. Uma opção é o sharding, que divide os shards das tabelas com base nas localizações geográficas. Mas também é possível escalar as leituras por meio da replicação. Assim, você pode ter o banco de dados primário para gravação e replicá-lo para uma réplica de leitura em uma
1:50:12 zona de disponibilidade e outra réplica em uma zona de disponibilidade diferente. Dessa forma, o usuário se conecta ao banco de dados disponível mais próximo, e a busca ou consulta fica mais rápida. Neste caso, imaginemos as reservas. Digamos que estamos consultando as reservas deste usuário com um ID de usuário específico. Podemos ter uma variável que detecta se devemos usar o primário ou não. Se a gravação for recente, devemos usar o primário, porque
1:50:43 a réplica de leitura talvez ainda não esteja atualizada. Se for esse o caso, obteremos a conexão com o primário e faremos a leitura desse banco de dados. Mas, se não for uma gravação recente, podemos ler da réplica mais próxima: obteremos a conexão com a réplica e executaremos a consulta nela, em vez de no banco de dados primário. Em seguida, vamos ver também o balanceamento de carga que usaremos aqui. Podemos ter usuários de muitos
1:51:15 locais diferentes, certo? Podemos ter usuários dos EUA, da Europa, da Ásia e de outros continentes. Então, para isso, faz sentido usar o algoritmo de latência geográfica, que se baseia na região deles. É claro que pode haver exceções. Talvez alguém dos EUA queira viajar para a Europa. Esse é um caso diferente. Mas, em geral, os usuários da Europa querem reservar hotéis na Europa. Os usuários dos EUA querem reservar hotéis nos EUA. Então, esse é
1:51:48 o algoritmo que faz mais sentido aqui. E podemos replicar os dados com base nas localizações. Podemos ter réplicas de banco de dados diferentes, somente para leitura. Podemos ter o cache do Redis em cada localização, porque provavelmente armazenaremos em cache os hotéis da região dos usuários dos EUA nessa região, e não faz sentido armazenar em cache aqui também os hotéis da Europa. Então, teremos isso em uma réplica diferente, em outra região, e também o Elasticsearch.
1:52:20 Isso porque, novamente, é mais provável que usuários dos EUA pesquisem hotéis nos EUA. Então, temos todos esses dados em uma região, onde podem ser acessados rapidamente. Da mesma forma, temos os dados para a Europa, para a Ásia e para quaisquer outros continentes que atendamos. Mas o banco de dados primário continua sendo único, mesmo que este seja um dos shards, digamos, o shard 7. Ele continua sendo um único banco de dados para evitar conflitos entre diferentes regiões gravando no
1:52:52 banco de dados, que depois teríamos de gerenciar. Assim, podemos ter um único banco de dados primário. Como o número de transações por segundo não é alto, conforme vimos nos cálculos, podemos ter um shard primário e várias réplicas em diferentes regiões. Esta é a análise mais detalhada que você encontrará na internet sobre como projetar do zero um app de mensagens como o WhatsApp. Apps de mensagens como esse aparecem o tempo todo em entrevistas para cargos sênior e staff
1:53:22 porque obrigam você a lidar com conexões com estado. Pense na entrega em tempo real, na consistência e na escala do sistema, nas falhas de partes do sistema ou na recuperação de desastres em geral, e na sincronização entre vários dispositivos, caso os usuários usem vários dispositivos, como celulares e apps para desktop. Então, em uma entrevista curta, você precisa mostrar que entende não só o fluxo ideal, mas também tudo o que pode dar errado com as mensagens enquanto elas trafegam entre
1:53:53 usuários desconectados e redes móveis. E não vamos ignorar as partes difíceis nem fingir que tudo simplesmente funciona sem que impeçamos as falhas. Então, vamos começar construindo tudo do zero. Primeiro, com uma versão simples que funciona para alguns milhares de usuários. Depois, vamos escalá-la para lidar com bilhões de usuários enviando mensagens por redes móveis não confiáveis. Em seguida, vamos identificar onde ela falha e como corrigir isso adequadamente. No final, você também encontrará
1:54:24 o conjunto completo de notas de rodapé que explica cada conceito técnico que mencionei durante este projeto. Assim, você pode rever qualquer coisa e se aprofundar em cada tópico. Antes de chegar ao projeto de alto nível e esboçar diagramas, precisamos começar pelos requisitos para entender o que estamos construindo. O requisito principal é que os usuários possam enviar e receber mensagens em conversas individuais. Além disso, também temos conversas em grupo com até 100 participantes que precisamos
1:54:56 suportar, e as mensagens devem ser entregues não apenas quando os usuários estiverem online, mas também quando estiverem offline. Precisamos lidar com esse cenário também. Eles podem anexar arquivos de mídia, como imagens, vídeos ou clipes de áudio, como mensagens de voz. Precisamos acompanhar o status das mensagens: quando são enviadas, quando são entregues e quando são lidas pelo outro usuário. E, claro, precisamos acompanhar o status online ou offline para mostrar a data da última vez em que o usuário esteve
1:55:27 online. Esses são os requisitos principais. Quanto aos requisitos não funcionais, precisamos oferecer entrega com baixa latência: menos de 500 milissegundos quando os usuários estiverem online. Também precisamos garantir a entrega das mensagens. Afinal, as mensagens não podem simplesmente desaparecer sem chegar ao outro usuário. Na fase de escalabilidade, precisamos lidar com bilhões de usuários e alto throughput. E, para manter o armazenamento gerenciável, não guardamos as mensagens nos servidores por mais tempo do que o necessário.
1:55:58 Então, ele também precisa continuar resiliente quando componentes individuais falham. Digamos que um dos componentes falhe, como o servidor que precisa enviar a mensagem para o segundo usuário. Também precisamos lidar com esses cenários para garantir que o usuário receba a mensagem eventualmente, quando o servidor voltar a ficar online. Com isso, podemos fazer alguns cálculos aproximados para entender a escala do sistema e, então, começar com o projeto de alto nível. Vamos começar pelas métricas de usuários. Na fase de escala, temos 1 bilhão de usuários registrados e 500 milhões
1:56:31 de usuários ativos diariamente. Durante os horários de pico, ele lidará com 50 milhões de conexões simultâneas e, em média, esperamos que cada usuário tenha de 10 a 20 mensagens por dia. Com isso, podemos começar as estimativas de mensagens. Por exemplo, para as mensagens diárias, precisamos multiplicar os usuários ativos pela média de mensagens diárias enviadas, que é de 20 no máximo. Isso resulta em 10 bilhões de mensagens enviadas todos os dias pelo sistema,
1:57:01 o que significa 10 bilhões de gravações no banco de dados todos os dias. Isso dá, em média, 115 mil mensagens por segundo. Ou seja, 115 mil gravações no banco de dados. Durante os horários de pico, podemos esperar um tráfego de três a cinco vezes maior que a média, o que dá de 350 mil a 500 mil gravações de usuários por segundo, em média. Agora vamos também estimar o armazenamento. Então, cada mensagem com metadados,
1:57:33 vamos considerar que tenha 1 kilobyte. Ela deve incluir o conteúdo de texto, os IDs do servidor e do destinatário, o registro de data e hora de quando a mensagem foi enviada e os sinalizadores de status. Como temos 10 bilhões de mensagens por dia, serão 10 bilhões vezes 1 kilobyte. E armazenaremos 10 terabytes de mensagens por dia. Em uma base anual, isso dá 3,6 petabytes por ano, sendo que cada petabyte equivale a 1.000 terabytes. E a largura de banda será de
1:58:05 50 milhões de conexões simultâneas durante o horário de pico do tráfego. Cada conexão terá, em média, 10 kilobytes por segundo para mensagens ativas, o que dá 500 GB por segundo de largura de banda. Um ponto importante aqui é que a maioria das mensagens é entregue e removida do servidor em segundos. Portanto, não vamos armazenar tudo para sempre. Os usuários baixam as mensagens para seus dispositivos e também fazem backup, se quiserem, por exemplo, no Google Drive, e então podemos
1:58:37 limpar o lado do servidor para não armazenar dados desnecessários. No início, nos requisitos, precisamos definir um período de retenção de 30 dias para mensagens não entregues e, digamos, talvez 10% dos usuários mantendo o histórico nos servidores. Isso permite que o WhatsApp funcione com uma infraestrutura mínima, porque é muito eficiente em não enviar dados desnecessários. Agora, com essa série de cálculos, podemos começar o projeto de altíssimo nível de como o sistema
1:59:09 vai ser. Vamos começar de forma simples e adicionar complexidade apenas quando escalarmos para mais usuários. Mas, por enquanto, esta é a configuração inicial. Teremos o aplicativo móvel, que é o cliente, como o telefone do usuário. Ele é responsável por manter uma conexão consistente com o servidor de chat, cuidar da interface do usuário e gerenciar o armazenamento local de mensagens, além de tentar novamente quando ocorrerem falhas nas operações. A próxima peça é o balanceador de carga, que fica entre o aplicativo móvel e os servidores de chat.
1:59:40 Ele será responsável por direcionar o tráfego de entrada entre vários servidores de chat, para lidar com o pico de carga e tráfego. Esses servidores de chat manterão uma conexão WebSocket com o cliente, um canal persistente de comunicação bidirecional que permanecerá aberto. Eles também encaminharão mensagens entre usuários, acompanharão quem está online e cuidarão da persistência das mensagens. Depois, vamos introduzir uma fila de mensagens, que servirá para desacoplar a gravação das mensagens da
2:00:10 entrega. Essa também é uma das coisas que nos permitirá lidar com as falhas. Quando um dos servidores de chat receber a mensagem, ele confirmará imediatamente o recebimento e informará ao aplicativo móvel que a mensagem foi enviada. Em seguida, enviará a mensagem de forma assíncrona para a fila de mensagens, para armazenamento e entrega. Por exemplo, aqui pode funcionar uma fila de mensagens do Kafka ou o RabbitMQ. Agora, o armazenamento de mensagens consumirá a mensagem que vem do
2:00:42 servidor, do servidor de chat, e a persistirá no banco de dados de armazenamento de mensagens. Como solução de banco de dados, NoSQL funcionará melhor aqui, algo como Cassandra ou DynamoDB, porque precisamos de alta capacidade de gravação, como vimos nas estimativas, e nossos padrões de consulta são, em sua maioria, simples. Vamos buscar mensagens por usuário, por conversa ou por registro de data e hora. E, como gravamos bilhões de mensagens por dia, precisamos de algo capaz de lidar com esse enorme volume de gravações sem ficar lento.
2:01:14 Por isso, algo como Cassandra ou DynamoDB é a melhor opção aqui. Em seguida, também precisaremos de algum cache de conexões de usuários, algo como Redis, que acompanhará quais usuários estão online, a qual servidor de chat estão conectados e o horário da última atividade. Isso ajudará esses servidores de chat a tomar decisões de roteamento mais rapidamente, porque consultar esse armazenamento Redis leva algo como microssegundos, em vez de consultá-lo no banco de dados, o que levaria milissegundos. Em
2:01:46 escala, essa diferença realmente começa a aparecer. Nos requisitos, também temos a necessidade de armazenar arquivos de mídia, como mensagens com áudios ou imagens. Para isso, usaremos algum tipo de armazenamento em nuvem. Pode ser o AWS S3 ou o Google Cloud Storage, que armazenará os arquivos de mídia e manterá os arquivos propriamente ditos. Mas também distribuiremos esses arquivos por uma rede CDN, que armazenará em cache os arquivos populares em locais de borda no mundo todo. Assim,
2:02:18 os downloads serão rápidos, independentemente de onde os usuários estiverem, porque um usuário pode estar nos EUA e o segundo, na Europa. Nesse caso, haverá também uma cópia do mesmo arquivo na região da Europa. O usuário europeu fará o download a partir da CDN mais próxima de sua localização. Em seguida, também precisamos de um serviço de notificações. Ele é principalmente para os usuários offline, que precisam receber notificações pelo Apple Push Notification Service
2:02:48 ou pelo Firebase Cloud Messaging no Android. Quando alguém envia uma mensagem para você enquanto está offline, digamos que o usuário A envie uma mensagem para o usuário B e o usuário B esteja offline naquele momento, ele receberá uma notificação push. Depois, poderá ler a mensagem quando abrir o aplicativo. Por último, também precisamos de um serviço de presença dedicado, que será usado para gerenciar os status online e offline dos usuários. Digamos que o usuário A esteja online novamente e o usuário B esteja
2:03:21 offline. Esse servidor de presença receberá os heartbeats dos usuários, atualizações do Redis e também publicará as mudanças de presença para os assinantes interessados, como o usuário A, que quer saber se o usuário B está online ou offline. Agora vamos escolher o método de comunicação entre o aplicativo móvel e os serviços, os servidores de chat. Temos três opções: polling, long polling e comunicação por WebSocket. É claro que a maioria dos aplicativos de mensagens usa WebSocket para se comunicar.
2:03:53 Mas vamos entender por que isso acontece, analisando as vantagens e desvantagens. Vamos começar pela opção de polling. Digamos que você tenha o cliente deste lado e o servidor de chat do outro. Com a opção de polling regular, funciona assim: faremos consultas periódicas do frontend ao backend, perguntando por meio de algum endpoint de API se há mensagens novas. Digamos que não haja mensagens novas. Então, teremos uma resposta vazia, informando que não há mensagens novas no servidor. Depois,
2:04:26 em algum momento, precisaremos consultar a mesma coisa novamente e perguntar se há mensagens novas. Digamos que, na segunda vez, haja mensagens novas. Então, o cliente receberá as mensagens. Como você pode ver, isso desperdiça muita largura de banda e adiciona latência, porque, se você verificar a cada 2 segundos e a mensagem chegar logo depois da verificação, terá de esperar 2 segundos para vê-la, quando poderia recebê-la instantaneamente. E, se multiplicarmos isso pelos milhões de usuários que pedem atualizações constantemente, mesmo quando não há
2:04:58 Se não há nada de novo no servidor de chat, você está consumindo muitos recursos sem motivo. Por isso, uma abordagem melhor do que essa é a opção de long polling. Nesse caso, esperamos por novas mensagens ou pelo tempo limite. Digamos que solicitamos as novas mensagens ao servidor, e então o servidor de chat espera por algum tempo, até que o tempo limite seja atingido. Se houver novas mensagens, ele as envia de volta ao cliente. E, se não houver novas mensagens, depois de algum tempo, ele envia a resposta, que será
2:05:29 mais uma resposta vazia, dizendo que não há novas mensagens no chat. E, se não houver novas mensagens, é preciso consultar o servidor de chat novamente, e então esse processo basicamente se repete. Isso ainda é problemático, porque você está constantemente abrindo e fechando conexões, e cada mensagem exige um handshake HTTP completo. Em escala, isso gera uma sobrecarga, porque os servidores de chat passam mais tempo gerenciando o ciclo de vida das conexões do que realmente entregando mensagens. Além disso, haverá algumas conexões HTTP ociosas, como
2:06:01 esta, em que ficam aguardando a resposta e consomem recursos do servidor, como memória para o estado da conexão e slots do pool de threads, sem os benefícios de eficiência dos websockets. Por isso, a maioria dos serviços de chat usa comunicação por websocket. Trata-se de uma conexão bidirecional persistente entre o servidor de chat e o aplicativo móvel. Assim, o cliente e o servidor podem enviar dados a qualquer momento. Primeiro, o cliente inicia a
2:06:32 conexão HTTP com o servidor e, em seguida, eles realizam o handshake do websocket. A partir desse momento, a conexão é estabelecida e permanece persistente durante toda a sessão. Assim, digamos que o usuário A envie uma mensagem para o usuário B. O servidor do usuário que enviou a mensagem consegue enviar de forma independente as novas mensagens ao usuário B, que as recebeu. Ou seja, envia as novas mensagens assim que ficam disponíveis. O cliente, que é o usuário B, também consegue
2:07:03 enviar uma mensagem ou confirmar o recebimento a qualquer momento, sem estabelecer uma nova conexão. Essa conexão é persistente e permite atualizações bidirecionais em tempo real, tanto do lado do servidor de chat quanto do lado do cliente. Uma desvantagem dos websockets é que eles exigem balanceadores de carga especiais que ofereçam suporte a eles. Portanto, precisaremos usar balanceamento de carga na camada 4 em vez da camada 7. Eles também consomem mais memória por conexão no servidor, porque as conexões permanecem abertas, mas ainda vale a pena por causa da
2:07:35 necessidade de mensagens em tempo real e dos requisitos de latência das mensagens. Em seguida, vamos ver como gerenciar os servidores de chat que passam pelo balanceador de carga. Em produção, na prática, os servidores de chat aparecem e desaparecem. Eles podem falhar, ser implantados novamente e aumentar ou reduzir a escala. Como o sistema acompanha quais estão disponíveis? O problema é que o balanceador de carga precisa de alguma forma saber quais servidores estão disponíveis — por exemplo, se os servidores de chat um e dois estão disponíveis neste
2:08:06 momento e o servidor de chat três está com problemas. Digamos que ele esteja causando tempos limite ou retornando erros às solicitações. Uma solução que podemos usar aqui é um cluster de descoberta de serviços, como um registro de serviços semelhante ao Consul ou ao AWS cloud map. Veja como funcionaria. Quando esses servidores de chat são iniciados, digamos que o servidor um seja inicializado em algum endereço IP, ele se registra nesse cluster de descoberta de serviços. Depois disso, vamos
2:08:37 fazer verificações de integridade a cada 10 segundos no servidor um para ver se ele está ativo e funcionando. E, da mesma forma, faremos isso para os servidores dois e três. Agora, digamos que o balanceador de carga precise da lista de servidores disponíveis. Primeiro, ele consulta o cluster de descoberta de serviços pelos servidores saudáveis, e então o cluster envia a lista de servidores ao balanceador de carga. Com essa nova lista, digamos que encaminhamos apenas os endereços IP dos servidores de chat um e dois.
2:09:09 Assim, o balanceador de carga consegue atualizar a tabela de roteamento e direcionar todo o tráfego apenas para os servidores um e dois. Ele sabe que o servidor três foi removido do roteamento. Portanto, não fazemos nenhuma solicitação ao servidor três. É assim que vamos gerenciar o balanceamento de carga e o roteamento do tráfego. Agora vamos passar ao modelo de dados e ao projeto das tabelas principais que teremos. Primeiro, precisaremos de uma tabela de usuários, que pode ser
2:09:39 armazenada em um banco de dados SQL, como PostgreSQL ou MySQL. O usuário terá um ID, que será a chave primária. Depois, um número de telefone, que precisa ser exclusivo para todos os usuários. Em seguida, um nome de usuário, a data de criação, que é um registro de data e hora, e a última vez em que esteve online, que usaremos para mostrar aos outros usuários quando esse usuário esteve online pela última vez. E, claro, haverá mais campos, mas esses são, digamos, os principais de que precisaremos. A próxima tabela
2:10:10 será a de mensagens, que fica no Cassandra, onde armazenaremos as mensagens, já que se trata de um banco de dados de alto volume. Vamos gravar e ler muitas mensagens nele. Precisaremos do ID do remetente, que se relaciona com o ID do usuário na tabela de usuários. Também precisaremos do ID do destinatário, que se relaciona novamente com o mesmo campo na tabela de usuários, e de um ID da mensagem, que será a chave primária dessa tabela. Além disso, haverá outros dados
2:10:41 relacionados à mensagem, como o conteúdo, que é o texto, a URL da mídia, caso estejamos armazenando notas de voz ou arquivos de imagem e vídeo. O status da mensagem, que será enviada, entregue ou lida; o registro de data e hora de quando a mensagem foi entregue; e se ela foi excluída ou não, porque também é possível excluir mensagens no WhatsApp. Para gerenciar os grupos no WhatsApp, também precisaremos das tabelas de grupos e de membros dos grupos, que podem ficar novamente no banco de dados PostgreSQL. Para os
2:11:14 grupos, precisaremos de um ID para cada grupo, que será a chave primária e usaremos para identificar o grupo; o nome do grupo; quando foi criado; e qual usuário criou esse grupo. Depois, haverá uma relação de um para muitos entre grupos e membros dos grupos, onde adicionaremos novos membros. Essa é a ID do grupo ao qual foram adicionados, o papel de cada membro, quando ingressaram, e o ID do usuário, que faz a ligação com o ID do usuário na tabela de usuários
2:11:45 — e essa é a chave primária dos usuários. Além disso, outra coisa importante é ter um registro das conexões dos usuários. Ele pode ser armazenado, digamos, em um cache como Redis. Esse registro guardará os IDs dos usuários, o ID do servidor ao qual estão conectados e também o ID da conexão, se ela estiver aberta, o horário da última atividade e o tipo de dispositivo. Podemos adicionar esses dados ao cache Redis e atualizá-los, digamos, a cada 300 segundos, para renová-los com cada heartbeat. Depois que
2:12:17 tivermos o esquema do banco de dados, podemos passar ao projeto da API, especificamente aos fluxos de websocket e da API REST para o aplicativo de mensagens. O primeiro passo do cliente será se autenticar no servidor, enviando o ID do usuário, o token e as informações do dispositivo. Se for bem-sucedida, a autenticação será confirmada pelo servidor, que enviará o ID da sessão, o horário do servidor e a contagem de usuários online. Depois disso, o cliente poderá começar a enviar e receber mensagens. Digamos que ele
2:12:48 envie uma mensagem ao destinatário. Então, enviará as informações de quem é o destinatário, o ID do usuário, o conteúdo, a mídia, se houver, e o registro de data e hora da mensagem. Nesse momento, a conexão websocket já está estabelecida. Portanto, há uma comunicação bidirecional, o que significa que o servidor também pode enviar mensagens ou confirmações ao cliente. Por exemplo, ele confirmará quando a mensagem for recebida. Também entregará todas as novas mensagens de outros usuários que houver,
2:13:19 como o ID da mensagem, o remetente, o conteúdo e os arquivos de mídia. Então, o cliente também poderá confirmar quando receber as mensagens do servidor. Receber a mensagem é uma coisa; talvez a tenham recebido, mas ainda não a tenham aberto. Quando a abrirem, também enviarão a confirmação de leitura, com o ID da mensagem e o registro de data e hora de quando ela foi lida. Quando, por exemplo, a mensagem for lida, o servidor também precisará atualizar a confirmação de leitura com o ID da mensagem e o momento em que ela foi lida.
2:13:50 foi lida, para que o remetente, do outro lado, também saiba quando a mensagem foi lida. Além disso, o cliente e o servidor precisam manter entre si um heartbeat de presença a cada, digamos, cinco ou dez segundos. Assim, o cliente verifica se o servidor ainda está disponível e, da mesma forma, o servidor precisa verificar se o cliente está disponível, porque talvez você feche o aplicativo no celular ou a aba do navegador do WhatsApp. Nesse caso, a conexão precisa ser encerrada para não desperdiçar largura de banda.
2:14:22 Para fazer o upload dos arquivos de mídia, vamos usar uma API REST. Por exemplo, para fazer o upload de um arquivo, usaremos um endpoint POST, algo como /API/media, para enviar o tipo do arquivo, o tamanho do arquivo e, claro, o próprio arquivo e a conversa para a qual ele está sendo enviado. Isso será enviado à API REST, que ficará responsável por processar os uploads de mídia. Do outro lado, também temos o armazenamento de mídia, onde isso
2:14:54 será armazenado. Então, o servidor responderá com a URL de upload, o ID da mídia e o momento em que essa URL expira. Depois disso, o cliente poderá enviar o arquivo para a URL pré-assinada do armazenamento de mídia. Quando o upload for concluído com sucesso, o armazenamento de mídia responderá apenas confirmando o sucesso do upload, e o cliente confirmará o recebimento. Como última etapa, digamos que a mensagem incluísse o arquivo de mídia a ser enviado: o servidor simplesmente confirmará que a mensagem foi
2:15:25 entregue ou, se houver algum erro durante o upload do arquivo ou o envio da mensagem, enviará uma resposta informando que a entrega da mensagem falhou, com a mensagem de erro no corpo. Agora vamos falar sobre a sincronização entre vários dispositivos. Digamos que o usuário A tenha três dispositivos e o usuário B tenha dois. Como também vamos sincronizar as mensagens entre os dispositivos? Temos, deste lado, o celular do usuário A, o tablet do usuário A e o computador de mesa do usuário A. Esses são os três dispositivos do usuário A.
2:15:56 Do outro lado, temos o celular do usuário B e o notebook do usuário B. Primeiro, digamos que a primeira interação seja o usuário A enviar uma mensagem para o servidor de chat A. Em seguida, o servidor de chat confirmará o recebimento e exibirá o status da primeira mensagem como enviada. Como combinamos que vamos armazenar os status de disponibilidade dos usuários em um cache Redis, o servidor de chat A perguntará onde está o usuário B, porque
2:16:27 digamos que a mensagem tenha sido enviada ao usuário B. Então, o Redis responde que o usuário B tem dois dispositivos e está conectado aos servidores B e E. Cada um dos outros dois dispositivos do primeiro usuário estará conectado, digamos, um ao servidor de chat C e o outro ao servidor de chat D. Assim que o servidor de chat armazenar a mensagem enviada, ele também a exibirá por meio do servidor de chat C, que
2:16:58 está conectado ao tablet do usuário A. Ele exibirá a mensagem enviada, e o servidor de chat D exibirá a mensagem enviada no computador de mesa do usuário A. É assim que vamos sincronizar a mesma mensagem entre os três dispositivos do primeiro usuário. Agora vamos ver como essas mensagens são enviadas ao usuário B e sincronizadas com os dois dispositivos que ele tem. O celular do usuário B está conectado ao servidor de chat B, e o notebook do usuário B está conectado ao servidor de chat E.
2:17:29 O servidor de chat B lerá a mensagem do banco de dados e a entregará ao celular. Esse servidor também entregará a mesma mensagem ao notebook. Digamos que o usuário B confira a mensagem no celular. Então, o celular enviará a confirmação ao servidor de chat B, que enviou a mensagem ao celular. Depois disso, o servidor de chat B marcará a mensagem como entregue para o servidor de chat A, que poderá então enviar essa informação ao dispositivo móvel do primeiro usuário e atualizar
2:18:01 o status para “entregue”. A próxima ação será o usuário B ler a mensagem. Assim que ele a ler, enviaremos a confirmação de leitura ao servidor de chat B. Em seguida, esse servidor de chat B encaminhará essa confirmação ao servidor de chat A, desta vez com o status de mensagem lida. Então, o servidor de chat A atualizará o status, que passará a ser exibido como lido. E, como a mensagem agora foi lida, também precisamos sincronizar isso entre os dispositivos do usuário B. Então, o servidor de chat B
2:18:32 também a marcará como lida no notebook do usuário B, que está aqui. Dessa forma, o tablet, o celular e o computador de mesa do usuário A, bem como o celular e o notebook do usuário B, ficam sempre sincronizados com os dados mais recentes: se a mensagem foi enviada, entregue ou lida. E, caso haja conflitos entre vários dispositivos, usaremos a técnica “a última gravação vence”, com números de versão para evitar conflitos. E, claro, essa não é a solução ideal.
2:19:04 Em primeiro lugar, precisamos otimizar isso. Por exemplo, podemos fazer uma sincronização incremental, porque enviar todo o histórico de mensagens para um dispositivo novo é caro. Digamos que estejamos sincronizando com o servidor de chat B e o celular do usuário, e também com o notebook do usuário no servidor de chat E. Não precisamos enviar todo o histórico para o dispositivo. Em vez disso, o dispositivo pode solicitar as mensagens dos últimos 30 dias, já que a maioria dos usuários só precisa do histórico recente; as mensagens mais antigas são
2:19:36 buscadas sob demanda quando necessário. Quando o usuário rolar para cima, as mensagens antigas começarão a carregar, e as muito antigas serão arquivadas em armazenamento frio e carregadas sob demanda. Adicionar essa sincronização entre vários dispositivos também envolve uma contrapartida, porque precisamos dar suporte a aproximadamente o dobro da carga de entrega. Cada mensagem é enviada para vários dispositivos de cada usuário. Mas esse é um recurso importante de aplicativos de mensagens como o WhatsApp, porque os usuários querem
2:20:08 que as mesmas mensagens e os mesmos chats sejam sincronizados entre os celulares e os aplicativos de computador. Agora vamos ver os fluxos principais de como as mensagens realmente circulam quando os dois usuários estão online e quando, às vezes, um deles está offline. Vamos começar pelo caso em que ambos estão online. Nesse caso, digamos que o usuário A envie a mensagem ao usuário B e esteja conectado ao servidor de chat A. A mensagem passa pelo WebSocket e chega a esse servidor de chat.
2:20:41 Esse servidor gerará o ID da mensagem, um ID único baseado em timestamp para essa mensagem, e então enviará a confirmação com o status de mensagem enviada. Em seguida, esse servidor enviará a mensagem para a fila de mensagens para que ela seja persistida. Depois disso, precisamos verificar onde está o usuário B e a qual servidor está conectado, para podermos encaminhar a mensagem ao usuário B. Podemos verificar isso no
2:21:12 cache Redis, onde temos o cache de conexões dos usuários. Consultaremos esse cache para saber onde está o usuário B. Digamos que a resposta indique que o usuário B está no servidor B. Com essa informação, o servidor A poderá encaminhar a mensagem ao servidor B usando o modelo pub/sub do Redis. Deste lado, temos o servidor B e o usuário B conectados por WebSocket. Então, o servidor B entregará a mensagem em tempo real, e o dispositivo do usuário B confirmará a
2:21:43 entrega. Em seguida, precisamos informar ao primeiro servidor que a mensagem foi encaminhada e entregue. Então, o servidor B fará uma solicitação ao servidor A confirmando o encaminhamento e a entrega. Depois, podemos atualizar o status de “enviada” para “entregue”. E, quando o usuário B abrir a mensagem, o mesmo processo será repetido, e então marcaremos a mensagem como lida. Opcionalmente, também podemos enfileirar a mensagem para análise ou
2:22:14 arquivamento, usando processamento assíncrono entre o servidor A e a fila de mensagens. Enquanto isso, a fila de mensagens gravará a mensagem no banco de dados de mensagens, como o Cassandra, para armazenar o histórico. Agora vamos ver esse mesmo fluxo, mas no caso em que o usuário B está offline, o que torna o gerenciamento um pouco mais difícil. Ainda assim, precisamos garantir que a mensagem seja recebida e também notificar o usuário B quando ele a receber, mesmo quando
2:22:45 Eles estavam offline. Então, as etapas iniciais são as mesmas para essa situação. O usuário A envia a mensagem para o usuário B pelo servidor de chat A. Em seguida, verificamos no cache do Redis se o usuário B está online e, caso esteja offline, que é a situação aqui, adicionamos a mensagem à caixa de entrada do usuário B no Redis. Depois, armazenamos isso na tabela de caixa de entrada do banco de dados e, após
2:23:16 isso, esse servidor de chat se conecta ao serviço de notificações que temos aqui e envia notificações push para o usuário B. Quando o serviço de notificações recebe a mensagem, ele também aciona uma notificação push pelo sistema de notificações da Apple ou do Google. Em seguida, enfileiramos a mensagem para processamento posterior na fila de mensagens e, depois disso, a fila de mensagens persiste isso no banco de dados. Quando o usuário B voltar a ficar online, digamos que abra
2:23:48 o aplicativo e estabeleça a conexão WebSocket com o servidor de chat C, encaminharemos a mensagem para entrega ao servidor de chat C, que tem a conexão WebSocket com o usuário B. Depois, entregaremos a mensagem em tempo real ao dispositivo do usuário B. Caso o usuário B estivesse online nesse momento, simplesmente encaminharíamos a mensagem para entrega ao servidor de chat ao qual ele está conectado. Digamos que seja o servidor de chat C. Mas, se o usuário B estivesse
2:24:19 offline antes e agora ficar disponível, este será o fluxo. Digamos que o usuário B fique online depois de estar offline por algum tempo. Já enviamos a notificação push, e ele se conecta a um dos servidores de chat, como o servidor de chat C. A primeira coisa que faremos nesse servidor será recuperar as mensagens pendentes da caixa de entrada no Redis. Se houver mensagens pendentes, vamos recuperá-las — quaisquer mensagens perdidas — da caixa de entrada do banco de dados
2:24:51 e entregaremos as mensagens pendentes ao usuário B. De forma semelhante ao fluxo anterior, também notificaremos o usuário A de que a mensagem foi lida. Em seguida, precisamos projetar isso também para mensagens em grupo e ver como podemos lidar com grupos de 50 a 100 pessoas no WhatsApp. Em alto nível, será assim. Digamos que temos vários usuários no mesmo chat em grupo. Primeiro, precisaremos de um serviço de grupos que gerencie esses
2:25:21 grupos. Por exemplo, digamos que o usuário A faça parte deste grupo e que haja outras 50 a 100 pessoas nele. O primeiro fluxo ocorre quando o usuário A envia uma mensagem para o grupo. Digamos que cada grupo tenha um ID, como descrevemos no modelo de dados. A mensagem é enviada ao servidor de chat ao qual o usuário está conectado. A primeira solicitação que o servidor de chat fará ao serviço de grupos será para verificar quem está neste grupo, cujo ID é
2:25:53 789, como exemplo. O serviço retornará a lista de usuários, como os IDs dos usuários que fazem parte desse grupo. Depois, podemos verificar o status desses 50 usuários. Podemos usar o Redis para armazenar em cache o status dos usuários e evitar consultas desnecessárias ao banco de dados para ler essas informações. Digamos que verifiquemos o status dos 50 usuários e descubramos que 30 estão online e os outros 20 estão offline. Para cada usuário online, precisamos
2:26:26 descobrir a qual servidor de chat ele está conectado. Então, publicaremos nos canais dos usuários de um a 30 no modelo pub/sub do Redis, e isso será entregue em paralelo aos usuários online. Todos os usuários que estiverem online receberão as mensagens no próprio servidor de chat ao qual estão conectados. Digamos que 15 dos usuários estejam conectados ao servidor B e os outros 15 ao servidor C. Distribuiremos a
2:26:57 mensagem em paralelo para os dois servidores, e eles as distribuirão por WebSockets. Assim, as mensagens serão entregues por WebSocket aos 15 usuários do servidor B e aos outros 15 do servidor C. Para cada uma dessas entregas, o dispositivo do usuário enviará uma confirmação de que a mensagem foi entregue. O mesmo vale para os outros 15 que estão conectados ao outro servidor. Depois, encaminharemos essas confirmações ao servidor A, que
2:27:29 enviou a mensagem originalmente, para que ele possa atualizar o status da mensagem para enviada ou entregue no dispositivo do usuário A. Para os outros 20 usuários que estão offline, precisamos usar, em geral, o mesmo método que usamos para os usuários offline numa conversa individual. Precisaremos de uma caixa de entrada no Redis onde escreveremos as mensagens que eles devem receber, ou seja, as dos outros 20 usuários que estavam offline. Escreveremos nas caixas de entrada dos usuários
2:27:59 e gravaremos 20 registros de caixa de entrada no banco de dados. Para todos eles, precisamos enviar uma notificação push pelo sistema de notificações push da Apple ou do Google, para avisá-los, por meio do serviço de notificações, de que uma mensagem foi enviada no grupo. Em seguida, o serviço de notificações enviará isso pelo sistema de notificações da Apple ou do Google. Dessa forma, armazenamos a mensagem apenas uma vez. Então,
2:28:29 fazemos uma operação de gravação do servidor de chat A, que recebeu a mensagem do usuário, para a fila de mensagens, e depois a fila de mensagens grava isso no banco de dados. Assim, no fim, há apenas uma operação de gravação por mensagem. Por último, também atualizaremos o status, no dispositivo do usuário A, para indicar que a mensagem foi entregue a 30 usuários e está pendente para 20. Até agora, não abordamos como vamos gerenciar os arquivos de mídia, os recursos de imagem e os vídeos. Então, vamos tratar disso
2:29:01 a seguir. É claro que o jeito errado seria os clientes enviarem a imagem ao servidor de chat, o servidor de chat armazená-la no banco de dados e, depois, o destinatário baixá-la do servidor de chat. Isso, é claro, não é escalável. Seus servidores de chat ficam limitados pela largura de banda necessária para transferir arquivos e não fazem aquilo em que são bons, que é encaminhar mensagens rapidamente. Em vez disso, fazem aquilo em que são péssimos: transferir grandes blocos de dados. Portanto, a abordagem correta é usar URLs pré-assinadas e
2:29:33 todos esses termos técnicos, como URLs pré-assinadas e serviços de notificação, incluindo o serviço de notificações da Apple, serão incluídos nas notas de rodapé. Se não tiver certeza do que são, fique à vontade para consultar as notas. Então, vamos ver a abordagem correta para enviar arquivos de mídia. Comecemos pelo fluxo de envio. Digamos que temos o usuário A, que é quem envia o arquivo, deste lado, e o dispositivo do usuário A, que é o aplicativo, e temos o usuário B, que receberá o arquivo. Então, o usuário A
2:30:03 toca para enviar uma mensagem, e é nesse momento que o aplicativo solicita uma URL de envio pré-assinada ao servidor de chat ao qual está conectado. Digamos que seja o servidor A. Nesta etapa, o servidor de chat gera uma URL de envio pré-assinada para enviar o arquivo a algum armazenamento de objetos, como o AWS S3. O armazenamento de objetos retorna a URL de envio ao servidor de chat, que então encaminha essa URL ao aplicativo do usuário A. Depois que tiver essa URL de envio, o aplicativo do usuário A
2:30:35 pode fazer o envio diretamente. Ele pode enviar a mídia usando a URL pré-assinada que acabamos de obter do servidor, e isso fará o upload diretamente para o bucket do S3. Em seguida, o S3 confirmará o envio e retornará a URL da mídia para acessá-la mais tarde. Na etapa seguinte, já podemos enviar a mensagem de chat, incluindo a URL do arquivo de mídia, ao servidor de chat A ao qual estamos conectados. É nesse momento que precisamos avisar o usuário B de que recebeu a
2:31:05 mensagem e também incluir a URL do arquivo de mídia. Então, entregaremos a mensagem com a URL da mídia ao aplicativo do usuário B. Primeiro, o usuário B receberá a notificação. Em seguida, se conectará ao servidor de chat. Digamos que seja o servidor de chat B, ao qual ele está conectado por WebSockets. Assim, receberá as mensagens junto com a URL do arquivo de mídia. Agora, quando tocar para visualizar a mídia, solicitaremos a URL de download pré-assinada. E, nessa
2:31:38 Nesse caso, vamos gerar a URL de download pré-assinada do servidor de chat B usando o armazenamento de blobs que temos no S3. Então, o S3 retornará a URL de download para esse servidor de chat B, que agora podemos encaminhar ao aplicativo do usuário para que ele abra e revise o arquivo de mídia. Aqui temos duas abordagens para baixar e ler o arquivo de mídia. A abordagem mais escalável é usar uma CDN. Assim, podemos replicar os arquivos de mídia em várias CDNs para que fiquem mais próximos de cada usuário.
2:32:11 Nesse caso, vamos baixar o arquivo de mídia por meio da CDN. Este é o aplicativo do usuário e esta é a CDN; eles vão ler o arquivo de lá e transmitir a mídia de volta ao aplicativo do usuário. A abordagem mais simples é apenas ler o arquivo diretamente do armazenamento. Assim, o usuário solicitará o download do arquivo de mídia diretamente do bucket do S3. A mídia será transmitida de volta. E, claro, aqui temos uma compensação entre ler diretamente do armazenamento ou por meio da CDN. Temos um fluxo um pouco mais complexo
2:32:42 com CDNs porque precisamos fazer solicitações adicionais para obter a URL pré-assinada. Mas os benefícios de desempenho e custo são enormes nessa escala. Portanto, a CDN provavelmente é a melhor solução e opção, porque, sem ela, um único servidor de chat que gerencie, digamos, 100.000 conexões pode ficar sobrecarregado com apenas 100 usuários enviando simultaneamente alguns arquivos pequenos, de alguns megabytes, para o armazenamento de blobs. Com essa configuração, os servidores de chat, como
2:33:14 o servidor A, lidam apenas com texto e mensagens pequenas. Sempre que precisarmos enviar arquivos pesados, como vídeos, imagens ou mensagens de voz, vamos transferir esse tráfego pesado para algum armazenamento de blobs ou CDN e usar URLs pré-assinadas, que expirarão após o TTL que definirmos. O TTL da mídia também estará alinhado à política de retenção que temos nos servidores de chat. Com essa configuração, os servidores de chat apenas encaminham mensagens pequenas com URLs, e o armazenamento de blobs
2:33:46 e as CDNs lidam com o tráfego pesado. Assim, nossos servidores de chat continuam rápidos e focados em sua função, que é entregar as mensagens. Os arquivos de mídia recebem um TTL, que significa tempo de vida, com expiração automática, digamos, após 30 dias, em conformidade com a retenção das mensagens. Depois disso, eles serão excluídos automaticamente. Agora, vamos ver como poderíamos escalar isso, porque nossos serviços de chat agora conseguem atender milhares de usuários simultaneamente. Mas, se quisermos
2:34:19 escalar ainda mais, precisamos pensar em uma forma de desacoplar nossos servidores. A abordagem mais comum aqui é usar algum tipo de fila de mensagens, como Kafka. Na nossa configuração, podemos ter uma fila de mensagens deste lado e, em seguida, atribuir partições a cada instância. Aqui temos as instâncias do serviço de armazenamento de mensagens. Digamos que escalemos para duas, três ou até mais instâncias. Como se trata de uma instância sem estado, podemos escalá-la para quantas forem necessárias.
2:34:50 Então, podemos atribuir partições a cada uma dessas instâncias. Por exemplo, esta pode receber as partições de 0 a 9, esta, de 10 a 19, e esta, de 20 a 29. Vamos atribuir essas partições a cada uma das instâncias aqui. O funcionamento será assim: as mensagens chegam a essa fila de mensagens e, em seguida, cada instância consome essas mensagens do Kafka. Podemos até fazer gravações em lote para ganhar eficiência e, então, tratar as novas tentativas e as falhas dentro de uma instância.
2:35:21 Primeiro, temos o consumidor do Kafka em cada uma dessas instâncias. Vamos tentar usar gravações em lote para gravar os dados no cluster de banco de dados. Se isso falhar por qualquer motivo, teremos uma lógica de novas tentativas dentro de cada uma dessas instâncias. Depois de algum tempo de espera, tentaremos novamente gravar em lote no cluster de banco de dados. E, se ainda assim falhar por qualquer motivo, garantiremos que a mensagem não seja excluída da fila de mensagens, para
2:35:53 não acabarmos perdendo as mensagens. Ou podemos usar algo chamado fila de mensagens não entregues. São filas que você pode usar separadamente da fila principal. Assim, quando alguma mensagem não puder ser processada pela instância, ela será armazenada nessa fila de mensagens não entregues. Depois, podemos usar outros mecanismos para processá-la mais tarde dentro da mesma instância e gravá-la no banco de dados. Então, assim que a gravação no banco de dados for bem-sucedida, precisamos
2:36:23 também atualizar os índices. Dentro de cada instância, teremos o atualizador de índices, que atualizará a indexação no banco de dados para permitir pesquisas posteriores e consultas eficientes. Mas vamos analisar um fluxo mais detalhado de como será o processamento a partir das filas de mensagens. Novamente, à esquerda temos a fila de mensagens, que recebe todas as mensagens que chegam dos dispositivos dos usuários. Em seguida, podemos definir um tamanho de lote, como 500 mensagens por lote, e vamos
2:36:54 consumir esse lote em uma instância. Teremos o consumidor do Kafka. Esse consumidor pode buscar novas mensagens a cada, digamos, 100 milissegundos, para ter tempo de processar as anteriores. Depois, ele processará o lote em paralelo. Digamos que possamos ter um pool de threads com 20 workers e fazer o processamento em paralelo. Para cada mensagem individual, primeiro vamos validar o esquema e, em seguida, fazer verificações de metadados
2:37:24 para conferir o tamanho do arquivo. Se o usuário tiver enviado algum arquivo de mídia que exceda o limite, precisaremos responder com uma mensagem de erro. Caso contrário, se ele atender aos critérios, vamos validar e enriquecer o esquema. É aqui que podemos calcular o hash da mensagem para deduplicação. Também extraímos as menções, como quando alguém menciona um nome de usuário no WhatsApp usando o símbolo de arroba, por exemplo. Se houver uma menção, precisamos
2:37:54 extrair essas menções do conteúdo da mensagem. Precisamos identificar os links para gerar prévias, porque no WhatsApp também são geradas prévias de links quando alguém anexa algo à mensagem. É também aqui que precisamos gerar os índices para fazer consultas posteriormente. Em seguida, em vez de gravar todas essas mensagens uma por uma, podemos enfileirar as mensagens validadas para um gravador em lote. Podemos acumular na memória um lote com, por exemplo, 1.000
2:38:26 mensagens a cada 5 segundos. Quando esse buffer atingir o limite de 1.000, vamos começar a ordenar as mensagens pelo chat de destino. Vamos agrupar as mensagens por conversa e, em seguida, preparar o lote para inserção no banco de dados. Fazemos isso para economizar operações de gravação no banco de dados. Claro, se pudermos agrupar mil mensagens e depois tentar gravá-las em lote no banco de dados, ainda prepararemos instruções de inserção para cada
2:38:57 uma das mensagens que não puder ser inserida de uma só vez. Mas, pelo menos, isso poupa o banco de dados, que processará o lote atual e, depois de 5 segundos, receberá o próximo. O primeiro caminho feliz é fazer uma inserção atômica em lote no cluster de banco de dados. Também atualizaremos os índices de pesquisa e análise por meio do atualizador de índices e, se tudo correr bem, marcaremos as mensagens como processadas. Mas, caso isso falhe, teremos uma
2:39:28 falha e, em seguida, o handler de novas tentativas e da fila de mensagens não entregues. Se essas mensagens — ou algumas delas — falharem, digamos que, entre essas mil, dez não tenham sido processadas, vamos gravá-las na fila de mensagens não entregues. Antes de gravá-las nessa fila, você pode ter uma lógica simples para tentar novamente. Talvez tenhamos atingido um limite de taxa ou ocorrido algum outro problema simples que possamos resolver com uma nova tentativa. Se ainda falhar depois disso, vamos gravá-las na fila de mensagens não entregues.
2:39:59 Então, teremos mais mecanismos, como as etapas um, dois, até algo como a etapa dez, que serão as tentativas de inserir essas mensagens no banco de dados antes de enviar uma notificação ao usuário dizendo que a mensagem não foi entregue por algum motivo. O principal benefício que obtemos com o envio em lotes é que uma única inserção no banco de dados leva 5 milissegundos. Se inserirmos algo aqui, geralmente leva 5 milissegundos. E todos esses
2:40:32 Essas mil inserções individuais equivalerão a 5 segundos, em vez disso. 1 milissegundo. 1 segundo tem 1.000 milissegundos. Mas, se fizermos uma única inserção em lote com mil mensagens, isso nos custará apenas 50 milissegundos, o que representa, na verdade, uma melhoria de 100 vezes na taxa de transferência. Isso começará a fazer diferença quando começarmos a gravar milhões de mensagens simultaneamente no banco de dados. E, se você quiser ver isso
2:41:02 no nível do código, como exemplo, é assim que vamos gravar no banco de dados Cassandra com o diagrama de mensagens. Isso será uma transação. Vamos iniciar o lote. Vamos inserir as mensagens na tabela de mensagens e passar os valores, como o registro de data e hora, o ID do remetente e do destinatário, que são os IDs dos usuários, depois o conteúdo e qualquer outra coisa, como arquivos de mídia. Em seguida, faremos todas as outras inserções de mensagens em um único lote, e então isso
2:41:35 será como um único lote para gravar 1.000 mensagens na tabela de mensagens do banco de dados Cassandra. O próximo recurso importante do WhatsApp que talvez peçam para você projetar é a adição de criptografia de ponta a ponta e gerenciamento de chaves. Normalmente, no WhatsApp, as mensagens são criptografadas no dispositivo do remetente e descriptografadas apenas no dispositivo do destinatário. Nem mesmo os servidores do WhatsApp conseguem ler o conteúdo. Então, se você tem o usuário A e o usuário B e está
2:42:05 enviando mensagens entre si, nem mesmo o usuário que está no meio — ou seja, o usuário conectado ao servidor de chat A e o usuário B, conectado ao servidor de chat B — consegue ler a mensagem. Então, como vamos projetar algo assim em um app de mensagens? Podemos usar o protocolo Signal. Assim, podemos adicionar criptografia de ponta a ponta. E é assim que vai funcionar em detalhes. Sempre que o usuário A enviar uma mensagem ao usuário B, vamos enviar a chave pública
2:42:38 de identidade para o servidor de chaves. Cada usuário gera um par de chaves de identidade ao se cadastrar no WhatsApp; depois, a chave pública é enviada ao servidor, enquanto a chave privada nunca sai do dispositivo. Então, você tem a chave privada no dispositivo do usuário A e a chave pública, à qual o servidor tem acesso. Do outro lado, temos o usuário B. O usuário B também envia a chave pública de identidade para o
2:43:09 servidor de chaves e também envia as prekeys. Vamos supor que sejam 100 prekeys assinadas, que são geradas e enviadas ao servidor. O servidor as armazena para futuros iniciadores. Sempre que o usuário A enviar uma mensagem, também buscaremos as chaves do usuário B, que incluem a chave de identidade e uma das prekeys armazenadas. Digamos que ele tenha armazenado cerca de 100 chaves. Vamos buscar uma delas para o usuário A. Então, quando o servidor de chaves devolver as
2:43:40 chaves do usuário B ao usuário A, a sessão será estabelecida. O usuário A busca no servidor a chave pública de identidade e a prekey do usuário B. Ele gera o par de chaves que corresponderá para ambos os usuários, A e B, e então realiza a troca de chaves. Depois disso, os usuários A e B têm acesso às mesmas chaves. Assim, sempre que enviarem mensagens um ao outro, todas as mensagens serão criptografadas com a chave pública do usuário A e, quando o usuário B receber a
2:44:11 mensagem, vai descriptografá-la usando a chave pública do usuário A. Cada uma dessas mensagens será criptografada com alguns algoritmos e, então, teremos a mensagem, a chave de sessão trocada entre esses usuários e, além disso, podemos usar algo como um contador de mensagens, que aumenta a cada troca de mensagens entre os usuários A e B. Desse modo, mesmo que a chave de sessão seja roubada, será muito difícil
2:44:42 descriptografar cada uma das mensagens, porque também adicionamos o contador a cada mensagem durante a criptografia e, depois, descriptografamos no dispositivo do outro usuário. É assim que garantimos a privacidade entre os usuários A e B; nem mesmo os servidores de chat conseguem ler as mensagens trocadas. Agora, vamos passar às práticas de segurança. Mas, em relação ao sistema como um todo, vamos começar pela limitação de taxa e pela prevenção de abusos. Sem limites de taxa, qualquer
2:45:13 hacker pode começar a coletar dados ou realizar um ataque DDoS contra o sistema. Por isso, precisamos criar proteções em qualquer sistema desse tipo. Primeiro, temos as ações dos usuários, que podem ser mensagens enviadas pelos usuários ou arquivos carregados no armazenamento de mídia. Para cada tipo de ação, precisamos ter alguma proteção. São os limites por usuário. Por exemplo, podemos aplicá-los quando os usuários enviam mensagens, criam grupos ou
2:45:44 carregam arquivos de mídia. Quando enviam uma mensagem, podemos limitar o envio de mensagens. Primeiro, precisamos verificar o token bucket de mensagens. Aqui podemos definir as regras. Por exemplo, um usuário pode enviar 100 mensagens por minuto. Qualquer quantidade acima disso não parece razoável. Então, podemos aplicar um limite de taxa nesse caso. Se o limite for excedido, vamos bloquear a ação. Vamos notificar o usuário e exibir uma
2:46:15 mensagem de erro informando que ele excedeu o limite. Pode ser algo como: “Muitas solicitações em um minuto. Tente novamente em 60 segundos”, por exemplo. Ou podemos exigir que a pessoa resolva um CAPTCHA. São aquelas caixas que ela precisa marcar e, digamos, selecionar as imagens com motocicletas para comprovar que é humana. Também podemos aplicar penalidades progressivas. A primeira é o aviso que exibimos: “Muitas solicitações”. Se a pessoa começar a abusar do
2:46:45 serviço novamente, vamos restringir o acesso por uma hora. É assim que acontece, por exemplo, quando você tenta digitar a senha de uma conta e erra várias vezes. Primeiro, restringem seu acesso por algum tempo, certo? Na segunda vez, podem restringi-lo por ainda mais tempo, como 24 horas. Depois disso, a próxima etapa é sinalizar a conta como suspeita e desativá-la por algum tempo, para que uma pessoa analise manualmente a conta e verifique se era um bot ou
2:47:17 se era um hacker tentando roubar informações, ou se ainda era o próprio usuário, que simplesmente não entendeu os requisitos e continuou abusando do serviço. Então, precisamos entrar em contato com ele, informar o problema e, depois, reabrir a conta. É claro que os mesmos limites de taxa continuarão aplicados, e a conta poderá até receber uma marcação indicando que é suspeita. Assim, se algo acontecer mais uma vez, a conta poderá ser excluída permanentemente do
2:47:47 WhatsApp. E esse mesmo processo se repete para todas as outras ações. Digamos que os usuários também possam criar grupos, certo? Então, depois que criarem um grupo, verificaremos o contador de criação de grupos. É aqui que precisamos definir limites razoáveis. Por exemplo, criar cinco ou mais grupos por hora pode parecer excessivo. Então, podemos aplicar um limite de taxa nesse caso. Ou, se carregarem arquivos de mídia e enviarem mais de, digamos, 10 ou 100 arquivos de mídia por minuto, isso pode ser
2:48:19 outra restrição. É claro que esses números são apenas exemplos, e alguns desses detalhes serão esclarecidos na fase de planejamento. Mas cada uma dessas ações precisa ter algum limite de taxa. O processo de aplicação das restrições costuma ser idêntico. Precisamos verificar se o limite de taxa foi excedido. Se foi, vamos bloquear a ação. Caso contrário, ela funcionará como esperado. Vamos permiti-la e continuar com a gravação no banco de dados, seja de um arquivo de mídia, de uma mensagem ou da criação de um grupo. Este é um exemplo
2:48:51 de bloco de código que mostra como implementar o limitador de taxa. Vamos inicializá-lo com a capacidade e a taxa de reposição, que podem ser diferentes para cada ação. Depois, podemos ter algo como tokens, que representam a capacidade. Para cada solicitação, vamos verificar se a quantidade de tokens foi excedida ou se ainda está dentro do limite. Pode ser uma unidade, como um. Se esse valor for maior que um, significa que o limite de taxa não foi atingido; vamos retornar true e isso não será processado de fato
2:49:23 com outras ações, como armazenar a mensagem no banco de dados, e então vamos descontar um de cada etapa. Assim, se tentarem novamente na próxima vez, o valor dos tokens será zero. Então, eles não entrarão nesses blocos if e, em vez disso, entrarão no bloco else, e retornaremos false porque excederam os limites de taxa, bloqueando-os. Esta é a etapa aqui. Vamos bloqueá-los de processar a mensagem ou criar o grupo, e vamos notificá-los. Como alternativa, podemos
2:49:56 também pedir que resolvam um teste CAPTCHA e, para isso, definiremos um limite, digamos, de 1 hora. É nesse ponto que precisaremos reabastecer. Vamos verificar o horário atual. Definiremos o horário do último reabastecimento e, com base nisso, calcularemos quando poderão repetir a mesma ação, o que pode ser daqui a 1 hora. O próximo tipo de ação que precisamos limitar são as conexões, porque podemos ter um fluxo de conexões com ataques e, digamos, recebemos cinco tentativas de conexão em
2:50:27 um único minuto, vindas de diferentes endereços IP. É aqui que precisamos verificar o contador de conexões por IP e, novamente, aplicar o limite de taxa para essa situação específica. Então, se, em um minuto, tentaram fazer login de cinco endereços IP diferentes, isso parece suspeito. Portanto, é mais um caso em que precisamos aplicar o controle de taxa, seguindo o mesmo processo das outras ações. Outro exemplo são os limites de convites para grupos. Então, se convidarem usuários,
2:51:00 criarem um grupo e, depois, o usuário começar a convidar outras pessoas para o grupo, verificaremos o contador de convites do grupo nesse caso. E, assim como todas as outras ações, isso seguirá o mesmo processo. O que muda é apenas o contador e o limite de cada ação. Para que isso funcione, também precisamos de algum armazenamento centralizado para todos os servidores, porque cada usuário está conectado a um servidor, certo? Por meio de uma conexão websocket. Então, todos precisam usar algum armazenamento centralizado distribuído,
2:51:32 algo como Redis, porque mantemos esses dados na memória por até, digamos, 24 horas, caso sejam restringidos após atingirem o maior número de tentativas. Também podemos usar um banco de dados para manter esses dados, mas, se vamos mantê-los por até 24 horas, Redis pode ser uma boa opção aqui. E nossas operações atômicas garantirão a precisão correta. Assim, não será possível contornar isso trocando de servidor, conectando-se a outro servidor
2:52:04 e torcendo para que funcione, sem que o limite de taxa seja aplicado naquele servidor. Em seguida, também precisamos escalar o banco de dados. Claro, com essa escala, escalamos os servidores e aplicamos o controle de taxa. Mas um único banco de dados não consegue lidar com todo esse tráfego. Então, precisamos dividir os dados entre várias instâncias de banco de dados, o que também é chamado de sharding. Quando se trata de sharding, uma das decisões importantes é qual campo usaremos para dividir os
2:52:35 bancos de dados. Uma abordagem é fazer o sharding com base no ID do usuário. Assim, garantimos que todos os direitos, todas as mensagens do usuário A, fiquem em um único shard, em um único banco de dados. Dessa forma, sempre que buscarmos mensagens, consultaremos apenas um banco de dados e não precisaremos nos conectar a vários shards para ler as mensagens do mesmo usuário. O problema é que alguns usuários podem se tornar pontos de concentração, porque alguns naturalmente usam o sistema muito mais do que outros.
2:53:06 E, assim, esses usuários começarão a criar bancos de dados grandes, tabelas de mensagens grandes, enquanto os usuários que usam o sistema com menos frequência criarão shards menores. Isso causará um desequilíbrio no sharding. Outra opção é fazer o sharding pelos IDs das mensagens. Isso distribuirá os dados de maneira uniforme. Mas buscar todas as mensagens de um usuário exigirá consultar vários shards, porque elas estarão em shards diferentes. Então, sua consulta atingirá
2:53:37 todos os shards, agregará os resultados e, depois, os devolverá ao servidor. A desvantagem, nesse caso, é que será mais lento e caro do que usar o ID do usuário. E a desvantagem de usar o ID do usuário é que teremos shards desequilibrados. Um shard pode ser muito pequeno em comparação com outro, que contém usuários que usam o sistema com muita frequência. Então, o objetivo principal nessas entrevistas não é simplesmente escolher uma dessas opções, mas explicar as diferentes abordagens que
2:54:09 existem e qual é a melhor solução. Afinal, ou qual solução você escolheria? Uma boa abordagem aqui pode ser continuar fazendo o sharding pelo ID do usuário, mas aplicando replicação. Digamos que o usuário A seja atribuído ao banco de dados do shard A. Todas as conversas do usuário A serão armazenadas no shard A. Mas, se ele conversar com o usuário B, que está conectado ao shard B, também
2:54:39 duplicaremos as gravações. Então, também gravaremos uma cópia de cada shard do usuário no shard B. É claro que isso tem uma desvantagem: dobra o volume de gravações. Mas, por outro lado, torna as leituras extremamente rápidas. Cada usuário consultará apenas o próprio shard. O usuário B consultará o shard B, e o usuário A consultará o shard A. Podemos até replicar cada um desses shards. Assim, o shard B será replicado para a réplica B, e o shard A será
2:55:11 replicado para a réplica A. O usuário A agora lerá o histórico de mensagens somente da réplica A, e o usuário B o lerá da réplica B. Eles não afetarão os shards primários, responsáveis pelas operações de gravação; em vez disso, farão as leituras aqui. Isso aumentará bastante o desempenho de leitura. A compensação é que gravamos cada mensagem duas vezes. Por isso, também é importante esclarecer essas
2:55:41 coisas no início, na fase de requisitos. Você precisa antecipar isso: mais adiante, vamos otimizar as mensagens para operações de gravação ou para operações de leitura. Qual delas é mais importante? E, se você ainda não conseguiu esclarecer isso no início, este ainda é um bom momento para perguntar, em vez de tentar adivinhar. Então, pergunte se, nessa situação, a leitura ou a gravação é mais importante. Com base nisso, você vai replicar, usar sharding por ID de mensagem ou até mesmo usar
2:56:12 sharding por ID de timestamp. Há diferentes abordagens, e o principal aqui é entender as compensações entre cada uma. Em geral, quando se trata de manter os dados disponíveis, fazemos sharding para escalabilidade de gravação. Se você está aplicando sharding, ou seja, dividindo o banco de dados em partes menores, isso melhora o desempenho de gravação. E, quando aplicamos replicação, é para melhorar a escalabilidade de leitura. Então, se você simplesmente replica um dos shards,
2:56:43 isso costuma ser para melhorar o desempenho de leitura. Depois de decidir a estratégia de sharding, digamos que você tenha escolhido o sharding pelo ID do usuário, todas as gravações dos clientes chegarão ao shard, ao nó primário de cada shard. Digamos que os IDs de usuário de 1 a 1.000 sejam armazenados no shard um. Esse será o nó primário e, então, podemos replicar esses dados e usar
2:57:15 sincronização assíncrona entre o nó primário e as réplicas. Podemos ter três réplicas, que serão eventualmente consistentes com o nó primário, lendo e duplicando os dados nas réplicas. Então, sempre que tivermos operações de leitura, como abrir as mensagens e ver as mensagens não lidas, será necessário executar consultas de leitura. Para essas consultas, usaremos apenas as réplicas. Não nos conectaremos ao nó primário.
2:57:45 Só nos conectaremos aqui quando houver gravações de mensagens, ou seja, quando alguém escrever para o usuário A ou quando o usuário A escrever para outra pessoa. Agora, para sistemas tão grandes, nos quais armazenamos muitos arquivos de mídia e muitas mensagens, precisamos de algum mecanismo de limpeza, uma forma de limpar os dados inúteis que nunca mais serão lidos. Isso pode incluir arquivos de mídia expirados, mensagens antigas não entregues ou conexões obsoletas. Precisamos criar um mecanismo de limpeza
2:58:16 infraestrutura para executar algum job do Chrome ao longo do tempo e tentar limpar as mídias expiradas e coisas do tipo. Então, você precisa ter algum agendador de jobs, algo como Airflow, ou algum job do Chrome que será agendado para ocorrer diariamente ou semanalmente. E isso precisa verificar o status do job no monitoramento. Depois, deste lado, temos um pool de workers, que são executores de jobs conteinerizados. Então, essa fila de jobs que você vê aqui é separada da fila de mensagens. Ela serve para
2:58:48 incluir tarefas agendadas e encaminhar os jobs ao pool de workers para processamento. Então, vamos confirmar que recebemos a mensagem ou, se houver falha, vamos colocá-la novamente na fila após a falha. Depois, começaremos a executar as tarefas neste pool de workers, que podem consistir em limpar mídias expiradas, verificando a data em que foram publicadas, ou mensagens não entregues. Podem ser mensagens que não conseguimos gravar no banco de dados, mas que ainda temos em algum lugar na memória. Então, podemos
2:59:19 remover isso, ou conexões obsoletas que temos, e qualquer outro tipo de dado de que não precisamos mais. A responsabilidade deste pool de workers será excluir todos os dados e, em seguida, enviar os resultados do job para o monitoramento, para que possamos ver, ao longo do tempo, o desempenho dele e se há melhorias a fazer, porque isso não é algo que você configura uma vez e depois esquece. Também é algo que será aprimorado com o tempo. Então, a compensação aqui é que
2:59:49 esses jobs agendados vão acrescentar complexidade operacional, porque são mais uma coisa importante para monitorar e depurar. Mas a alternativa, ou a outra compensação, é deixar o lixo se acumular até o sistema ficar tão grande que você terá de fazer isso de qualquer jeito. Então, uma limpeza regular como essa é essencial. E, para manter a saúde do sistema a longo prazo, é importante configurar algum pool de workers que comece a limpar os dados inúteis que não estão sendo usados. Esse é o problema geral de projetar um sistema grande
3:00:22 como o WhatsApp. Mas normalmente você não vai parar por aí, porque esse é apenas o caminho feliz, em que todos os serviços funcionam juntos e todos estão felizes, inclusive os clientes, certo? Mas, na realidade, nunca é assim. Então, pode haver falhas em todas as partes do sistema. E uma das principais coisas que você pode discutir, por exemplo, em um app de mensagens, é a falha do cache. Por exemplo, o que acontece com as mensagens não entregues, já que o Redis mantém nossas caixas de entrada de mensagens não entregues,
3:00:52 e o que acontece se o próprio cluster do Redis cair? Vamos começar com um exemplo do problema. Digamos que o usuário A envie uma mensagem e esteja conectado ao servidor de chat. É aqui que eles estabelecem a comunicação bidirecional em tempo real. Agora, o servidor de chat primeiro gravará as mensagens não entregues na caixa de entrada do Redis, que é mais rápido que o banco de dados. Em seguida, na segunda etapa, não gravamos apenas no Redis, certo? Também gravamos um backup, que é
3:01:23 mais durável, no banco de dados. Esse é nosso armazenamento permanente, caso o cluster do Redis caia. Então, primeiro tentaremos buscar as mensagens pendentes no Redis para que o servidor de chat as encaminhe. Mas, se ele estiver indisponível, digamos que o Redis caia, precisaremos recorrer ao banco de dados. Nesse caso, se o Redis estiver vazio ou indisponível, precisaremos recorrer ao banco de dados e ler de lá. Então, precisamos recuperar as mensagens não entregues
3:01:54 do banco de dados e encaminhá-las ao servidor. De qualquer forma, no fim, precisamos conseguir entregar a mensagem ao usuário B, mesmo que ele esteja offline e depois volte a ficar online e busque as mensagens no banco de dados, caso o cache do Redis estivesse fora do ar. Quando o Redis se recuperar e voltar a ficar online, primeiro teremos um job em segundo plano que sincronizará a caixa de entrada do banco de dados com o Redis. Depois, faremos uma consulta como `select all
3:02:24 from messages inbox where the delivered is null, ou seja, em que o campo de entrega não está definido na caixa de entrada. Para cada mensagem não entregue, vamos gravá-la na caixa de entrada do Redis e também manter o registro no banco de dados. Depois disso, retomaremos a entrega normal, gravando no Redis e, em seguida, lendo do Redis novamente. Para visualizar isso no nível do código, por exemplo, é assim que seria a gravação com fallback. Teremos alguma função como store undelivered
3:02:55 message`, que receberá o ID do usuário da mensagem e a própria mensagem. Primeiro, tentaremos ler do cache do Redis. Tentaremos obter, do cache do Redis, a mensagem associada a este ID de usuário e ao horário da mensagem. Mas, se algo der errado, precisamos registrar que o Redis está indisponível e começar a usar o banco de dados novamente. Então, precisaremos consultar o banco de dados. Isso pode ser uma operação de inserção, se a mensagem tiver sido gravada para
3:03:26 o outro usuário, ou pode ser uma operação de leitura, como uma consulta select, se o usuário estiver tentando buscar as mensagens e o Redis estiver indisponível. Esse é o segundo cenário. Por exemplo, estamos tentando obter mensagens não entregues. Estamos tentando lê-las do Redis. Mais uma vez, tentamos ler do Redis primeiro, porque é o mais rápido, se estiver disponível. Se virmos que o comprimento da lista de mensagens é maior que zero, vamos mapear, analisar e então enviar as mensagens ao usuário. Mas, se algo der errado neste bloco try,
3:03:56 isso significa que o Redis está indisponível. Então, novamente, vamos registrar isso para podermos rastrear o problema mais tarde no CloudWatch ou em outro lugar e, depois, precisaremos ler isso novamente do banco de dados. Em vez disso, faremos a consulta select no banco de dados, que é mais lenta, mas é melhor do que simplesmente deixar toda a operação falhar. Quando o Redis voltar a ficar online, ele será sincronizado com o banco de dados e terá todas as mensagens aqui. Então, da próxima vez, vamos ler deste bloco try.
3:04:28 O próximo tópico que você pode esperar discutir é sobre regiões e distribuição global, porque o WhatsApp não está apenas nos EUA ou na Europa. Há usuários de todas as partes do mundo. Então, precisamos ter regiões diferentes para cada um desses usuários. Para isso, podemos usar regiões. Por exemplo, se você estiver usando os EUA, podemos usar US East 1, US West 2 ou regiões desse tipo que estão disponíveis no
3:04:58 AWS ou no provedor de nuvem que estivermos usando. Cada região deve ter a pilha completa de servidores de chat, clusters do Redis e servidores de banco de dados, para que possa operar de forma independente. Digamos, por exemplo, que o usuário A abra o app e esteja em Tóquio. Primeiro, faremos uma consulta DNS para ver a que região ele deve se conectar. Depois, ele se conectará ao endereço IP mais próximo, que será o da região de Singapura. Nessa
3:05:29 região, teremos a pilha completa: o balanceador de carga, ao qual o usuário se conectará primeiro. Ele fará uma solicitação aqui para ser direcionado ao servidor de chat. Aqui teremos mais servidores de chat replicados, e isso pode variar de uma região para outra. Por exemplo, nos EUA — ou, digamos, na Irlanda —, temos muitos usuários usando o app. Então, pode haver muitos servidores de chat. Já em Tóquio ou Singapura, digamos que temos menos usuários. Então, teremos menos réplicas de servidores de chat. De qualquer forma, eles
3:06:00 se conectarão a um servidor de chat por meio de uma conexão WebSocket e terão o cache do Redis para cada usuário. É aqui, por exemplo, que vamos consultar onde está o usuário B para poder enviar a mensagem à região dele. É assim que o usuário B, digamos, em Londres, vai se conectar. Primeiro, mais uma vez, fará uma consulta DNS. Vai se conectar ao endereço IP mais próximo. Enviará uma solicitação ao balanceador de carga e receberá a mensagem que foi enviada do usuário A para o usuário B pela região US West, à qual
3:06:32 está conectado. Outra coisa que pode dar errado aqui é o cluster do Kafka que temos. O Kafka é muito escalável por si só. Ele consegue processar milhares de mensagens por segundo sem problemas. Mesmo assim, precisamos planejar o que acontece se ele cair ou se houver algum problema com o cluster do Kafka. Dentro do cluster, normalmente temos vários brokers, que são chamados de servidores no cluster. Então, os tópicos são
3:07:02 divididas em partições, e cada partição tem um líder e réplicas. Então, os produtores geralmente escrevem no líder, de forma semelhante a operações de escrita em um banco de dados, e as réplicas apenas nos fornecem redundância. Então, o cenário um é quando um dos brokers falha dentro do cluster Kafka. Se um broker parar, precisamos substituí-lo. Digamos que este fosse o broker número três, por exemplo. Precisamos substituí-lo e adicionar outro broker ao cluster. Então, o Kafka vai
3:07:35 rebalancear as partições ao longo do tempo se adicionarmos esse broker ao cluster. Mas e se o cluster Kafka inteiro ficar fora do ar? Isso é muito raro, mas ainda é possível. Isso significa que os servidores de chat não conseguem publicar nenhuma mensagem. Todos os servidores de chat estavam enviando mensagens para esse cluster, certo? E agora não conseguem mais fazer isso. Então, o serviço de armazenamento de mensagens não consegue consumir as mensagens, o que significa que elas não são persistidas, e isso pode causar perda de dados. Nesses casos, precisamos ter uma
3:08:07 mitigação com um buffer local. Digamos que criemos um buffer de mensagens antes de tentar publicá-las no Kafka, no código. E é assim que a função de publicação de mensagens ficaria. Primeiro, temos um bloco try, que é o caminho feliz: tentamos publicar a mensagem nesse cluster Kafka, mas, se falhar, significa que o cluster está fora do ar. Então, precisamos de algum mecanismo de rollback para isso. Para essa situação, temos o bloco catch para o erro do Kafka. Primeiro, vamos
3:08:40 persistir esse erro no nosso sistema de logs para depois identificar por que esse buffer de mensagens falhou. Mas então vamos tentar enviar a mensagem para o buffer local de mensagens. Essa é apenas uma abordagem que podemos usar. Mas esse buffer é limitado. Então, podemos armazenar até, digamos, 100.000 mensagens no buffer. Se isso também falhar, não falhamos a operação inteira. Em vez disso, começamos a descartar do buffer as mensagens mais antigas. E então precisamos adicionar um intervalo de espera
3:09:12 para o Kafka se recuperar. Quando ele se recuperar, vamos tentar publicar a mensagem novamente no Kafka. Esse intervalo precisa ser exatamente o tempo que levamos para iniciar um novo Kafka. Então, se ele for completamente destruído ou excluído, precisamos iniciar um novo Kafka. Precisamos saber exatamente quanto tempo isso leva. Digamos que leve cinco segundos — na verdade, nem cinco. Precisa levar menos de cinco. Então, digamos que sejam dois segundos para carregar, ou seja, 2.000 milissegundos. Assim, sabemos que esse
3:09:45 número é maior que o tempo de que precisamos para iniciar um novo Kafka. Então, esperamos esse tempo na memória do servidor de chat e, quando o novo Kafka estiver de volta on-line, publicamos as mensagens nele, em vez de perder toda a memória das mensagens. Isso evita a perda de mensagens caso o Kafka fique fora do ar. Mas e se os dois servidores falharem ao mesmo tempo? Estamos mantendo as mensagens na memória, mas o Kafka falhou e agora
3:10:17 esse servidor também falha. Nesse caso, também podemos armazenar esses arquivos no disco local do servidor. Assim, o arquivo de log é somente de acréscimo e, quando o servidor de chat se recuperar, vamos reproduzir as mensagens a partir desse arquivo de log no disco e, depois que publicarmos tudo com sucesso no cluster Kafka, vamos excluir o log. Em resumo, vai funcionar assim: se o Kafka for completamente destruído, vamos buscar as mensagens no
3:10:48 serviço de armazenamento de mensagens e, em seguida, solicitar as mensagens desde o último ID. Vamos consultar no banco de dados as mensagens processadas e verificar quais ainda não foram publicadas no banco de dados e ainda estão na memória. Então, vamos republicar as mensagens que estiverem faltando no Kafka quando ele voltar a ficar on-line. Quando gravarmos tudo no armazenamento persistente, como um banco de dados, retomaremos o
3:11:19 consumo normal. Portanto, você precisa ter mecanismos como esses para garantir que está evitando a perda de dados, ou pelo menos minimizando-a, caso várias partes do sistema falhem ao mesmo tempo. Por fim, também é esperado que você fale sobre algum controle de funcionalidades em sistemas tão grandes, porque geralmente não lançamos novas funcionalidades para todos os usuários de uma só vez. Normalmente, fazemos uma implantação gradual. Nesses casos, costumamos usar um armazenamento de feature flags. Todos os provedores de nuvem
3:11:50 oferecem soluções desse tipo para disponibilizar feature flags. Por exemplo, no Azure, há um sistema assim. Na AWS, também há, e existem até ferramentas externas que você pode usar, como a LaunchDarkly, uma das mais populares que usamos, por exemplo, na empresa anterior. Essas ferramentas permitem definir pares de chave e valor. Por exemplo, pode ser “o novo tema está ativado?”, com o valor verdadeiro ou falso. Esse valor também é controlado com base no ambiente. Assim, por exemplo, pode ser verdadeiro no ambiente de staging
3:12:22 e ainda ser falso em produção. Dessa forma, digamos que os desenvolvedores estejam testando em staging: eles veem a nova funcionalidade, mas os usuários em produção não. E, quando você decidir disponibilizá-la também em produção, ainda poderá ativá-la para apenas 20% dos usuários. Assim, somente 20% dos usuários começarão a testar essa funcionalidade beta. Se tudo correr bem e você não observar nenhum registro de falha, aí poderá ativá-la para todos. Nesse caso, o servidor de chat vai
3:12:54 verificar as funcionalidades disponíveis nesse armazenamento de feature flags, que pode ser a LaunchDarkly ou a solução nativa do provedor de nuvem. Depois, vamos habilitá-las gradualmente por usuário ou por grupo. Por exemplo, habilitá-las por usuário significa que 20% dos usuários terão acesso a elas. Em seguida, vamos retornar as funcionalidades habilitadas desse armazenamento de feature flags ao servidor de chat. Com isso, por exemplo, o servidor de chat pode enviar mensagens com novas funcionalidades para os aplicativos clientes que estão
3:13:24 acima dessa versão e remover os campos ou funcionalidades não compatíveis dos aplicativos clientes que estão abaixo da versão 2.2, ou de alguma versão que você definir. Esse foi, por exemplo, um panorama da arquitetura completa. Agora também podemos falar sobre como abordar isso normalmente, seguindo um framework. Como você pode ver, começamos de forma simples. Geralmente, começamos esclarecendo os requisitos e depois calculando as estimativas, porque isso é importante para estimar quais
3:13:56 soluções de armazenamento você vai usar e quais serviços vai introduzir: será que vai adicionar um broker Kafka ou talvez ele não seja necessário? Então, você não começa direto com Redis, pop-ups ou sharding de banco de dados. Normalmente, começa com uma arquitetura simples de alto nível, com apenas milhares de usuários. Depois, você a desenvolve naturalmente conversando com o entrevistador e identifica os gargalos, como vimos aqui. Por exemplo, a mensagem no Kafka pode falhar. Também podemos ter problemas com rate limiting.
3:14:27 Podemos ter um banco de dados que fica fora do ar. Então, você também precisa começar a falar sobre isso. Geralmente, não é algo que dê para simplesmente memorizar. Você só precisa conhecer as etapas e, para cada sistema, seguir o fluxo e discutir as compensações. Entender as compensações é outra parte importante disso. Toda decisão tem custos. Por exemplo, se você usa WebSockets, nem tudo são vantagens, porque eles usam mais memória do que
3:14:58 HTTP, mas oferecem entrega em tempo real e, por exemplo, para um aplicativo de mensagens, essa compensação vale a pena. Em outras situações, talvez não valha a pena. Talvez você opte por algo como uma abordagem baseada apenas em polling, em vez de usar WebSockets. Você também precisa estar preparado para fazer cálculos aproximados antes de chegar a esse ponto. Então, prepare-se para calcular esses números. Talvez faça alguns exemplos e depois confira se os calculou corretamente ou não. E então
3:15:29 Quando você tiver algo estável, só então começará a pensar nas falhas. Não precisa se precipitar, porque você ainda não decidiu quais são os componentes principais nem projetou o sistema completo. Não convém pensar nas falhas antes disso. E não se preocupe se isso foi detalhado demais. Não se espera que você projete um sistema perfeito em uma ou duas horas. Esse não é o objetivo das entrevistas. Geralmente, eles querem ver como você pensa, como discute os trade-offs ou se simplesmente
3:16:01 escolhe algo porque gosta. Então, você precisa mostrar seu processo de raciocínio. Fale isso em voz alta sempre que estiver projetando algo, pois isso também ajudará a encontrar a direção certa caso você esteja seguindo pelo caminho errado. O entrevistador vai corrigir você e dar dicas para escolher a solução certa para o trade-off que estiver considerando. Aliás, se você quiser aprender todos esses conceitos que estou usando para projetar esses sistemas do zero, tenho um guia completo de design de sistemas que aborda todos os conceitos necessários para entrevistas de design de sistemas no
3:16:33 meu canal no YouTube. Você pode acessar o primeiro link na descrição para encontrar o link do meu canal. E o primeiro vídeo fixado no meu canal é esse guia de design de sistemas, que explica todos os conceitos necessários para passar nessas entrevistas de design de sistemas.
Transcrição original
[00:00] Learn software system design and pass interviews. This course provides a practical five-step framework for structuring your thinking and tackling vague requirements under pressure. Additionally, the course walks through realworld examples designing systems similar to Spotify, Booking.com, and WhatsApp from scratch. Hike Simon developed this course. >> Most software engineering roles today require system design knowledge. Whether it’s for the job itself or for
[00:30] interviews, you are expected to know how to design systems at scale. And especially for senior and staff level positions, it’s one of the deciding factors for the company. That’s why I put together this complete system design interview guide with real production system examples that show up the most often in system design interviews. First, we’ll start with the framework to know how to structure your thinking, what clarifying questions to ask, and how to make sure you cover everything
[01:00] the interviewer is looking for without going off track. And then we’ll apply this same framework across three different system design examples so that you can see how the same framework applies to different production systems. This is the most practical framework that I know for passing system design interviews. Early in my career, I failed a lot of these interviews when applying to senior roles. And looking back now, there is one pattern that was the same across all of them, which is system
[01:31] design was almost always part of the conversation. And you can argue that nowadays, this is also a requirement for mid-level positions, especially now when all of the software engineers use AI to write the code implementation. Companies would like to know whether you understand the bigger picture and how components talk to each other, how to design solutions from scratch and discuss the trade-offs. Of course, the depth of knowledge is different depending on whether you apply for mid
[02:03] to senior roles, but still you have to be prepared for these type of interviews, whether you’re mid, senior or above. So, this shows up in different ways. Actually the most common one that we know is the whiteboarding style where there is a session where you design a full system or a feature from scratch. But there are actually three more types to this. Another way can be discussion about bottlenecks in the company’s existing infrastructure and see if you can suggest solutions to this and you
[02:36] have the basic knowledge of system design. Another type is walking through your past projects. So questions about your past projects to see if you actually understand the architecture of what you built there. And it can also be conceptual questions that test the depth of your knowledge about system design concepts. But even if you learn all the concepts of system design, you still need a framework to navigate a 45 or 60inut discussion and make the most out
[03:06] of that time. That’s exactly why we have this framework and guide. And another important thing before we’ll get to discuss the framework is that most engineers think they are only being evaluated on the system they design which is just one out of five dimensions that you’re being tested on. And just by knowing what these dimensions are will help you way more to prepare for these type of interviews. Before getting to the dimensions, let’s first understand why this feels hard compared to other
[03:37] type of interviews like behavioral rounds or live coding. For example, if it’s a live coding round, this usually is either a lead code style problem or some real world challenge. But either way, there are clear inputs and outputs, right? Let’s say if it is a lead code style challenge, then you have a function which accepts these type of arguments. you know exactly what the type of the argument is. You know exactly what you’re expected to return as an output from the function. So it is
[04:08] clear whether your solution works or not because you can copy paste this code and try to run it and you can also double check by running test cases on a whiteboard without even running the code. But system design is actually the opposite of all of that. First of all, the requirements are very vague and it is your job to clarify those requirements and lock in the scope and also the output which is the solution that you will propose. There is no single right answer to it. And this type
[04:39] of interview is testing how you think not just what you know or not whether you can come up with the right solution because there is no single right answer to the question that you will get during system design interview. With that, let’s see what are you actually being evaluated on. Most candidates prepare by just memorizing couple of architectures like Spotify, WhatsApp and so on, which is of course a step forward if you know couple of variants of systems and what components go into this. But you need to
[05:11] also understand the other four dimensions that interviewers score you on. So let’s start with the first one which is communication and collaboration. This is whether you ask clarifying questions, whether you validate assumptions as you go or you just make assumptions and start building the system. And this kind of testing how you would work in a real world setup as well. Let’s say your manager assigns a task to you like a big feature or a big system service you need to build from
[05:41] scratch. What do you do in this kind of situations? If some things are not clear, do you just make assumptions, build a solution and then figure out that it was not what they were expecting? So now you just wasted a lot of time in between and you need to come back and write another solution which is actually what they were looking for or you clarify this in the beginning. So they are trying to also understand whether it’s easy to work with you or not. Whether you are good at communication skills apart from
[06:13] technical knowledge. The next one is whether you have structured way of thinking and approaching problems or can you break down the vague problem that you just got into specific outlined actions that you will take or you just jump between random decisions. you go back and forth between different designs phases and so on. So strong candidates here usually show that they can organize their faults even when the problem is not very clear. They at least have an outline of what are the steps we’re
[06:44] going to take and that every step is clear what we’re working on right now and what’s up next. The next one is your technical judgment. So when you pick a solution let’s say database a cache a message queue can you explain your decision why like why you chose specifically SQL database why you chose the caching mechanism or the specific type of message queue that you chose. This part shows that if you choose some tools to be used in a system, you choose
[07:14] them based on the requirements not just out of habit or not just because you know what Kafka is but maybe there was another message cue that was a better fit for this type of system. The fourth dimension is your practical depth. This part differentiates between two candidates who might have same theoretical knowledge but one of them also has practice with building these type of systems. So it will show up by seeing whether you think about failure modes about the scale about things that
[07:47] can go wrong in the system because if you just learned at the theory level you wouldn’t be able to catch these type of failures in the system. So this part specifically is for interviewers to spot engineers who have actually shipped production systems versus those who just learned from theory. And then lastly, it’s the time management as well because these type of interviews are usually either 45 or 60 minutes and here you need to not get stuck on one area and
[08:17] run out of time by doing highle design only. And it’s worth asking your interviewer early where they want to focus on or proposing it yourself and getting alignment to make sure that you’re managing your time effectively. And along the process of an interview, you need to always know what’s the time and how much is left, how much you spent on this section. And if you’re spending a lot of time on it, it’s time to move on. So you need to also show time management which again all of these five
[08:48] pillars translate to how you will work in a real environment. If you can’t manage your time during an interview then you’re also not good at managing your time during work. If you don’t have practical depth then you also won’t be able to ship projects confidently if you only know the theory. Right? And same applies to all of these five pillars. And now that we know these five pillars, let’s focus on the time management part and what are the stages you need to take to complete a system design solution.
[09:21] There are five steps for any system design problem. This is a repeatable structure you can apply to any whiteboarding system design interview. And the goal here is to stay organized and show the interviewer how you think through the problems throughout these 45 or 60 minutes. 60 minutes is actually more common because there is a lot to cover in this type of interviews. So assuming it’s 60 minutes, let’s see what are the steps we will take and how much time we will dedicate to each step. The very first one is clarifying
[09:52] requirements. You don’t want to start designing anything before you understand the scope and this is the most common mistake candidates make. So let’s see what these five steps are at a high level and then we’ll get to discuss each of them in more detail. The very first one is you need to clarify requirements at least for 5 to 10 minutes depending on the length of the interview. You need to make sure you have all the functional and non-functional requirements before moving on to designing anything. After that you can either start by data and
[10:24] API layer and then get into the highle architecture or this part is kind of variable. You can also start from architecture and get into the data and API layer afterwards. whichever feels easier for you. For each of these you would spend 15 to 20 minutes not more and then you would move on to discussing scale and edge cases towards the end once you have the highle architecture the data layer and APIs. So here you might discuss either how you would scale
[10:55] this system or if reliability is more important for that type of system you would discuss other edge cases and how you would handle the failures. And the last part is to wrap up and talk about anything that you could improve on this system but you didn’t have time for it. So just briefly mentioning this before wrapping up the interview. Let’s start with the first step which usually takes up to 10 minutes or less and this is where you clarify the problem first before getting into the solution. You do
[11:26] not want to jump into designing anything yet before clarifying the functional and non-functional requirements. By functional requirements, we mean what are the main use cases of this system that you’re building, what is explicitly out of scope. Because if you’re given something to design like Spotify, you can never design it in 60 minutes. This usually takes multiple teams, months of efforts if not years to build and design all of this. Right? So of course you
[11:57] can’t do it as a single engineer during 60 minutes. The goal here is to see what are the main use cases we are focusing on. What we leave out of scope and it also helps to clarify who are the users and what type of platform we are building. Then you also focus on nonfunctional requirements which answers what are the constraints for this system. For example, how many daily or monthly active users you will have? What is the readto write ratio? You would clarify the latency requirements and
[12:28] also what is the acceptable downtime which refers to availability of the system before moving on to the next stage. And it’s a good idea to always check at the end of each phase whether you’re good to move on because if you missed some things during this stage, interviewer can give you hints. For example, you may failed to clarify some of the important functional requirements. This is a good place to ask something like does this scope and these requirements make sense before I
[12:58] move forward to highle design. If they say yes, then you clarified everything. If they give you a hint then usually you missed something in functional and non-functional requirements. So you would clarify that before moving on to the next stage. And next is step two which is either the data and API layer or highle design depending on which one is easier for you. If you think it helps to start with data layers understand how entities connect to each other and then get to the high level design that’s the approach to take. If you think imagining
[13:30] it high level first helps you understand the user flow then you start with high level design. I personally start with highle design then get here but sometimes I also start with data and API layer. This part is to model what you’re storing and how clients will interact with your system. When it comes to data model we should focus on what are the main entities for this type of system. Let’s say if it’s something like YouTube, then the main entities are videos and users. And you would also map
[14:01] out the relationship between them. What are the primary keys, foreign keys, key indexes that we will use in the database and how big each record can get. This is also a good place to clarify which data needs strong consistency and where you can afford eventual consistency. Next part of this stage is to also define the API contracts like what are the main endpoints with request and response formats. Here it will also be good if you can talk about pagination, error handling, rate limiting,
[14:33] versioning and also follow the best practices of rest API in case you’re using REST type of APIs which is in most cases that’s the case and this is also where you will make the database choice whether it’s poss MongoDB whatever you pick you should also explain why given the requirements you went with that specific option and also or walk through the tradeoffs of what are other things you considered but you didn’t choose. The other important
[15:04] part is the highle architecture which takes another 15 minutes. This is where you spend a big portion of a time design the components and show how data flows through them. In case nothing comes to your mind, you can start with these four components which are almost always present in majority types of systems. These are API servers which will be accepting the requests, a load balancer that distributes traffic between them, database where you persist the data and
[15:35] optionally cache layers that caches some common data that you’re fetching from the database. At this step, it usually helps to map out the user journey from front end to back end all the way to the database. So what’s the front end? It’s some sort of web app or a mobile app or desktop app and then it connects to your API gateway and load balancers which will distribute among API servers. You might have caching layer among these
[16:06] servers. Optionally you may have message cues that decouple the services and you almost always have a database to store the data in the back end. So walk the interviewer through how a request moves through your system from client through load balancer API server into cache and database and back all the read and write operations. If you’re using caching make sure to also mention the TTL and what invalidation mechanism you use. And for different parts of your system, you
[16:37] should also walk through your choice between consistency, availability, and partition tolerance, which is coming from the CP theorem, which means some of your components may need different approaches. And explaining that shows that you understand the theorem and also the trade-offs that you’re making for this system. The fourth step is discussing bottlenecks, scale and reliability which can be anywhere from 5 to 10 minutes depending on how much time you spend on highle design data and API
[17:09] layer. This is one of the important parts where you show that you can think about what breaks under heavy load not just the happy paths and this is also one of the clearest signals that separates who’s a mid-level candidate here who’s a senior level candidate. This is where you discuss things like single points of failure. You find them and propose solutions on what we can do to fix this single point of failure. This is where you will talk about fallbacks and failures. For example,
[17:40] whether you have mechanisms to fall back to a slower but functional path in case something goes wrong. Here you can also expect to talk about the redundancy like multi availability zone replicas for high availability application servers to auto scale across availability zones distributed caching with replication and so on. Another point is whether you can spot how you will be monitoring logging and alerting issues for this system.
[18:11] This part is often overlooked but it’s a strong signal that you think like a senior engineer. This is where you would cover API latency as well, error rates and thresholds, what alerts you will set up for this type of system and so on. And again, it is always a good idea to check in with the interviewer whether there are specific failure scenarios. For example, if you were discussing failures that they would like you to address and same goes for highle design, whether you covered everything they were looking for and same goes for database
[18:43] and API design. The last part is where you can expect to discuss trade-offs and extensions. So this might be an open-ended question like whether you can spot some things that can be improved in the system. You usually wrap up by summarizing the key decisions you made, what you prioritized for this system, what you sacrificed because it’s always about trade-offs in system design. And you should always be explicit about the why behind the sacrifices. And this part
[19:13] is also to see whether you will propose some possible extensions for the future. For example, if you had more time and the load scaled to 10x or more, what would you do to extend this service or system? So this is for you to show that you’re thinking beyond the problem that was discussed here. So what if we scale this? What if something goes wrong? And remember that you cannot propose a perfect design in 60 minutes. So of course there are always things that you
[19:44] can improve. The goal here is not to check whether your design was perfect or not because it is not. You just need to show that you could extend this. If you had more time, you had more ideas on how to improve this system. Now that’s the five-step system to manage your time. But let’s also come back to these five dimensions that you’re being evaluated on and see what you should do and what you should avoid doing during the interview. So first thing I recommend you do is ask clarifying questions
[20:15] before designing. This is the first step. So if you proceed jump into a solution before understanding the scope that is already a red flag for the interviewer. So always this is a mandatory step before you get into designing anything. Another common issue I see is engineers get stuck into one of these extremes. They either stay silent and draw diagrams without talking to the interviewer or they talk all the time and try to fill in all the silence
[20:46] without inviting the interviewer for their opinion. What you should do instead is treat the interviewer as your colleague. This is like two software engineers in the same company who are working on a solution together. So after each major decision or each of the phases, it’s a good idea to first double check whether this is what they were looking to get out of this part. And if you missed anything, for example, if it was functional requirements that you were clarifying, whether there are some
[21:17] more things that you didn’t ask for, but those are still expected to be part of the system. The other thing is always suggest multiple approaches before committing especially when there are multiple ways to go about things. For example, if you chose SQL and even if it’s a very obvious choice, for example, it was let’s say payment system where consistency is the most important thing. If you chose SQL without explaining why,
[21:49] we just assume you didn’t know or the interviewer then has to follow up and ask a question why you chose SQL, but they may also not. So if you don’t explain, they would assume you don’t know if you chose SQL, still mention why you didn’t go with something like NoSQL and what are the tradeoffs between those solutions. And same goes for any other decision that you will make where there are more than one approaches to the problem. So this also ties to the tradeoffs which is you should always
[22:20] explain trade-offs when picking any technology. If you just pick them without justifying the choice then it’s again another red flag during the interview. And this last one overlaps with the second point a bit which is you need to check in at each major step. For example, if you did the highle design and you just move on without asking if there are things we forgot to address, then there might be things that you miss during this step. [snorts] So to summarize, use this framework to manage your time and remember to always involve
[22:50] the interviewer into the discussions whenever you’re working on any of the stages of this interview. Remember that this is a conversation between two engineers, not a monologue where you talk all the time and also not a take-home assessment where you just work on the solution without communicating with them. And to quickly recap what are these five steps, it’s first to clarify the requirements functional and non-functional and understand what’s in scope and what’s out of scope. Next, you
[23:21] get to the models of data and API layers. Then you come up with the high level design with all the components that are involved and how the data flows for read and write operations. Next you discuss the scale, failure modes and reliability of the system. And last part is usually open-ended which is where you reflect on the system you built. What are the tradeoffs you made and how would you improve this if you had more time? Building a music
[23:51] streaming platform like Spotify is a classic system design problem and that is because it includes audio delivery, metadata management, object storage and everything in between that you need to know to design things from scratch and especially if you’re a mid-level developer who struggles to design systems or features from scratch, then this will be very helpful for you. So let’s see how we can design this in seven simple steps. First we’ll start by understanding the requirements and planning the capacity. Then we’ll come
[24:23] up with the highle architecture and the system flow of read and write operations. After which we’ll need to design the API and also the storage and database. And then finally we’ll move on to scalability and understanding how we can scale this system for let’s say 50 million users or above. And at the end, I will also show you the pyramid that separates senior engineers from mid-level developers so that you can understand what are the skills that you need to learn to be able to design such
[24:53] systems from scratch. No matter if it’s system design for Spotify or for Google Doc or for any other system, let’s start from understanding requirements as a first step because without this we cannot move any further if we don’t know what we are building here. So we are looking to build a music streaming platform for about 500,000 users and it will handle over like 30 million songs in this platform. So the core requirements here will be that artists need to be able to upload their songs to
[25:24] the platform. On the other end, users need to be able to search and play the songs that are uploaded by these artists. They need to be able also to create and manage playlists on their accounts. For this we also need to have a profile for users so that they can maintain their own playlists and some basic monitoring and observability like health checks for the servers, error tracking, performance metrics and so on. For audio formats, we will use OG and
[25:54] AAC type of files which are just audio file formats that compress the music without losing the quality and they also take up less space. For example, we can use 64 kilobits per second for mobile data saving and 128 for standard quality. And for premium users, we can deliver 320 kilobits per second. And on average, we also need to know the size of these files like what is the average size of a music file. And let’s assume that for normal audio quality, which is
[26:25] the 128 kilobits per second, it is 3 mgabytes on average. But this is also something you need to clarify before moving on to the next stage. Once we have the requirements, the next step is to plan the capacity of this system and especially for song storage, strong metadata and also the user metadata and daily bandwidth that we’ll be dealing with. for song storage. Since we assumed that 3 megabytes is the average size of a song and we have 30 million songs that
[26:56] we are working with, this translates to 90 terabytes on average of raw audio data. And this doesn’t include the replicas across different regions which might be duplicates of the same song. And it also doesn’t include the versioning overhead when artists re-upload the songs for certain reasons. Let’s say they want to change something and they just end up re-uploading the same song. So that’s why we will also need to estimate this like at least two or three times higher than the amount
[27:27] that we have here which is 90 terabytes. For song meta data let’s say each song needs a title, artist references, duration, file urls and so on. So we will need at least like 100 bytes per song to store the metadata on average. And if we assume that it’s 100 bytes, then we’ll need to multiply that by the 30 million, which will end up being 3 GB. So this is practically like nothing compared to the song storage. As you can see, the main storage that we’ll be
[27:58] dealing with in this system is the song storage, which will be an object storage. For user metadata, again, this won’t be something huge like let’s say we need to obtain the user profiles, preferences, and playlist data. If we assume it’s like 1 kilobytes per user profile data that we’ll be storing and we have the 500k users which we clarified in the first requirements here. That means that we’ll be dealing with 500 * 1 kilobyte which will be just half GB of storage for profiles. And the
[28:31] fourth thing we need to estimate is the daily bandwidth that we’ll be using. The average listen time let’s say is 3.5 minutes. This is also something that needs to be clarified in the previous step. And if you are using the 128 to 160 kilobits per second, that is roughly 3 to 4 mgabytes per streaming. And let’s assume each user streams like 10 to 15 songs daily. This will lead to significant egress costs. Igress costs are the fees that cloud providers charge
[29:03] when data leaves their network to reach to the users. Overall the key insight that we get from the capacity planning is that audio will dominate both storage costs and bandwidth. The metadata is just a small part or portion of this system. So with that we can move on to the highle architecture like a very high level architecture for now. We’ll need to have some sort of mobile app or let’s say the web interface of the application. This will be the userfacing app that handles the UI, the user
[29:35] searches, the playback controls and playlist management. It will make HTTP calls to the rest APIs. But before that, we will have a load balancer that will distribute these incoming requests across multiple API servers. And these servers will be the restful APIs. As an algorithm for the load balancer, we can use either roundroin or we can use the list connections algorithm. And the load balancer will also make the health checks of the API servers and make sure
[30:05] that they are always available. Next, we have the API layer where we have our restful APIs. Here as a validation, we can use JWT tokens and query the database song for metadata and URLs. We’ll use an SQL database for storing the metadata and these servers will be querying it from the SQL database. But for audio files, we’ll need some sort of blob storage to store these files since they are the biggest portion of our system. And these will be queried with
[30:37] generated signed URLs, which is a one-time URL that expires after some time. So that we keep the URLs safe and they are not always accessible through the same URL. An example of this blob storage can be like AWS’s S3 storage which is a way for storing large files like the OG files that we need to store as part of this Spotify design and these type of blob stoages offer virtually unlimited scalability and it also comes with built-in durability and cost
[31:09] effectiveness for the large files that’s why this is a good solution for storing these type of audio files with this highle design. Now we can think about the read and write workflows of the system and let’s start from the read workflow which is users playing a song. So the very first step is user hits the play button and they play a song in the mobile app. This will make a request like get to songs/ ID. This is also something we need to design in the API
[31:40] design very soon in the next step. And then after that we’ll hit the load balancer which will route the request to the API server. API server as a first step it will validate whether the user is authenticated with the JWT token. And if they are authenticated then we’ll get into this block where we will be starting to play the song. So it will first query the song metadata from SQL database where we store the metadata of the song and user profiles. It will return the song details. Once we have
[32:12] the metadata, we will also generate the signed URL here in the API server and then we will return the metadata plus the signed URL of the song to the mobile app. Then mobile app will range requests for audio chunks to the blob storage using the signed URL and blob storage will stream the audio back to the mobile app. Let’s say we are also tracking some analytics for each played song. For that reason, we will also make another post request to songs/ ID of the song /
[32:43] SLplay to track the analytics. And here we can send the metadata that we want to store for later. And we will return authentication error in case the JWT is invalid. So all of the steps above won’t be handled if the user is not authenticated. Next we have the right workflow of this same system which is artists uploading a media file which is the audio file. So the first point again is the mobile app. It will make a post
[33:14] request to store the song with the meta data and this will again reach to the load balancer first. Then it will forward the upload request to the API server. In the API we will likely have some file size limits. So we will first validate the file format and also the size and if it is valid then we will continue with the next steps. If it is invalid then we will just return the rejected payload with unsupported format or unsupported size of the file.
[33:45] Otherwise as a first step we need to store the audio file in the blob storage. So we’ll send it to the blob storage which we are using and then after that we will extract the audio metadata like the duration the bite rate and also the other things which we will get in the post body like the title of the song and so on. And once we have all the metadata like the song type, the artist, the title, we can insert this into the SQL database. Also once it is uploaded we can also
[34:16] move it by hierarchal structure if we want to like if we want to store the file inside of the artists then by artist ID then the album than the album ID we can do that also in double storage. Technically the object stoages don’t have hierarchal structures. This is just an illusion that you have it but this is also useful for us to keep track of things like they are in a folder format. Then if you are going to use any sort of CDN, we’ll need also some CDN
[34:46] invalidation. CDN here will be used for caching copies of the content like artist pages, album metadata, song details which will be closer to the users for fast delivery and it will also clear the old cached data so users see the latest songs and artist updates right away. And that will also be used in this request to make sure we are keeping everything up to date in the CDNs. And lastly, we will also send the response to the mobile app notifying that the upload has been successful. The
[35:19] next step for us will be to design this API and the database in a bit more detail. So let’s start from the API design. For the search and discovery, we’ll need a way to search different content types with pagination. Let’s say user searches for a specific song. We will need to make a request to / search endpoint and provide the query which will be the exact thing they search for. And here we can pass more details like if we know that the user is searching for a song, we can pass the type as
[35:50] song. And we’ll pass the limit which can be like 20 by default and the offset which means that from where we need to start the counter in the songs. Next we will also get the trending songs and optionally this can also be filtered by genre or by some other things. So it will be a get request to slash songs/trending and then we can pass the additional filters if we want to and the limit on how many songs we want to query from the API. [snorts] Users will also need to
[36:22] get all songs by a specific artist. Let’s say they are browsing the songs for a specific artist and they want to see the songs only of this artist. For that we’ll need to have an endpoint for SL artists/ ID of the artist and then / songs which will return the songs that only belong to this artist and the limit as always to limit the response from the API. Next, we need to also control the content access. The first endpoint here will be to get the song metadata and the
[36:53] URL of the audio file. It will be a get request to / songs/ ID of the song which will return all the metadata and the streaming URL. We also need to have a direct streaming endpoint like / songs/ ID of the song and then slash something like stream which will be an alternative to the signed URLs. And we need to also design for the playlist since the users can maintain playlists and they will be querying it back optionally with also the song list that is included in the
[37:25] playlist. So it can be a get request to/plays id which will just return the playlist details and then if you also include the query like include songs then it will also return the songs that are included in this playlist. Now let’s see what are the user actions that we can expect. For example, regular users will also be creating playlists, right? So for that we need some sort of post endpoint to/plays as a metadata. We can expect like something simple like just the name of
[37:56] the playlist and whether it is public or only available to this user. Then once they create it, they will be also adding songs to it which will be a put request to the same playlist with the ID and then / songs and here we can include the ids of the songs like some new songs that they are adding to this playlist and the position where we are adding this. Let’s say they already have four songs in the playlist or five songs and then we are adding from position of five or six. So we will be sending the
[38:28] position along with it as well. Similarly, they will also remove a song from the playlist, which will be the same request URL, but at the end, we will also pass the song ID that we want to remove from this playlist. And they can also like or unlike a song so that it is added to their favorites and that will be done with a post request to slash songs/ ID of that song and slike which will be maintaining their favorites in the database. And lastly, we’ll need some way to manage the users.
[39:00] For example, we will need to fetch the current users playlists with some request like get to/ users/me/plays. And we can also limit this with some limit if the playlists are too many for the user. Similarly, we need the songs liked by the user. So here they are adding it to their favorites. Here we can use like users/me/ either likes or liked songs. But whatever we are using here, it will be also nice if you use the same plural
[39:30] here as well. So that it’s following the restful API principles. And then the limit which can be like 50 or something else and the offset where we are starting to retrieve this from. And they need to also be able to follow artists to be notified from their latest songs which will be done with a post request to the users/me/follow and then we will pass the artist ID to follow the specific artist. That is it for the API basic design. Now we can move on to the data storage. First let’s
[40:03] talk about the blob storage we will be using. Let’s say it’s an S3 in AWS or it can be like Azure’s blob storage. We will need to use this for storing the audio files because such large files cannot be stored in SQL or some simple databases. So we need some blob storage or object storage for this. The audio files here are immutable meaning once you upload them you cannot change them. So if you need to make a change to the uploaded file, you need to re-upload it and remove the old one. And we can also
[40:36] organize them in a hierarchical structure as we saw before. So we can store them like artist/ the artist ID/ album/ album ID and then /d this id. This will be easier for us for retrieving the files later. As a database, we will be using some sort of relational database like either PostcarSQL or MySQL. And this is the basic structure we will have in the database. Let’s start with users where we will be storing the user ID and some
[41:07] metadata about them like the email, the password, hash created at which will be the creation or like the sign up date for this user when they last logged in, subscription type and country. And this can be many more metadata but this is just the bare minimum that we need to have. And with the user ID we will have a foreign key linked to the owner ID of playlists. So since we will have playlists we also need to have the owner of which playlist belongs to which user
[41:40] and we will also have the playlist ID to for linking this to the playlist items. We need the name and created at for this playlist. Then each playlist will have some songs in it. Right? So here we can use a join table. This can be something like playlist items which will contain first of all the playlist ID and also the song ID and the position like where is this song positioned in the playlist and when is it added to the playlist if
[42:10] we need to also maintain that. This will basically be the table that joins the playlists and the songs together. For the songs again we will have the ID which is the most important thing here and some metadata like the title, duration and so on and another important part here is the file URL which we’ll be using to query the audio file to stream it from the blob storage. Then we will also have artists which we can store in another type of table other than the users for keeping this separate. So we
[42:42] can have the artist ID and then the metadata about them like the name, country and so on. And we’ll need to connect the artists to the songs. For that we can use another join table like artist songs where we can keep track of most importantly the song ID that they uploaded and the artist ID like which song belongs to which artist. And additionally we can store some more metadata here. But this is the bare minimum that we need to have in this table. And why we choose SQL here?
[43:14] Because we need a lot of joins as you can see for playlists and songs for songs and artists. So this is a natural selection for us to go with SQL type of database. We can do even more complex queries here like find all songs by artists that a user follows or some other complex queries like that. And that is possible with SQL type of databases. And once the database grows, we’ll need to also add indexes for the important properties here, which will be the
[43:46] frequently accessed columns. And in our case, we can, for example, index the title in the song table. We can index the name in the artists table. And we can also index the song ids in this join table where we’ll be joining the playlists and songs. These indexes will enable us to have fast search capabilities which will also handle the typos and partial matches that user expects when they are searching for a song or for a playlist. And the playlist
[44:18] item index will speed up the queries when fetching songs for a specific playlist or finding which playlists contain a specific song. That’s it for the planning of general object storage and also the SQL database. Now let’s see how we can scale this. Let’s say the user base grows to 50 million users now and the songs grow to 200 million songs. This means our current architecture cannot handle it more. Which means if we calculate the requirements again we will
[44:48] have 100 bytes for 200 million songs. For song metadata that will be 20 GB. For user metadata it will be 1 kilob times the user base which will be 50 GB. But for audio storage, you can see the estimated 3 megabytes per audio file. And if you multiply that by the amount of song, which is now 200 million, let’s say, it will be equal to something like 600 terabytes. And this is not even including the replicas, the regional copies or the multifile uploads by
[45:20] artists. So in reality, this will be like two or three times more. So let’s see how our scale phase might look like. As a first step, we can add CDN edges for different regions of the world. For example, we can have one for North America, we can have in Europe, we can have in South America. This will speed up the process of retrieving it from CDN. And similar to that, we can have load balancers in different regions. Let’s say we have at least two load balancers in two different regions that
[45:51] are still mapping the incoming requests to the API servers that we have. Before getting to API servers for the CDNs, we need to use some sort of eviction policy for cache management. And here it’s a good use case to use the LRU cache method which is automatically removing the list accessed content when storage fills up and the cache would fetch from the origin storage on a cashm for the API servers. Since we are not maintaining any type of user session or
[46:24] any storage, these are completely stateless meaning we can scale them as many as needed to handle the incoming traffic. So adding more capacity here is straightforward. We are just replicating the existing API servers. The main thing here is that when designing the API server, we need to design for truly stateless servers so that it’s possible for us to scale this easily. Next, we need to also scale the database because the single instance cannot handle that many metadata storage. Which means we
[46:55] need to separate the reads and the rights in the databases. For the rights, we can use a leader replica where we will be writing the new files of the songs or new playlists that users are creating. But when reading it, we can have some slave or follower databases which will be only used for reading. So this will be in sync with the leader database. But whenever we get any read operations like getting the song metadata, getting the playlist metadata,
[47:27] we will be reading them from the follower databases. As a next step, if this is not enough, we can also consider sharding the database. Sharding will split the data across many database instances. Let’s say we will have one shard for the US users and one shard for the European users. We can split it by geographically. And then we will partition songs by the artist ID ranges. This will give us horizontal scalability and it will improve the performance by keeping the related data together. And
[48:00] also for the blob storage, we can replicate this to be in different regions. Like for Europe users, they will be uploading to a blob storage that is in Europe region. For North America, they will be uploading songs to the blob storage that is in their own region and so on. For the other geographics in the world and all of them will still be using signed URLs for accessing the audio files and streaming the music chunks. Of course, there are many things that can be improved on this system like
[48:31] improving the reliability or monitoring. But the key, as you can see, is starting simple. We don’t want to overengineer for small amount of users and then gradually increasing the scale as we go. And lastly, let’s see how we can also be able to design such systems or features from scratch and what’s the real difference that separates senior engineers from mid-level developers. If you think the designing part as this pyramid on the bottom which is the main like most important part is the planning
[49:02] which is the blueprinting phase the technical design understanding requirements as you saw estimating capacity choosing the architecture. This is where everyone needs to start when designing such large scale systems and only then we move on to the text selection which is like picking the right database whether it’s SQL or NoSQL and if it’s SQL then what exact type of SQL database it will be the caching layers eviction policies storage solutions and all of that tech related
[49:34] stock selection and only last stage which is the most smallest part actually at is designing is the coding which is like starting to implement the things but that is actually the thing that requires the least brain power and especially if coding is the only thing that you can do then we have now these cloud or GBT models which can do it faster better and cheaper so what’s the point of any company to pay you if the only thing that you can do is coding
[50:06] that is where the most mid-level developers are stuck because they have this completely upside down. They start developing things without even having any plan, without having any text selection. They just start coding and see where it takes them. Whereas the fundamental stickers who learn to think through problems before even touching a keyboard or before even starting to build any code, these are the ones that will never struggle to find roles because all the companies are always in
[50:36] need for such developers who can think through problems from users perspective and also bring rough ideas to reality by understanding the requirements. By the way, if you’d like to learn all these concepts that I’m using for designing these systems from scratch, I have a whole system design guide covering all these concepts that you need for system design interviews on my YouTube channel. You can check out the first link in the description to find the link to my channel and the first pinned video in my
[51:08] channel is that system design guide explaining all these concepts you need for passing these system design interviews. Last design a hotel reservation system similar to booking.com or Airbnb which is a typical system design interview problem. The mistake that most developers make here is when they ask to design such a system they treat it like a typical crowd application with some caching and replication for the availability. But that’s not the correct way to think about it. For instance, one of the
[51:39] problems that you might have is a moment when two users are trying to book the last room available at the same time and you try your simple select then insert approach which creates the double booking problem. So you end up charging two credit cards for one room and now you’re dealing with refunds with angry customers and the system that cannot be trusted for the next time. That’s why hotel reservation systems are hard because they also test the exact skills that separate mid-level developers from seniors. Things like handling
[52:10] concurrency without breaking it under heavy load, keeping the data consistent across multiple systems, and also designing for failure in case something goes wrong in parts of the system. And it’s not about memorizing these patterns, but rather understanding the actual problems that might occur. What are the solutions to those? and what are the trade-offs between those solutions. So, we are going to design this system in four simple steps. First, we will gather the design requirements, estimate the storage and queries per second,
[52:41] transactions per second, and anything else that we need for designing the system. Then, we’ll get into the basic highle design including the API design, database schema, reservation flows, and so on. And in the first step, we will scale the system to handle 1,00 queries per second and 17 transactions per second, which are the numbers that are close to the realbook.com scale. And then we will handle the failures under heavy load and discuss the system recovery whenever somethings or some parts of the system fail. So if you’re
[53:12] tired of system design explanations that skip the hard part and only give you the surface level, then this one is for you. Let’s start with the first step which is the design requirements. In such challenges, we usually follow a framework to come up with the solution. And the very first step is always to ask clarifying questions to lock in the scope that we are going to design and then also about functional and non-functional requirements. So let’s start with the clarifying questions that we might have. For example, one of the questions can be about the payments
[53:43] which is are we going to allow them to pay at the platform or it is going to be a payment in checking and this will let us know whether we support payments in the platform or not. For this one let’s say yes. So we are going to allow them to pay at the platform which means we need to have some sort of payment system inside. Another question can be about booking channels. So is this a website only web app where they are going to book the hotels or we also support the mobile app which might have different rates and different pricings. So let’s
[54:14] say we are going to design both like web version and mobile version of this application. Then about cancellations and over booking. So do we support cancellations of the reservations and also over booking because hotels typically allow some sort of over booking taking into account that there might be some cancellations. So if they are getting let’s say 10 bookings per month then out of these like two or three are going to be cancelled. So they allow users to also overbook because of
[54:46] the cancellations. Once they get some cancellations the room is free now. So they will allow over booking as well. Then about the dynamic pricing. So are the prices fixed or the price changes based on the demand and the dates. And for this let’s say we are going to have dynamic pricing. Whenever the demand is higher so it’s let’s say Christmas time which is around now. We are going to increase the prices. For example this room where I’m recording from it was triple of the price as in usual because
[55:17] that is the Christmas time. So they are going to increase the numbers and similar to that any other popular dates that people are reserving we are going to increase the prices based on the demand that we are getting. Then we can get to the functional requirements. So one question might be about the hotel management. Are we going to support the admin panel like adding, updating, removing hotels or we are only focused on the user part. Admin panels might be included in this but the core
[55:47] functionality evolves usually around the users which are booking the hotels cancelelling them. Let’s say we are going to also support some sort of admin panel in this system. Then we get to the search and discovery questions like what are the core functionalities we are going to support. And a way you can think about this is what are the functionalities that if we remove from booking.com it doesn’t become Booking.com anymore. This might be a good approach for identifying what are the core
[56:18] functionalities. For example, if users are not able to search hotels by location, then it becomes some sort of useless application. Right? So of course one of the core functionalities we will support is that users need to be able to search by location, by dates, by guest number, price range and some other filters that we might set. Then about the reservation flow, how usually it happens is users book the rooms. Then they view the reservations, they should be able to cancel the bookings because
[56:50] we also clarified it above. And also the system should prevent double bookings. This is one of the challenges we need to identify here so that we also solve it in the next steps. Then about the payment processing since we clarified that we are going to support payments in the app then we need to integrate some sort of payment processing like stripe, PayPal or something else. And optionally it might also include notifications. So sending confirmations for bookings or cancellations or payments to both user
[57:22] and the admins brokers who are uploading these hotels. Then we get into the non-functional requirements which are more about the consistency, load and latency that we are going to support. Let’s start with the consistency. We are going to have strong consistency because two users shouldn’t be able to book the same room for the same dates. That is non-negotiable. But whenever they are searching or discovering the rooms there, the eventual consistency is fine and you
[57:53] will see why also when we get to the designing phase. Then about the concurrency. So we need to handle thousands of users trying to book the same hotel simultaneously and without it breaking the system. Also about the latency, we need to provide low latency. So search results should appear under 500 milliseconds and booking confirmations are fine if they are handled between two or 3 seconds. You should also clarify the availability and scale. Let’s say for availability we are
[58:25] going to support the free 9 uptime. And when it comes to scale we need to be able to scale this horizontally without redesigning the system from scratch in the future. Once we gather the design requirements, we can move on to the estimation of data and capacity and after that we can start designing the system. Now when it comes to the data estimation, the most important things we are interested here is the queries per second that are going to happen in the system and also transactions per second
[58:55] which are the rights to the database when users are booking hotels. So in this kind of system you can think about it as this funnel here. The users that come uh to the platform are at the top here. So these are the users who are viewing the hotel room. This is the 100% of the traffic somewhere here. And then next we have out of these users probably like only 10% will go into browsing the hotels and searching for some good
[59:26] matches and booking the hotel. So that will be the second layer here. And then third we will have the reservations. So only out of this only the 10% are actually going to book anything because most of the visitors are tire kickers. They are not looking to book anything. They are just browsing. But we still need to design the systems for them because they are also going to increase the queries per second that are happening in the system. So based on this there are two ways for us to
[59:56] calculate the load. One approach is let’s say to take a look at the booking.com and you can use some sort of extension like similar web to see the traffic that it’s getting per month. For example this month it got 500 million visitors in the website. That means that 500 million users were browsing the system this month. So at the top and then you can start calculating it down. based on this to see also the transactions per second and what are the queries per second based on this load
[1:00:28] and the other way is the bottom up approach which is to get the number of reservations per year let’s say and for booking.com that is 1.1 billion reservations per year and then we can calculate this upwards so starting from here how many visits we get per month or per second to the platform and we will also be able to calculate the transactions per second because we have the number for the year reservation. So each of these numbers is fine. You just
[1:00:59] need to get one of them from the interviewer and then you can start the calculations and also take into account that these numbers are also rough estimates. So you might get these numbers exactly to calculate or you might get just the visits per year and the bookings per year and you should be able to calculate it after that. The other important metric that we need is the average state. So what is the average stay that users are booking the hotels? Let’s just say two or three nights per booking. So let’s go with the
[1:01:30] two nights per booking and then we will be able to calculate the rest. So let’s start with the daily reservations. We have the number of bookings per year which is 1.1 billion rooms per night. Let’s just start with this approach. Then we can divide this by the nights per booking and that comes down to 550 million bookings per year because this is the room nights in total. This is not the bookings. So we need to divide this by the nights per booking to get the actual number that we are getting booked
[1:02:02] per year. Then we can divide this by 365 which is the days in the year and we’ll get the number of bookings per day which is 1.5 million. And after that we can divide the 1.5 million by the seconds per day which is 86,400. And that means that we are getting 17 bookings per second which is the transactions per second that we are going to support in the database. Then when it comes to the traffic funnel part
[1:02:32] and the reads as you saw with similar web we are getting something like 500 million visits per month in the booking.com with about eight pages per visit which means that on each visit the users are going to browse around eight pages before they leave the website. Based on this, we can first get the visits per day and we can get that by dividing the monthly traffic with 40 which are the days in the month and we’ll get to 17 million visits per day. After that, we can get the visits per
[1:03:03] second. So, if we divide that by the seconds in the day, we will get uh 200 visits per second to our website. And if we assume that they are going to browse around eight pages per visit before they leave, let’s say that it comes down to five backend reads per visit, which can be things like search queries, availability checks. So we need to just increase this number by multiplying it with the five and we’ll get around 1,000 read requests per second. And based on
[1:03:34] these calculations, we can clearly see that this is going to be a strong read heavy system because we have 1,000 QPS queries per second that we need to support and only 17 TPS which are the transactions per second that we will get. So our challenge here will be to optimize the architecture for reads while still keeping the rights consistent to avoid the double booking problem. These are the core numbers we need to start designing the system and then the rest we can look at while
[1:04:04] designing the API database and if there are any more things that we need it’s fine to calculate those there but at least we need to get the main things that we need before starting with the highle design. After the scale and data size calculations we can now design an architecture that fits these numbers. So we can start by using a serviceoriented architecture with clear boundaries but also keep the tightly coupled data in the same database. So as a starting
[1:04:34] point let’s say we have the mobile app or the web app where users are browsing these hotels and making the reservations. We’ll use CDN in front here for the static assets like rendering the images, media files, also the JavaScript content and the hotel images. Then we have an API gateway which will be the single entry point to our application. This is where we will handle the authentication, the rate limiting and also routing the requests
[1:05:05] between all the services that we have. It can also serve as a load balancer across service instances. So if you have multiple instances of hotel service, let’s say, this API gateway can serve as a load balancer and route the traffic between the instance one and instance two. When it comes to services, we can separate this with boundaries for hotel service. This is where we will have hotel and room information, mostly static and heavy cachable service. An
[1:05:37] important service here is a reservation service. This will handle the booking logic, inventory checks, reservation creation and cancellation. And this is also where concurrency will be handled in the application. Next, we have a rate service. This will be for room pricing by date because depending on the date and when you book the hotel, the price will be different. So this will be applying dynamic pricing based on the demand of the rooms. To integrate the
[1:06:08] payment gateways, we will also need a payment service that will implement let’s say Stripe payments, PayPal payments, Apple Pay, Google Pay and Adam providers. And we also have a notification service that talks with the reservation service and with the payment service. This is what sends the emails and push notifications for the data layer. Initially we can start by using some sort of SQL database like possql primarily because we will need the acid
[1:06:39] guarantees and this will be also useful for the transaction store and we will be using many transactions. So we need the consistency of SQL databases. That’s why we go with SQL in this case. We also need some sort of caching like radius for the data that doesn’t change that often. But we can also improve this a bit further. So first of all if we think about the scale of the reads that we will have which is around thousand queries per second postsql database
[1:07:12] won’t be able to handle that load. So we need to give some sort of solution to this. For such situations, tools like elastic search are a good solution for handling the massive scale for the search. So we will introduce a search service that will be responsible for retrieving the hotels by the filters that are set on the front end. And elastic search will handle the full textbased search on hotel names and descriptions. Also, it will be able to
[1:07:44] filter hotels, let’s say within 5 kilometers of location or within some range to that location that is set and it will be able to filter by the price, range, ratings and any other filters that we need. The thing with elastic search is that it’s way faster than the postgrql database that we will use let’s say for listing the hotels and also it is eventually consistent meaning that eventually it gets updated with the database and gets the latest data in the
[1:08:16] elastic search and indexes everything properly to be able to search quickly based on locations or based on the price range and any other filters. We can also decouple the services a bit. Another approach is to use different databases for each microservices. Like for the payment service, we can have payment database. For the hotel service, we can have hotel database. And we’ll discuss what’s the pros and cons of this. But to also decouple them, we can introduce a
[1:08:48] message cube that will be responsible for instance for payments. Whenever a payment is processed in a payment service, we will process that and publish a message in a Kafka rabbit MQ or some other messaging queue. And this will first of all send email push notification to the user or to the owner as well as an email or as an inop notification that the payment was successful and the hotel is reserved. And it will also update the availability
[1:09:20] meaning the status of the hotel. So it will push that to the search service which will update it in the elastic search and that is again getting eventually consistent. So right after the payment maybe if users search they will still see that hotel available but they won’t be able to book it because we are using the hotel database to double check whether it’s really available or not. And eventually after some time this will be persisted with the database and
[1:09:51] it will have the same data as the postgrql database. Next when it comes to choosing whether all these services will be like microservices or a single monolith or they can also be pragmatic microservices. For example, in our case, a single monolith would simplify the transactions, but it will become hard to scale and deploy independently because if many developers, let’s say hundreds of developers are working on this single monolith, then it becomes very hard to
[1:10:22] deploy independently and work on things without stepping on each other. Another approach is full microservices like this where you have rate service, notification service and so on with separate databases which will introduce distributed transactions and consistency problems that don’t exist at the monolith level. That is because each service as you can see like hotel service owns its own database, reservation service owns its own database. So another approach is to keep
[1:10:55] the tightly coupled data together in one database and share it between the services. For example, in our case, the reservations and the hotel databases can be kept together. This will let us to use the local ACIT transactions instead of the distributed transactions. And we will have a service level separation at the application layer but we will still have the shared data for tightly coupled entities. After you agree on the highle
[1:11:26] design the next step is the API and database design and you can start with API design then database or vice versa whichever way it’s easier for you to talk about the flow. So in our case let’s start with the API design. We will use restful APIs for all the operations because that is what makes sense in this kind of system. Although as you can see here the front end directly makes calls to hotel API to other APIs as well that
[1:11:59] we have. Maybe sometimes it will make calls to reservation API. Another approach here a very common one is when you have multiple microservices usually you would use a GraphQL layer in front of the front end. So front end will only make requests to the graphql and it also will know the schema based on the graphql schema and then the graphql layer will be responsible for making calls to all these services that you have behind the scenes. So it kind of extracts away the complexity and the routing between these services. But to
[1:12:32] keep it simple on the diagram let’s just assume that front end makes the calls directly. So we have here admins who are listing the properties. We have users who are browsing the hotels and making reservations. And we have the front end app which is either a mobile app or the web version. The very first step for admins is they need to be able to loged in to be able to use the features for the admins while users can be browsing the hotels without being logged in. But whenever it comes the point for the
[1:13:03] reservation, they need to also create an account or login. For listing the available hotels, we will make a get request to / hotels with any necessary filters that the user sets and we will get the available hotels and then if they want to browse a specific hotel then we will make a request same get request to / hotel/sp specific ID of the hotel which will return us the hotel that has that specific ID. Now how it will happen behind the scenes is hotel
[1:13:35] API will fetch the hotel details from the database. So for specific hotel we can also read this from the postcarql database. But in the initial call where we are making a request to / hotels that is where we will be using the elastic search to quickly filter based on the filters that are set on the front end. And at this step for a single hotel we just basically forward the hotel details to the front end and then front end is responsible for parsing and showing the
[1:14:06] details in the page. For admins who want to add a new hotel, they will need to make a post request to / hotels endpoint. And this is where we will insert it into the SQL first and then once we get a successful insert, we will just forward the 2011 status that the hotel is created and later this will be eventually persisted with the elastic search database. But we will show the confirmation at this step to the admin
[1:14:36] that the hotel has been created. And similarly, if they want to update the hotel information and they make some edits, we can make a put or patch request to hotels/ ID of that hotel and again update it in the database, get the update result and send the confirmation and show it for the admin. A similar flow for removing hotels. Again a delete request in this case to / hotel/ ID of that hotel. We remove it from the
[1:15:08] database and we show it to the user and later this will be removed from the elastic search. And within hotels we will also have rooms because one hotel has many available rooms. We will treat them as a sub resource. So under hotels and specific ID of the hotel we will have / rooms first of all which will give you all the rooms that are available under this hotel and to fetch the specific hotel you can make a
[1:15:38] request to / id and get more details from that hotel which will again fetch from the database and then you will forward it to the client and basically here we are just repeating whatever we have for the hotels but for the rooms. So if you want to add a room you will make a request to post/ id of the hotel/ the rooms. This is again following the general restful principles. We will insert the room in the database and provide with the successful response. Of course you need
[1:16:09] to also handle the failures whenever something goes wrong showing the error on why something went wrong in the API. But when talking about the happy paths or at least the available endpoints, these are what we will have. We will also have a put endpoint which is for editing the room type again / rooms. In this case, we need to provide an ID because we are not creating a new one but we are editing one. So we need to specify which ID is that room that we
[1:16:39] are editing. And same goes for deleting the same URL as we have for the put. But in this case the method will be delete which will remove the listing from the hotel. Then for the reservations this is where we will make requests to the reservation service. Let’s say a user wants to see their reservation history. We need to provide an endpoint here to fetch the reservations. So a get request to /reservations which will read it from
[1:17:09] the SQL database and then forward the response to the client. Then they want to see let’s say a reservation details and the invoice that they paid. So that is where we will have the get endpoint to /reservations/ ID of a single reservation which will fetch the details from the database. we will get the details back and we will forward it to the front end and we will parse and show it to the client. Next, when creating new reservations, they
[1:17:40] will fill the reservation form in the front end. Then we will send this form details in a post request to reservations. One important thing here is that at this step we can already prevent double booking for single user. There are two scenarios like one user can try to doubleclick or click the same reservation room twice. So that’s one scenario which we can handle here. The other one is that two users can try to book the same room twice. We will handle
[1:18:14] that in the later step. But at this step for example one solution is we can include a key like a reservation ID and this will be generated on the front end which will be used to prevent double bookings when user clicks submit multiple times. So if we have the hotel ID and the room ID that this user is trying to book, basically we generate a reservation ID once and then if they try to double book it like book it again, we
[1:18:45] will be able to catch it in the database and see that the reservation ID is the same. So we will block them from making a request or we can even do it on the front end side. Another solution is you can also do it on the back end. For example, when they make a request to book something, you can combine the hotel ID in combination with room ID, the user ID who tries to book this and also the date or some sort of timestamp.
[1:19:17] Why? Because in the future this user might try to book it again. And if you just use these in combination, then basically you are blocking them to make a future reservation on the same hotel. So you can also use these in combination to generate some sort of hash and whenever the hash match is from the database then you block them from making the reservation. But this is way simpler solution on the front end side to just generate some sort of UU ID uh for the
[1:19:48] reservation ID. If you are talking only about a single user and preventing them from booking again, then this is a very lightweight solution and way simpler than inserting this hash into the database and checking it every time in the back end. So here we will first check for existing reservation ID. If it’s not found, then we will insert the new reservation. So assuming it’s not a double booking and we get the reservation confirmation, we will just show them the confirmation and behind
[1:20:19] the scenes the Kafka message will be published and it will send the notifications to the user and to the owner of the hotel. But if it’s a duplicate reservation attempt, then they will make a request and this will contain that reservation ID. So we will check for the existing reservation ID and if a transaction like that already happened and we have the reservation and it already exists then we will return the existing reservation and show it to the user that this is already reserved.
[1:20:52] Similarly they might want to either cancel the reservation or change the dates of the reservation. So we need to provide endpoints for that as well. In case of cancelling, it will be a delete request to reservations/ ID of that reservation. And in the database, we just need to check whether it’s allowed to cancel because if they try to cancel a week before the appointment, then it’s allowed or if they try to cancel it last minute, then it’s probably not allowed.
[1:21:23] So, in case it is allowed, we will just send the confirmation. Otherwise, we will send a response that we cannot delete the reservation, meaning we cannot cancel it and show the options to the user. Similar to this, you might also have edit reservation with put request which will update the dates. For example, if plans have changed and you want to update the dates, then we will make a put request to that ID reservation. Let’s also see how the search will happen. So another service
[1:21:53] we will have for this is the search service which will be connected with the elastic search. So let’s say user sets the filters like the location where they want this hotel to be located at the dates start and end date and so on and we make a get request to / search. These will include the location, the check-in, checkout time, how many guests you have, how many beds you want, the price range, and any other necessary filters. This is where we will query the available hotels
[1:22:24] and room types. But an important thing is we will query this from the elastic search. In this case, we won’t query it from the database because it’s way faster and that’s the point of us setting up elastic search so that we can quickly return the search results. And this will return the available hotels within the range of that location and within that price range and the check-in checkout time and front end will be parsing and showing it to the users.
[1:22:56] Next, let’s move on to the data models and the database design. One important thing here is that you book the room type and not the specific room. So you book a specific type of room like is it a double queen bed or something else and not a specific room because the room numbers are assigned at the check-in and not here. But let’s start from the hotels because it will make more sense this way. So we will have of course the
[1:23:27] ID of the hotel which is the indicator of the unique ID for this hotel. It can have a name, address, location, description and so on. Now there might be also hotels that are under the same brand name. In this case you can either have the same hotel name but different hotel ids. So it will be a different hotel LLC in US. Let’s say it will be a different hotel LLC in Europe. The name will be the same but the ID will be
[1:23:58] different. Or you can use a helper table like hotel groups and then group them under a brand name if there are brands that have multiple hotels at different locations. Then the important part is the room type that we need to have. So the ID of the room type, its name, description and then how many people it can serve, the ID of the hotel which connects back to the hotel. So one hotel can have multiple room types and this is
[1:24:31] a one to many relation. Then each room type is also connected with room type rate because each of these rooms will have a different rate. Let’s say we have a room which is the basic package. And as a basic example let’s say it costs 100 bucks per day. So this is the basic package. Then you can have a room with a better view. In this case it will be something like 150 per day. You can have a hotel room with breakfast included and
[1:25:05] that will be something like 200 and so on. So that is the reason why we have this room type rates because for different room type we will have a different rate that will be multiplied by the base uh number that we have here. And this is again one to many relation because one room type can have multiple room type rates. Then this is where we have the actual rooms. So what is the room ID? This is the exact room that we are going to assign at the check-in.
[1:25:37] Then it will have some more metadata like floor number whether it’s available. This is the important part that will be tracking the status. And this is also what we are reading when checking the availability and the room type ID which again refers back to the room type. So one room type can have multiple rooms in it which means you can have a hotel under that hotel you have multiple room types like you have let’s say the basic type and in the basic type you might have three
[1:26:08] rooms available that are all basic rooms. This also connects to the hotel ID so that you can determine which exact hotel this belongs to. You could also join it with the room types and then get the hotel ID like that. But it will be helpful for us to have the hotel ID here. So this will be like which hotel does it belong to the room that we are assigning at the check-in. Next we will have the room type inventory. Inventory is tracked per hotel, per room type and
[1:26:41] day. So not the physical room. This will enable us to have bulk availability updates also scalable pricing and search and it will be simple over booking logic because we will also support over booking considering the cancellations that we will have in the rooms. So here we will track the room ID type, the hotel ID and this is again one to many relationships between the room type and between the hotel and some more things like the date, the total inventory,
[1:27:13] total reserved and so on. Another important table here is the reservations because once user selects the hotel room specific room we will need to reserve it. So we will track the reservation ID which we will link later to the transactions. We will have the room type ID. This refers to the room types table. We will have the exact hotel ID where they booked this which refers to the hotels table. The guest ID which is the user who booked it. So for that we will
[1:27:45] have a guests table. And we can additionally have let’s say admins or brokers table which will be the users who are publishing these rooms. So here the most important thing for us is the guest ID the first name last name email and so on. So we will connect it with one to many relationship here because one user one guest can reserve multiple hotels. So that’s why it is one to many. Then some more metadata like start date,
[1:28:17] end date, status, whether it’s reserved, whether it’s canceled and so on. And another important piece here is that we will separate transactions into a separate table. The reason here is that let’s say you reserve the hotel first. So first the status is reserved. Then the user can either change their mind and here they can either cancel the reservation or they might request a change in the date. So they might request to change the start and end
[1:28:48] date. In the second case, there will be a price difference that they will either have to pay or we have to refund them. So that will be another transaction, right? And that’s why one reservation can have multiple transactions. So the first transaction is when they reserve and then the second one is when they decide to change the dates let’s say and update the reservation they make another payment here which is a second transaction or if they decide to cancel that is another transaction where the
[1:29:20] amount in this case is minus so we are refunding the payment but that is still a separate transaction and that’s why we will have separate transactions table where we will track the ID of transaction which reservation Does this belong to the type of the transaction like refund, change or the new transaction and the amount which can be both positive and a negative number. With this schema, the room type inventory table has one row per hotel
[1:29:51] room type and date combination. We will prepopulate this for the next two years and then a daily chrome job can add new dates as the time moves forward. So we can also take a look at some example data here. We’ll have the hotel ID where these room types belong to. Then the room type ID. Let’s say these three rooms are from the same type like the basic type. uh dates where we are checking these for like December 15, 16,
[1:30:23] 17 and so on and then we will see the total inventory and also total reserve which will give us an idea on how many rooms we have available. When checking the availability, if you’re using the SQL database, let’s say for December 23 to 27, we will select the date, total inventory, and total reserved, which is this column here. And then the total inventory, which is the available. We will select it from the room type
[1:30:53] inventory where the hotel ID and room type ID match. And we will set the date to be between the range that we want to. This will give us the total reserved and total inventory. And by using this we can check that if the total reserve plus rooms to book is still less than equal to the total inventory then we allow the booking. Which means if let’s say here we have 100 and here we have 92. So we still have eight rooms available. And if
[1:31:23] user is trying to book just one room that will be 92 + 1 and we are checking this against 100. So the condition will be true and we will allow the booking and coming back to the initial requirements since we allow let’s say 10% over booking or whatever that number is we can include that over booking factor here. Let’s say if we allow 10% over booking then we will allow 10% like set the 10% here. Meaning if we have 100
[1:31:56] total inventory available, then we will allow them to book up to 110 rooms because we know that the other 10 will probably be cancelled until the date of the reservation. And lastly, let’s also discuss why we move with relational database. In this case, we kind of touched on this, but let’s get a bit deeper in why we choose this. So first the most important thing we need here is the acid guarantees and specifically the consistency of SQL databases. This is
[1:32:28] important for us for preventing double pooking issues which we will handle soon. From the calculations we can also see that this is a very read heavy system. So relational databases handle these kind of reads well and we can always combine it with elastic search for scaling it. But with 1,000 reads queries per second versus 17 writes per second, this clearly shows that we need to go with an SQL database here in combination with the consistency factor
[1:32:59] that we need from the ACIT. And then the relationships are more clear this way like between rooms, hotels, the room types, reservations, transactions and so on. And we’ll need to make complex queries to check the availability of the rooms or make multiple joins for inventory with pricing, filter by dates, aggregate totals and so on. So SQL will handle this well as well. We also need to handle the concurrency. We already
[1:33:30] discussed the first scenario where the same user is trying to book the same room twice and the solution here is just to have that reservation key. So any second request will get duplicate key error and we will return the existing reservation. So that’s the simple one that we can solve. But then the second scenario is we have two different users who are trying to book the same room type which is only one spot left for that hotel. And if you know about the SQL isolation layers, by default this
[1:34:02] will be read committed and concurrency bugs still happen at read committed isolation layer which means that you only see data after other transactions commit. But there is a gap between when you read like check the availability and when you write which is you book the room. And in that gap you might have two different transaction happening like user A reads the total reserved. He sees that one room is available because the total is 100. And user B also reads and
[1:34:33] sees that there are 99 rooms reserved and 100 available which means that there is one available. So for both of them the check shows successful like the condition is true which means that we allow them to proceed with the booking. So read committed by itself is unsafe for bookings because two transactions can both read valid data pass checks and then override each other and that’s exactly how double bookings happen. As a solution to this we have two main
[1:35:04] approaches in this case. One approach here is that we can use serializable isolation layer which means that when beginning the transaction at the code level we can set the isolation layer to be serializable. By default, it is read committed. This is simple to implement and it will work fine for low TPS meaning transactions per second because these won’t be blocking each other if you don’t have that much high volume. So it’s fine to use serializable in that case. The downsides to this is that
[1:35:36] transactions will block each other. So if you have high TPS then this won’t scale well and also you introduce potential deadlocks if locking multiple resources in different orders. However, you can use let’s say log timeouts and set it to some number to prevent indefinite weights and also keep the transactions short. But still there is a better solution in this case which is to use read committed isolation layer which is still the default mode in combination
[1:36:07] with row locking. This is when you’re using select for update and it is used to lock the inventory rows while we are checking and booking them. So that other users have to wait their turn. If you have two transactions who try to log the same row, the second one needs to wait for this transaction to be processed. But if there are other transactions which are not trying to write to this same row, then they are free to move forward and basically they can go in
[1:36:39] parallel instead of the serializable where the transactions need to go one by one. So the for update here prevents the race condition by serializing access to specific rows in this case. The read committed is still the postgrql’s default and works fine here. And serializable is fine to be used if you have low TPS and not that much traffic coming to write to the database every second. The next phase is talking about
[1:37:11] scaling the system. things like database sharding, whether it’s needed, caching strategy, how we are going to optimize the elastic search queries. So, let’s start from the database sharding. Since we’re going to handle 1,000 queries per second and 17 transactions per second, we need to eventually shard this and that will be obvious by the time it takes for typical queries to run. And there are tables here that we can split. When it comes to sharding, the important
[1:37:41] decision is what will be the short key and by which field you are going to shart. One example here is to shard it by hotel ID. Why? Because most queries will filter by hotel first to see their location whether it matches with the search query filter and then they are going to fetch the room types available in this hotel. All data for one hotel will still stay on the same shard. So we will not be crosssharding the joints and
[1:38:13] this will also enable local transactions within the shard. For initial deployment we can use modulo sharding which is the formula for sharding. So the shard number will be equal to this won’t be consistent hash initially. So it will be just the hash of the hotel ID in combination with the number of shards that we will have. This will work well when you know your shard count won’t change often. So if we decide to let’s say split this,000 read queries per
[1:38:44] second between 16 shards then this will range about 62 reads meaning queries per second and one writes transactions per second per shard. So in that case the modular sharding will work fine and then later that is where we can integrate consistent sharding if needed to minimize the data movement. So the thing with modular sharding is that if we decide to add another database let’s say number 17. This will require us to
[1:39:15] reshuffle almost all the data from 1 to 16. Whereas if we use consistent hashing, this only moves about one of the 17th of the data, which is the data that is going to be stored in the 17th chart. And you can check out the video in the system design course where I talk about consistent hashing. Basically, it uses a hash ring where both servers and keys map to points on a circle and each
[1:39:47] key goes to the next server clockwise to the ring. So, if you have a ring like this, then you calculated the key which is ending up here and the closest server to that is here. Then this will be stored in that server. So with consistent hashing adding a new server only affects the keys between it and the previous server which is the number 16. Whereas with modulo sharding you need to basically reshuffle all the data. So with small number of shards modulo
[1:40:18] sharding is the way to go and if you know that you’ll be typically adding new shards to the database then consistent hashing is a better solution here. Now let’s see the caching strategy that we can use for the hotels. We will need to cache the hot inventory data in radies or memcache for smart invalidation. So this is the cache structure that we can have which is for the key we will store the inventory which will be the
[1:40:48] combination of hotel ID, room type ID and the date and for the value we will store the inventory available. Let’s say in this hotel again 100 are available and the total reserved which can be something like 87 and we will also have in combination with this the over booking rate which will be 10% but of course we don’t store this in the cache we have this as a fixed number somewhere and when calculating we will use this in combination with total inventory and
[1:41:20] total reserved and we will assign the cash a time to leave which can be something like 5 minutes per inventory. Now the cash flow will look something like this. User will first search for hotels and this is where we will check the radius cache first for the hotel inventory. If the cache is hit meaning we stored this data in the cache. If that is true then we will show the hotel inventory to the user immediately from reading from the cache. But in case it
[1:41:52] is not in the cache we have cache miss. This means that we need to read it from the database then we will also populate it after reading in the radis cache so that next time this will be a cache hit and also if we read it from the cache so it was cash hit but the TTL is expired then we basically repeat the same flow so we read it from the database we store it in the cache and then we show it to the user after we show this to the user let’s say the hotel is available user
[1:42:23] can click on booking. So to book the hotel, this is where we will need to validate the booking against database and update the database with the booking transaction. Again, this is where we will invalidate the affected cache keys. If they decide to book it and the booking goes successful then we need to basically invalidate the cache update the fields and on the next user search we will check the radius again and this will be up to date with what just
[1:42:55] happens when this user let’s say books the hotel. So next time we don’t show it as available but we show that this room type is no longer available if it was booked with the previous search. Let’s also discuss the search optimization with elastic search and also a bit more in detail on what’s the use case for elastic search. So elastic search is a search and analytics engine that is a specialized database for specifically search operations and not transactional
[1:43:25] workloads like with PostgrSQL or MySQL. So, PostcarSQL or MySQL are still great with give me the exact row where ID equals to 1 2 3 or insert this into the database where the hotel ID equals to something. But on the other hand, Elastic Search is great at finding complex filters. For example, find all documents that kind of match with this text and rank them by relevance. filter
[1:43:55] by multiple criteria and do it fast across millions of documents. For example, in our case, it will be find all hotel rooms where hotel is located within Georgia. Let’s say the price range is between 80 to 180 bucks per day. The stars are above 8 plus and some more stuff. And again, do it quickly. This is why we use elastic search for
[1:44:26] bookings as well. If you were to just use postgrql for search, this is what we would have to do like select all from hotels where location is like Paris, let’s say, where amenities is like Wi-Fi included, where base price is within 200, where the description contains something like luxury because we have this in the search. Basically every time you use the like keyword, this kills the performance of SQL because that is very slow. The problem here is that like and
[1:44:59] then some text this does full table scan in case of SQL and even indexes won’t help us here because in case of like this is basically a full table scan and you can imagine having so many hotels or room types and we basically have to scan the full table just for getting the hotels available in Paris which is probably less than 10% of the full table. Also, it cannot rank by relevance. So, it cannot get the which
[1:45:29] hotel is most relevant. It cannot handle typos. If a user misses the last S, let’s say from Paris, then again, you need to use the like keyword or otherwise if you set it to exactly party, then you won’t find any results. It is very bad at geographical like distance calculations and also aggregations are expensive in SQL where we are aggregating data together. Instead, if we use elastic search, we have the full text search with relevance
[1:46:02] ranking. So when user let’s say searches luxury hotels in Paris, we first tokenize the luxury hotel and Paris all the keywords. We find all documents that contain these terms. Then we also rank them by relevance by using an algorithm. One of these algorithms that elastic search uses. It can also detect the typos. So if in the Paris you missed the I here, it can detect that user was searching for Paris and edit the
[1:46:33] distance of one. And it’s also great at hotels with ranking. So it can give you the hotels within 5 kilometers of coordinates close to Paris. So maybe you are fine with booking a hotel near Paris in a smaller town with a cheaper price. Elastic search basically has that indexes optimized for this. But how do we basically store this data in elastic search? Do we duplicate or what do we do? And yes, we kind of duplicate the data. So let’s say you have the hotels
[1:47:05] table in SQL. You have the hotel ID, the name, address, and then you have the room types within this hotel that belong to this hotel specifically. You have the amenities for this hotel and anything else. Basically, elastic search stores this in its own way, which means it will convert this into this JSON like format where it has the hotel ID, the name, address, and then it will nest the location and the room types in it. So here as you can see there are no joints.
[1:47:36] It basically condenses everything into one document which will also be able to use to search quickly. And then how do we also keep elastic search in sync with postgrql because our single source of truth is still postgrql. This is where we check whether the room is available or not or whether we can do over booking or anything else basically that you check postsql is the single source of truth. That’s why when we are doing
[1:48:07] transactions we are doing transactions by checking the postgrql and not elastic search. But when we are doing searches we use elastic search in that case. So we can use change data capture CDC to keep elastic search eventually consistent with postrSQL. This is how it works. We have postSQL which is again single source of truth. Then it uses write ahead log to send it to the bases connector which reads this write ahead
[1:48:39] log. It publishes this to Kafka and starts the event stream. Then elastic search consumer will process the events. So we will have some consumer that processes these events and it updates the index in elastic search and makes it eventually consistent with the postgrql database. This is also for crash recovery. So if even the postgrql is crashing then you can read it from the transaction log. You have dbium which is
[1:49:10] an open-source CDC tool. It connects to PostgrSQL and reads the logs in real time. Then it publishes the changes. Whenever changes are happening, it publishes it to a Kafka topic and each table gets its own topic. So then the consumer reads it from the Kafka events from each topic for each table meaning and it updates it with the elastic search. That is overall the use case of elastic search why we use it and how we keep it up to date with our SQL
[1:49:41] database. Next, it will also need database replication this system because we are handling thousand queries per second. So, one option is the sharding which is scaling the scans in the tables and it’s splitting them based on the geographical locations. But you can also scale it for the reads by replicating it. So you can have the primary write database and basically replicate it to a read replica one in different
[1:50:12] availability zone and a different replica in another availability zone. So then the user will connect to the closest available database and the search or the query will be faster. In this case, if we imagine the reservations, let’s say we are quering the reservations for this user with the specific user ID. We can have a variable which detects whether we should use the primary or not. If it’s a recent, write, then we should use the primary because
[1:50:43] the read replica might not be up to date yet. If that’s the case, then we will get the primary connection and read it from this database here. But if it’s not the case, if it’s not a recent write, then we can basically read from the closest replica, we will get the replica connection and run the query against this replica instead of against the primary database. Next, let’s see also the load balancing that we will use here. We can have users from very
[1:51:15] different various locations, right? We can have users from US, from Europe, from Asia, from other continents of the world. So the algorithm for this of course it makes sense to use geol latency algorithm which is based on their region. Of course there might be exceptions. Maybe a US person is looking to travel to Europe. So that is a different story. But in general the users in Europe are looking to book hotels in Europe. The users in US are looking to book hotels in US. So that’s
[1:51:48] the algorithm that makes the most sense here. And the thing that we can replicate here is based on the locations we can have different database replicas which are read only. We can have the cache for radies again in each location because we will probably cach the US region users hotels in these regions radius and it doesn’t make sense to also cach the Europe hotels here. So we will have it in a different replica in a different region and also the elastic
[1:52:20] search because users again are more likely from US to search for hotels within US. So we have all that data in one region which is quickly accessible and similarly we have it for Europe we have it for Asia and any other continents that we support but the primary database stays one here at least even if this is one of the shards let’s say shard 7 this stays as a single database to avoid having conflicts between different regions rewriting to
[1:52:52] the database and then we have to manage the conflict so we can have one primary database. Since the transactions per second are not a lot as we saw in the calculations, we can have one primary chart and multiple replicas and regions for each of these replicas. This is the most in-depth breakdown of designing a messaging app like WhatsApp from scratch that you will be able to find on the internet. Messaging apps like this show up all the time in senior and staff
[1:53:22] level interviews and that is because they force you to handle stateful connections. Think about real-time delivery, consistency and scale of the system, failures of pieces of the system or overall disaster recoveries and multi-device sync in case of users using multiple devices like mobile phones and desktop apps. So in a short interview, you need to show that you understand not just the happy path but also all the things that can go wrong in messages when they are flying around between
[1:53:53] disconnected users and mobile networks. And we are not going to ignore the hard parts and pretend that everything just works without us preventing the failures. So we will start by first building this from scratch. Starting with a simple version that works for a few thousand users. Then we’ll scale it up to handle billions of users sending messages across unreliable mobile networks. And after that, we’ll identify where it breaks and how to fix it properly. At the end, you will also find
[1:54:24] the full set of footnotes explaining every technical concept that I mentioned while designing this. So you can revisit anything and you can go deeper on each topic. Before getting to the high level design and sketching diagrams, we need to start with the requirements to understand what we are building here. So the core requirements that we have is that users need to be able to send and receive messages in oneonone setup. So on top of that, we also have group chats with up to 100 participants that we need
[1:54:56] to support and messages are delivered not only when users are online but also when they are offline. We need to handle that scenario as well. They can attach media files like images, videos or audio clips like voice notes. We need to track the statuses of messages like when they are sent, when they are delivered and when the message is read from the second user. And of course, we need to track the online offline status to show them the last scene date of when the user was
[1:55:27] online. These are the core requirements. When it comes to non-functional requirements, we need to provide low latency delivery. So under 500 milliseconds when users are online. We also need guaranteed message delivery. So of course messages can’t just disappear and not reach to the second user. In the scaling phase, we need to handle billions of users with high throughput. And to keep the storage manageable, we don’t store messages on servers longer than they are necessary.
[1:55:58] So it needs to also stay resilient when individual components fail. Let’s say one of the components fails like the server that needs to push the message to the second user. We need to handle these scenarios as well to make sure that the user eventually receives the message when the server is back online. With that we can do some back of the envelope calculations to understand the scale of the system and then we’ll start with the highle design. Let’s start with user metrics. We have at the scale phase 1 billion registered users and 500 million
[1:56:31] daily active users. During peak hours it will be handling 50 million concurrent connections and on average we will expect to have 10 to 20 messages per user daily. With this we can start the message estimations. For example, for the daily messages, we need to multiply the active users with the average daily messages that will be sent, which is 20 at max. And that will be 10 billion messages sent every day in the system,
[1:57:01] which means 10 billion writes to the database every day. That comes down to 115,000 messages per second on average. So 115,000 rightes to the database. And during peak hours, we can expect to get three to five times more of the traffic than on the average, which will be 350 to 500,000 users rights on average per second. Now let’s also estimate the storage. So each message with metadata,
[1:57:33] let’s just consider it to be 1 kilobytes. It should include the text content, the server and receiver ids, the timestamped when the message was sent and the status flux. Since we have 10 billion messages per day, it will be 10 billion times the 1 kilobyt. And we will be storing 10 terabytes of storage per day for the messages. And on annual basis, this comes down to 3.6 pabytes per year, which is 1,000 terabytes for each pabyte. And the bandwidth will be
[1:58:05] 50 million concurrent connections at the peak traffic time. And each connection will average out to 10 kilobytes per second for active messages which comes down to 500 GB per second of bandwidth. One important thing here is that most messages get delivered and cleared from the server within seconds. So we are not going to store this everything forever. users download the messages to their devices and also back up if they want to for example to Google Drive and then we
[1:58:37] can clean up the server side to not store any unnecessary data. Here in the beginning of requirements we have to set it to be stored for 30-day retention for undelivered messages and maybe let’s say 10% of users keeping history on servers. So this allows WhatsApp to run on minimal infrastructure because they are very efficient about not sending the unnecessary data. Now with this row of calculations we can start with the very high level design of what the system
[1:59:09] will look like. Let’s start simple and add complexity only when we will scale to more users. But for now this is the initial setup. We will have the mobile app which is the client like the user’s phone. It is responsible to maintain a consistent connection with the chat server, handle the UI and manage the local message storage and also retries when failed operations happen. The next piece is the load balancer which sits between the mobile app and the chat
[1:59:40] servers. This will be responsible for routing the incoming traffic between multiple chart servers to handle the peak load and traffic. These chat servers will maintain a websocket connection to the client, persistent two-way communication channel that will stay open and they will route messages between users and track who’s online and handle messages persistence. Then we’ll introduce a message queue which will be for decoupling message writing from
[2:00:10] delivery. And this is also one of the things that will allow us to handle the failures. So when one of the chat servers receives the message, it will immediately acknowledge and tell the mobile app that the message was sent and then it asynchronously pushes the message to the message queue for storage and delivery. And as an example, it is can be either a Kafka message Q or rabbit MQ that will work here. Now this message storage will consume that message that is coming from
[2:00:42] the server from the chat server and persist it to the message storage database as a database solution. NoSQL will work better here something like Cassandra or Dynamo DB because we need high write throughput as you saw in the estimations and our query patterns are mostly straightforward. We will be fetching messages by user by conversation or by time stamp. And since we’re writing billions of messages per day, we need something that can handle the massive write volume without slowing
[2:01:14] down. And that’s why like something like Cassandra or Dynamo DB is the best fit here. Next, we’ll need also some user connection cache, something like radius, which will be tracking which users are online, which chat server they are connected to, and their last activity time stamp. This will help these chart servers to make the routing decisions faster because checking this ready storage takes something like microsconds versus quering it from the database which will take milliseconds and at
[2:01:46] scale that difference really starts to show up. In the requirements we also have the requirement to store media files like messages with voice notes or with images. For that we will use some sort of globe storage. This can be like the AWS S3 or Google cloud storage and this will be storing the media files and hold the actual files. But then we will also distribute this to a CDN network which will catch the popular files at age locations worldwide. So that
[2:02:18] downloads are fast regardless of where the users are because one user might be in US and the second user might be in Europe. And in this case you will have the same copy of the file in Europe region as well. And for the Europe user he will download it from the CDN which is closer to his location. Next we need also a notification service. This is mainly for the offline users which need to receive the notifications either through Apple push notification service
[2:02:48] or Firebase cloud messaging for Android. And when someone messages you while you are offline like let’s say user A messages to user B and then user B is offline at the moment they will receive instead the push notification and then later they can read the message when they open the app. And lastly, we need also a dedicated presence service which will be used for managing online and offline statuses of the users. Let’s say user A again is online and user B is
[2:03:21] offline. This presence server will receive the heartbeats from users updates from the radius and also publish the presence changes to the interested subscribers like the user A which is interested whether the user B is online or offline. Now let’s choose the communication method between the mobile app and the services the chat servers. We have three options here. Polling, long polling, and websocket communication. Of course, most messaging apps use websocket for communication.
[2:03:53] But let’s understand why is that the case by looking at their pros and cons. Let’s start with the polling option. Let’s say you have the client on this side and then chat server on that side. with the regular polling option. This is how it works. We will do periodic polling from the front end to back end asking whether they have any new messages or not with some API endpoint. And then let’s say we don’t have any new messages. So this will be an empty response saying that there are no new messages in the server. And then from
[2:04:26] sometime again we will also need to query the same thing and ask whether there are any new messages. And then let’s say for the second time there are new messages. So they will receive the messages that they received. This as you can see wastes a lot of bandwidth and adds latency because if you’re checking every 2 seconds and the message arrives right after you check, you wait 2 seconds to see it while you could get it instantly. And if you also multiply that by the millions of users constantly asking for updates even when there is
[2:04:58] nothing new in the chat server, you’re burning a lot of resources for no reason. That’s why one better approach than this is the long polling option. In this case, we wait for the new messages or timeout. So let’s say we request the new messages from the server and then the chat server will wait for some time until the timeout. If there are any new messages, it will push them back to the client. And if there are no new messages, then after some time out, it will push the response, which will be
[2:05:29] again an empty response saying that there are no new messages in the chat. And if there are no new messages, then this needs to again query to the chat server and then basically this process occurs again. This is still problematic because you’re constantly opening and closing connections and each message requires a full HTTP handshake. At scale, this is again an overhead because your chat servers spend more time managing connection life cycles than actually delivering messages. Plus there will be some idle HTTP connections like
[2:06:01] in this one where they are waiting for the response and they will consume the server resources like memory for connection state and thread pool slots without the efficiency benefits of websockets. That’s why most of the chat services work with websocket communication. This is one persistent birectional connection between the chat server and between the mobile app. So client and server can both push data at any time. First client will initiate the
[2:06:32] HTTP connection with the server and then they will perform the web socket handshake and after this point the connection is established and it is persistent throughout the session. This way let’s say user A sends a message to user B which is this user server is able to independently push the new messages to the user B which received the message. So push the new messages as soon as they are available and also the client which is the user B is able to
[2:07:03] send message or acknowledge any time without making a new connection and this connection is persistent and allows real time birectional updates both from the chat server side and from the client side. The trade-offs of websockets is that it requires special load balancers that support them. So, we’ll need to use layer 4 load balancing instead of layer 7. They also use more memory per connection on the server side because you’re keeping connections open, but it’s still worth it because of the
[2:07:35] real-time messaging and the latency of the message requirements. Next, let’s see how we are going to manage the chat servers which are routed through the load balancer. In production and in reality, chat servers come and go. they will crash, get deployed again, scale up and down. And how does the system keep track of what’s available? So the problem here is that the load balancer needs some way to know which servers are available like chat server one and two are available at
[2:08:06] this moment and chat server 3 is unhealthy. Let’s say it either throws timeouts or it’s throwing errors on requests. So one solution that we can use here is service discovery cluster like a service registry similar to console or AWS cloud map and here is how it will work. Whenever these chat servers start, let’s say this server one boots up on some IP address, it will register itself in this service discovery cluster and after that we will
[2:08:37] do health checks every 10 seconds with this server one to see if the server is up and running. And then similarly we’ll do that for server two and server three. And now let’s say the load balancer needs the list of available servers. It will first query the healthy servers from the service discovery cluster and then the cluster will send the server list to the load balancer. With this new list, let’s say we forwarded only the IP address of the chat server one and two.
[2:09:09] So the load balancer will be able to update the routing table and then route all the traffic only to server one and to server two and it will know that server 3 is removed from the routing. So we are not making any requests to the server free. That’s how we will manage the load balancing and traffic routing. Now let’s move on to the data model and designing the data tables that we will have the primary ones. We will first need a users table and this one can be
[2:09:39] actually stored in a SQL database like PostgresSQL or MySQL. So the user will have an ID which will be the primary key. Then a phone number which needs to be unique for all users. Then a username created at which is a time stamp and last scene which we will use for controlling and showing to the other users when this user was last online. And of course more fields will be here but these are let’s say the primary ones that we will need. The next table will
[2:10:10] be messages which is the table in Cassandra actually where we will be storing messages since this is a high volume database. We will be writing and reading a lot of messages from. We will need the sender ID here which is the connection with the user ID from the users table. We will need the receiver ID which is again a connection to the same field in the users and a message ID which will be the primary key for this table. Apart from that any other things
[2:10:41] that relate with the message like the content which is the text media URL if we are storing any voice notes or any image video files. The status of the message which will be either sent, delivered or read. the time stamp of when this message was delivered and whether it’s deleted or not because they are also able to delete messages on WhatsApp. For managing the groups on WhatsApp, we will also need groups and group members tables and this can be again in the postgrql database. For the
[2:11:14] groups, we will need ID for each group, which will be the primary key, which we will use for identifying the group, the name of the group, when it was created, and who’s the user that created this group. And then this is a one to many connection with the groups and group members where we will be adding new members. And this is the ID of the group where they are added, the role of each member when they joined and the user ID which links to the users table user ID
[2:11:45] and that’s the primary key of users. Apart from this, one more important thing is to have user connections registry where let’s say this can be stored in a cache like radius. This will store the user ids and the server ID where they are connected to also the connection ID. if it is open, last active time and device type and we can add this to the radius cache and then update every let’s say 300 seconds to refresh it with each heartbeat. Once we
[2:12:17] have the database ro schema, we can move on to the API design specifically for webocket and rest API flows for the messaging app. The first step from the client will be to authenticate with the server by sending the user ID, the token and device information. If it is successful, the server will respond with off success, sending the session ID, the server time and the onlet count. After that point, the client can start sending and receiving messages. Let’s say it
[2:12:48] sends a message to the receiver. So it will send the information about who’s the receiver, the user ID, the content, the media if there are any and the time stamp of the message. And at this point the websocket connection is already established. So there is a birectional communication which means that server is also able to push messages or acknowledgements to the client. So it will acknowledge for example when the message is received. It will also deliver any new messages that there are
[2:13:19] from other users like the message ID, the sender, the content and the media files. And then client will be able to also acknowledge when they receive the messages from the server. The receiving is one part. Maybe they received but haven’t opened the message. Once they open the message, they will also send the read received with message ID and the time stamp when that was read. Whenever let’s say the message was read, the server needs to also update the read received with the message ID and when it
[2:13:50] was read so that the sender who’s on the other side also knows when the message was read. Also the client and server need to maintain the presence heartbeat between each other every let’s say five or 10 seconds. So client will check whether the server is still available and similarly the server needs to check whether the client is available because maybe you close the app on your phone or you close the browser tab of WhatsApp and in this case the connection needs to be closed to not waste the bandwidth.
[2:14:22] For uploading the media files we will handle this through a rest API. For example, for uploading a file, we will use a post endpoint something like / API/ media where we will send the file type, the size of the file and also of course the file and conversation where this file is uploaded to. So this will be sent to rest API which will be responsible for handling the media uploads. And then on the other side we have also the media storage where this
[2:14:54] will be stored. So server will respond with the upload URL the media ID and when this expires and after that the client will be able to send the upload file to the pre-signed URL for the media storage and once upload is successful the media storage will just respond with the upload success and the client will acknowledge it and as a last step let’s say it included the media file to be uploaded the server will just acknowledge that the message was
[2:15:25] delivered or if there was any errors while uploading the file or sending the message, it will send the message delivery failed response with the error message in the body. Now let’s cover the multi-device sync. Let’s say the user A can have three devices and user B can have two devices. How are we going to sync messages between devices as well? We have only this side user A’s phone, user A’s tablet and user A’s desktop. These are the three devices for user A.
[2:15:56] And on the other side we have user B’s phone and user B’s laptop. So first thing let’s say first interaction that happens is user A sends a message to the chat server A. And then chat server will acknowledge and display the status as sent for this first message. Since we agreed that we are going to store the statuses of users availability in a radius cache. So the chat server A will then ask where is the user B because
[2:16:27] let’s say this message was sent to the user B and then radius responds that user B has two devices and it is connected on server B and server E each of these devices the other two devices of first user will be connected let’s say one of them is connected to chart server C and the second one is to chart server D. So the once the chat server stores the send message, it will also display through the chat server C which
[2:16:58] is connected to user A’s tablet. It will display the sent message and the chat server D will display the sent message for user A’s desktop. This is how we will sync the same message between three devices of first user. Now let’s see how these messages are sent to the user B and synced with the two devices that the second user has. So user B’s phone is connected to chat server B and user B’s laptop is connected to chat server E.
[2:17:29] The chat server B will read the message from database and deliver it to the phone. And this chat server will deliver the same message to the laptop. And let’s say user B checks the message from the phone. So it will deliver the acknowledgement to chat server B which sent the message to the phone after which the chat server B will mark this as delivered for the chat server A which then it will be able to send to the first user’s mobile device and update
[2:18:01] the status to delivered and next action that will happen is user B will read the message. So once it reads the message, we will send the read receipt to the chat server B and then this chat server B will again forward this to the chat server A with the message red status in this case and then the chat server A will update the status and now it will display as red and since this is red now we need to also sync that between the user B’s devices. So the chat server B
[2:18:32] will mark it as red also on user B’s laptop which is here. So this way the user A’s tablet, mobile phone and desktop and also user B’s phone and laptop always stay synced with the latest data whether the message was sent whether it was delivered or it was read. And in case there are conflicts between multiple devices, we will use the last right wins technique with version numbers to prevent the conflicts. And of course this is not the ideal solution.
[2:19:04] First of all, we need to apply optimization here. For example, this can be incremental sync because sending the entire message history to a new device is expensive. Let’s say we are syncing with this chat server B and user’s phone and also user’s laptop with chat server E. We don’t need to send the whole history in the device. So instead the device can request messages for last 30 days like most users only need the recent history and older messages are
[2:19:36] fetched on demand when needed. When they scroll up the user scrolls up and then the old messages will start to load and very old messages will be archived in cold storage and they will be loaded lazily. Adding this multi-device sync comes also with a trade-off because we need to support roughly double of the delivery workload. Each message goes to multiple devices per users. But that is an important feature of chat messaging app like WhatsApp because users demand
[2:20:08] the same messages the same chats to be synced between their mobile phones and between their desktop apps. Now let’s see the core flows on how messages actually move in case of users both being online and sometimes one of the users might be offline. So let’s start with both online version. In this case user A let’s say sends the message to user B and it’s connected with the chat server A. Message goes through web socket and reaches to this chat server.
[2:20:41] Now this chat server will generate the message ID which is a unique ID timestamp based for this message and then it will send the acknowledgement with status of the message being sent. Then this server will push the message to the message Q for persistence. After that we need to check where user B is and which server it’s connected to so that we can send forward this message to user B and we can check that from the
[2:21:12] radius cache where we have the user connection cache. We will read where the user B is from this cache and then let’s say it responds that user B is on server B and once we have that information the server A can route message to server B via Rady’s popsup model and on this side we have the server B and user B connected with web soocket. So server B will now deliver message in real time and user B’s device will acknowledge the
[2:21:43] delivery. Next, we need to let the first server know that the message was forwarded and delivered. So, server B will make a request to the server Acknowledging the forward delivery. And then we can update the status with delivered instead of send. And then whenever user B opens the message, the same process will repeat and we will now mark the message as read instead. And optionally we can also incue the message for analytics or
[2:22:14] archival by using async processing between server A and between the message Q. And meanwhile the message Q will be writing the message to the message database like Cassandra for storing the history. Now let’s see this same flow but in case when the user B is offline which makes it a bit harder to manage but still we need to make sure that the message is received and we also notify the user B when they received even when
[2:22:45] they were offline. So the initial steps are the same for this situation. User A sends the message to user B through chat server A and then we check whether the user B is online from the radius cache and in case the user B is offline which is our situation here we will add the message to user B’s inbox with radius and then we will store this in inbox table in the database inbox and after
[2:23:16] that this chat server will connect with the notification server. service that we have here and send push notifications for user B. Once the notification service receives the message, it also triggers push notification through Apple’s or Google’s notification system and then we incue the message for further processing to the message queue and after that message Q will persist this into the database and once the user B comes back online let’s say they open
[2:23:48] the app and establish the websocket connection with chat server C we will forward the message for delivery to the chat server C which has the websocket connection with the user B and after that we will deliver the message in real time to the user B’s device in case user B was online during this time we will just forward the message for delivery to the chat server where the user B is connected let’s say it’s the chat server C but if user B was
[2:24:19] offline first and now they become available this is the flow that we will have. So let’s say user B comes online after being offline for a while. We already sent the push notification and they connect to one of the chat servers like chat server C. First thing we will do from this server is we will retrieve the pending messages from the inbox from radius and if there are any messages that are pending we will retrieve them any missed messages from the database
[2:24:51] inbox and we will deliver the pending messages to user B and similar to the previous flow we will also notify the user A that the message was read. Next, we need to design this for group messages as well and see how we can handle 50 to 100 person groups in WhatsApp. This is how it will look like at a high level. Let’s say we have multiple users in the same group chat. We will first of all need a group service which will be managing these
[2:25:21] groups. And as an example, let’s say user A is part of this group and we have another 50 to 100 persons in this group. First flow is when user A sends a message to the group. Let’s say for each group we have ID as we described in the data model. This gets sent to the chart server where they are connected. And then the chat server first request it will make to the group service is to check who’s in this group which is ID of
[2:25:53] 789 as an example and it will retrieve the list of users like user ids who are in this group part of this group and after that we can check the status of these 50 users we can use radius to cach the status of users to not waste the database queries for reading this and let’s Say we check the status of the 50 users and find out that 30 of them are online and the rest the 20 other users are offline. For each online user, we
[2:26:26] need to look up which chat server they are on. So we will publish to channel user one, user two to user 30 to the radius popsup model and then this will parallelly deliver to online users. Whoever is online, they will receive their messages in their own chat server where they are connected. Let’s say 15 of the users are connected to server B and the other 15 are connected to server C. We will in parallel distribute the
[2:26:57] message to both servers and then these servers will distribute them via websockets. So it will deliver via websocket to the 15 users from server B and to the other 15 users from server C. For each of these deliveries, they will acknowledge from user’s device that the message was delivered. And same for the other 15 who are connected to the other server. And then we will forward these acknowledgements to the server A which
[2:27:29] originally sent the message so that it can update the status of the message to send or delivered in the user A’s device. For the other 20 users who are offline, we need to use the same thing generally that we did for the offline users in oneonone connection. We’ll need a radius inbox where we will write the messages that they need to receive which is let’s say these other 20 users who were offline. We will write it to user
[2:27:59] inboxes and we will write to 20 inbox records in the database inbox. And for all of them we need to send the push notification either through Apple’s or Google’s push notification system to notify them through notification service that the message was sent in the group and then the notification service will push this through either Apple’s or Google’s notification system. So this way we are just storing message once. So
[2:28:29] we are doing one write operation from chat server A which received the message from the user to the message Q and then message Q writes this to the database. So eventually it is just one write operation per message and lastly we will also update the status that it was delivered to 30 and it is pending to 20 users for the user a device. Now, so far we haven’t covered how we are going to manage the media files, the image assets, the videos. So, let’s cover that
[2:29:01] next. Of course, the wrong way here will be to clients upload the image to the chat server and then chat server store it in database and then recipient downloads from chat server. This is of course not scalable. Your chat servers are now bandwidth limited by file transfers and they are not doing what they are good at which is routing messages quickly and instead they are doing what they are terrible at which is moving large blobs around. So the right approach is using pre-signed URLs and
[2:29:33] for all these technical terms like pre-signed URLs notification services Apple’s notification service these kind of things will be included in the footnotes. If you’re not sure what they are, feel free to check it there. So, let’s look at the correct approach for uploading the media files. Let’s start with the upload flow. Let’s say we have user A who’s the sender of the file on this site and we have user A’s device which is the app and we have user B who will be receiving the file. So user A
[2:30:03] taps to send a message and this is where the application will request pre-signed upload URL from the chat server which they are connected to. Let’s say this is the server A. At this step the chat server will generate pre-signed upload URL to upload this into some blob storage like AWS S3. The blob storage will return the upload URL to the chat server and then we will forward this URL to user ace application. Once they have this upload URL, the user ace
[2:30:35] application can do a direct upload. It can upload the media using the pre-signed URL we just got from the server and this will upload it directly to the S3 bucket and then S3 will confirm that upload and return the media URL for accessing it later. And in the next step, we can already send that chat message including the media file URL to the chat server A which we are connected to. And this is where we need to notify the user B that they received the
[2:31:05] message and also include this media file URL. So we will deliver the message with media URL to user B’s application. So first user B will receive the notification. Next they will connect to the chat server. Let’s say this is the chat server B that they are connected to with via web soockets. So they will receive the messages along with the media file URL. And now then they tap to view the media. This is where we will request the pre-signed download URL. And in this
[2:31:38] case we will generate the pre-signed download URL from the chat server B with the blob storage that we have on S3. And then S3 will return the download URL to this chat server B which we can now forward to the user P application for it to open and review the media file. And then here we have two approaches for downloading and reading the media file. The more scalable approach is via CDN. So we can replicate the media files across CDNs to be closer to each user.
[2:32:11] And in this case we will download the media file through CDN. So this is the user application and this is the CDN and they will read it from there and stream the media back to the user application and the simplest approach is to just read it directly from the storage. So the user will request to download the media file directly from the S3 bucket. It will stream back the media. And of course here we have a trade-off between directly reading from the storage or from CDN. We have slightly more complex
[2:32:42] flow with CDNs because we need to do extra requests to get the pre-signed URL. But the performance and cost benefits here are massive for this scale. So CDN is most likely the best solution and option to go with because without this a single chat server handling 100,000 let’s say connections can get crushed by just 100 users simultaneously uploading some small megabyte files into the blob storage. So with this setup, the chat servers like
[2:33:14] the server A only handles the small text and messages and whenever we need to upload some heavy files like videos, images or voice notes, we will offload that heavy bandwidth to some blob storage or to some CDN and we will use pre-signed URLs which will expire after the TTL that we will set and then the media TTL will align also with the retention policy that we have in the chat servers. So with this setup, chat servers only route small messages with URLs and blob
[2:33:46] storage and CDNs will handle the heavy bandwidth and our chat servers stay fast and focused on their job which is delivering the messages and media files get the TTL which stands for time to live with automatic expiration of let’s say 30 days which will match the message retention and then they will get automatically deleted. Now let’s get into how we would scale this because our chat services now can handle thousands of users concurrently. But if you want
[2:34:19] to scale it even further, we need to think of a way to decouple our servers. And the most common approach here is using some sort of message Q like a Kafka. For our setup, we can have message Q on this side and then we can assign partitions to each instance. Over here we have the message storage service instances. Let’s say we scale it up to two, three or even more instances. Since this is like a stateless instance, we can scale it to as much as many as
[2:34:50] needed. So we can assign partitions to each of these instances. And as an example like this can be from 0 to 9, this can be 10 to 19. This can be 20 to 29 partitions that we will assign to each of these instances here. And the way it will work is we will have messages that arrive to this message queue and then each instance will consume these messages from Kafka. We can even do batch rights for efficiency and then we will handle retries and
[2:35:21] failures inside of an instance. So first we have the Kafka consumer in each of these instances. We will try to use batch writing to write this to the database cluster. If for any reason this fails, we will have a retry logic inside of each of these clusters and then we will retry to budge right after some time out and write this to the database cluster. And if for any reason this still fails, we make sure that we don’t delete the message from the message Q so
[2:35:53] that we don’t end up losing the messages or we can use something which is called dead letter Q. These are cues that you can use separate from the main queue. So whenever some of the messages here fails to be processed from the instance, it will instead be stored in this dead letter Q and then we can have some other mechanisms here for processing it later inside of the same instance and writing it to the database. And then once a database write is successful, we need to
[2:36:23] also update the indexes. So inside of each instance we will have the index updater which will update the indexing in the database for searching later for querying them effectively. But let’s look at a more detailed flow of how the processing will work from message cues. So again on this left side we have the message queue which receives all the messages that arrive from user devices and then we can set some batch size like 500 messages for each batch and we will
[2:36:54] consume this batch inside of one instance. We will have the Kafka consumer. This consumer can poll for new messages every let’s say 100 milliseconds so that it has time to process the previous ones and then it will process the batch in parallel. Let’s say we can have thread pool of 20 workers and we can process this in parallel. For each of these single message, we will validate the schema first of all and then do meta data
[2:37:24] checks to check the size of the file. If they uploaded some media file that exceeds the limit, we will need to respond with an error message. And otherwise, if it passes the criterias, we will validate and enrich the schema. Here is where we can calculate the message hash for dduplication. Extract the mentions like if they mentioned some username right in the WhatsApp you can mention someone with at symbol for example. So if they mention that we need
[2:37:54] to extract those mentions from the message content. We need to identify the links for preview generation because on WhatsApp you also generate the links if they attached anything to the message. And this is also where we need to generate the indexes for querying them later. And next, instead of writing all these messages one by one, we can cue the validated messages into a batch writer, we can accumulate some batch of messages in the memory like 1,000
[2:38:26] messages every 5 seconds. And whenever this buffer hits the 1,000 limit, we will start sorting messages by the target chart. We will group messages by conversation and then we will prepare the botch for inserting into the database. And the reason we are doing this is to save write operations in the database. Of course, if we can budge thousand messages and then try to budge write them into the database. So we will still prepare insert statements for each
[2:38:57] of those that cannot be inserted in one go. But at least that saves the database sometimes to process the current batch and then after the 5 seconds we will send it the next batch. So the first happy path is that it will be inserted with atomic batch insert into the database cluster. We will also update the search and analytics and indexes via index updater and then we will mark them as processed if everything goes well. But in case this fails, we will have a
[2:39:28] failure and then we will have the retry and dead letter Q handler. So if these messages or one of these messages, let’s say out of this thousand, we have 10 of them which failed to be processed, we will write them into the dead letter Q. And before writing it to dead letter Q, you can have some simple logic that tries to retry. Maybe that was a rate limit we hit or something else simple that we can just retry. If after that point it fails, we write it to the dead
[2:39:59] letter Q. And then here we will have more mechanisms like step one, step two, two, step something like 10, which are the ways we will try retry to insert this into the database before sending the user a notification that this message was not delivered because of some reason. So the main benefit that we get with dispatching is a single database insert takes 5 milliseconds. If we are inserting something here, it usually takes 5 milliseconds. And all of
[2:40:32] these thousand individual inserts will be equal to 5 seconds instead. 1 millisecond. 1 second is 1,000 milliseconds. But if we do one batched insert of thousand messages, it will instead end up costing us 50 milliseconds which is actually 100x throughput improvement and it will start to show up when we are starting to write millions of messages concurrently to the database. And if you’d like to see this
[2:41:02] at the code level as an example, this is how we will write to Cassandra database with the message chart. So this will be a transaction. We will begin the batch. We will insert the messages into the messages table and we will pass the values like the timestamp. the ID of the sender and receiver which are the user ids then the content then anything else like media files and then similarly we will do all the other inserts of other messages into one batch and then this
[2:41:35] will be like one batch of writing 1,000 messages into the messages table in Cassandra database the next important feature of WhatsApp that you might be asked to design is to add end to end encryption and key management. Typically on WhatsApp, messages are encrypted on the sender’s device and only decrypted on the recipient’s device. Even WhatsApp servers can’t read the content. So if you have user A and user B and you’re
[2:42:05] sending some messages between each other, even the user that is in the middle or the user that is connected with the chat server A and then the user B which is connected to chat server B. Even this A and B servers cannot read the message. So how we are going to design something like that in messaging app? The thing we can use here is a signal protocol. So we can add end to end encryption. And this is how it will work in detail. Whenever user A sends a message to user B, we upload the public
[2:42:38] identity token into the key server. So each user generates an identity key pair on registration on WhatsApp and then they have the public key uploaded to the server and private key which never leaves the device. So you have private key which is on user A’s device and you will have the public key which server has access to and then on the other side we have the user B. So user B will again upload the public identity key to the
[2:43:09] key server plus they will upload prekeys. These are generated let’s say 100 signed prekeys and it uploads them to the server. Server stores them for future initiators. And whenever user A sends a message, we will also fetch the user B’s keys which includes their identity key plus one of these prekeys that they stored. Let’s say they stored something like 100 keys here. We will fetch one of these for user A. And then once the key server returns the user B’s
[2:43:40] keys to user A, this is where the session will be established. So user A fetches the user B’s public identity key and prekey from the server. It generates the key pair which will match for both user A and B and then it will perform the key exchange. After that both user A and B have access to the same keys. So whenever they are sending messages between each other, all of the messages will be encrypted with user A’s public key and then once user B receives the
[2:44:11] message will decrypt the message by using the user A’s public key. So each of these messages will be encrypted with some of the algorithms and then we will have the message the session key which is exchanged between these users and even further we can use something like message counter which will increase after each back and forth messages between these user A and B. So this way it’s very hard even if the session key is stolen. It will be still very hard to
[2:44:42] decrypt each of the messages because all for each message we are also adding the counter for encrypting it and then we are decryting it on the other users end. So this is how we will ensure the privacy between user A and B and even the chat servers we won’t be able to read the messages in between. Now let’s move on to security practices. But in terms of the overall system, let’s start from rate limiting and abuse prevention. Without having any rate limits, any
[2:45:13] hacker can start scraping data or perform DDOS to the system. So we need to build a protection in any kind of system like this. First of all, we have the user actions here which can be messages sent from the user or files uploaded to the media storage. For every action type, we need to have some protection. And these are the per user limits. For example, that we can send whenever users are sending messages or they are creating groups or they are
[2:45:44] uploading media. So whenever they are sending a message, we can have a limit for sending messages. We need to check first the message token bucket. And here we can have the rules set. For example, these can be 100 messages per minute that a user can sense. And anything above that doesn’t seem reasonable. So we can apply rate limit for that situation. And if the rate limit is exceeded, we will block the action. So we will notify the user and show an
[2:46:15] error message that they exceeded the limit. And this can be something like too many requests in one minute try again from 60 seconds for example. Or we can require some capture to be solved. So these boxes that they need to check and let’s say pick which ones are motorcycles to verify that they are human. And then we can also have progressive penalty. So the first one is the warning that we show which is too many requests. If they start abusing the
[2:46:45] service again like we will have restriction for one hours. This is how you see for example in passwords where you’re trying to type your password for an account and you fail multiple times. They will first restrict you from for some time, right? The second time they might restrict you even more like 24 hours. And then the next step after that is we will suspect the account and we will deactivate it for some time for a human to manually review their account and verify whether this was a bot or
[2:47:17] this was a hackers trying to steal information or it might be still the user who just didn’t understand the requirements and they kept abusing the service. So we need to contact them, let them know the issue and then they will reopen the account. Of course with the same rate limits applied and they might even get a tag on their user that this account is suspicious. So if anything happens one more time their account might be permanently deleted from
[2:47:47] WhatsApp. And now this same process repeats for all the other actions as well. Let’s say users can create group as well. Right? So once they create a group, we will check the group crate counter. And this is where we need to set reasonable limits. For example, if they are creating five or more groups per hour, that might seem too much. So we can apply a rate limit for that situation. Or if they upload media and they upload more than let’s say 10 or 100 media files per minute, this can be
[2:48:19] another restriction. Of course, these numbers are just examples and these are some things that will be clarified in the blueprinting phase. But for each of these actions, you need to have some rate limit. And then the process is usually identical for enforcement. So we need to check if the rate limit is exceeded. We will block it. Otherwise, it will work as expected. So we will allow and then we will continue with writing to the database whether it’s a media file or it’s a message or it’s creating a group. This is an example
[2:48:51] code block like how this can be implemented the rate limiter. So we will initialize with the capacity and with the refill rate which can be different for each action and then we can have something like tokens which show the capacity. So for every request we will check whether they exceeded the tokens or they are still under the limit. So it can be some unit like one and if this is more than one which means they haven’t received the rate limit we will return true and this will not really process
[2:49:23] with other actions like storing the message to the database and then we will deduct one from each step. So if they try this next time this will be zero of for the tokens. So that will end up not getting into this if blocks and then instead they will get into the else block and we will return false because they exceeded the rate limits and this will block them. This is the step here. So we will block them for processing the message or creating the group and we will notify them and alternatively we
[2:49:56] can also ask them to solve a capture problem and then for that we will set a limit right for let’s say 1 hour. So this is where we will have to refill. We will check the time that we have now. We will set the last refill time and then based on this we will calculate when is the next time they can repeat the same action which can be from 1 hour from now. The next action type that we need to limit is connections because we can have connection flow the tax and let’s say we get five connection attempts in
[2:50:27] the single minute for different IP addresses. This is where we need to check their IP connection counter and again apply the rate limit for this specific situation. So if within a minute they have tried to login from five different IP addresses that is something that looks suspicious. So this is again where we need to apply the rate limiting and it will follow the same process as with the other actions. Another example is we can have group invite limits. So if they invite users,
[2:51:00] they create a group and then the user starts inviting other users to the group. We will check the group invite counter in this case. And similar to all the other actions, it will follow the same process here. Just the counter and the limit is different for each of the actions. So for this to work, we need to also have some centralized store for all servers because each of the users is connected to one server, right? with a websocket connection. So all of them need to use some distributed central
[2:51:32] store something like radius because this is something we keep up to let’s say 24 hours in the memory and that is in the case they get restricted with the most attempts or we can still use database for keeping this data but if it’s something we are going to keep up to 24 hours then radius might be a good option here and then our atomic operations will ensure that we have the right accuracy And there is no way to bypass the server hopping by connecting to other server
[2:52:04] and wishing that it will work and the rate limit isn’t applied or that server. Next, we need to scale the database as well. Of course, with this scale now we scaled the servers and applied rate limiting. But the database a single database cannot handle all this traffic. So we need to split data across multiple database instances which is also called sharding. When it comes to sharding, one of the important decisions is on which field we are going to shard by for the
[2:52:35] databases. So one approach can be to shard it based on user ID. So this way we will make sure that all the rights all the messages for user A end up in one shard in one database. So that whenever we are fetching messages, we are fetching only from one database and we don’t have to connect to multiple shards to read the messages for the same user. The catch here is that individual users can become hotspots because some users naturally use it way more often
[2:53:06] than others. And this way these users will start creating big databases, big tables of messages where the users who use it less often will create smaller charts. So this will create an imbalanced sharding in this case. Another option is to shard it by message ids. This will create even distribution of data. But fetching all messages for a user will require quering multiple shards because there will be in different shards. So your query will hit
[2:53:37] every chart, aggregate the results and then return them to the server. So the downside in this case is it will become slow and expensive compared to the user ID. And the downside with the user ID is that we will have imbalanced shards. So one shard might be very small compared to the other shard which uses users who are using it very often. So the main goal also for these interviews is not to just choose one of these but just tell them the different approaches that there
[2:54:09] are and what is the best solution. after all or what is the solution you will go with? So one of the good approaches here it can be sharding still by user ID but applying replication. So let’s say user A is sharded to this shard A database. All of the user A’s conversations will be stored in the shard A. But if he talks to user B who’s connected to shard B, we will also
[2:54:39] duplicate the rights. So we will write the copy of each user’s shard into the shard B as well. And of course this comes with a downside. This doubles the right volume. But on the other hand, it makes the reads blazingly fast. Each user will query only their own shard. User B will query it from shard B and user A will query it from shard A. And we can even replicate each of these shards. So shard B will be replicated into B replica and shard A will be
[2:55:11] replicated into A replica and user A will now read this history of messages only from the replica A and user B will read it from replica B. they won’t affect the shards the primary ones which are responsible for write operations and instead they will be reading here. So it will increase the read performance a lot. The trade-off here is that we are writing each message twice. So that’s why it’s also important to clarify such
[2:55:41] things in the beginning in the requirements phase. You need to see this in advance like later we are going to optimize the messages either for write operations or for read operations and which is the more important and if you still failed to clarify that at the beginning this is still a good place to ask instead of trying to guess. So ask them whether reading is the more important in this situation or writing and then based on that you will either replicate or you will go with message ID sharding or you can even go with
[2:56:12] timestamp ID sharding. There are different approaches here and the main thing here is to understand the tradeoffs between each approach. So typically when it comes to keeping the data available we usually shard for right scalability. So if you’re applying sharding which means splitting the database into smaller chunks this is improving the right performance and when we apply replication this is for improving the read scalability. So if you just replicate one of the shards
[2:56:43] this is usually for the read performance. So once you decide on the sharding strategy and let’s say you decided to go with user ID sharding all the client rights will arrive to the shard to the primary node for each of the shards. Let’s say user ids from 1 to 1,000 are stored into the shard one. This will be the primary node and then we can basically replicate this and use
[2:57:15] asynchronous syncing between the primary node and replicas. We can have three replicas and they will be eventually consistent with the primary node by reading and duplicating the data into the replicas. And then whenever we have read operations like opening the messages and seeing the unread messages, this is where we will need to perform read queries. And for read queries, we will only use the replicas. We won’t be connecting to the primary node at all.
[2:57:45] We will connect here only when we have write messages which is someone writing to the user A or user A writing to someone else. Now next for such big systems where we are storing too much media files, too many messages, you need some cleanup way, some way to clean up the garbage data that is never read again. So this can be expired media, old undelivered messages or stale connections. We need to build a cleanup
[2:58:16] infrastructure to run some chrome job over time and try to clean the expired medias and stuff. So you need to have some job scheduler something like airflow or some chrome job that will be scheduled every either daily basis or weekly basis and this needs to check the job status from monitoring. Then on this side we have a worker pool which is containerized job executors. So this job queue that you see here this is separate from message Q. This is for
[2:58:48] incuing scheduled tasks and pulling the jobs to the worker pool for processing. Then we will acknowledge that we received the message or if we failed then we will recue it on failure and then we will start executing the tasks in this worker pool which can be cleaning up expired media by checking the date that they were published or the undelivered messages. These can be messages that we failed to write to the database but we still have them somewhere in the memory. So we can
[2:59:19] remove this or stale connections that we have and any other kind of data that we don’t need anymore. This is what the responsibility of this worker pool will be to delete all the data and then submit the job results to the monitoring so that we can see over time how well it is doing and if there are any improvements here as well because this is not something that you will set up once and then forget. This is also something that will be improved over time. So the trade-off here is that
[2:59:49] these scheduled jobs will add operational complexity because it is another big thing to monitor and debug. But the alternative to this is or the other tradeoff is to let the garbage accumulate until the system becomes so big that you have to do this either way. So regular cleanup like this is essential. And for long-term health, this is important to set up some worker pool that will start cleaning up the junk data that is not used. That’s the general flaw of designing such large
[3:00:22] system like WhatsApp. But you won’t usually end there because that is just the happy path of all services work together, everyone is happy, customers are happy, right? But that’s never the case in reality. So there can be failures in every part of the system. And one of the major things that you can discuss for example for a messaging app is cache failure. For example, what happens to undelivered messages because radius holds our undelivered messages
[3:00:52] inboxes and what if the radius cluster goes down itself. Let’s start with the problem example. Let’s say user A sends a message and they are connected to chat server. So this is where they established the veet communication both ways. Now chat server will first write the undelivered messages to the radius inbox which is the fastest compared to the database and then the second step is we don’t only write to the ready store right we also write the backup which is
[3:01:23] more durable to the database so this is what our permanent storage is in case the radius cluster goes down. So we will first try to fetch the pending messages from radius for the chat server to forward it. But in case this is unavailable let’s say radius goes down. We need to fall back to the database instead. So in this case if radius is empty or it’s unavailable then we need to fall back and read from the database. So we need to retrieve the undelivered
[3:01:54] message from the database and then forward it to the server. And either way, we need to at the end be able to deliver the message to user B even if they are offline and then they come back online and fetch the messages from the database in case the radius cache was down. And whenever the radius recovers and comes back online, we will first have a background job that will sync the inbox from database to radius. And then we will query something like select all
[3:02:24] from messages inbox where the delivered is null which is not set in inbox. And then for each undelivered message we will write to radius inbox and then we will keep the database record as well. So after that we will resume to the normal delivery which is writing to radius and then reading from radius again. So to visualize this at a code level this is for example what the write with fallback will look like. So we will have some function like store undelivered
[3:02:55] message which will be the user ID of the message and then the message itself. So we will first try reading radius from the cache. So we will try to get the message with this user ID with the message timestamp from radius cache. But in case this goes wrong, we need to log that the radius is unavailable and we will need to start using database again. So we will need to query this from database. This can be either insert operation if the message was written to
[3:03:26] the other user or this can be a read operation like select operation if the user is trying to fetch the messages and the radius is unavailable. So that is the second scenario. For example, we are trying to get undelivered messages. So we are trying to read it from radius. This is again trying to read from radius because that is the fastest if it’s available first. If we see that messages length is greater than zero then we will map parse and then send it to the user. But in case something goes wrong in this try
[3:03:56] block that means that the radius is unavailable. So again we will log for us for tracing it later in cloudatch or somewhere else and after that we’ll need to again read this from the database. So we’ll do the select query instead to the database which is again slower but it’s better than just failing the whole operation and then once the radius is back online it will sync with the database have all the messages here. So next time we will be reading from this try block.
[3:04:28] The next topic that you can expect to discuss is about regions and global distribution because WhatsApp is not only in let’s say US or it’s not only in Europe. It has users from all parts of the world. So you need to have different regions for each of these users. For this we can use the regions. For example, if you are using US, we can use the US East one, US West 2 or these kind of regions that are available to us in
[3:04:58] the AWS or the cloud provider that we are using. And each region should have the full stack chart servers, radius clusters, database charts so that it can operate independently. Let’s say for example, user A opens their app and they are located in Tokyo. So we will first do the DNS lookup to see where they should connect first and then they will connect to the nearest IP address which will be the Singapore region and in this
[3:05:29] region we will have the full stack which will be the load balancer that they will first connect to. They will make a request here to route them to the chat server. Here we will have more chat servers replicated and for each region this can be different. For example, for US maybe we are or let’s say for Ireland we have a lot of users using the app. So here it can be many chart servers and let’s say in Tokyo or in Singapore we have less users. So we will have less chart server replicas. Either way they
[3:06:00] will connect with a chat server with websocket connection and they will have the radius cache for each user. This is for example where we will look up where user B is to be able to send the message to their region and that is how the user B let’s say in London will connect. So first again they will do DNS lookup. They will connect to the nearest IP address. They will make a request to the load balancer and they will receive the message that was sent from user A to B through the US West region which is the
[3:06:32] one that they are connected to. Now another thing that can go wrong here is the Kafka cluster that we have. Kafka itself is very scalable. It can handle thousands of messages per second without any issues. But still we need to plan what happens in case this goes down or something happens with the Kafka cluster. Inside of the cluster we typically have multiple brokers which are called servers in the cluster. So topics are
[3:07:02] split into partitions and each partition has leader and it has replicas. So producers usually write to the leader similar like write database actions and replicas just provide us with redundancy. So scenario one is that one of the brokers fail inside of the Kafka cluster. So if a broker dies, we need to replace the broker. Let’s say this was broker number something like three. we need to replace it and rejoin another broker to the cluster. So, Kafka will
[3:07:35] rebalance the partitions over time if we add this broker to the cluster. But what if the entire Kafka cluster goes down? This is very rare, but it’s still possible. So, this means that chat servers can’t publish any messages. All of the chat servers were sending messages to this cluster, right? And now they are no longer able to do that. So message storage service can’t consume the messages which means that messages are not persisted and this can be data loss. In such cases we need to have a
[3:08:07] mitigation of a local buffer. So let’s say we create a message buffer before trying to publish them to Kafka in the code. And this is what the publish message would look like as a function. So first we have a try block and this is the happy puff which is we are trying to publish the message to this Kafka cluster but in case it fails it means that this cluster is down. So we need to have some roll back mechanism for that. So we have the catch block for that situation with Kafka error. So first we
[3:08:40] will persist this error to our logging system to then identify why this message buffer failed. But then we will try to push the message to the local message buffer instead. This is just one approach that we can use. But then this buffer is limited. So we can store up to let’s say 100,000 messages in the buffer. In case that also fails, we don’t fail the whole operation. So we’ll just start dropping the oldest ones from the buffer instead. And then we will need to add some interval for waiting
[3:09:12] for the Kafka to recover. And once it recovers, we will try to publish it again to the Kafka. And this time needs to be the exact time that takes us to spin up a new Kafka. So if this is entirely like destroyed or deleted, we need to spin up a new Kafka. We need to know exactly how much this takes. Let’s say this takes five, like not five even. It needs to take less than five. So let’s say 2 seconds to load which is 2,000 milliseconds. So we know that this
[3:09:45] number is greater than the time we need to spin up a new Kafka. So we are just waiting for that time in the memory in the chat server and then once this is back online with the new Kafka we are publishing this to that one instead of losing the whole memory of messages. So this prevents the memory loss in case the Kafka goes down. But what if this server even like both fail at the same time like we are keeping this in the memory but then Kafka failed and now
[3:10:17] this server also fails. This is where we can also store these files into the server’s local disk. So append only the log file and whenever the chat server also recovers we will replay it from the disk this log file and then we will delete the log after we successfully publish this to the Kafka cluster. So in summary, this is how it will look like. If Kafka is completely destroyed, we are returning the messages from the
[3:10:48] message storage service and then we request messages since last id. We will query the processed messages from database and then we will check which are the messages that are still not published to the database that we still have in the memory. So this is where we will republish the missing messages into the Kafka once it is back online and once we write this all to the persistent storage like a database this is where we will resume back to the normal
[3:11:19] consumption. So you need to have such mechanisms to ensure that you are avoiding data loss or at least minimizing the data loss that can happen in case many parts of the system fail concurrently. And lastly, you can expect to also talk about some feature control in such big systems because you don’t usually release new features for all users. You usually do gradual rollout. In such cases, we usually use feature flux store. All of the cloud providers
[3:11:50] come with such solutions to provide you with feature flux. For example, on Azure, you have such system. for AWS you have it and you even have external tools that you can use like launch darkly is one of the popular ones that we used for example in the previous company. So these are just tools that allow you to set key value pairs. It can be like is new theme enabled and it will be true or false and the true or false is also controlled based on the environment. So it can be for example true for staging
[3:12:22] but it can still be false for production. So this way, let’s say developers are testing on staging, they see the new feature, but users on production don’t see it. And when you decide to also roll out it for production, you can still set it to true for only 20% of users. So only 20% of users will start this beta feature testing it. And then if it goes well, you don’t notice any failure logs. That is when you can enable it for everyone. So in this case the chat server will
[3:12:54] check the supported features from this feature flag store which can be large dartly it can be the cloud provider’s native solution and then we will use gradual enablement per user or per group for example per user will be that 20% users have access to this and then we will return the enabled features from this feature flag store to the chat server and by having this for example the chat server can send messages with new features to the client apps that are
[3:13:24] above this version and strip the unsupported fields or features for the client apps that are below 2.2 or some version that you set. So that is for example discussing the complete architecture. Now we can also talk about how usually to approach this like a framework. So as you can see we are starting simple. You usually start with the clarifying requirements and then calculating the estimates because this is important for you to estimate what
[3:13:56] are the storage solutions you are going to use, what are the services are you going to introduce a Kafka broker or maybe it’s not needed. So you don’t jump straight into radius popsup or database sharding. You usually begin with the high level simple architecture with just thousands of users. Then you build it naturally by talking with the interviewer and then you identify bottlenecks like you could see here where for example the Kafka message can break. We can have rate limiting issues.
[3:14:27] We can have database that goes down. So you need to start talking about this as well. So it’s not something that you can usually memorize. you need to just uh know the steps that are there and then for each system you need to go with the flow and then discuss the trade-offs. So understanding trade-offs is another important piece of this. Every decision has costs. For example, if you are using websockets, it’s not all green because for example, they use more memory than
[3:14:58] HTTP but they provide real-time delivery and for example for messaging app that tradeoff is worth it. For some other situation, the trade-off might not be worth it. Maybe you will go with something like just a polling option instead of just a web socket. So you’ll need to also be ready to discuss the back of the envelope calculations before getting to this. So be ready to calculate these numbers. Maybe do some examples and then check whether you calculated it properly or not. And then
[3:15:29] once you have something stable, then only you will start thinking about failures. You don’t need to rush into that because you still didn’t decide on the main components and haven’t designed the full system. You don’t want to get into the failures before that. And don’t worry if this was too much in detail. You are not expected to design a perfect system in one or two hours. That’s not the goal in the interviews. They will usually want to see how you think, how you discuss tradeoffs, or are you just
[3:16:01] picking something because you like it. So you need to show your thinking process. Say this out loud whenever you are designing something and that will also help you to get direction if you’re going to the wrong prof. The interviewer will correct you and they will give you hints on choosing the right solution for the trade-off that you’re thinking about. By the way, if you’d like to learn all these concepts that I’m using for designing these systems from scratch, I have a whole system design guide covering all these concepts that you need for system design interviews on
[3:16:33] my YouTube channel. You can check out the first link in the description to find the link to my channel. And the first pinned video in my channel is that system design guide explaining all these concepts you need for passing these system design interviews.