jiechenjiechen

The world is quiet here.

© jiechen

Rebuild in 2023   |   Start in 2021
Total View 0 Site Visitors 0

好的系统设计:核心原则与实践指南

2025年8月22日computer-science

在系统设计领域,存在不少欠佳的建议:一类是面向行业新人、主打 “你肯定没听过队列” 的 LinkedIn 风格内容,另一类是标榜 “用数据库存储布尔值即不合格工程师” 的 Twitter 式 “小聪明”。即便部分优质资料(如《设计数据密集型应用》),对工程师日常面临的多数系统设计问题也未必具备实用性。

什么是系统设计?从行业共识来看,软件设计是 “组装代码行”,核心元素包括变量、函数、类等;而系统设计是 “组装服务”,核心元素涵盖应用服务器、数据库、缓存、队列、事件总线、代理等基础设施。本文将梳理 “好的系统设计” 的核心认知 —— 尽管具体决策需依赖实践经验,但仍可提炼出具有普适性的核心原则

一、好的系统设计的识别标准

好的系统设计往往呈现 “不起眼” 的特征,甚至与直觉相悖:

  • 外观平淡,运行稳定:具体表现为 “长时间无故障”“操作复杂度低于预期”“无需特意关注某部分运行状态”。例如,当接入某一服务时,若出现 “流程比预想中顺畅” 的感受,大概率是优质设计的作用。
  • 复杂不等于优秀:劣质设计常更具 “视觉冲击力”—— 若一个系统堆砌了分布式一致性机制、多模式事件驱动、CQRS 等复杂技术,反而可能是在弥补底层决策失误,或存在过度设计问题。当然,并非所有复杂系统均不合理(部分场景确实需要一定复杂度),但能稳定运行的复杂系统,必然源于能稳定运行的简单系统,从零构建复杂系统属于高风险行为。

二、核心矛盾:状态与无状态

软件设计的核心难点,本质在于 “状态管理”,二者的定义与特征如下:

  • 有状态(Stateful):需长期存储信息(如与数据库交互的服务),这类组件易出现故障且无法自动修复 —— 例如数据库中存储了触发应用崩溃的异常数据时,需手动清理;磁盘空间不足时,需手动扩容或删除冗余数据。
  • 无状态(Stateless):不持久化存储信息(仅在请求生命周期内暂存数据),典型案例为 GitHub 的内部 API:接收 PDF 文件后返回 HTML 渲染结果,重启服务无需恢复任何前置数据。

设计原则:最小化有状态组件

  • 实践方案:设置单一服务专门管理状态(仅该服务与数据库交互),其他服务仅承担无状态处理任务。例如,避免 5 个服务同时写入同一张数据库表,而是让其中 4 个服务通过 API 或事件调用 “状态管理服务”,由后者统一执行写入逻辑。

  • 读逻辑灵活性:读操作可适当简化 —— 若直接读取user_sessions表的速度比调用内部会话服务快 2 倍,无需强求通过统一服务调用。

三、数据库:状态管理的核心载体

由于状态管理是系统设计的关键,存储状态的数据库自然成为核心组件。以下以 SQL 数据库(MySQL/PostgreSQL)为例,梳理核心设计要点:

1. Schema 与索引:平衡灵活性与可读性

  • Schema 设计:需具备一定灵活性(避免数据量达百万级后修改 Schema 的繁琐操作),但不可过度灵活 —— 若将所有数据存入 “value” JSON 列,或通过 “键值表” 存储任意数据,会将复杂度转移至应用层,还可能引发性能隐患。核心原则是人类可读:通过 Schema 即可大致理解 “存储内容及存储目的”。

  • 索引设计

    • 必要性:仅当表数据量极少(如仅几行)时可省略索引,否则必须添加 —— 索引是提升查询效率的核心手段。
    • 匹配查询场景:若常用email与type组合查询,需建立包含这两个字段的联合索引。
    • 字段顺序技巧:索引结构类似 “嵌套字典”,需将高基数字段(如email,值唯一度高)置于前方,避免出现 “查询email时先扫描所有同type数据” 的低效情况。
    • 避免过度索引:每新增一个索引,都会增加写操作的开销(写入数据时需同步更新索引)。

