跳至正文
Starone

能力

在高负载与部分故障下依然保持正确的后端系统

评判一个后端,要看依赖超时、消息重复到达或流量增至三倍时会发生什么。我们围绕这些情况来设计 API、服务和数据存储:明确的契约,幂等的操作,有界的队列,以及按工作负载选定的存储。

概览

后端工程设计的是承载系统状态、执行系统规则的服务、API 和数据存储。难点很少出在正常路径上,而在于并发下的行为,即两个请求同时修改同一条记录;在于部分故障下的行为,即一次调用可能已经成功、已经失败,或者仍在执行;也在于增长之后的行为,即一条在小表上很快的查询,到了大表上变成全表扫描。

系统一旦跨越多个进程,每两个组件之间就隔着网络,分布式事务也不会凭空得来。调用超时并不会告诉你结果如何,消息至少投递一次,副本存在延迟。此时的正确性取决于有意识地运用一系列模式:幂等键、带退避与抖动的重试、熔断器、事务性发件箱(transactional outbox)、带补偿操作的 Saga,以及明确界定哪些数据必须强一致、哪些可以最终收敛。

我们使用 Java 与 Spring Boot、Go、Node.js 和 Python 构建后端,底层采用 PostgreSQL、MySQL、MongoDB、Redis、Kafka 和 OpenSearch,每一项都根据眼前的具体问题来选择。架构从访问模式、一致性要求和故障模式出发;在出现具体的拆分理由之前,优先采用模块化单体。

我们构建什么

技术领域

01

API 架构

基于 HTTP 的 REST 适合公开 API 和合作方 API,因为它可缓存、为业界普遍熟悉,也容易在网关处做安全防护。GraphQL 适合在关联紧密的领域模型之上、数据需求各不相同的客户端;gRPC 适合需要严格契约、流式传输和低开销的内部服务间调用。契约先行,以 OpenAPI 或 Protocol Buffers 编写,破坏性变更在 CI 中检测。

  • 基于 OpenAPI 契约的 REST
  • 带持久化查询与复杂度限制的 GraphQL
  • gRPC 与 Protocol Buffers
  • API 网关与请求路由
  • 基于游标的分页
  • 增量式版本演进与弃用策略
  • 带签名载荷的 Webhook
02

认证、授权与流量控制

认证确定调用方是谁:用户侧使用 OAuth 2.0 与 OpenID Connect,服务侧使用双向 TLS 或工作负载身份。授权在每个请求上都做到对象级校验,因为一个租户的有效令牌绝不能读到另一个租户的记录。限流与配额用于保护容量,按客户端、按租户、按端点分别施加。

  • OAuth 2.0 与 OpenID Connect
  • 短有效期 JWT 与令牌内省
  • 服务间双向 TLS(mTLS)
  • 基于 OPA 等策略引擎的 RBAC 与 ABAC
  • 令牌桶与滑动窗口限流
  • 负载削减与请求优先级
03

异步处理与事件流

不需要在请求内完成的工作被移到队列和流中,以此吸收突发流量并隔离缓慢的依赖。Kafka 提供持久化、分区、可重放的日志,并保证同一键内有序;RabbitMQ 等队列型 broker 则提供逐条消息的路由与确认。由于投递语义是至少一次,消费者都按幂等方式编写。

  • Apache Kafka 的主题、分区与消费者组
  • RabbitMQ 与云上队列服务
  • 用于轻量级管道的 Redis Streams
  • 死信队列与重放工具
  • 配合 Avro 或 Protobuf 的 Schema Registry
  • 背压与消费延迟控制
04

韧性与一致性模式

每一次远程调用都配有超时、带指数退避与抖动且次数有上限的重试策略,以及在依赖发生故障时停止向其发起调用的熔断器。带副作用的操作接受幂等键,重试因此不会重复扣款、重复发货或重复通知。跨服务的工作流采用 Saga 与发件箱模式,而不是两阶段提交。

  • 幂等键与去重存储
  • 带指数退避与抖动的重试
  • 熔断器与舱壁隔离
  • 事务性发件箱配合变更数据捕获(CDC)
  • Saga:编排式与协同式
  • 乐观锁与版本列
