土法炼钢兴趣小组的算法知识备份

【系统架构设计百科】扩展性原理:水平、垂直与对角扩展

文章导航

分类入口
architecture
标签入口
#scalability#Amdahl#USL#horizontal-scaling#vertical-scaling

目录

一、从一次促销崩溃说起

2023 年双十一前夜,某中型电商平台的技术团队信心满满:他们把订单服务从 4 台机器扩到了 32 台,理论上吞吐量应该翻 8 倍。凌晨零点流量涌入,前 30 秒一切正常;第 31 秒开始,响应时间从 50ms 飙升到 3 秒,到第 45 秒时系统完全不可用。事后复盘发现:32 台订单服务实例都在竞争同一个库存表的行级锁,而分布式锁的续约风暴又让 Redis 集群的 CPU 打满。机器翻了 8 倍,吞吐量反而低于 4 台时的水平。

这不是偶然。扩展性(Scalability)有其数学边界,加机器不等于加性能。本文用两个核心模型——Amdahl 定律和通用可扩展性定律(Universal Scalability Law,USL)——量化这些边界,再给出工程上识别和消除瓶颈的具体方法。

二、三种扩展方向的定义与边界

垂直扩展(Vertical Scaling / Scale Up)

垂直扩展的含义是把单台机器换成更强的硬件:更多 CPU 核心、更大内存、更快磁盘。

具体例子:

垂直扩展的核心优势是简单——应用代码不需要任何改动。操作系统、数据库引擎、中间件都能自动利用更多的硬件资源。但它有一个刚性天花板:单台物理机的配置存在上限。2024 年市面上单台 x86 服务器最多 448 核(双路 Intel Xeon w9-3595X)、12TB 内存。到了这个上限,垂直扩展就走到了尽头。

此外,垂直扩展的成本曲线是超线性的。性能翻倍,价格往往翻 4-8 倍。一台 64 核服务器的价格远不止两台 32 核的总和。

水平扩展(Horizontal Scaling / Scale Out)

水平扩展是增加更多相同规格的节点,把负载分散到多台机器上。

具体例子:

水平扩展的理论上限远高于垂直扩展——只要架构允许,可以一直加机器。但它引入了分布式系统的全部复杂性:数据分片、一致性协调、网络分区、服务发现、故障检测。应用代码必须为分布式场景做专门设计。

对角扩展(Diagonal Scaling)

对角扩展是垂直和水平的组合策略:先把单台机器升级到性价比最优的配置,再通过增加节点数实现进一步扩展。

具体例子:

对角扩展是工程实践中最常见的策略。它在成本、复杂度和性能之间寻找最优平衡点。关键在于找到垂直扩展的边际效益拐点——超过这个点,每增加一单位硬件性能的成本增长速度快于性能增长速度,此时切换到水平扩展更划算。

三、Amdahl 定律:扩展性的硬上限

基本公式

Gene Amdahl 在 1967 年提出了一个简洁的模型来描述并行化加速比的理论上限。设一个任务中有比例为 σ 的部分必须串行执行,其余 (1 - σ) 的部分可以完美并行化,那么使用 N 个处理器时的加速比为:

S(N) = 1 / (σ + (1 - σ) / N)

其中:

极限行为

当 N 趋向无穷大时,(1 - σ) / N 趋向于 0,加速比的极限为:

S(∞) = 1 / σ

这意味着即使你拥有无限多的处理器,加速比也永远无法超过 1/σ。几个例子:

串行比例 σ 最大加速比 1/σ 含义
50% 2x 加再多机器,吞吐最多翻倍
10% 10x 10 台机器就接近上限
5% 20x 理论极限 20 倍,实际 16 台后收益递减
1% 100x 看起来很好,但 100 台以后几乎没有收益
0.1% 1000x 真正可扩展的系统

实际加速比随节点数的变化

下面用 Python 计算不同串行比例下加速比随节点数的变化:

def amdahl_speedup(n: int, sigma: float) -> float:
    """计算 Amdahl 定律的加速比。

    Args:
        n: 处理器(节点)数量
        sigma: 串行比例,取值 [0, 1]

    Returns:
        加速比 S(N)
    """
    return 1.0 / (sigma + (1.0 - sigma) / n)


# 不同串行比例下,1 到 64 个节点的加速比
serial_fractions = [0.01, 0.05, 0.10, 0.25, 0.50]
node_counts = [1, 2, 4, 8, 16, 32, 64]

print(f"{'节点数':<6}", end="")
for sigma in serial_fractions:
    print(f"{'σ=' + str(sigma):<10}", end="")
print()

for n in node_counts:
    print(f"{n:<6}", end="")
    for sigma in serial_fractions:
        s = amdahl_speedup(n, sigma)
        print(f"{s:<10.2f}", end="")
    print()

执行结果:

节点数  σ=0.01    σ=0.05    σ=0.10    σ=0.25    σ=0.50
1       1.00      1.00      1.00      1.00      1.00
2       1.98      1.90      1.82      1.60      1.33
4       3.88      3.48      3.08      2.29      1.60
8       7.48      5.93      4.71      2.91      1.78
16      13.91     9.14      6.40      3.37      1.88
32      24.43     12.55     7.80      3.66      1.94
64      39.26     15.42     8.77      3.82      1.97

σ = 0.50 的系统,64 台机器只换来 1.97 倍加速——63 台机器基本在空转。

Amdahl 定律的加速比曲线

graph LR
    subgraph "Amdahl 定律加速比曲线"
        direction TB
        A["σ = 1%<br/>极限 100x"] --- B["σ = 5%<br/>极限 20x"]
        B --- C["σ = 10%<br/>极限 10x"]
        C --- D["σ = 25%<br/>极限 4x"]
        D --- E["σ = 50%<br/>极限 2x"]
    end

    style A fill:#388bfd,color:#cdd9e5
    style B fill:#3fb950,color:#cdd9e5
    style C fill:#f0883e,color:#2d333b
    style D fill:#a371f7,color:#cdd9e5
    style E fill:#f85149,color:#cdd9e5

