应用层设计
把 Web 层、应用层(平台层)和后台 worker 拆开:每层各自扩缩什么指标、状态放在哪、怎么做健康检查和优雅下线。含 Kubernetes readiness 探针与 HPA 公式、SQS visibility timeout 默认值、按层扩容算例、翻车表和面试答法。注意本章讲的是服务架构里的应用层,不是 OSI 第 7 层。
先区分名字:这里的「应用层」指服务架构里处理业务逻辑的那一层服务器(也叫平台层),不是 OSI Model 的第 7 层协议。
将 Web 服务层与应用层分离,可以独立缩放和配置这两层。添加新的 API 只需要添加应用服务器,而不必添加额外的 web 服务器。拆开之后要回答三个问题:每一层按什么指标扩容、请求相关的状态放在哪一层、一台机器下线时正在处理的请求怎么办。
有约束的设计问题
一个在线题库站,高峰每秒 3,000 个请求,流量分三类:
- 静态资源和页面外壳:量最大,几乎不耗 CPU。
- 答题 API:每个请求查库、判分,平均耗时 50 ms,CPU 密集。
- 交卷后生成解析报告和 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 都是网络调用,要处理超时、重试和部分失败。
面试时这样回答
- 按负载特征分流量:「静态资源、同步 API、长任务三类,特征不同,分三层。」
- 说状态归属:Web 层和应用层无状态,会话放 Redis;长任务的状态在队列和数据库。
- 给扩容指标和数字:应用层按 CPU 或 p99,用 HPA 公式举一个扩容例子;worker 按队列积压。
- 说健康检查:readiness 决定接不接流量,liveness 决定要不要重启,liveness 不查依赖。
- 说一个故障:visibility timeout 小于任务耗时导致重复处理,修法是续期加幂等。
相关章节:Monoliths vs Microservices、Service Discovery、异步模式、Message Queues、Stateful vs Stateless、N-tier architecture、Reverse Proxy。