应用层设计

把 Web 层、应用层(平台层)和后台 worker 拆开:每层各自扩缩什么指标、状态放在哪、怎么做健康检查和优雅下线。含 Kubernetes readiness 探针与 HPA 公式、SQS visibility timeout 默认值、按层扩容算例、翻车表和面试答法。注意本章讲的是服务架构里的应用层,不是 OSI 第 7 层。


资料来源:可缩放系统构架介绍

先区分名字:这里的「应用层」指服务架构里处理业务逻辑的那一层服务器(也叫平台层),不是 OSI Model 的第 7 层协议。

将 Web 服务层与应用层分离,可以独立缩放和配置这两层。添加新的 API 只需要添加应用服务器,而不必添加额外的 web 服务器。拆开之后要回答三个问题:每一层按什么指标扩容、请求相关的状态放在哪一层、一台机器下线时正在处理的请求怎么办。

有约束的设计问题

一个在线题库站,高峰每秒 3,000 个请求,流量分三类:

  1. 静态资源和页面外壳:量最大,几乎不耗 CPU。
  2. 答题 API:每个请求查库、判分,平均耗时 50 ms,CPU 密集。
  3. 交卷后生成解析报告和 PDF:单个任务 20–40 秒,用户可以等通知。

如果三类都跑在同一组服务器上,PDF 任务会占满 CPU,答题 API 的延迟跟着上升;静态资源的流量也迫使你按最大流量去扩一台台「全能」服务器。

三层各做什么

层职责状态按什么扩容典型实现
Web 层TLS 终止、静态资源、路由、限流无连接数、带宽Nginx、CDN、API Gateway
应用层同步业务逻辑,读写数据库和缓存无(会话放 Redis 或 token)CPU、p99 延迟无状态服务,多副本
Worker 层异步长任务任务状态在队列和数据库队列积压长度消费队列的进程

关键约束是应用层无状态:会话、上传中的文件、限流计数都不能只存在某台机器的内存里,否则扩缩容和故障切换都会丢数据。做法见 Stateful vs Stateless。

单一职责原则提倡小型的,自治的服务共同合作。小团队通过提供小型的服务,可以更激进地计划增长。

应用层中的工作进程也可以实现异步化:同步请求只负责把任务写进消息队列并立即返回任务 ID,worker 在后台处理。

按层扩容:算一下

以下单机容量是假设值,实际要压测:

  • 应用层:一个副本 2 vCPU,每个请求 50 ms CPU 时间,单副本约 2 / 0.05 = 40 请求/秒;为了 p99 不恶化,目标 CPU 利用率定 60%,即每副本按 24 请求/秒算。
  • 答题 API 高峰占 3,000 请求/秒中的 1,200,需要 1,200 / 24 = 50 个副本。
  • Web 层 Nginx 处理静态资源和转发,4 个副本足够(假设值)。静态资源大部分被 CDN 挡掉。
  • Worker 层:高峰每分钟 600 份交卷,每份 30 秒,需要同时处理 600 × 30 / 60 = 300 个任务。每个 worker 进程同时处理 1 个,就是 300 个进程。

三层扩容指标不同,所以要分开扩:

  • 应用层用 Kubernetes HPA 按 CPU 扩。文档给出的算法是 desiredReplicas = ceil(currentReplicas × currentMetricValue / desiredMetricValue):当前 30 个副本、CPU 90%、目标 60%,扩到 ceil(30 × 90 / 60) = 45 个。
  • Worker 层按队列积压扩,不按 CPU。只算积压:队列里堆了 3,000 个任务、每个 worker 每分钟消化 2 个,想在 5 分钟内清空要 300 个 worker,这还没算同时新进来的任务。

健康检查与优雅下线

负载均衡器只应把请求发给「已经准备好」的副本。Kubernetes 把这件事拆成两种探针:

readinessProbe:          # 失败 = 从 Service 的后端里摘掉,不再接新请求
  httpGet: { path: /ready, port: 8080 }
  periodSeconds: 5
  failureThreshold: 2
livenessProbe:           # 失败 = 重启容器
  httpGet: { path: /healthz, port: 8080 }
  periodSeconds: 10
  failureThreshold: 3