上图展示了不同串行比例对应的加速比极限。串行比例越大,扩展天花板越低。这解释了为什么减少串行比例比增加节点数更有效——把 σ 从 10% 降到 1%,等效于把天花板从 10x 提升到 100x。

如何识别串行比例

在真实系统中,串行比例 σ 不是一个直接可观测的数值,需要通过分析和测量来推断。以下是常见的串行化来源和识别方法:

全局锁与互斥量

最直接的串行化来源。Java 中的 synchronized 块、Go 中的 sync.Mutex、数据库中的表级锁或行级锁,都会让并行执行退化为串行。

// 典型的串行化瓶颈:全局计数器
public class OrderCounter {
    private long count = 0;
    private final Object lock = new Object();

    public void increment() {
        synchronized (lock) {  // 所有线程在此串行化
            count++;
        }
    }

    public long getCount() {
        synchronized (lock) {
            return count;
        }
    }
}

改进方案——使用 LongAdder 降低争用:

import java.util.concurrent.atomic.LongAdder;

public class OrderCounter {
    private final LongAdder count = new LongAdder();

    public void increment() {
        count.increment();  // 内部分段,减少争用
    }

    public long getCount() {
        return count.sum();
    }
}

LongAdder 内部维护多个 Cell,每个线程优先写入自己的 Cell,只在读取时汇总。这把串行比例从接近 100%(所有线程争同一个锁)降到了接近 0%(写操作几乎无争用)。

顺序 I/O 瓶颈

即使计算可以并行,如果所有结果都必须写入同一个文件或同一个数据库连接,I/O 就成为串行瓶颈。

import threading

results = []
lock = threading.Lock()

def process_and_write(item):
    result = heavy_computation(item)  # 可以并行
    with lock:                         # 写入时串行
        results.append(result)
        write_to_file(result)          # 串行 I/O

度量串行比例的方法

  1. 基准测试法:在 1、2、4、8、16 个节点下分别测量吞吐量,用最小二乘法拟合 Amdahl 公式,反推 σ 值
  2. 锁竞争分析:使用 perf lock(Linux)、async-profiler(JVM)、pprof(Go)等工具直接测量锁等待时间占比
  3. 火焰图分析:生成 On-CPU 和 Off-CPU 火焰图,Off-CPU 火焰图中因锁等待而阻塞的栈帧就是串行化的直接证据
# Linux perf 锁竞争分析
perf lock record -a -- sleep 30
perf lock report

# Go pprof 互斥量分析
go tool pprof http://localhost:6060/debug/pprof/mutex

# Java async-profiler 锁分析
./asprof -e lock -d 30 -f lock_profile.html <pid>

四、通用可扩展性定律(USL)

Amdahl 定律的局限

Amdahl 定律有一个关键假设:加速比单调递增。在 Amdahl 模型中,增加节点永远不会让性能变差,只是收益递减。但现实中,我们经常看到一个更糟糕的现象:节点数超过某个阈值后,吞吐量开始下降。开头那个电商案例就是典型——32 台反而不如 4 台。

Amdahl 定律遗漏了什么?答案是节点间的协调开销(Coherency Cost)。每当系统中多个节点需要就某个共享状态达成一致时,它们必须互相通信。通信的成本随节点数的增加而呈二次方增长——N 个节点之间有 N(N-1)/2 条潜在通信链路。

USL 公式

Neil Gunther 在 1993 年提出了通用可扩展性定律(Universal Scalability Law),在 Amdahl 定律的基础上增加了一个 coherency 参数 κ(kappa):

C(N) = N / (1 + σ(N - 1) + κN(N - 1))

其中:

三种退化模型

USL 公式涵盖了三种特殊情况:

条件 模型 行为
σ = 0,κ = 0 理想线性扩展 C(N) = N,完美线性
σ > 0,κ = 0 Amdahl 定律 单调递增,收益递减
σ > 0,κ > 0 USL 完整模型 先增后降,存在最优节点数

为什么会出现负扩展

当 κ > 0 时,C(N) 公式的分母中包含 κN(N-1) 这一项,它随 N 的增大呈二次方增长。当 N 足够大时,这一项会主导分母,使得 C(N) 开始减小。

最优节点数 N* 可以通过对 C(N) 求导并令其为零来计算:

N* = √((1 - σ) / κ)

超过 N* 后,每增加一个节点带来的协调开销大于其贡献的计算能力,系统吞吐量开始下降。

一致性开销的来源

以下是导致 κ 参数增大的典型场景:

分布式缓存失效(Cache Invalidation)

当一个节点更新了缓存数据,必须通知所有其他节点失效或更新对应的缓存条目。N 个节点意味着每次更新产生 N-1 条失效消息。

class DistributedCache:
    """模拟分布式缓存失效的开销。"""

    def __init__(self, nodes: list[str]):
        self.nodes = nodes
        self.invalidation_count = 0

    def update(self, key: str, value: str, source_node: str) -> int:
        """更新一个键值,并通知所有其他节点失效。

        返回本次更新产生的网络消息数。
        """
        messages_sent = 0
        for node in self.nodes:
            if node != source_node:
                self._send_invalidation(node, key)
                messages_sent += 1
        self.invalidation_count += messages_sent
        return messages_sent  # = N - 1

分布式共识协议

Raft、Paxos 等共识协议需要多数节点确认才能提交写入。领导者节点必须等待至少 (N/2 + 1) 个节点的 ACK。节点越多,等待时间越长,但延迟的增长方式取决于网络拓扑和协议实现。

