跳至正文
Starone

能力

经得起变化与负载考验的软件架构

我们围绕领域边界、失效模式以及将要运维系统的团队来设计系统,再用能让系统保持可演进的工程实践把它构建出来。

概览

软件工程是这样一种实践:构建的系统要在多年时间里、在负载之下、在不同的人参与开发的情况下,始终保持正确和可演进。架构是其中日后改动代价高昂的部分:边界划在哪里,哪个组件拥有哪些数据,组件之间如何通信,以及其中一个失效时会发生什么。

大多数架构问题都是边界问题。共享同一个数据库的服务无法独立部署。互相直接访问对方数据表的模块,无法各自独立地分析和理解。一条由五次调用组成的同步链路,其可用性只等于这五次调用可用性的乘积。把边界划对,比在单体和微服务之间做选择更重要,而同一套领域分析对两者都适用。

Starone 的架构师根据约束来选择结构:团队规模、变更频率、一致性要求、负载特征和运维成熟度。本页介绍架构与工程实践;前端、后端和移动端工程各有独立的能力页面。

我们构建什么

技术领域

01

架构风格

模块化单体是一个可部署单元,内部边界被强制约束。微服务是可独立部署的服务,各自拥有自己的数据。面向服务的架构介于两者之间,服务粒度更粗,且常常共享基础设施。我们根据有多少团队需要独立发布,以及各部分的扩展需求差异有多大,来选择相匹配的风格。

  • 模块化单体
  • 微服务
  • 面向服务的架构(SOA)
  • 六边形架构
  • API 网关与 BFF(Backend-for-Frontend)
  • 绞杀者模式(Strangler Fig)迁移
02

领域驱动设计

领域驱动设计(DDD)通过研究业务而不是数据库 Schema 来寻找边界。限界上下文是这样一个范围:其中每个术语只有一种含义,并由一个团队负责;聚合则定义了哪些内容必须在同一个事务中一起变更。这些边界会成为模块或服务,是系统中最持久的决策。

  • 限界上下文与上下文映射
  • 聚合与不变量
  • 通用语言
  • 领域事件
  • 防腐层
03

事件驱动与异步系统

事件把生产者与对其作出响应的消费者解耦,既在时间上解耦,也在可用性上解耦。不需要立即得到结果的工作转入队列,从而吸收突发流量并隔离缓慢的依赖。代价是最终一致性和更困难的调试,因此我们在解耦确有价值的地方使用事件,在调用方需要拿到结果的地方使用直接调用。

  • 基于 Kafka 与 RabbitMQ 的消息通信
  • 事务性发件箱(Transactional Outbox)
  • 幂等消费者
  • 用于多步事务的 Saga
  • 死信队列与重放
  • 带 Schema 版本的事件
04

分布式系统与数据模式

一旦系统跨越多个进程,网络会出故障,消息会重复到达,时钟也会不一致。我们对此做显式设计:按操作选择一致性级别,让写入具备幂等性,并在读写模型的形态或负载差异明显时用 CQRS 将二者分离。当变更历史本身就是需求时,例如账本和审计要求高的领域,才使用事件溯源。

  • CQRS
  • 事件溯源
  • 幂等键
  • 乐观并发控制
  • 分布式缓存
  • 一致性与可用性的权衡
05

容错与水平扩展

容错意味着某个依赖发生故障时,只有一项功能降级,而不是整个系统受损。水平扩展要求服务无状态,状态存放在为此而设计的数据存储中。两者都要从一开始就设计进去,手段是超时、有上限的重试和负载削减,并通过负载测试和故障测试来验证。

  • 超时与带退避的重试
  • 熔断器与舱壁隔离
  • 限流与背压
  • 无状态服务与自动扩缩容
  • 分区与只读副本
  • 优雅降级
06

工程实践

架构只有在日常实践的保护下才能延续。我们采用分层测试策略,评审每一次变更,持续集成,并把 API 契约当作版本化制品来管理。重要的决策以架构决策记录的形式留存,让之后维护系统的人能够了解当初的理由。

  • 单元测试、集成测试与契约测试
  • 代码评审与主干开发
  • 持续集成
  • OpenAPI 与 gRPC 契约
  • 架构决策记录(ADR)
  • 链路追踪、指标与结构化日志