2. 突破数据库瓶颈:优化读写逻辑

在高流量应用中,数据库常成为性能瓶颈(即便应用层代码效率一般,如 Ruby on Rails 服务),原因是复杂请求可能需顺序执行数百次数据库调用。解决思路包括:

  • 最大化数据库处理能力:多表数据查询优先使用JOIN语句,而非多次查询后在内存中拼接;需警惕 ORM 框架的 “内循环查询”—— 例如将 “查询所有记录的 id 和 name” 拆分为 “先查询所有 id,再循环查询每个 id 对应的 name”,会导致查询次数从 1 次增至 101 次。
  • 复杂查询拆分:极少数情况下,拆分极复杂查询比优化索引更高效(尽管理论上可通过索引优化解决,但实际操作中拆分更易落地)。
  • 读写分离:采用 “1 主库 + 多从库” 架构,读请求优先分配至从库(主库已承担全部写操作)。仅当无法容忍 “毫秒级同步延迟” 时读取主库 —— 例如数据更新后,可直接在内存中使用更新后的值,避免立即查询主库。
  • 应对查询峰值:写请求与事务最易导致数据库过载(每笔操作需消耗大量计算资源)。若服务可能引发查询峰值(如批量导入 API),需实施 “节流” 策略控制请求频率,防止数据库因过载陷入 “越慢越拥堵” 的恶性循环。

代码示例:数据库索引与查询优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
-- 1. 合理的用户表Schema设计(人类可读,避免过度JSON化)
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
email VARCHAR(255) NOT NULL UNIQUE, -- 高基数字段
user_type VARCHAR(50) NOT NULL, -- 低基数字段(如"admin"/"user")
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- 2. 联合索引设计(高基数字段email在前,匹配常用查询场景)
CREATE INDEX idx_users_email_type ON users (email, user_type);

-- 3. 高效查询:用JOIN替代多次查询,避免内存拼接
-- 需求:查询用户及其所属组织信息
SELECT u.id, u.email, o.org_name
FROM users u
JOIN organizations o ON u.org_id = o.id
WHERE u.user_type = 'admin' AND u.created_at > '2024-01-01';

-- 4. 避免ORM内循环查询:一次性获取所需数据
-- 反例(低效):先查用户id,再循环查每个用户的详情
-- SELECT id FROM users WHERE user_type = 'user';
-- SELECT * FROM users WHERE id = 1; SELECT * FROM users WHERE id = 2; ...

-- 正例(高效):一次性获取所有目标用户详情
SELECT id, email, user_type FROM users WHERE user_type = 'user';

四、快慢操作:任务拆分与后台处理

系统需同时处理 “低延迟响应” 与 “长耗时操作”,核心策略是拆分任务,通过后台异步处理长耗时操作:

  • 快操作标准:用户交互场景(如 API 调用、网页加载)需在数百毫秒内返回响应(游戏领域虽要求 10 毫秒内,但实际成功产品中,用户可接受稍慢响应,前提是功能具备实用价值)。
  • 慢操作处理方式:优先完成 “对用户有直接价值的最小任务集”,剩余任务移交后台处理。例如转换大型 PDF 文件时,先返回第一页的 HTML 结果,其余页面通过后台任务异步处理。

后台任务的两种实现方式

  • 常规任务(短周期):采用 “队列 + 任务执行器” 架构 —— 队列(如 Redis)存储任务(格式示例:{job_name: “pdf_render”, params: {file_id: 123}}),任务执行器从队列中提取任务并运行,支持定时执行(如每日日志清理)。
  • 长期任务(长周期):Redis 不适用于存储 “一个月后执行” 的任务(持久化可靠性不足,且查询难度高),此时需改用数据库表:创建包含任务参数与scheduled_at(计划执行时间)字段的表,通过每日定时任务筛选 “scheduled_at ≤ 当日” 的记录,执行完成后标记状态或删除。