3 节点 Raft:等待 2 个 ACK,1 轮网络往返
5 节点 Raft:等待 3 个 ACK,1 轮网络往返(但最慢的 ACK 决定延迟)
7 节点 Raft:等待 4 个 ACK,尾部延迟进一步增大

分布式事务

两阶段提交(2PC)中,协调者必须等待所有参与者的投票。一个慢节点会拖慢整个事务。N 个参与者意味着 2N 条消息(准备阶段 N 条 + 提交阶段 N 条)。

全局排序

某些系统需要对所有事件建立全局顺序(Total Order),比如数据库的 Serializable 隔离级别。这通常需要一个中心化的排序点(如 Google Spanner 的 TrueTime 或 CockroachDB 的 HLC),成为扩展瓶颈。

USL 的计算实现

import numpy as np
from scipy.optimize import curve_fit


def usl_throughput(n: np.ndarray, sigma: float, kappa: float) -> np.ndarray:
    """计算 USL 模型预测的相对吞吐量。

    Args:
        n: 节点数量数组
        sigma: 争用参数
        kappa: 一致性参数

    Returns:
        相对吞吐量数组(归一化到单节点 = 1)
    """
    return n / (1.0 + sigma * (n - 1.0) + kappa * n * (n - 1.0))


def fit_usl(node_counts: list[int], throughputs: list[float]) -> tuple[float, float]:
    """用最小二乘法拟合 USL 参数。

    Args:
        node_counts: 节点数量列表
        throughputs: 对应的相对吞吐量列表(归一化到单节点 = 1)

    Returns:
        (sigma, kappa) 元组
    """
    n = np.array(node_counts, dtype=float)
    c = np.array(throughputs, dtype=float)

    popt, _ = curve_fit(
        usl_throughput, n, c,
        p0=[0.01, 0.001],       # 初始猜测
        bounds=([0, 0], [1, 1])  # 参数范围
    )
    return popt[0], popt[1]


# 示例:用实测数据拟合 USL
measured_nodes = [1, 2, 4, 8, 16, 32, 48, 64]
measured_throughput = [1.0, 1.9, 3.5, 6.1, 9.2, 10.8, 9.5, 7.8]

sigma, kappa = fit_usl(measured_nodes, measured_throughput)
print(f"拟合结果:σ = {sigma:.6f},κ = {kappa:.6f}")

# 计算最优节点数
n_star = np.sqrt((1 - sigma) / kappa)
print(f"最优节点数 N* = {n_star:.1f}")

# 预测 1-128 节点的吞吐量
predicted_n = np.arange(1, 129)
predicted_c = usl_throughput(predicted_n, sigma, kappa)
peak_n = predicted_n[np.argmax(predicted_c)]
peak_c = np.max(predicted_c)
print(f"峰值吞吐量 {peak_c:.1f}x,出现在 {peak_n} 个节点")

USL 吞吐量曲线的三种形态

graph TD
    subgraph "USL 吞吐量曲线示意"
        direction LR
        L["线性扩展<br/>σ=0, κ=0<br/>C(N) = N"] -->|"理想但不现实"| R["现实世界"]
        A["Amdahl 型<br/>σ>0, κ=0<br/>收益递减"] -->|"串行瓶颈"| R
        U["USL 完整型<br/>σ>0, κ>0<br/>先升后降"] -->|"一致性开销"| R
    end

    style L fill:#3fb950,color:#2d333b
    style A fill:#f0883e,color:#2d333b
    style U fill:#f85149,color:#cdd9e5
    style R fill:#388bfd,color:#cdd9e5

上图总结了三种扩展模型的关系。线性扩展是理论极限,Amdahl 模型考虑了串行化瓶颈导致的收益递减,USL 完整模型还增加了节点间协调开销导致的负扩展。绝大多数分布式系统的行为都落在 USL 完整型的范围内。

五、识别与消除串行化瓶颈

知道了数学模型之后,工程上的核心问题变成了:如何在真实系统中找到那些贡献 σ 和 κ 的组件,并有针对性地优化?

全局锁和互斥量

全局锁是最常见的 σ 贡献者。一个被所有请求路径共享的锁会让系统的扩展性急剧恶化。

检测手段

# Java:使用 jstack 检测锁竞争
jstack <pid> | grep -A 5 "BLOCKED"

# Go:使用 pprof 生成互斥量 profile
curl -o mutex.prof http://localhost:6060/debug/pprof/mutex
go tool pprof -http=:8080 mutex.prof

# Linux:使用 perf 追踪 futex 系统调用
perf trace -e futex -p <pid> -- sleep 10 2>&1 | head -50

消除策略

  1. 分段锁(Striped Locking):把一个全局锁拆成多个分段锁,每个分段保护一部分数据。Java 的 ConcurrentHashMap 就是典型实现。
  2. 无锁数据结构:使用 CAS(Compare-And-Swap)操作替代锁。适用于简单的计数器、队列等场景。
  3. 读写锁分离:读多写少的场景使用 ReadWriteLock,允许多个读操作并行。
import java.util.concurrent.ConcurrentHashMap;

// 分段锁示例:按用户 ID 分段
public class UserBalanceService {
    private static final int SEGMENTS = 64;
    private final Object[] locks = new Object[SEGMENTS];
    private final ConcurrentHashMap<String, Long> balances = new ConcurrentHashMap<>();

    public UserBalanceService() {
        for (int i = 0; i < SEGMENTS; i++) {
            locks[i] = new Object();
        }
    }

    public void debit(String userId, long amount) {
        int segment = Math.abs(userId.hashCode() % SEGMENTS);
        synchronized (locks[segment]) {  // 只锁一个分段
            long current = balances.getOrDefault(userId, 0L);
            if (current < amount) {
                throw new IllegalStateException("余额不足");
            }
            balances.put(userId, current - amount);
        }
    }
}

数据库单写约束

关系型数据库的自增主键、唯一索引约束检查、外键校验等操作往往需要表级或页级的排他锁。在高并发写入场景中,这些锁成为主要的串行化瓶颈。