05

数据存储与缓存

关系型数据库是默认的记录系统(system of record);只有在某种具体访问模式确有需要时,才引入其他存储。Redis 提供分布式缓存、限流计数器、带过期时间的锁和会话存储。搜索引擎和向量索引是由记录系统填充的派生视图,绝不作为事实来源。

  • PostgreSQL 与 MySQL
  • MongoDB
  • Redis
  • Elasticsearch 与 OpenSearch
  • 基于 pgvector、Qdrant 或 Milvus 的向量检索
  • Cache-aside、write-through 与 TTL 策略
  • 只读副本、分区与分片
06

语言与运行时工程

Java 搭配 Spring Boot 适合领域逻辑繁重的大型系统,这类系统受益于成熟的生态、强类型,以及在大规模阻塞 I/O 场景下的虚拟线程。Go 适合对延迟可预测性有要求、需要小体积静态二进制的网络服务和基础设施组件。Node.js 适合 I/O 密集型 API,以及与前端共用 TypeScript 的团队;Python 适合数据密集型服务和机器学习服务。

  • Java 与 Spring Boot
  • Go
  • Node.js 搭配 TypeScript 与 NestJS
  • Python 搭配 FastAPI
  • 连接池与线程或事件循环调优
  • 性能剖析、堆分析与 GC 调优

按工作负载而不是按潮流选择数据库

合适的数据库由工作负载的四个属性推导而来:访问模式、业务规则所要求的一致性、写入量,以及查询的形态。在挑选产品之前,先列出最主要的查询及其预期频率、基数和延迟预算,大部分争论自然就消失了。一个需要多行事务、关联查询和即席查询、数据规模适中的系统,应当放在 PostgreSQL 或 MySQL 上,而大多数业务应用正是如此。

专用存储要凭借关系型数据库处理不好的某种访问模式,才配得上一席之地。每多一个存储,也就多一块运维面:备份、升级、监控、故障切换,以及必须保持同步的第二份数据。PostgreSQL 凭借 JSONB、全文检索、分区和 pgvector,在需要第二套系统之前就能覆盖相当广的范围,因此举证责任在主张新增的一方。

  • PostgreSQL 或 MySQL:事务型记录系统、关系完整性、读写混合负载,报表通过副本承担
  • MongoDB:以整份文档为单位读写、结构可变的聚合,跨文档事务只是例外情况
  • Redis:亚毫秒级的键访问,用于缓存、计数器、会话、排行榜和短时协调,持久化只视为附带的便利
  • Elasticsearch 或 OpenSearch:基于倒排索引的全文相关性检索、分面和日志分析,数据异步写入,可容忍刷新延迟
  • 向量数据库:使用 HNSW 或 IVF 索引对 embedding 做近似最近邻检索,以召回率换取延迟和内存
  • Kafka:系统之间有序、可重放的事件日志;它是传输通道和缓冲区,不是查询引擎

架构

事件驱动架构

生产者

  1. 01

    命令

    服务处理一次请求

  2. 02

    本地事务

    状态与 Outbox 记录一并提交

  3. 03

    Outbox 中继

    轮询或跟踪日志后发布

  4. 04

    发布

    带键和 Schema 版本的事件

Broker

  1. 01

    主题

    每种事件类型或聚合一个

  2. 02

    分区

    同一个键内保证顺序

  3. 03

    保留

    可从更早的 offset 重放

  4. 04

    死信队列

    存放持续失败的事件

消费者

  1. 01

    消费者组

    跨分区横向扩展

  2. 02

    幂等处理器

    应用前按事件 ID 去重

  3. 03

    退避重试

    仅用于瞬时故障

  4. 04

    提交 offset

    在副作用成功之后

  5. 05

    投影状态

    更新读模型与缓存

生产者通过事务性 Outbox 发布事件,Broker 按键保证顺序,消费者以幂等方式处理每个事件。无法处理的事件会被转入死信队列,而不是阻塞整个分区。

跨服务一致性:发件箱、Saga 与幂等

