跳转到正文
zeno's blog
返回

系统设计基础(二):System Design 到底关注什么

专题: 系统设计基础

Table of contents

Open Table of contents

系统设计关注什么

DDD 和 Clean Architecture 关注的是单个服务内部怎么组织代码。系统设计关注的是多个服务组成的整体系统如何应对大规模、高可用、高性能的需求。

Clean Architecture:  一个服务内部的代码分层
System Design:       多个服务如何协作、数据如何流动、故障如何应对

核心问题

系统设计要回答的四个问题:

1. 功能设计    → 系统拆成哪些服务?各自负责什么?怎么通信?
2. 性能估算    → 多少用户?多少 QPS?多少数据量?需要多少机器?
3. 数据设计    → 数据存在哪?怎么分表分库?缓存策略?
4. 容错设计    → 某个服务挂了怎么办?数据中心挂了怎么办?

一、功能设计:服务拆分

单体 vs 微服务

单体 (Monolith):
  所有功能在一个进程里
  ┌─────────────────────────────────────┐
  │  登录 + 背包 + 交易 + 匹配 + 战斗    │
  │  一个二进制,一个数据库                │
  └─────────────────────────────────────┘

  优点: 简单、无网络开销、事务容易
  缺点: 改一个功能要部署整体、不能独立扩缩

微服务 (Microservices):
  每个功能独立部署
  ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
  │ 认证    │ │ 背包    │ │ 交易    │ │ 匹配    │
  │ 服务    │ │ 服务    │ │ 服务    │ │ 服务    │
  └───┬────┘ └───┬────┘ └───┬────┘ └───┬────┘
      │          │          │          │
      └──── gRPC / 消息队列 ────────────┘

  优点: 独立部署、独立扩缩、技术栈可以不同
  缺点: 网络通信复杂、分布式事务难、运维成本高

建议:从单体开始,等业务复杂到单体维护不下去时再拆。过早微服务是最常见的过度设计。

mini_tarkov 的服务划分

阶段 1(单体,够用很久):
  ┌──────────────────────────────┐
  │  mini_tarkov_server          │
  │                              │
  │  /api/auth     → 登录注册    │
  │  /api/players  → 玩家管理    │
  │  /api/items    → 物品管理    │
  │  /api/trade    → 交易市场    │
  │  gRPC          → 客户端 API  │
  │  UDP :7777     → 战斗同步    │
  │                              │
  │  PostgreSQL + Redis          │
  └──────────────────────────────┘

阶段 2(DAU 增长到需要拆分时):
  ┌─────────┐  ┌─────────┐  ┌────────────┐
  │ API 网关 │──│ 大厅服务 │──│ PostgreSQL │
  │         │  │ (gRPC)  │  │ (主从复制)  │
  │ (axum)  │  └────┬────┘  └────────────┘
  └────┬────┘       │
       │       ┌────┴────┐  ┌──────┐
       │       │ 匹配服务 │──│ Redis│
       │       └────┬────┘  └──────┘
       │            │
       │       ┌────┴────────┐
       │       │ 战斗服务集群  │  每个房间一个实例
       │       │ (UDP, 无状态) │  可水平扩展
       │       └──────────────┘

       └──── 管理后台 (HTTP REST)

二、性能估算

估算方法

从用户规模开始倒推:

DAU (日活用户): 10,000
同时在线: DAU × 10% = 1,000
同时在战斗中: 在线 × 30% = 300

API QPS:
  每个在线用户每分钟 2 次 API 调用(打开背包、查看市场等)
  1,000 × 2 / 60 ≈ 33 QPS
  峰值 × 3 ≈ 100 QPS
  → 单机 axum 轻松处理(万级 QPS)

战斗同步:
  300 个玩家 × 每秒 20 个输入包 = 6,000 包/秒 入站
  假设平均每局 5 人,60 局同时进行
  每局广播: 5 人 × 20 快照/秒 = 100 包/秒 出站
  60 局 × 100 = 6,000 包/秒 出站
  → 单机 tokio UDP 轻松处理

数据库:
  100 QPS × 平均 2 条 SQL/请求 = 200 SQL/秒
  → 单个 PostgreSQL 实例足够(万级 QPS 能力)

带宽:
  每个快照 ~500 字节 × 6,000 包/秒 = 3 MB/s 出站
  → 家用带宽都够

结论: 1 万 DAU 以下,一台 4 核 8G 的机器就够了

什么时候需要扩展

瓶颈          信号                          解决方案
──────────────────────────────────────────────────────────────
CPU           game tick 耗时超过预算         战斗服务水平扩展(每房间一进程)
数据库        SQL 延迟 > 100ms              读写分离、加 Redis 缓存
内存          在线玩家状态吃满内存            战斗服务分布式部署
带宽          出站带宽打满                   增量压缩、兴趣区域过滤
连接数        TCP 连接数达到系统限制          多节点 + 负载均衡

三、数据设计