检测手段

-- MySQL:查看 InnoDB 锁等待
SELECT * FROM information_schema.innodb_lock_waits;

-- PostgreSQL:查看锁等待
SELECT
    blocked.pid AS blocked_pid,
    blocked.query AS blocked_query,
    blocking.pid AS blocking_pid,
    blocking.query AS blocking_query
FROM pg_catalog.pg_locks bl
JOIN pg_catalog.pg_stat_activity blocked ON bl.pid = blocked.pid
JOIN pg_catalog.pg_locks kl ON bl.locktype = kl.locktype
    AND bl.database IS NOT DISTINCT FROM kl.database
    AND bl.relation IS NOT DISTINCT FROM kl.relation
    AND bl.page IS NOT DISTINCT FROM kl.page
    AND bl.tuple IS NOT DISTINCT FROM kl.tuple
    AND bl.transactionid IS NOT DISTINCT FROM kl.transactionid
    AND bl.pid != kl.pid
JOIN pg_catalog.pg_stat_activity blocking ON kl.pid = blocking.pid
WHERE NOT bl.granted;

消除策略

  1. 使用 UUID 或雪花算法替代自增主键:避免自增序列的全局串行化
  2. 批量写入:把多次单行 INSERT 合并为一次批量 INSERT,减少锁获取次数
  3. 分库分表:按业务键拆分到多个物理表,让锁争用分散

顺序消息处理

消息队列中的顺序消费是另一个常见的串行化来源。当业务要求”同一用户的消息必须按顺序处理”时,一个用户的所有消息会被路由到同一个消费者线程。如果热点用户的消息量特别大,这个线程就成为瓶颈。

# 反模式:所有消息由单个消费者顺序处理
def consume_all(queue):
    while True:
        msg = queue.get()
        process(msg)  # 一次只能处理一条


# 优化:按用户 ID 分区,每个分区独立并行
def consume_partitioned(queues: dict[int, Queue], num_workers: int):
    """每个分区一个独立的消费者线程。"""
    threads = []
    for partition_id, queue in queues.items():
        t = threading.Thread(
            target=consume_partition,
            args=(partition_id, queue),
            daemon=True,
        )
        t.start()
        threads.append(t)
    return threads


def consume_partition(partition_id: int, queue: Queue):
    """单个分区内的消息按顺序处理,分区间完全并行。"""
    while True:
        msg = queue.get()
        process(msg)

共享可变状态

任何需要在多个节点间保持一致的可变状态都会产生 κ 开销。最常见的例子包括:

消除策略的核心思路:减少共享,或降低一致性要求。

  1. 无状态设计:把 Session 数据编码到 JWT Token 中,每个节点独立验证,不需要共享存储
  2. 最终一致性:在线人数不需要精确到个位数,可以每 10 秒汇总一次
  3. 本地缓存 + 异步更新:配置信息在本地缓存,通过消息队列异步推送更新

网络瓶颈

单点网络设备——负载均衡器、API 网关、DNS 服务器——也是串行化的来源。

瓶颈分析:单负载均衡器

客户端 ──→ [单台 LB] ──→ 应用服务器 1
                    ──→ 应用服务器 2
                    ──→ 应用服务器 3
                    ──→ ...
                    ──→ 应用服务器 N

问题:所有流量必须经过这一台 LB,它的带宽和连接数成为上限。

解决:
1. DNS 轮询 + 多台 LB(L4 层 ECMP)
2. 客户端直连(Service Mesh / 客户端负载均衡)
3. Anycast IP + BGP 路由

瓶颈识别工具汇总

工具 平台 用途
perf lock Linux 内核级锁竞争分析
async-profiler JVM Java/Kotlin 锁竞争、火焰图
pprof Go Go 程序互斥量、阻塞分析
py-spy Python Python 程序采样分析
bpftrace Linux 自定义内核探针,追踪任意系统调用
pt-query-digest MySQL 慢查询聚合分析
pg_stat_statements PostgreSQL SQL 执行统计
Wireshark / tcpdump 通用 网络层瓶颈分析

六、超线性扩展:罕见但真实存在

Amdahl 定律和 USL 都假设加速比的上限是 N(线性扩展)。但在极少数场景中,增加节点数可以获得超过 N 倍的加速比。这被称为超线性扩展(Superlinear Scaling)。

缓存效应

最常见的超线性扩展来源是缓存效应。考虑一个数据库查询服务,数据总量为 D,每台机器的内存缓存容量为 M。

10 台机器不仅分担了负载,还让每台机器的工作集完全装入了内存。缓存命中率从 10% 提升到 100%,这个效果叠加在负载分担之上,产生了超线性加速。

def effective_throughput(
    n_nodes: int,
    total_data_gb: float,
    cache_per_node_gb: float,
    disk_latency_ms: float,
    cache_latency_ms: float,
    requests_per_sec: float,
) -> float:
    """计算考虑缓存效应后的有效吞吐量。

    当数据分片后每个节点的工作集可以装入缓存时,
    吞吐量的提升会超过线性。
    """
    data_per_node = total_data_gb / n_nodes
    hit_rate = min(1.0, cache_per_node_gb / data_per_node)

    avg_latency = hit_rate * cache_latency_ms + (1 - hit_rate) * disk_latency_ms
    per_node_throughput = 1000.0 / avg_latency  # 每秒请求数(单连接)

    # 单节点基准(所有数据在一台机器上)
    base_hit_rate = min(1.0, cache_per_node_gb / total_data_gb)
    base_latency = base_hit_rate * cache_latency_ms + (1 - base_hit_rate) * disk_latency_ms
    base_throughput = 1000.0 / base_latency

    total_throughput = per_node_throughput * n_nodes
    speedup = total_throughput / base_throughput
    return speedup