模块化单体还是微服务

微服务解决的是一个组织问题:许多团队按各自的节奏发布,系统的不同部分有不同的扩展方式或失效方式。为此付出的代价是:函数调用变成网络调用,出现分布式事务,每个服务一条发布管道,以及大得多的运维面。

模块化单体能以更低的成本获得大部分结构上的好处。模块有明确的公开接口,拥有自己的数据表,通过进程内调用或内部事件通信,边界则由构建规则或架构测试强制保证。只有一次部署、一个事务边界和一个调试的地方。

务实的路径是从模块化起步,在有具体理由时再抽取服务:某个模块需要独立的扩展特征、发布节奏、技术栈或隔离性。如果边界保持得干净,抽取只是机械性的工作。如果边界不干净,那么采用微服务只会得到一个分布式单体,兼具两者的缺点。

架构

微服务架构
  1. 客户端

    • Web 应用
    • 移动应用
    • 合作方集成
  2. API 网关 / BFF

    • 路由
    • 身份认证
    • 限流
    • 响应裁剪
  3. 领域服务

    每个服务负责一个限界上下文

    • 订单
    • 支付
    • 商品目录
    • 账户
    • 通知
  4. 异步消息

    • Kafka 主题
    • Outbox 发布器
    • 事件消费者
    • 死信队列
  5. 数据存储

    每个服务独立数据库,不共享表

    • 各服务独立 PostgreSQL
    • Redis 缓存
    • 搜索索引
    • 对象存储
  6. 平台

    • 链路追踪与指标
    • 集中式日志
    • CI/CD 流水线
    • 服务配置
网关之后是可独立部署的服务,各自拥有自己的数据,在不需要同步调用的地方通过事件通信。正是平台层让这么多活动部件变得可运维。

跨服务边界的一致性

单个数据库可以提供原子事务。跨服务则没有这样的保证,而两阶段提交会以大多数系统无法接受的方式把各方的可用性耦合在一起。标准的做法是 Saga:一系列本地事务,每个事务发布一个事件,后续步骤失败时执行补偿操作。

有两种机制让这种做法变得安全。事务性发件箱把状态变更和待发出的事件写入同一个数据库事务,因此事件既不会丢失,也不会为已回滚的变更而发布。幂等消费者让重复投递不再造成危害,因为消息中间件在实践中是至少投递一次。两者都到位后,最终一致性就是可预测的。

并非所有操作都能容忍最终一致性。余额、库存预留和唯一性检查通常需要强一致性,所以我们把这些不变量保持在同一个聚合、同一个服务之内。

为部分失效而设计

在分布式系统中,部分失效是常态。响应缓慢的依赖比彻底宕机的依赖更糟,因为调用方在等待期间会一直占用线程和连接,缓慢会向上游蔓延。

因此,每一次远程调用都有一个从调用方自身延迟预算推导出的超时,有次数上限并带抖动的重试,以及一个熔断器,用来停止对不健康依赖的调用。舱壁为每个依赖分配独立的连接池,使任何一个依赖都无法耗尽共享资源。系统过载时,会尽早丢弃低优先级的工作,而不是无限制地排队。

  • 根据端到端延迟预算设置超时
  • 仅对幂等调用进行带指数退避和抖动的重试
  • 按依赖配置熔断器与舱壁
  • 带背压和负载削减的有界队列
  • 为每项关键功能定义降级行为

我们如何构建

我们的方法

架构工作从领域和约束出发,并尽早在可运行的代码中得到验证。

  1. 01

    理解领域

    与实际执行业务的人一起梳理业务流程,绘制出限界上下文、关键实体和重要的事件。

  2. 02

    明确质量属性

    把可用性、延迟、吞吐量、一致性和合规约束写成可度量的目标。它们对结构的影响比功能更大。

  3. 03

    选定并记录架构

    选择架构风格、数据归属和通信模式,并把每一项重要决策连同被否决的备选方案记录为 ADR。

  4. 04

    构建行走骨架(Walking Skeleton)

    在扩大范围之前,先交付一条贯穿所有层的薄切片,包括 CI、部署和可观测性。它能在改动成本还很低的时候,用现实检验架构。

  5. 05

    在护栏之内增量交付

    在自动化测试、契约检查和代码评审的保护下交付小步变更,并配备一旦越过边界就让构建失败的架构测试。

  6. 06

    在负载和故障下验证

    上线前进行负载测试并注入依赖故障,之后随着真实流量数据的到来重新审视各项决策。