数据库选型(已有文档 database-fundamentals.md

PostgreSQL:
  玩家账号、物品、交易记录、匹配历史
  需要事务、复杂查询、数据不能丢

Redis:
  会话 token、在线状态、匹配队列、排行榜
  高速访问、可以丢失(重登录就行)

缓存策略

读取路径 (Cache-Aside):
  1. 查 Redis → 命中 → 返回
  2. 未命中 → 查 PostgreSQL → 写入 Redis(设 TTL)→ 返回

写入路径:
  1. 写 PostgreSQL(真相源)
  2. 删 Redis 缓存(下次读时重新加载)

不要:
  先删缓存再写 DB — 并发时会导致旧数据回填缓存
  先写缓存再写 DB — DB 写失败时缓存和 DB 不一致

数据量增长后的应对

阶段 1: 单库
  一个 PostgreSQL 实例,所有表

阶段 2: 读写分离
  主库写 → WAL 复制 → 从库读
  读多写少的查询走从库

阶段 3: 分表
  按 player_id 分表(items_0, items_1, ..., items_15)
  同一个玩家的数据在同一张表

阶段 4: 分库
  不同 player_id 范围在不同 PostgreSQL 实例
  跨库事务用 Saga 模式

不要过早优化:阶段 1 能撑很久。


四、容错设计

单点故障

问题: 只有一个 PostgreSQL 实例,挂了全站不可用

解决:
  ┌──────────┐     WAL 复制     ┌──────────┐
  │ 主库      │────────────────►│ 从库      │
  │ (读写)    │                 │ (只读)    │
  └──────────┘                  └──────────┘
       ↑                             ↑
       │ 主库挂了                     │ 提升为新主库
       ✕ ─────────── failover ──────►│

                              Patroni / pg_auto_failover
                              自动检测 + 自动切换

重试与幂等

客户端请求超时了 → 重试 → 服务端可能已经处理了第一次

幂等设计:
  客户端每个请求带唯一 request_id
  服务端: 先查 request_id 有没有处理过
    处理过 → 直接返回之前的结果
    没处理过 → 处理 + 保存 request_id + 返回结果

  CREATE TABLE idempotency_keys (
      request_id UUID PRIMARY KEY,
      response JSONB,
      created_at TIMESTAMPTZ DEFAULT NOW()
  );

限流

防止恶意请求或突发流量打垮服务:

令牌桶 (Token Bucket):
  桶里有 N 个令牌,每秒补充 R 个
  每个请求消耗一个令牌
  令牌用完 → 返回 429 Too Many Requests

Redis 实现:
  key: rate_limit:{player_id}
  INCR + EXPIRE
  每秒最多 10 次请求

tower 中间件:
  tower::limit::RateLimitLayer

熔断

下游服务(比如交易服务)出问题了:
  正常 → 每个请求都调交易服务 → 都超时 → 线程全卡住 → 自己也挂了

熔断器 (Circuit Breaker):
  ┌──────┐    失败率 > 50%    ┌──────┐   超时后尝试一个请求  ┌───────────┐
  │ 关闭  │─────────────────►│ 打开  │──────────────────────►│ 半开      │
  │(正常) │                  │(拒绝) │                       │(试探)     │
  └──────┘◄──────────────────└──────┘◄──────────────────────└───────────┘
              成功率恢复          快速失败                   成功 → 关闭
                                不调下游                   失败 → 打开

优雅降级

Redis 挂了:
  方案 A: 直接报错 → 全站不可用 ✗
  方案 B: 跳过缓存,直查数据库 → 慢但可用 ✓(临时降级)

匹配服务过载:
  方案 A: 拒绝所有匹配请求 ✗
  方案 B: 延长匹配间隔,显示"匹配队列繁忙" ✓

战斗服务满载:
  方案 A: 新对局排队等待 ✓
  方案 B: 缩小对局人数上限 ✓

五、通信模式

同步通信(请求-响应)

服务 A ──请求──► 服务 B ──响应──► 服务 A
A 阻塞等待 B 的响应

适合: 需要立即知道结果的操作(登录、查询)
风险: B 挂了 A 也卡住(级联故障)

异步通信(消息队列)

服务 A ──消息──► 消息队列 ──消息──► 服务 B
A 发完就走,不等 B 处理

                  ┌──────────────┐
  交易服务 ─────►│  消息队列      │────► 成就系统(检查成就)
  "交易完成"      │ (Redis/NATS)  │────► 排行榜(更新排名)
                  │              │────► 日志系统(记录交易)
                  └──────────────┘

适合: 不需要立即结果、多个消费者、削峰

六、三者的关系

┌──────────────────────────────────────────────────────────┐
│                    System Design                          │
│        系统整体架构:服务划分、通信、扩展、容错              │
│                                                          │
│   ┌────────────────────┐  ┌────────────────────┐         │
│   │    服务 A            │  │    服务 B            │        │
│   │                    │  │                    │         │
│   │  Clean Architecture│  │  Clean Architecture│         │
│   │  内部代码分层        │  │  内部代码分层        │         │
│   │                    │  │                    │         │
│   │  ┌──────────────┐  │  │  ┌──────────────┐  │         │
│   │  │    DDD       │  │  │  │    DDD       │  │         │
│   │  │ 领域模型      │  │  │  │ 领域模型      │  │         │
│   │  │ 业务逻辑      │  │  │  │ 业务逻辑      │  │         │
│   │  └──────────────┘  │  │  └──────────────┘  │         │
│   └────────────────────┘  └────────────────────┘         │
│            │                        │                    │
│            └──── gRPC / 消息队列 ────┘                    │
└──────────────────────────────────────────────────────────┘

DDD:               一个服务内,业务逻辑怎么建模
Clean Architecture: 一个服务内,代码怎么分层
System Design:      多个服务间,系统怎么组织

三者不冲突,是不同层面的设计决策。小项目只需要 Clean Architecture 的分层思路就够了;业务复杂了引入 DDD 建模;用户量上来了再考虑 System Design 的分布式方案。


分享这篇文章:

上一篇
Rust 泛型(二):结构体与 Trait 逐步理解
下一篇
Rust 泛型(一):总览