# 示例:100GB 数据,每台机器 16GB 缓存
for n in [1, 2, 4, 8, 16]:
    s = effective_throughput(
        n_nodes=n,
        total_data_gb=100,
        cache_per_node_gb=16,
        disk_latency_ms=5.0,
        cache_latency_ms=0.1,
        requests_per_sec=1000,
    )
    print(f"{n} 节点:加速比 = {s:.1f}x({'超线性' if s > n else '亚线性'})")

CPU 缓存效应

类似的效应也发生在 CPU 缓存层面。当工作集超过单核 L3 缓存容量时,增加核数可以让每个核分到的工作集足够小,完全装入 L3 缓存。这种效应在科学计算领域比较常见。

超线性扩展的局限

超线性扩展本质上是一次性的阶梯效应,而非持续的趋势。一旦所有节点的工作集都装入了缓存,继续加节点就会回到亚线性扩展。因此,超线性扩展不能作为容量规划的依据——它是一个令人愉快的意外,不是可以依赖的设计属性。

七、实用扩展模式

按功能分区(微服务拆分)

把单体应用按业务功能拆分为独立服务,每个服务独立扩展。订单服务需要 32 个实例,用户服务只需要 4 个实例——按需分配,避免整体过度配置。

graph TB
    LB["负载均衡器"]
    LB --> US["用户服务<br/>4 实例"]
    LB --> OS["订单服务<br/>32 实例"]
    LB --> PS["商品服务<br/>8 实例"]
    LB --> SS["搜索服务<br/>16 实例"]

    OS --> DB1["订单库<br/>分片 x4"]
    US --> DB2["用户库<br/>主从"]
    PS --> DB3["商品库<br/>主从"]
    SS --> ES["Elasticsearch<br/>集群"]

    style LB fill:#388bfd,color:#cdd9e5
    style US fill:#3fb950,color:#2d333b
    style OS fill:#f0883e,color:#2d333b
    style PS fill:#a371f7,color:#cdd9e5
    style SS fill:#f85149,color:#cdd9e5
    style DB1 fill:#2d333b,color:#adbac7
    style DB2 fill:#2d333b,color:#adbac7
    style DB3 fill:#2d333b,color:#adbac7
    style ES fill:#2d333b,color:#adbac7

上图展示了按功能分区后的架构。每个服务根据自身负载特征独立选择扩展策略和实例数量。订单服务面临高写入压力,选择了分库分表;用户服务以读为主,选择了主从复制。

按数据分区(分片)

把数据按某个键(通常是业务主键的哈希值)分散到多个存储节点上。每个节点只负责一部分数据,查询和写入可以并行执行。

分片键的选择至关重要。好的分片键应该满足:

  1. 高基数(High Cardinality):键的取值空间足够大,数据能均匀分布
  2. 查询亲和(Query Affinity):大部分查询可以路由到单个分片,避免跨分片查询
  3. 增长均匀:不会随时间推移产生热点分片
import hashlib


def consistent_hash(key: str, num_shards: int) -> int:
    """简单的一致性哈希分片。

    Args:
        key: 分片键(如用户 ID)
        num_shards: 分片数量

    Returns:
        分片编号 [0, num_shards)
    """
    digest = hashlib.md5(key.encode()).hexdigest()
    return int(digest, 16) % num_shards


# 示例:4 个分片
for user_id in ["user_001", "user_002", "user_003", "user_004", "user_005"]:
    shard = consistent_hash(user_id, num_shards=4)
    print(f"{user_id} -> 分片 {shard}")

读写分离与复制

对于读多写少的系统(大部分 Web 应用的读写比在 10:1 到 100:1 之间),可以通过复制来扩展读能力:

# HAProxy 配置示例:读写分离
frontend mysql_frontend
  bind *:3306
  default_backend mysql_read

  # 写请求路由到主节点
  acl is_write_query req.payload(0,4) -m bin 03000000
  use_backend mysql_write if is_write_query

backend mysql_write
  server master 10.0.1.1:3306 check

backend mysql_read
  balance roundrobin
  server replica1 10.0.1.2:3306 check
  server replica2 10.0.1.3:3306 check
  server replica3 10.0.1.4:3306 check
  server replica4 10.0.1.5:3306 check

读写分离的主要代价是复制延迟(Replication Lag)。从节点的数据可能落后主节点几十毫秒到几秒。对于需要”写后读一致性”(Read-Your-Writes Consistency)的场景,需要额外的路由逻辑:用户刚写入的数据在短时间内从主节点读取。

队列削峰(Queue-Based Load Leveling)

对于突发流量,用消息队列在生产者和消费者之间做缓冲。生产者以任意速率写入队列,消费者以自身的最大处理能力匀速消费。

// 使用 Kafka 做订单削峰
@Service
public class OrderService {
    private final KafkaTemplate<String, OrderEvent> kafka;

    public OrderService(KafkaTemplate<String, OrderEvent> kafka) {
        this.kafka = kafka;
    }

    /**
     * 接收订单请求,写入 Kafka 队列。
     * 即使每秒涌入 10 万笔订单,Kafka 也能承受。
     * 下游消费者按自身能力(如每秒 1 万笔)匀速处理。
     */
    public CompletableFuture<Void> submitOrder(OrderRequest request) {
        OrderEvent event = OrderEvent.fromRequest(request);
        return kafka.send("orders", request.getUserId(), event)
                     .thenApply(result -> null);
    }
}

@Component
public class OrderConsumer {
    private final OrderProcessor processor;

    public OrderConsumer(OrderProcessor processor) {
        this.processor = processor;
    }

    /**
     *  Kafka 消费订单事件。
     * concurrency = 16 表示 16 个消费者线程并行处理。
     */
    @KafkaListener(topics = "orders", concurrency = "16")
    public void onOrder(OrderEvent event) {
        processor.process(event);
    }
}