只要一个服务既要更新自己的数据库又要发布消息,就会遇到双写问题。如果这是两个独立的操作,那么两者之间的一次崩溃就会造成数据库已变更而事件未发出,或者相反。事务性发件箱消除了这个空隙:事件与状态变更在同一个本地事务中写入发件箱表,随后由一个中继,通常是基于 Debezium 的变更数据捕获,把它发布到 Kafka。投递语义因此成为至少一次,这也是消费者必须去重的原因。

一个跨越多个服务的业务流程,例如预留库存、收取款项、创建发货单,无法用一个 ACID 事务把它们全部包住。Saga 把它建模为一系列本地事务,每个事务都配有一个在语义上将其撤销的补偿操作。编排式(orchestration)把整个顺序放在一个协调者中,可以基于 Temporal 这样的工作流引擎,也可以是数据库里的一个状态机,流程因此可见、可测试。协同式(choreography)则让各服务对彼此的事件作出响应,对短流程而言降低了耦合,流程一长就很难推理。

幂等让重试变得安全。客户端在每个变更类请求中携带一个唯一的键;服务端在产生效果的同一个事务里,把这个键连同结果一起保存,再次见到该键时直接返回已保存的结果。再配合沿调用链传播的截止时间,以及防止重试风暴的重试预算,系统就能从超时中恢复,而不会产生重复的副作用。

同步调用、队列与事件流

同步调用把调用方的可用性和延迟与被调用方绑在一起:如果一条请求路径穿过五个服务,它的可用性就是这五者可用性的乘积。当调用方必须拿到结果才能继续时,同步调用仍然是正确的选择,例如在授权一笔支付之前检查余额。凡是可以在响应之后再发生的事情,例如通知、投影、搜索索引和分析,更适合由事件驱动。

队列和流的区别在于它们保留什么。队列把每条消息交给一个消费者,确认之后即删除,适合需要逐条重试的任务分发。Kafka 主题则在配置的保留期内保存日志,与是否被消费无关,因此新的消费者可以重放历史,多个消费者组可以各自独立读取,并且分区内保证有序。分区键因此同时决定了顺序和并行度,如何选择它是一项设计决策,通常取账户或订单这类聚合的标识符。

最终一致性既是技术决策,也是产品决策。由事件更新的读模型会滞后于写入侧,因此界面要么从源头读取自己刚写入的数据,要么展示处理中状态,要么容忍这段延迟。我们为每个投影记录预期的滞后时间,并在消费延迟超出该值时告警。

做分布式缓存而不返回错误数据

缓存是数据的第二份副本,自带一致性问题。带过期时间(TTL)的 cache-aside 是最简单的正确模式:先读 Redis,未命中时回源到数据库,把结果连同过期时间一起写入,底层数据行变更时删除对应的键。写入时删除比写入时更新更安全,否则并发的写入方可能把较旧的值留在缓存里。

它的故障模式是可以预见的,需要明确处理。缓存击穿发生在一个热门键过期、大量请求同时重新计算它的时候,可以通过请求合并、概率性提前刷新或短时锁来应对。热点键会把负载集中到一个分片上;键空间如果没有上限,就会耗尽内存,除非事先规划好淘汰策略和键的基数。此外,缓存不可用时,服务即便变慢,也必须保持正确。

我们如何构建

我们的方法