Kubernetes 文档里探针默认 periodSeconds 10 秒、timeoutSeconds 1 秒、failureThreshold 3 次。按默认值,一个坏掉的副本最长要约 30 秒才被摘掉,这期间分到它的请求会失败。

两个探针要查不同的东西:

  • /ready 可以检查数据库连接池是否可用,失败就暂时不接流量。
  • /healthz 只检查进程本身是否卡死,不要检查数据库。否则数据库抖一下,所有副本的 liveness 同时失败、同时重启,一次依赖故障被放大成全站重启。

下线时(发布、缩容),Pod 默认有 30 秒优雅终止时间。进程收到 SIGTERM 后应先让 /ready 返回失败、停止接新请求,处理完手上的请求再退出。

Worker 的优雅下线靠队列语义:以 SQS 为例,消息被取走后在 visibility timeout(默认 30 秒)内对其他消费者不可见,处理完要显式删除;worker 中途被杀,消息超时后重新可见,被别的 worker 重做。所以 visibility timeout 必须大于单个任务的最长处理时间,任务本身要能安全重做(幂等)。上面 20–40 秒的 PDF 任务用默认 30 秒会被重复处理。

microservices

与此讨论相关的话题是 microservices,可以被描述为一系列可以独立部署的小型的,模块化服务。按 Martin Fowler 和 James Lewis 的定义,每个服务运行在自己的进程里,通过明确定义的轻量级机制(通常是 HTTP API)通讯,共同实现业务目标。

例如,Pinterest 可能有这些 microservices:用户资料、关注者、Feed 流、搜索、照片上传等。

拆出 Web / 应用 / Worker 三层并不等于要做微服务:一个单体应用同样可以部署成「无状态应用副本 + 独立 worker 进程」两种角色,共享一份代码。先按负载特征拆层,再按团队和领域边界决定要不要拆服务。

service discovery

应用层副本随时扩缩,IP 不断变化,调用方需要按名字找到当前健康的实例。像 Consul、etcd 和 ZooKeeper 这样的系统可以通过追踪注册名、地址、端口等信息来帮助服务互相发现对方。Health checks 通常访问一个 HTTP 路径来确认服务是否健康。Consul 和 etcd 都有一个内建的 key-value 存储,用来存放配置信息和其他共享信息。在 Kubernetes 里,Service 和 DNS 承担了这件事。完整的注册、发现模式见 Service Discovery。

常见翻车

翻车用户看到什么修法
长任务在应用层同步执行交卷时答题 API 整体变慢、超时长任务进队列,由 worker 层处理
会话存在应用副本内存里扩缩容或发布后用户被登出会话外置到 Redis,或用签名 token
liveness 探针检查数据库数据库抖动引发所有副本同时重启liveness 只查进程;依赖检查放 readiness
发布时直接杀进程每次发布有一批请求返回 502处理 SIGTERM:先摘流量,再处理完在途请求
visibility timeout 小于任务耗时同一份报告被生成两次、通知发两次调大超时或处理中续期;任务做幂等
Worker 按 CPU 扩容队列积压几万条,worker 数量却不涨按队列积压长度扩容

不利之处:应用层

  • 添加由多个松耦合服务组成的应用层,从架构、运营、流程等层面来讲将非常不同(相对于单体系统)。
  • microservices 会增加部署和运营的复杂度。
  • 多一层网络跳转:Web 层到应用层、应用层到 worker 都是网络调用,要处理超时、重试和部分失败。

面试时这样回答

  1. 按负载特征分流量:「静态资源、同步 API、长任务三类,特征不同,分三层。」
  2. 说状态归属:Web 层和应用层无状态,会话放 Redis;长任务的状态在队列和数据库。
  3. 给扩容指标和数字:应用层按 CPU 或 p99,用 HPA 公式举一个扩容例子;worker 按队列积压。
  4. 说健康检查:readiness 决定接不接流量,liveness 决定要不要重启,liveness 不查依赖。
  5. 说一个故障:visibility timeout 小于任务耗时导致重复处理,修法是续期加幂等。

相关章节:Monoliths vs Microservices、Service Discovery、异步模式、Message Queues、Stateful vs Stateless、N-tier architecture、Reverse Proxy。

来源及延伸阅读