队列削峰把峰值问题转化为了延迟问题——订单不会丢失,但处理时间会延长。系统设计时需要在响应时间和吞吐量之间做权衡。

八、工程案例:电商平台的 USL 拟合与容量规划

背景

某跨境电商平台的核心交易服务在 2024 年 Q2 进行了一次全面的容量规划。该服务负责订单创建、库存扣减和支付发起,部署在 Kubernetes 集群上,使用 MySQL 分片存储订单数据。

压测设计

团队在预发布环境中进行了阶梯式压测,逐步增加 Pod 数量并测量吞吐量。每个 Pod 的规格为 4 核 8GB,连接同一组 MySQL 分片(4 个分片)。

压测使用固定的请求速率模型:客户端以恒定速率发送请求,测量系统在不同 Pod 数量下的最大可持续吞吐量(P99 延迟不超过 200ms 的前提下)。

实测数据

Pod 数量    吞吐量(TPS)    相对吞吐量    P99 延迟(ms)
1           850             1.00         45
2           1,620           1.91         48
4           3,050           3.59         52
8           5,400           6.35         68
12          7,100           8.35         95
16          8,200           9.65         125
24          9,100           10.71        168
32          9,400           11.06        195
48          8,800           10.35        280
64          7,200           8.47         450

关键观察:吞吐量在 32 个 Pod 时达到峰值 9,400 TPS,之后开始下降。64 个 Pod 时吞吐量反而不如 24 个 Pod。P99 延迟在 Pod 数增加后持续上升,48 个 Pod 时已经超过了 200ms 的 SLA 阈值。

USL 拟合

用前文的 fit_usl 函数拟合实测数据:

nodes = [1, 2, 4, 8, 12, 16, 24, 32, 48, 64]
throughput = [1.00, 1.91, 3.59, 6.35, 8.35, 9.65, 10.71, 11.06, 10.35, 8.47]

sigma, kappa = fit_usl(nodes, throughput)
print(f"σ = {sigma:.6f}")
print(f"κ = {kappa:.6f}")

n_star = np.sqrt((1 - sigma) / kappa)
print(f"最优节点数 N* = {n_star:.1f}")

拟合结果:

σ = 0.027143(争用参数,约 2.7%)
κ = 0.000891(一致性参数)
最优节点数 N* = 33.1

结果分析

σ = 0.027(2.7% 的串行比例)

主要来源经排查为:

  1. MySQL 分片内的行级锁竞争(库存扣减操作)—— 占 σ 的约 60%
  2. 分布式 ID 生成器(雪花算法中的时钟同步等待)—— 占 σ 的约 25%
  3. 应用内的限流器锁 —— 占 σ 的约 15%

κ = 0.000891(一致性开销)

主要来源:

  1. Kubernetes Service 的 iptables 规则更新:Pod 数量增加时,iptables 规则链变长,每次连接建立的开销增大
  2. MySQL 连接池:每个 Pod 需要与 4 个分片各维持连接池,32 个 Pod 意味着 128 个连接池,MySQL 的连接管理开销显著增加
  3. 分布式跟踪(OpenTelemetry):链路追踪的上报量随 Pod 数量线性增长,Collector 成为瓶颈

优化措施

团队基于 USL 分析结果,采取了针对性优化:

优化措施 目标参数 效果
库存预扣减 + 异步确认 降低 σ σ 从 2.7% 降到 1.2%
本地 ID 生成(Snowflake 改进版) 降低 σ 消除时钟同步等待
限流器改为令牌桶(无锁实现) 降低 σ 消除限流器锁
iptables 换 IPVS 模式 降低 κ 连接建立开销不再随 Pod 数增长
MySQL ProxySQL 连接复用 降低 κ 后端连接数从 N*4 降到固定 32
追踪采样率从 100% 降到 10% 降低 κ Collector 不再成为瓶颈

优化后重新压测:

优化前:σ = 0.027,κ = 0.000891,N* = 33,峰值 TPS = 9,400
优化后:σ = 0.012,κ = 0.000120,N* = 91,峰值 TPS = 28,500

吞吐量上限提升了 3 倍,最优节点数从 33 增加到 91。团队最终选择部署 48 个 Pod,在 SLA 约束内提供约 25,000 TPS 的处理能力,足以应对预期的峰值流量(20,000 TPS)并留有 25% 的余量。

案例总结

这个案例说明了 USL 模型的工程价值:

  1. 定量诊断:σ 和 κ 分别量化了争用和协调两类不同性质的瓶颈,让优化有的放矢
  2. 容量预测:拟合出参数后,可以预测任意节点数下的吞吐量,避免盲目加机器
  3. 优化验证:优化前后对比 σ 和 κ 的变化,直接衡量优化效果
  4. 投资决策:N* 告诉团队”最多部署多少台有意义”,超过这个数就是浪费预算

九、三种扩展策略的综合对比

维度 垂直扩展(Scale Up) 水平扩展(Scale Out) 对角扩展(Diagonal)
定义 升级单机硬件 增加相同规格的节点 先升级硬件到最优配置,再增加节点
成本曲线 超线性(性能翻倍,成本翻 4-8 倍) 近似线性(成本与节点数成正比) 初期超线性,后期线性
应用改造成本 零或极低 高(需要分布式改造) 中(初始阶段无需改造)
运维复杂度 低(单机运维) 高(集群运维、自动化部署、监控) 中到高
故障域 单点故障(整台机器宕机影响全部) 分散故障(单节点故障只影响部分流量) 分散故障
数据一致性 天然一致(单机内存) 需要额外机制(复制延迟、分布式事务) 需要额外机制
理论最大容量 受限于单机物理极限 理论无上限(受 USL 约束) 理论无上限
扩展速度 慢(需要停机换硬件或迁移) 快(分钟级增加节点,如 K8s HPA) 取决于当前阶段
缩容弹性 差(硬件降级困难) 好(直接减少节点数) 好(减少节点数)
适用场景 数据库、中间件、遗留系统 无状态服务、计算密集型任务 大多数生产系统
串行化影响(σ) σ 对单机性能无影响 σ 限制加速比上限 σ 限制水平扩展阶段的上限
一致性开销影响(κ) 不存在(单机无协调开销) κ 决定最优节点数和负扩展拐点 κ 在水平扩展阶段开始起作用