取舍

决定设计走向的决策

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

我们应该一开始就用微服务吗?
通常不应该。从模块化单体起步,除非已经有多个团队需要独立的发布周期,或者系统的某些部分有明显不同的扩展或隔离需求。当某条具体的边界显现出这种需求时,再抽取服务。
组件之间用同步 API,还是异步事件?
当调用方需要拿到结果才能继续时,例如查询或校验,使用同步调用。当其他部分只需知道某件事发生了、工作可以延后处理,或者生产者必须在消费者宕机时仍保持可用,使用事件。避免过长的同步调用链,因为每多一跳都会降低可用性并增加延迟。
CQRS 值得引入吗?
当读写负载的差异大到一个模型两头都服务不好时再引入:例如对通过严格不变量写入的数据做复杂查询,或者读取量比写入量高出几个数量级。对于普通的 CRUD,它带来两个需要保持同步的模型,回报却很少。事件溯源是另一项独立的决策,应留给需要完整历史的领域。
REST、gRPC 还是 GraphQL?
REST 加 OpenAPI 适合公开 API 和面向合作方的 API,因为它广为人知,且可缓存。gRPC 适合内部的服务间调用,这类调用受益于严格的契约、流式传输和低开销。GraphQL 适合面向客户端的聚合场景,即许多页面需要同一份数据的不同形态,并且它需要查询成本控制。
共用一个数据库,还是每个服务一个数据库?
每个服务或模块都应当拥有自己的数据,并且只通过自身接口对外暴露。在模块化单体中,这可以是同一个 PostgreSQL 实例中的不同 Schema,既保持运维简单,又保留了数据归属。当服务需要独立扩展、使用不同的存储引擎或实现故障隔离时,才有必要采用物理上分离的数据库。
重写遗留系统,还是渐进式迁移?
几乎在所有情况下都应渐进式迁移。绞杀者模式在一层门面(Facade)之后,每次把一项能力路由到新代码,让业务持续运转,并且能尽早获得反馈。只有当现有系统完全无法安全地修改时,全量重写才算合理;即便如此,也应分片交付。

生产就绪

怎样才算生产就绪

如何保障安全

安全是设计的一部分:在架构阶段做威胁建模,在每个服务边界强制执行认证与授权,校验输入,把密钥存放在专用的密钥保管库中,并在 CI 中扫描依赖。服务以其所需的最小权限运行。

如何扩展

服务无状态,在负载均衡器之后水平扩展;状态存放在数据库、缓存和消息中间件中,按需分区或复制。容量上限靠负载测试发现,而不是靠事故发现。

如何监控

每个服务都输出结构化日志、指标和分布式追踪,并共用同一个关联标识。服务等级目标(SLO)定义了什么叫健康,告警则与用户能够察觉的症状挂钩。

如何部署

每一次变更都要通过自动化测试,并构建出一个不可变制品,原样在各环境间逐级发布。发布采用滚动、蓝绿或金丝雀策略,数据库迁移保持向后兼容,功能开关把部署与发布分离开来。

如何集成

集成通过版本化契约完成:调用使用 OpenAPI 或 gRPC,消息使用在 Schema Registry 中注册的事件。防腐层把领域模型与第三方系统和遗留系统隔离开,消费者驱动的契约测试则在发布前捕获破坏性变更。

技术

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

  • Java
  • Spring Boot
  • Go
  • TypeScript
  • Node.js
  • PostgreSQL
  • Redis
  • Apache Kafka
  • RabbitMQ
  • gRPC
  • OpenAPI
  • Docker
  • Kubernetes
  • 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 规范、运维任务的操作手册(Runbook),以及一份能让新工程师把系统跑起来的 README。文档存放在代码仓库中,与代码一同变更。

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

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