我们从数据和故障模式出发,由内向外设计后端,并在需求允许的范围内把活动部件的数量压到最少。

  1. 01

    对领域及其不变量建模

    我们识别出聚合、必须在事务内成立的规则,以及可以接受最终一致性的边界。日后在哪里可以拆分服务,由这些边界决定,而不是由团队偏好决定。

  2. 02

    刻画工作负载

    针对每个操作,写明访问模式、读写比、数据量、增长速度和延迟目标。这些信息驱动 schema 设计、索引、数据存储选型和容量规划。

  3. 03

    契约先行

    在实现之前先就 OpenAPI、GraphQL 或 Protobuf schema 以及事件 schema 达成一致,其中包括错误模型、分页、幂等行为和版本规则。消费方基于同一份契约生成的 mock 进行开发。

  4. 04

    实现时即内建故障处理

    超时、重试、熔断器、发件箱发布和幂等消费者属于第一版实现的一部分,而不是留到后期的加固阶段。schema 变更采用 expand-and-contract(先扩展后收缩)式迁移,使新旧代码可以并行运行。

  5. 05

    在贴近真实的条件下测试

    集成测试针对容器中运行的真实数据库和 broker,契约测试守住服务边界,负载测试使用与生产形态一致的数据量。故障注入用于验证依赖变慢、不可用或返回重复数据时系统的行为。

  6. 06

    按服务等级目标运维

    每个服务都有明确的可用性与延迟目标,有覆盖四个黄金信号的仪表盘,有与错误预算消耗速率挂钩的告警,也有针对已知故障模式的运行手册。

取舍

决定设计走向的决策

在确定架构之前,我们会与您的团队逐一厘清的问题。

REST、GraphQL 还是 gRPC?
对于看重 HTTP 缓存、工具链和广泛客户端支持的公开 API 与合作方 API,使用 REST。当众多客户端页面需要同一张关联图的不同投影,并且你有能力在查询成本限制、应对 N+1 查询的批量加载和字段级授权上投入时,使用 GraphQL。内部服务之间使用 gRPC,以获得类型化契约、截止时间和流式能力;浏览器需要借助 gRPC-Web 代理,因此它很少适合作为对外接口。
模块化单体还是微服务?
当整个领域由一个团队负责时,从模块化单体起步:边界得到强制执行、schema 相互独立的模块,只需一次部署和本地事务,就能获得大部分设计上的好处。当某个模块需要独立伸缩、不同的发布节奏、不同的运行时或故障隔离时,再把它抽取为服务。微服务是用网络调用换掉进程内调用,因此它的正当理由来自组织和运维上的需要,而不是代码是否整洁。
Kafka、消息队列还是 Redis Streams?
当事件必须被保留和重放、被多个相互独立的消费者组消费,并且需要在高吞吐下按键有序时,选择 Kafka。对于需要逐条确认、延迟投递和路由、不需要历史数据的任务分发,选择 RabbitMQ 或托管队列。如果 Redis 已经在用、数据量不大,并且可以容忍故障切换时丢失一小段窗口内的数据,Redis Streams 就足够了。
哪些地方需要强一致性,哪些地方可以接受最终一致性?
那些哪怕短暂违反也不行的不变量,例如余额、库存扣减和唯一性,要放在单个服务的单个数据库事务之内。派生数据可以接受最终一致性:搜索索引、读模型、通知和分析。如果一个不变量横跨两个服务,那就说明服务边界划错了位置。
是否应该增加第二个数据库?
只有当某个经过度量的访问模式,在主存储上通过合理的索引、分区或副本仍然无法满足时才增加。搜索相关性、高维相似度检索,以及简单键上的极高写入吞吐,都是正当理由。每新增一个存储,都需要一条同步路径,通常是变更数据捕获,以及一套从记录系统重建它的方案。
后端应该使用哪种语言和框架?
先看团队的运维经验,再看工作负载。Java 搭配 Spring Boot 适合复杂的事务型领域和大型团队;Go 适合对内存和启动时间预算要求严格的高并发网络服务;Node.js 适合 I/O 密集型 API,不适合在事件循环上做 CPU 密集型工作;Python 适合贴近数据和模型代码的服务。把整个组织的运行时限制在两到三种,类库、流水线和值班经验才能共享。

生产就绪

怎样才算生产就绪

如何保障安全

请求在网关处完成认证,进入服务后再做一次对象级授权,租户身份由经过验证的声明携带,而不是来自请求参数。输入按契约校验,查询全部参数化,密钥来自支持轮换的密钥管理服务,服务间流量使用双向 TLS。审计日志记录谁对哪个资源做了什么,并以仅追加的方式写入。

如何扩展