选择决策流程

flowchart TD
    A["当前瓶颈是什么?"] --> B{"CPU / 内存 / 磁盘<br/>单项资源耗尽?"}
    B -->|"是"| C{"当前配置远低于<br/>硬件上限?"}
    C -->|"是"| D["垂直扩展<br/>升级对应硬件"]
    C -->|"否"| E{"应用是否<br/>可分布式化?"}
    B -->|"否"| F{"多项资源<br/>同时接近上限?"}
    F -->|"是"| E
    F -->|"否"| G["检查是否存在<br/>软件层瓶颈<br/>(锁、连接池、算法)"]
    E -->|"是"| H["水平扩展<br/>增加节点"]
    E -->|"否"| I["先重构为可<br/>分布式架构"]

    style A fill:#388bfd,color:#cdd9e5
    style D fill:#3fb950,color:#2d333b
    style H fill:#f0883e,color:#2d333b
    style G fill:#a371f7,color:#cdd9e5
    style I fill:#f85149,color:#cdd9e5

上图展示了扩展策略的选择流程。核心逻辑是:先判断瓶颈类型,再评估垂直扩展是否还有空间,最后考虑水平扩展。在任何扩展之前,都应该先排除软件层的串行化瓶颈——加机器不能解决锁竞争问题。

十、扩展性设计的核心原则

从 Amdahl 定律和 USL 出发,可以提炼出以下扩展性设计原则:

原则一:先降低 σ,再增加 N

减少串行比例比增加节点数更有效。σ 从 10% 降到 1% 等效于把扩展上限从 10x 提升到 100x。在加机器之前,先用性能分析工具找到最大的串行化瓶颈。

原则二:最小化跨节点协调

κ 决定了系统是否会出现负扩展。每一次跨节点的同步通信都在增大 κ。设计时应该:

原则三:度量优先,模型驱动

不要凭直觉做容量规划。通过阶梯式压测收集不同节点数下的吞吐量数据,拟合 USL 参数,然后基于模型做决策。这比”加到够用为止”的试探法更省钱,也更可靠。

原则四:注意缩容能力

能扩不能缩的系统在流量低谷期会浪费大量资源。设计时就要考虑优雅缩容:连接耗尽、请求排空、数据再平衡。Kubernetes 的 HPA(Horizontal Pod Autoscaler)是自动扩缩容的典型实现。

# Kubernetes HPA 配置示例
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: order-service-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: order-service
  minReplicas: 4
  maxReplicas: 64
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 60
      policies:
        - type: Percent
          value: 100
          periodSeconds: 60
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
        - type: Percent
          value: 25
          periodSeconds: 120

原则五:为扩展预留架构空间

即使当前流量不需要水平扩展,架构设计也应该预留扩展点:

  1. 无状态服务:不在应用进程内存中保存请求间的状态
  2. ID 无序化:使用 UUID 或雪花 ID 替代数据库自增 ID
  3. 配置外置:数据库连接串、缓存地址等配置从环境变量或配置中心读取
  4. 接口幂等:所有写接口支持幂等重试,为将来引入消息队列和重试机制做准备

十一、常见误区与陷阱

误区一:“线性扩展”是正常情况

线性扩展(C(N) = N)在理论上需要 σ = 0 且 κ = 0,即系统完全没有串行化部分,也没有任何跨节点协调。这在现实中几乎不存在。任何声称”线性扩展”的系统,要么是测试范围太小没有暴露瓶颈,要么是对”线性”的定义比较宽松。

误区二:加机器总能解决性能问题

USL 告诉我们,超过 N* 后加机器会让性能变差。在加机器之前,先用 USL 模型预测当前系统的 N。如果当前节点数已经接近 N,加机器不会有帮助——需要先优化 σ 和 κ。

误区三:微服务自动带来扩展性

把单体拆成微服务不会自动消除串行化瓶颈。如果所有微服务都在操作同一个数据库,串行化瓶颈只是从应用层转移到了数据库层。真正的扩展性需要数据层面的拆分——每个服务拥有自己的数据存储。

误区四:忽视一致性开销

很多架构设计过度关注 σ(串行化)而忽视 κ(一致性)。但在大规模集群中,κ 的影响往往更大——κ 乘以 N(N-1) 是二次方增长的。分布式缓存、分布式锁、服务发现的心跳、配置推送——这些都在悄悄增大 κ。

误区五:过早优化扩展性

在系统流量还很小的阶段就做复杂的分布式改造是过度工程。YAGNI(You Aren’t Gonna Need It)原则同样适用于扩展性设计。正确的做法是:在架构上预留扩展空间(无状态、幂等接口、配置外置),但不要在流量不足以证明必要性之前投入分布式改造的工程成本。

十二、数学附录:USL 参数的置信区间与模型验证

参数拟合的置信区间

拟合 USL 参数时,不能只报告点估计值,还应该提供置信区间。置信区间窄说明数据质量好、模型拟合可靠;置信区间宽说明数据不足或模型不够匹配。

from scipy.optimize import curve_fit
import numpy as np


def usl_model(n, sigma, kappa):
    return n / (1.0 + sigma * (n - 1.0) + kappa * n * (n - 1.0))