代码示例:后台任务实现(常规任务与长期任务)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# 1. 常规短周期任务(基于Redis队列,使用Celery框架)
from celery import Celery
import redis

# 初始化Celery(Redis作为消息队列)
redis_client = redis.Redis(host='localhost', port=6379, db=0)
app = Celery('pdf_tasks', broker='redis://localhost:6379/0')

# 定义PDF渲染任务(短周期,数分钟内完成)
@app.task
def render_pdf_page(file_id: str, page_num: int) -> str:
"""渲染PDF指定页面为HTML,返回HTML内容"""
# 模拟PDF渲染逻辑(实际需调用PDF处理库如PyPDF2)
pdf_content = redis_client.get(f"pdf:{file_id}")
html = f"<html><body>Page {page_num} of PDF {file_id}</body></html>"
return html

# 任务调用:先渲染第一页(即时响应),剩余页面入队列(后台处理)
def handle_pdf_request(file_id: str, total_pages: int):
# 1. 即时处理:渲染第一页,返回给用户
first_page_html = render_pdf_page(file_id, page_num=1)

# 2. 后台处理:剩余页面入队列
for page in range(2, total_pages + 1):
render_pdf_page.delay(file_id=file_id, page_num=page)

return {"first_page": first_page_html, "status": "processing_remaining_pages"}

# 2. 长期任务(基于数据库表,每日定时扫描)
import sqlite3
from datetime import datetime