服务是无状态的,在负载均衡器之后水平扩展,状态存放在数据库、缓存和 broker 中。瓶颈通常在数据库,因此扩展按顺序推进:索引与查询计划、连接池、缓存、只读副本、表分区,只有当单个主库再也承受不了写入时才做分片。队列吸收突发流量,有界并发加上负载削减,让过载的服务对它确实接下的请求仍然保持响应。

如何监控

OpenTelemetry 提供跨越 HTTP、gRPC 和消息边界的分布式链路追踪,trace 上下文通过消息头传递,异步环节因此同样可见。指标按端点覆盖请求速率、错误、耗时和饱和度,同时包括消费延迟、连接池使用率和缓存命中率。日志采用结构化格式,带有关联标识符,并且不包含个人数据和机密信息。

如何部署

服务以容器镜像的形式经由流水线交付:流水线运行测试、静态分析以及依赖与镜像扫描,随后以滚动或金丝雀方式渐进发布,并在错误率或延迟出现回归时自动回滚。数据库迁移保持向后兼容,与代码分开执行,遵循“扩展、迁移、收缩”的步骤。功能开关独立于部署,控制新行为的放量范围。

如何集成

外部系统通过适配器接入,把它们的数据模型和故障行为与核心领域隔离开来。入站 Webhook 先验签、持久化并快速确认,再带去重地异步处理;出站 Webhook 带签名,并以退避方式重试。遗留系统通过变更数据捕获或防腐层集成,而不是共享数据库访问。

技术

根据工作负载和您现有的技术资产来选择。

  • Java and Spring Boot
  • Go
  • Node.js
  • Python and FastAPI
  • PostgreSQL
  • MySQL
  • MongoDB
  • Redis
  • Apache Kafka
  • RabbitMQ
  • OpenSearch
  • gRPC
  • GraphQL
  • OpenTelemetry

从零到生产

工程生命周期

  1. 01

    想法

  2. 02

    探索

  3. 03

    系统架构

  4. 04

    UX / 产品设计

  5. 05

    应用工程

  6. 06

    AI / 数据层

  7. 07

    云基础设施

  8. 08

    安全

  9. 09

    质量保证与性能

  10. 10

    部署

  11. 11

    可观测性

  12. 12

    持续演进

常见问题

常见问题

什么是幂等,为什么重要?

幂等操作无论执行一次还是多次,产生的结果都相同。它之所以重要,是因为在分布式系统中,超时并不能说明请求是否已被处理,唯一安全的应对就是重试。API 上的幂等键和消息消费者中的去重,让这样的重试不会造成任何损害。

Kafka 能保证恰好一次(exactly-once)投递吗?

Kafka 提供幂等生产者和事务,对于完全停留在 Kafka 内部的“读取、处理、写入”循环,可以提供恰好一次语义。一旦消费者要写入外部数据库或调用其他 API,保证就退化为至少一次,消费者必须做到幂等,通常的做法是在状态变更的同一个事务中记录已处理的消息标识符。

API 应该如何做版本管理?

优先演进,而不是出新版本:增加可选字段和新端点,绝不改变已有字段的含义,并让客户端忽略未知字段。当破坏性变更无法避免时,在路径或请求头中发布新的主版本,让两个版本并行运行,公布弃用时间表,并在移除旧版本之前度量它剩余的使用量。

什么时候值得引入分布式缓存?

当性能剖析表明读负载或某项昂贵的计算是瓶颈,并且数据可以容忍明确限定的滞后时。缓存会带来失效问题和一种新的故障模式,因此它应当排在查询与索引优化之后,而不是取而代之。每一个缓存项都需要有归属方、过期时间和失效触发条件。

后端能否从单体逐步迁移到服务化?

可以,采用绞杀者模式(strangler pattern)。在单体之前放置一个路由层,把选定的路径转发到新服务,一次迁移一个限界上下文,过渡期间由变更数据捕获或事件保持数据同步。第一步通常是在单体内部强制执行模块边界,因为一个在进程内都隔离不开的模块,换成网络也同样隔离不开。

有一套必须在生产环境中正常运行的系统?

告诉我们您正在构建什么。工程师会阅读您的留言,并在两个工作日内回复。