def fit_usl_with_ci(
    nodes: list[int],
    throughputs: list[float],
    confidence: float = 0.95,
) -> dict:
    """拟合 USL 参数并计算置信区间。

    Args:
        nodes: 节点数量列表
        throughputs: 归一化吞吐量列表
        confidence: 置信水平,默认 95%

    Returns:
        包含参数估计值和置信区间的字典
    """
    n = np.array(nodes, dtype=float)
    c = np.array(throughputs, dtype=float)

    popt, pcov = curve_fit(
        usl_model, n, c,
        p0=[0.01, 0.001],
        bounds=([0, 0], [1, 1]),
    )

    # 标准误差
    perr = np.sqrt(np.diag(pcov))

    # t 分布临界值(近似为正态分布)
    from scipy.stats import t
    alpha = 1 - confidence
    dof = len(nodes) - 2  # 自由度 = 数据点数 - 参数数
    t_crit = t.ppf(1 - alpha / 2, dof)

    sigma_est, kappa_est = popt
    sigma_err, kappa_err = perr

    return {
        "sigma": sigma_est,
        "sigma_ci": (
            max(0, sigma_est - t_crit * sigma_err),
            sigma_est + t_crit * sigma_err,
        ),
        "kappa": kappa_est,
        "kappa_ci": (
            max(0, kappa_est - t_crit * kappa_err),
            kappa_est + t_crit * kappa_err,
        ),
        "r_squared": 1 - np.sum((c - usl_model(n, *popt))**2)
                        / np.sum((c - np.mean(c))**2),
    }

模型验证:R-squared 与残差分析

R-squared 值衡量模型对数据的解释程度。对于 USL 拟合,R-squared > 0.95 通常说明模型匹配良好。如果 R-squared 较低,可能的原因包括:

  1. 数据中存在异常点:某次压测受到外部因素干扰(如 GC 停顿、网络抖动)
  2. 系统存在多个瓶颈:不同节点数范围内的主要瓶颈不同,单一 USL 模型无法捕捉
  3. 非稳态测量:压测时间不够长,系统没有达到稳定状态

残差分析的核心检查:残差应该是随机分布的,没有明显的趋势。如果残差呈现系统性的模式(如小 N 偏高、大 N 偏低),说明模型结构有问题。

压测数据的采集建议

为了获得可靠的 USL 拟合结果,压测数据的采集应该遵循以下原则:

  1. 至少 6 个数据点:节点数的取值范围要足够宽(如 1, 2, 4, 8, 16, 32, 64)
  2. 每个数据点多次测量:取中位数或去除最高最低后取平均值
  3. 预热阶段:每次切换节点数后,先运行 5-10 分钟预热(填充缓存、JIT 编译),再采集数据
  4. 稳态验证:确保吞吐量在采集窗口内是稳定的,没有持续上升或下降的趋势
  5. 控制变量:除节点数外,其他因素(客户端数量、数据库配置、网络带宽)保持不变

十三、总结

扩展性不是一个可以用”加机器”简单解决的问题。Amdahl 定律揭示了串行比例 σ 对扩展上限的刚性约束——即使只有 5% 的串行部分,加速比也永远无法超过 20 倍。USL 进一步揭示了一致性开销 κ 导致的负扩展——节点间的协调成本随节点数的平方增长,过了拐点 N* 后加机器反而会降低吞吐量。

工程实践中,扩展性优化的优先级应该是:

  1. 识别并消除串行化瓶颈(降低 σ)
  2. 减少跨节点协调(降低 κ)
  3. 选择合适的扩展策略(垂直、水平或对角)
  4. 用 USL 模型做数据驱动的容量规划

上一篇:空间架构

下一篇:无状态设计

参考资料

  1. Amdahl, G. M. “Validity of the Single Processor Approach to Achieving Large Scale Computing Capabilities.” AFIPS Conference Proceedings, 1967.
  2. Gunther, N. J. “A Simple Capacity Model of Massively Parallel Transaction Systems.” CMG Conference, 1993.
  3. Gunther, N. J. Guerrilla Capacity Planning: A Tactical Approach to Planning for Highly Scalable Applications and Services. Springer, 2007.
  4. Gunther, N. J. “The Practical Performance Analyst.” https://www.perfdynamics.com/
  5. Bondi, A. B. “Characteristics of Scalability and Their Impact on Performance.” WOSP, 2000.
  6. Abbott, M. L., Fisher, M. T. The Art of Scalability: Scalable Web Architecture, Processes, and Organizations for the Modern Enterprise. Addison-Wesley, 2015.
  7. Kleppmann, M. Designing Data-Intensive Applications. O’Reilly, 2017. Chapter 1: Reliable, Scalable, and Maintainable Applications.
  8. Burns, B. Designing Distributed Systems: Patterns and Paradigms for Scalable, Reliable Services. O’Reilly, 2018.
  9. Gregg, B. Systems Performance: Enterprise and the Cloud. Prentice Hall, 2020. Chapter 2: Methodology.
  10. Kubernetes Documentation. “Horizontal Pod Autoscaling.” https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-13 · architecture

【系统架构设计百科】架构质量属性:不只是"高可用高性能"

需求评审时写下的'高可用、高性能、高并发',到了架构设计阶段几乎无法落地——因为它们不是可执行的需求。本文从 SEI/CMU 的质量属性理论出发,用 stimulus-response 场景模型把模糊需求变成可量化、可验证的架构约束,并拆解属性之间的冲突与联动关系。

2026-04-13 · architecture

【系统架构设计百科】告警策略:如何避免"狼来了"

大多数团队的告警系统都在制造噪声而不是传递信号。阈值告警看似直观,实则产生大量误报和漏报,值班工程师在凌晨三点被叫醒,却发现只是一次无害的毛刺。本文从告警疲劳的工业数据出发,拆解基于 SLO 的多窗口燃烧率告警算法,深入 Alertmanager 的路由、抑制与分组机制,结合 PagerDuty 的告警疲劳研究和真实工程案例,给出一套可落地的告警策略设计方法。


By .