# 初始化数据库表(存储长期任务)
conn = sqlite3.connect('long_term_tasks.db')
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS pending_tasks (
id TEXT PRIMARY KEY,
job_name TEXT NOT NULL,
params JSON NOT NULL,
scheduled_at TIMESTAMP NOT NULL,
status TEXT DEFAULT 'pending' -- pending/completed/failed
)
''')
conn.commit()

# 每日定时任务:扫描并执行到期任务
def daily_task_scanner():
today = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 查询今日及之前需执行的任务
cursor.execute('''
SELECT id, job_name, params FROM pending_tasks
WHERE scheduled_at <= ? AND status = 'pending'
''', (today,))
tasks = cursor.fetchall()

for task_id, job_name, params in tasks:
try:
# 执行任务(示例:发送月度账单)
if job_name == 'send_monthly_bill':
user_id = params['user_id']
amount = params['amount']
# send_bill_email(user_id, amount) # 实际发送邮件逻辑

# 标记任务完成
cursor.execute('''
UPDATE pending_tasks SET status = 'completed' WHERE id = ?
''', (task_id,))
conn.commit()
except Exception as e:
# 标记任务失败(后续可重试)
cursor.execute('''
UPDATE pending_tasks SET status = 'failed', error = ? WHERE id = ?
''', (str(e), task_id))
conn.commit()

五、缓存:缓解高成本操作的双刃剑

缓存用于解决 “重复执行高成本操作” 的问题,但需谨慎使用:

  • 适用场景:例如计费服务需调用外部 API 获取实时价格,若采用按次计费模式(如 OpenAI 按 token 收费),会导致 “响应延迟高 + 外部服务流量过载”,此时每 5 分钟查询一次价格并缓存,可显著优化性能。

  • 常见实现方案:内存缓存(实现简单但无法跨服务共享)、Redis/Memcached(支持跨服务共享,且读写速度快)。

  • 核心原则:先优化,再缓存:初级工程师易倾向 “缓存所有内容”,但资深工程师会尽量减少缓存使用 —— 因为缓存同样属于 “状态载体”,可能出现数据不一致、过期数据(stale data)等问题。例如,对于慢 SQL 查询,应优先添加索引,而非直接缓存查询结果。

  • 高级技巧:大结果缓存:若需缓存超大体积结果(如大客户的周度使用报表),Redis 存储能力不足时,可将结果按时间戳存入 S3 或 Azure Blob Storage,直接从存储服务返回文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import redis
import boto3
from datetime import timedelta
from typing import Dict

# 1. 常规缓存:Redis缓存外部API价格(5分钟过期)
redis_client = redis.Redis(host='localhost', port=6379, db=1)
PRICE_CACHE_TTL = timedelta(minutes=5).total_seconds() # 缓存过期时间

def get_price_from_external_api(product_id: str) -> float:
"""模拟调用外部API获取价格"""
# 实际逻辑:response = requests.get(f"https://api.example.com/price/{product_id}")
return 0.01 # 示例:假设每单位价格0.01元

def get_cached_price(product_id: str) -> float:
"""优先从缓存获取价格,缓存未命中则调用API并更新缓存"""
cache_key = f"price:{product_id}"

# 1. 尝试从缓存获取
cached_price = redis_client.get(cache_key)
if cached_price:
return float(cached_price)

# 2. 缓存未命中,调用API
price = get_price_from_external_api(product_id)

# 3. 更新缓存(设置过期时间,避免数据 stale)
redis_client.setex(cache_key, PRICE_CACHE_TTL, str(price))
return price

# 2. 大结果缓存:S3存储大客户周度报表(避免Redis内存不足)
s3_client = boto3.client('s3', aws_access_key_id='YOUR_KEY', aws_secret_access_key='YOUR_SECRET')
S3_BUCKET = 'report-cache-bucket'

def generate_weekly_report(customer_id: str) -> str:
"""生成大客户周度报表(模拟大体积结果,如100MB+)"""
# 实际逻辑:从数据库查询大量数据,生成详细报表
report_content = f"Weekly Report for Customer {customer_id}: ... (large content)"
return report_content

def get_cached_weekly_report(customer_id: str, week: str) -> str:
"""从S3获取缓存报表,不存在则生成并上传"""
# 构建S3对象键(包含时间戳,确保唯一性和可追溯性)
s3_key = f"weekly_reports/{customer_id}/{week}.html"

try:
# 1. 尝试从S3下载缓存
response = s3_client.get_object(Bucket=S3_BUCKET, Key=s3_key)
return response['Body'].read().decode('utf-8')
except s3_client.exceptions.NoSuchKey:
# 2. 缓存未命中,生成报表
report = generate_weekly_report(customer_id)

# 3. 上传到S3(作为持久化缓存)
s3_client.put_object(
Bucket=S3_BUCKET,
Key=s3_key,
Body=report,
ContentType='text/html'
)
return report

六、事件:解耦多服务通信的工具

多数企业会部署事件中心(如 Kafka),但该工具并非适用于所有场景:

  • 本质定义:事件中心是一种 “事件队列”,与后台任务队列的区别在于,其存储的是 “某事件已发生” 的信息,而非 “执行某任务” 的指令。例如 “新账户创建” 事件,可被 “发送欢迎邮件”“滥用行为扫描”“账户基础设施初始化” 等多个服务消费。
  • 使用边界:
    • 优先选择 API 调用:API 调用的日志集中、逻辑可追溯性强、能实时获取响应结果,多数场景下优于事件通信。
    • 适合使用事件的场景:发送方无需关注消费方行为(如 “新账户创建” 事件无需确认邮件是否发送成功),或事件流量大且对实时性要求低(如社交平台中每篇新内容的滥用扫描)。

代码示例:Kafka 事件通信实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
from kafka import KafkaProducer, KafkaConsumer
import json
from typing import Dict

# 1. 事件生产者:发送“新账户创建”事件
producer = KafkaProducer(
bootstrap_servers=['localhost:9092'],
value_serializer=lambda v: json.dumps(v).encode('utf-8')
)

def send_new_account_event(account_data: Dict[str, str]):
"""当新账户创建时,发送事件到Kafka"""
event = {
"event_type": "new_account_created",
"timestamp": json.dumps(datetime.now()),
"data": account_data # 包含账户核心信息:id、email、created_at等
}
# 发送到指定主题(topic)
producer.send('account_events', value=event)
producer.flush() # 确保事件被发送

# 2. 事件消费者1:接收事件并发送欢迎邮件
email_consumer = KafkaConsumer(
'account_events',
bootstrap_servers=['localhost:9092'],
value_deserializer=lambda m: json.loads(m.decode('utf-8')),
group_id='email_service_group' # 消费者组:确保事件被消费一次
)

def consume_for_email():
for message in email_consumer:
event = message.value
if event["event_type"] == "new_account_created":
account_email = event["data"]["email"]
# send_welcome_email(account_email) # 实际发送邮件逻辑
print(f"Sent welcome email to {account_email}")

# 3. 事件消费者2:接收事件并执行滥用扫描
abuse_consumer = KafkaConsumer(
'account_events',
bootstrap_servers=['localhost:9092'],
value_deserializer=lambda m: json.loads(m.decode('utf-8')),
group_id='abuse_scan_group'
)

def consume_for_abuse_scan():
for message in abuse_consumer:
event = message.value
if event["event_type"] == "new_account_created":
account_id = event["data"]["id"]
account_email = event["data"]["email"]
# scan_for_abuse(account_id, account_email) # 实际滥用扫描逻辑
print(f"Scanned abuse for account {account_id} ({account_email})")

七、数据流动:推模式与拉模式的选择

数据从 “源头” 传递至 “多个接收方” 时,存在两种核心模式,需根据场景选择:

  • 两种模式的核心差异

    • 拉模式(Pull):接收方主动请求数据,例如普通网站 —— 用户刷新邮箱时,浏览器向服务器请求最新邮件数据。缺点是易出现 “重复拉取相同数据” 的情况(如刷新页面时重新加载全部内容)。
    • 推模式(Push):接收方完成注册后,数据发生变化时由源头主动推送,例如 Gmail—— 新邮件到达后无需刷新页面,会自动显示。
  • 不同场景下的选择策略

    • 服务间通信:若数据更新频率低(如配置信息),推模式更优 —— 数据更新时向 100 个服务各发送 1 次请求,比 100 个服务每秒各拉取 1 次(累计 1000 次 / 秒)更高效。
    • 百万级客户端场景(如 Gmail):
      • 推模式:将推送任务加入事件队列,通过大量事件处理器从队列提取任务,分别向客户端推送数据。
      • 拉模式:部署数百台 “快速读从库缓存”(无需频繁与主库交互,可存储内存数据或磁盘静态数据),集中处理所有读请求。

代码示例:推模式与拉模式实现(服务间通信)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# 1. 拉模式(服务A主动拉取服务B的配置数据)
import requests
from time import sleep

def pull_config_from_service_b(service_a_id: str) -> Dict:
"""服务A每隔10秒拉取服务B的配置数据"""
while True:
try:
# 主动请求服务B的配置接口
response = requests.get(f"https://service-b.example.com/config?service_id={service_a_id}")
config = response.json()
# update_local_config(config) # 更新本地配置
print(f"Service A pulled config: {config}")
except Exception as e:
print(f"Pull config failed: {str(e)}")
sleep(10) # 每10秒拉取一次

# 2. 推模式(服务B主动推送配置到服务A)
from flask import Flask, request

app = Flask(__name__)
# 存储已注册的服务A实例(实际生产环境用分布式存储)
registered_services = set()

# 服务A注册接口:告知服务B“需要接收配置推送”
@app.route('/register', methods=['POST'])
def register_service():
service_id = request.json.get('service_id')
service_url = request.json.get('service_url')
registered_services.add((service_id, service_url))
return {"status": "success"}

# 配置更新时,主动推送到所有注册的服务A
def push_config_to_services(new_config: Dict):
"""服务B配置更新时,推送到所有已注册的服务A"""
for service_id, service_url in registered_services:
try:
# 主动推送配置到服务A的接收接口
requests.post(
f"{service_url}/config/update",
json={"service_id": service_id, "config": new_config}
)
print(f"Pushed config to service {service_id}")
except Exception as e:
print(f"Push to service {service_id} failed: {str(e)}")

# 模拟配置更新:当服务B配置变化时触发推送
new_config = {"max_requests": 1000, "timeout": 30}
push_config_to_services(new_config)

八、聚焦关键:热路径设计

系统设计无需追求 “全面覆盖”,应重点关注热路径(Hot Path):

  • 定义:系统中 “业务价值最高” 且 “数据流量最大” 的部分。例如在计量计费系统中,“判断是否向客户收费” 与 “接入所有用户行为以计算费用” 均属于热路径。
  • 重要性原因:
    • 解决方案局限性高:非热路径(如计费设置页面)的实现方式多样,而热路径(如处理用户行为数据流)的合理解决方案可能仅少数几种。
    • 故障影响范围广:非热路径故障(如设置页面异常)难以导致整体产品不可用,但触发所有用户行为的代码若存在漏洞,会直接引发全局故障。

代码示例:热路径设计实现(用户行为计费)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import redis
from typing import Dict

redis_client = redis.Redis(host='localhost:6379', db=2)
BILLING_RATE = 0.001 # 每行为单位计费价格

def process_user_action(user_id: str, action_type: str, action_data: Dict):
"""
热路径:处理用户行为并计算费用
特点:高并发(每秒数千次调用)、高准确性(计费不能出错)
"""
# 1. 快速过滤:仅对需计费的行为处理(减少无效计算)
billable_actions = {"api_call", "file_upload"}
if action_type not in billable_actions:
return {"status": "success", "billable": False}

# 2. 幂等性保障:避免重复计费(关键!热路径必须防重复)
action_id = action_data.get("action_id") # 唯一行为ID(如UUID)
if redis_client.exists(f"billed:{action_id}"):
return {"status": "success", "billable": True, "duplicate": True}

# 3. 轻量计算:减少热路径中的复杂逻辑(复杂计算移至后台)
# 示例:按API调用次数计费,直接用Redis自增计数
usage_key = f"usage:{user_id}:{action_type}:{datetime.now().strftime('%Y-%m')}"
redis_client.incr(usage_key) # 原子操作,确保计数准确

# 4. 标记已计费(先标记,再异步计算总费用,避免热路径阻塞)
redis_client.setex(f"billed:{action_id}", timedelta(days=30), "1") # 30天过期

# 5. 异步触发后续流程(非热路径:计算总费用、生成账单)
calculate_monthly_bill.delay(user_id, usage_key) # 后台任务(Celery等)

return {"status": "success", "billable": True, "duplicate": False}

九、可观测性:日志与监控体系

“提前发现系统问题” 比 “问题发生后修复” 更重要,核心依赖日志与监控:

  • 日志:重点记录 “异常路径”
    • 设计原则:在 “流程异常” 场景中增加日志输出。例如返回 422 响应时,记录 “触发 422 的具体条件”;计费逻辑中,记录 “因某原因不执行收费的决策依据”。
      核心价值:排查用户问题时不可或缺 —— 若重要客户遇到 422 错误,即便问题源于客户操作,也需明确 “具体操作行为”,不可为追求代码简洁省略关键日志。
  • 监控:关注 “核心指标 + 长尾延迟”
    • 运维指标:需监控主机 / 容器的 CPU 使用率、内存占用、队列长度、请求 / 任务的平均耗时。
    • 用户体验指标:必须关注p95/p99 延迟(即 95%/99% 的请求耗时低于该数值),而非仅依赖平均值。例如 100 个请求中,98 个耗时 100 毫秒、2 个耗时 10 秒,平均值仅 298 毫秒,但这 2 个慢请求可能来自核心客户,平均值会掩盖真实的用户体验问题。

代码示例:日志与监控实现(Python+Prometheus)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import logging
from prometheus_client import Counter, Histogram, start_http_server
from time import time

# 1. 日志配置:重点记录异常路径,包含关键上下文
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[logging.FileHandler("app.log"), logging.StreamHandler()]
)
logger = logging.getLogger("system_design")

def validate_user_input(input_data: Dict) -> bool:
"""验证用户输入,异常时记录详细日志"""
if "email" not in input_data:
# 记录“缺失字段”的异常日志,包含输入详情(脱敏敏感信息)
logger.error(
"User input validation failed: missing 'email' field. "
"Input (masked): %s",
{k: v for k, v in input_data.items() if k != "password"} # 脱敏密码
)
return False
if "@" not in input_data["email"]:
logger.warning(
"User input validation warning: invalid email format. "
"Email: %s",
input_data["email"]
)
return False
return True

# 2. 监控指标:统计请求量、错误率、延迟(含p95/p99)
# 初始化Prometheus指标
REQUEST_COUNT = Counter(
"http_requests_total",
"Total number of HTTP requests",
["endpoint", "method", "status_code"]
)
REQUEST_LATENCY = Histogram(
"http_request_duration_seconds",
"Duration of HTTP requests in seconds",
["endpoint"],
buckets=[0.1, 0.3, 0.5, 1.0, 3.0, 5.0] # 桶边界:用于计算p95/p99
)

# 监控装饰器:统计请求指标
def monitor_request(endpoint):
def decorator(func):
def wrapper(*args, **kwargs):
start_time = time()
status_code = 200

try:
# 执行实际请求处理函数
result = func(*args, **kwargs)
return result
except Exception as e:
status_code = 500
raise e
finally:
# 记录请求耗时
latency = time() - start_time
REQUEST_LATENCY.labels(endpoint=endpoint).observe(latency)
# 记录请求计数
REQUEST_COUNT.labels(
endpoint=endpoint,
method=kwargs.get("method", "GET"),
status_code=status_code
).inc()
return wrapper
return decorator

# 示例:用装饰器监控“用户注册”接口
@monitor_request(endpoint="/api/register")
def handle_register_request(method: str, data: Dict):
if not validate_user_input(data):
return {"status": "error"}, 422
# 实际注册逻辑:create_user(data)
return {"status": "success"}, 201

# 启动Prometheus暴露端口(默认9090)
start_http_server(9090)

十、容错设计:熔断、重试与优雅失败

系统故障不可避免,关键在于 “故障发生时减少影响范围”:

  • 重试的局限性与熔断的必要性

    • 避免盲目重试:若服务已返回 5xx 错误,盲目重试会增加服务负载,加剧故障严重程度。
    • 熔断机制的作用:对高流量 API,若连续收到大量 5xx 错误,应暂时停止发送请求(如暂停 30 秒),为服务预留恢复时间。
    • 写请求重试的注意事项:需使用 “幂等键(Idempotency Key)”—— 在请求中携带唯一 UUID,服务执行后存储该键;若收到相同键的请求,直接忽略,避免重复执行(如 “给用户计费” 请求返回 5xx,无法确定是否已计费,幂等键可防止重复扣费)。
  • 优雅失败:开放与关闭的权衡

    • 当部分系统故障时,需决策 “是否允许请求通过”:
      • 失败开放(Fail Open):故障时放请求通过。例如限流系统依赖的 Redis 不可用,暂时允许所有请求 —— 避免限流故障引发用户可见问题。
      • 失败关闭(Fail Closed):故障时拒绝请求。例如认证服务不可用,直接返回 401—— 宁可不允许合法用户访问,也不能泄露他人数据。
    • 多数场景无标准答案,需结合业务风险权衡。

代码示例:熔断与幂等性设计(Python+tenacity)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
from tenacity.before_sleep import before_sleep_log
from circuitbreaker import CircuitBreaker, CircuitBreakerError
import logging
import requests
import uuid

logger = logging.getLogger("fault_tolerance")

# 1. 熔断机制:使用circuitbreaker库,连续5次失败则熔断30秒
@CircuitBreaker(failure_threshold=5, recovery_timeout=30, fallback_function=None)
def call_external_payment_service(amount: float, user_id: str) -> Dict:
"""调用外部支付服务,熔断保护避免服务过载"""
try:
response = requests.post(
"https://payment-service.example.com/charge",
json={
"user_id": user_id,
"amount": amount,
# 幂等键:确保重复请求不会重复扣费(UUID唯一)
"idempotency_key": str(uuid.uuid4())
},
timeout=5.0
)
response.raise_for_status() # 触发HTTP错误(4xx/5xx)
return response.json()
except requests.exceptions.RequestException as e:
logger.error(f"Payment service call failed: {str(e)}")
raise # 抛出异常,让熔断器计数

# 2. 重试机制:仅对特定异常重试,且指数退避(避免瞬时故障)
@retry(
stop=stop_after_attempt(3), # 最多重试3次
wait=wait_exponential(multiplier=1, min=1, max=10), # 指数退避:1s→2s→4s
retry=retry_if_exception_type((requests.exceptions.Timeout, requests.exceptions.ConnectionError)),
before_sleep=before_sleep_log(logger, logging.WARNING) # 重试前打印日志
)
def call_retryable_service(api_url: str) -> Dict:
"""调用可重试服务(如读请求),仅对超时/连接异常重试"""
response = requests.get(api_url, timeout=3.0)
response.raise_for_status()
return response.json()

# 3. 优雅失败:限流系统Redis故障时的策略选择
def check_rate_limit(user_id: str) -> bool:
"""检查用户是否触发限流,Redis故障时选择“失败开放”"""
try:
# 正常逻辑:从Redis获取用户请求计数
redis_key = f"rate_limit:{user_id}"
current_count = redis_client.incr(redis_key)
if current_count == 1:
redis_client.expire(redis_key, 60) # 1分钟窗口
return current_count <= 100 # 限流阈值:1分钟100次
except Exception as e:
logger.error(f"Rate limit Redis failed: {str(e)}")
# 失败开放策略:Redis故障时允许请求通过(避免用户无法使用服务)
return True

# 4. 认证服务故障时的“失败关闭”策略
def authenticate_user(token: str) -> bool:
"""验证用户token,认证服务故障时拒绝访问"""
try:
# 正常逻辑:调用认证服务验证token
response = requests.post(
"https://auth-service.example.com/verify",
json={"token": token},
timeout=2.0
)
return response.json().get("valid", False)
except Exception as e:
logger.error(f"Auth service failed: {str(e)}")
# 失败关闭策略:认证服务故障时拒绝访问(避免数据泄露)
return False

十一、总结:好设计的本质是 “平淡”

本文未覆盖微服务拆分、容器 / VM 选择、链路追踪等话题 —— 要么是因为这些决策对 “系统好坏” 影响不大(如单体应用多数时候足够好),要么是太基础无需强调(如链路追踪是必备工具),要么是复杂度过高(如 API 设计需单独展开)。

核心观点始终是:好的系统设计,不是用花哨技巧,而是在正确的地方用成熟、经过验证的组件。就像优质的管道工程不会 “惊艳”—— 若操作太复杂,反而容易出现问题。

尤其在大型企业中,现成的基础设施(事件总线、缓存服务等)已足够完善,好的设计往往 “看不见”。实践中,“自定义数据结构实现关键功能” 的惊艳设计极少出现,而 “平淡的、可靠的设计”,才是支撑系统稳定运行的主流。