Skip to content

第 9 章 系统、数据库与分布式算法

第 81 节 并发控制

801 两阶段锁(2PL)

两阶段锁(Two-Phase Locking,2PL)是数据库并发控制的基石。它确保多个事务可以安全地同时运行,而不会破坏一致性。其核心思想很简单:一个事务首先获取它需要的所有锁,然后仅在完成后才释放它们。一旦开始解锁,就不能再锁定任何新资源,这使其具有两个截然不同的“阶段”。

我们要解决什么问题?

在数据库中,事务通常并行运行。如果没有协调,它们可能会相互干扰:

  • 一个事务读取了过时的数据
  • 另一个事务覆盖了未提交的更改
  • 或者两个事务试图获取对方的锁而导致死锁

我们需要一种方法来序列化并发事务,确保结果与它们一个接一个地运行相同。

这就是 2PL 的用武之地。它保证了冲突可串行化的调度,意味着没有竞态条件或交错混乱。

它是如何工作的(通俗解释)?

把事务想象成一个细心的购物者:

  1. 增长阶段 —— 拿取所有你需要的物品(锁)。
  2. 缩减阶段 —— 一旦你开始把物品放回去(释放锁),就不能再拿取任何新物品。

这个两阶段规则确保了顺序,没有事务可以在锁变更之间“插队”来破坏可串行性。

让我们看一个例子:

步骤事务 A事务 B持有的锁说明
1Lock(X)A:XA 开始增长阶段
2Lock(Y)A:X, B:YB 开始增长阶段
3Lock(Y)A:X,Y, B:Y冲突,等待
4Unlock(X), Unlock(Y)A 完成(缩减阶段)
5Lock(X)B:X,YB 继续

一旦 A 开始解锁,它就不能再上锁。这就是“两阶段”:先获取,后释放。

微型代码(简易版本)

C(概念模拟)

c
#include <stdio.h>
#include <stdbool.h>

typedef struct {
    bool growing;    // 增长阶段标志
    bool shrinking;  // 缩减阶段标志
} Transaction;

void lock(Transaction *t) {
    if (t->shrinking) {
        printf("进入释放阶段后无法获取锁!\n");
        return;
    }
    t->growing = true;
    printf("锁已获取。\n");
}

void unlock(Transaction *t) {
    t->shrinking = true;
    printf("锁已释放。\n");
}

int main() {
    Transaction T = {true, false};
    lock(&T);
    unlock(&T);
    lock(&T); // 在 2PL 中非法
}

输出:

锁已获取。
锁已释放。
进入释放阶段后无法获取锁!

为什么它很重要

  • 确保可串行性:所有调度都等价于某种串行顺序。
  • 基本原则:是更严格变体(如严格 2PL 和保守 2PL)的基础。
  • 防止脏读/脏写:保证并发下的一致性。
  • 广泛使用:关系型数据库(MySQL, PostgreSQL)的核心。

2PL 的类型

变体规则优势
基本 2PL先获取,后释放可串行性
严格 2PL持有锁直到提交避免级联中止
保守 2PL一次性锁定所有资源无死锁

动手试试

  1. 模拟两个涉及重叠数据(X, Y)的事务,并应用 2PL。
  2. 绘制一个锁时间线:显示每个事务何时获取/释放锁。
  3. 比较如果不使用锁的结果。
  4. 添加严格 2PL:持有锁直到提交,有什么变化?

测试用例

场景加锁顺序结果
T1 锁 X → T2 锁 Y → T1 锁 Y死锁(等待 Y)冲突
T1 锁 X, Y → 释放 → T2 锁 X正常可串行化
T1 解锁 X,然后锁 Y无效(违反 2PL)错误
所有锁持有直到提交(严格 2PL)安全可串行化

复杂度

  • 时间:每个事务 O(n)(锁/解锁操作)
  • 空间:锁表 O(#locks)

两阶段锁是你并发控制的护栏,它防止事务相互踩踏,确保每个结果都是一致、可预测且正确的。

802 严格两阶段锁(Strict 2PL)

严格两阶段锁(Strict 2PL)是 2PL 的一个更强版本,旨在简化恢复过程并防止级联中止。它遵循相同的两阶段规则:先增长,后收缩,但有一个重要的变化:在事务提交或中止之前,不会释放任何锁。

我们要解决什么问题?

基本的 2PL 保证了可串行化,但仍然存在一个微妙的问题: 如果一个事务读取了另一个未提交事务写入的数据,而前一个事务后来中止了,那么就会留下脏读。其他事务可能基于了本不应存在的数据进行计算。

严格 2PL 通过将所有解锁操作延迟到事务结束时来解决这个问题。这样,其他事务就无法读取或写入尚未完全提交的值。

它是如何工作的(通俗解释)?

想象一个谨慎的厨师在准备一道菜:

  • 在增长阶段,厨师获取所有食材(加锁)。
  • 在收缩阶段,他们释放所有东西,但只有在菜品上桌(提交或中止)之后。

这确保了没有人品尝(读取)或借用(写入)一道半成品菜肴。

让我们比较一下行为:

事务步骤操作持有的锁备注
T11Lock(X)X获取锁
T22Lock(X)等待必须等待 T1
T13Write(X), CommitX仍然持有锁
T14Unlock(X)仅在提交时解锁
T25Lock(X), Read(X)X读取已提交的值

没有事务能看到未提交的数据,即使在运行过程中发生崩溃,也能保证安全。

微型代码(简易版本)

C (锁管理器模拟)

c
#include <stdio.h>
#include <stdbool.h>

typedef struct {
    bool locked;
    bool committed;
} Lock;

void acquire(Lock *l) {
    if (l->locked) {
        printf("等待:锁已被持有。\n");
        return;
    }
    l->locked = true;
    printf("锁已获取。\n");
}

void release(Lock *l) {
    if (!l->committed) {
        printf("不能在提交前释放锁(严格 2PL)\n");
        return;
    }
    l->locked = false;
    printf("锁已释放。\n");
}

void commit(Lock *l) {
    l->committed = true;
    printf("事务已提交。\n");
    release(l);
}

int main() {
    Lock x = {false, false};
    acquire(&x);
    commit(&x);
}

为什么它很重要

  • 防止级联中止,未提交的数据永远不会被读取。
  • 简化恢复,回滚只影响失败的事务。
  • 确保严格调度,所有读/写都遵循提交顺序。
  • 行业标准,用于主要的 DBMS 引擎以保证 ACID 安全性。

示例时间线

时间T1 操作T2 操作共享数据备注
1Lock(X), Write(X=5)X=5 (已锁定)T1 拥有 X
2Read(X)?等待T2 必须等待
3CommitX=5 (已提交)安全
4Unlock(X)锁被释放
5Read(X=5)OKT2 读取干净数据

动手试试

  1. 模拟 T1 写入 X=10 和 T2 在 T1 提交前读取 X。
    • 展示严格 2PL 会阻塞 T2。
  2. 在提交前添加一个回滚操作,确认 T2 永远不会读取脏数据。
  3. 可视化锁表(资源 → 所有者)。
  4. 与基本 2PL 比较,如果 T1 提前释放锁会发生什么?

测试用例

场景严格 2PL 行为结果
T1 写入 X,T2 在提交前读取 XT2 等待无脏读
T1 在 T2 读取后中止不可能安全
T1 在提交前解锁(违反规则)错误不一致
所有锁在提交时释放OK可串行化 + 可恢复

复杂度

  • 时间:O(n) (每次加锁/解锁)
  • 空间:O(#锁定项数)

严格 2PL 牺牲了一点并发性来换取有保障的安全性。它是 ACID 合规性的黄金标准:所有读取都是干净的,所有写入都是持久的,所有调度都是严格的。

803 保守两阶段锁(C2PL)

保守两阶段锁(C2PL)将 2PL 原则更进一步,它通过强制事务在开始任何工作之前,就在最开始锁定它所需的一切资源,从而完全避免了死锁。即使只有一个锁不可用,事务也会等待,而不是部分锁定并冒着循环等待的风险。

我们要解决什么问题?

在基本的 2PL 中,事务在运行过程中获取锁。这种灵活性很方便但也有风险,如果两个事务以不同的顺序获取资源,它们可能会发生死锁(彼此永远等待对方)。

死锁示例:

  • T1 锁定了 X,想要 Y
  • T2 锁定了 Y,想要 X

两者永远等待,典型的僵局。

保守 2PL 通过提前规划来避免这种混乱。它规定:"如果你现在不能获得所有锁,就不要开始。" 这是提前等待,而不是事后等待,用吞吐量换取确定性。

它是如何工作的(通俗解释)?

可以把它想象成下棋:在你移动之前,你必须声明本轮你将触碰的所有棋子。如果任何棋子已被占用,你就退出并稍后再试。

步骤:

  1. 声明所需的所有锁(例如,{X, Y})。
  2. 一次性请求所有锁。
  3. 如果全部被授予,则运行事务。
  4. 如果有任何锁被拒绝,则释放所有已获得的锁并等待。
  5. 只有在提交或中止后才释放所有锁。

这种"全有或全无"的锁获取方式确保了没有循环等待,因为没有事务会部分持有任何资源。

步骤事务动作锁表状态说明
1T1 请求 {X, Y}授予X:T1, Y:T1T1 可以继续执行
2T2 请求 {Y, Z}等待Y 已被锁定避免部分锁定
3T1 提交,释放锁释放锁被清除
4T2 重试 {Y, Z}授予Y:T2, Z:T2安全

不可能发生死锁,每个事务要么获得所有锁,要么什么也得不到。

微型代码(概念模拟)

C (预声明锁集合)

c
#include <stdio.h>
#include <stdbool.h>

typedef struct {
    bool X;
    bool Y;
} LockTable;

bool acquire_all(LockTable *table, bool needX, bool needY) {
    if ((needX && table->X) || (needY && table->Y)) {
        printf("无法立即获取所有锁,等待中...\n");
        return false;
    }
    if (needX) table->X = true;
    if (needY) table->Y = true;
    printf("所有锁已获取。\n");
    return true;
}

void release_all(LockTable *table, bool needX, bool needY) {
    if (needX) table->X = false;
    if (needY) table->Y = false;
    printf("所有锁已释放。\n");
}

int main() {
    LockTable table = {false, false};
    if (acquire_all(&table, true, true)) {
        printf("事务运行中...\n");
        release_all(&table, true, true);
    }
}

为什么它很重要

  • 无死锁:事务永远不会在锁定过程中阻塞另一个事务。
  • 可预测的行为:事务要么运行,要么等待。
  • 安全的调度:非常适合实时或关键系统。
  • 简单的恢复:运行中的依赖关系更少。

权衡:并发性较低,即使资源稍后可能会释放,等待也会提前发生。

对比表

特性基本 2PL严格 2PL保守 2PL
可串行化
防止级联中止
防止死锁
锁定时机按需持有到提交全部在开始时

自己动手试试

  1. 模拟两个事务 (T1:{X,Y}, T2:{Y,X})。
    • 用基本 2PL 试试 → 死锁。
    • 用 C2PL 试试 → 一个事务提前等待,无死锁。
  2. 为每个资源构建一个锁请求队列。
  3. 实验部分锁被拒绝的情况 → 事务重试。

测试用例

场景锁请求结果
T1:{X,Y}, T2:{Y,X}全有或全无无死锁
T1:{A,B,C} 已授予,T2:{B} 等待有序访问安全
部分锁授予拒绝等待
所有锁空闲授予立即运行

复杂度

  • 时间:每次锁请求 O(n)(检查可用性)
  • 空间:O(#资源 × #事务)

保守 2PL 是你的和平维护者,通过提前思考,它避免了运行中争用的陷阱。是的,它很谨慎,但在那些可预测性比速度更重要的系统中,这是一个明智的选择。

804 时间戳排序(TO)

时间戳排序(Timestamp Ordering,TO)是一种非锁定的并发控制方法,它通过时间戳对所有事务进行排序。它不使用锁,而是使用逻辑时间来确保并发执行的结果等同于基于事务开始时间的某种串行顺序。

我们要解决什么问题?

像 2PL 这样的基于锁的协议通过阻塞事务来防止冲突,这可能导致死锁或等待。时间戳排序避免了这一点。

其思想是:每个事务在开始时获得一个时间戳。每次读或写都必须遵守该顺序。如果一个操作会违反该顺序,则事务回滚并重新启动。

因此,TO 不是阻塞,而是说:

"如果你来得太晚,就重新开始。不要排队等待。"

它是如何工作的(通俗解释)?

想象一个图书馆借阅系统,每个读者都有一个票号。只有当你的号码符合时间顺序时,你才能借书或还书;如果你来晚了却试图改写历史,图书管理员(调度器)会拒绝你的请求。

每个数据项 X 保存:

  • RT(X):读时间戳(读取 X 的最大时间戳)
  • WT(X):写时间戳(写入 X 的最大时间戳)

当一个时间戳为 TS(T) 的事务 T 尝试读或写时,我们比较时间戳:

操作条件动作
Read(X)如果 TS(T) < WT(X)中止(太晚,数据已过时)
Write(X)如果 TS(T) < RT(X)TS(T) < WT(X)中止(冲突)
其他情况安全执行并更新时间戳

没有锁,没有等待,只是根据逻辑时间进行即时验证。

示例演练

步骤事务操作条件动作说明
1T1 (TS=5)Write(X)OKWT(X)=5写入 X
2T2 (TS=10)Read(X)10 > 5OK读取 X
3T1 (TS=5)Read(Y)OKRT(Y)=5读取 Y
4T2 (TS=10)Write(X)10 > RT(X)=10OKWT(X)=10
5T1 (TS=5)Write(X)5 < WT(X)=10Abort太晚了

T1 尝试在一个较新的事务修改了 X 之后进行写入,这是不允许的。

微型代码(概念示例)

C(简化时间戳检查)

c
#include <stdio.h>

typedef struct {
    int RT; // 读时间戳
    int WT; // 写时间戳
} DataItem;

int read_item(DataItem *x, int TS) {
    if (TS < x->WT) {
        printf("Abort: read too late (TS=%d, WT=%d)\n", TS, x->WT);
        return 0;
    }
    if (TS > x->RT) x->RT = TS;
    printf("Read successful (TS=%d)\n", TS);
    return 1;
}

int write_item(DataItem *x, int TS) {
    if (TS < x->RT || TS < x->WT) {
        printf("Abort: write too late (TS=%d, RT=%d, WT=%d)\n", TS, x->RT, x->WT);
        return 0;
    }
    x->WT = TS;
    printf("Write successful (TS=%d)\n", TS);
    return 1;
}

int main() {
    DataItem X = {0, 0};
    write_item(&X, 5);
    read_item(&X, 10);
    write_item(&X, 5); // 中止
}

为什么它重要

  • 无死锁,没有等待或循环等待
  • 确定性顺序,基于时间戳
  • 乐观,事务自由进行,每次访问时进行验证
  • 适用于读密集型工作负载,冲突更少,吞吐量更高

缺点:如果许多并发写入在同一数据上发生冲突,则中止率很高。

变体

变体描述使用场景
基本 TO在每次操作时检查时间戳简单数据库
Thomas 写规则忽略过时的写入而不是中止减少中止
多版本 TO与快照结合(MVCC)现代系统(例如 PostgreSQL)

亲自尝试

  1. 为 T1=5,T2=10 分配时间戳。
    • 让 T1 写 X,然后 T2 写 X,允许。
    • 现在 T1 尝试再次写 X,应该中止。
  2. 添加第三个事务 T3 (TS=15),进行读写操作,跟踪时间戳更新。
  3. 与 2PL 比较结果,等待和中止有何不同?

测试用例

场景条件结果
T1 (TS=5) 在 T2 (TS=10) 写入后读取5 < WT(X)=10Abort
T2 (TS=10) 在 T1 (TS=5) 读取后写入10 > RT(X)=5OK
T1 (TS=5) 在 T2 (TS=10) 写入后写入5 < WT(X)=10Abort
T2 (TS=10) 读取 T1 (TS=5) 写入的 X10 > WT(X)=5OK

复杂度

  • 时间:每次访问 O(1)(时间戳检查)
  • 空间:O(#items) 用于存储 RTWT

时间戳排序用回滚换取了等待,事务可以快速前进,但如果到达顺序不对,则可能被回滚。它在乐观性和顺序性之间取得了优雅的平衡,非常适合那些重视速度而非争用的系统。

805 多版本并发控制 (MVCC)

多版本并发控制 (MVCC) 是一种基于快照的并发方法,它允许读取者和写入者和平共处。它不使用锁来相互阻塞,而是每次写入都会创建数据的一个新版本,而每个读取者看到的是数据库在其开始读取时的一个一致性快照。

我们要解决什么问题?

在传统的锁方案中,读取者会阻塞写入者,写入者也会阻塞读取者,这会拖慢混合了读写操作的工作负载。

MVCC 颠覆了这种模式。读取者不会阻塞写入者,因为它们读取的是旧的已提交版本;写入者也不会阻塞读取者,因为它们写入的是新版本。

结果是:高并发性、无脏读,并且每个事务都有一致的视图。

它是如何工作的?(通俗解释)

想象一个图书馆,没有人会争抢同一本书的单一副本。 每次有写入者更新一本书时,他们就会制作一个新版本。读取者继续阅读他们进入图书馆时借阅的那个版本。

每个版本都有:

  • WriteTS – 写入时间戳
  • ValidFrom, ValidTo – 版本的有效时间范围
  • 数据值

当一个事务开始时,它会获得一个快照时间戳(它看待时间的方式)。

  • 读取者看到的是 WriteTS ≤ 快照时间戳 的最新版本。
  • 写入者在提交时创建新版本,并将旧版本标记为过期。

示例演练

步骤事务操作版本表 (X)对谁可见
1T1 (TS=5)写入 X=10X₁: {值=10, WriteTS=5}所有 TS ≥ 5
2T2 (TS=8)读取 X看到 X₁ (TS=5)正常
3T3 (TS=12)写入 X=20X₂: {值=20, WriteTS=12}所有 TS ≥ 12
4T2 (TS=8)再次读取 X仍然是 X₁快照隔离
5T2 提交一致性快照

即使 T3 写入了新数据,T2 仍然看到其旧的快照,没有不一致,也没有阻塞。

微型代码(概念示例)

C 语言(简化版本表)

c
#include <stdio.h>

typedef struct {
    int value;
    int writeTS;
} Version;

Version versions[10];
int version_count = 0;

void write_value(int ts, int val) {
    versions[version_count].value = val;
    versions[version_count].writeTS = ts;
    version_count++;
    printf("写入: X=%d 于 TS=%d\n", val, ts);
}

int read_value(int ts) {
    int visible = -1;
    for (int i = 0; i < version_count; i++) {
        if (versions[i].writeTS <= ts)
            visible = i;
    }
    printf("读取: X=%d (TS=%d)\n", versions[visible].value, ts);
    return versions[visible].value;
}

int main() {
    write_value(5, 10);
    write_value(12, 20);
    read_value(8);  // 看到版本 5
    read_value(15); // 看到版本 12
}

为什么它很重要

  • 读取者永不阻塞,它们读取一致性快照。
  • 写入者永不阻塞读取者,它们添加新版本。
  • 防止脏读、不可重复读,确保快照隔离。
  • 被主流数据库采用:PostgreSQL、Oracle、CockroachDB。

权衡:需要版本清理(垃圾回收)来移除过时的数据。

关键概念

概念描述
快照隔离每个事务在开始时看到一个固定的快照
版本链每个数据项的旧值链表
垃圾回收移除不再可见的旧版本
冲突检测写入者在提交前检查是否有重叠的更新

自己动手试试

  1. 模拟 T1 (TS=5) 写入 X=10,T2 (TS=8) 读取 X,T3 (TS=12) 写入 X=20。
  2. 让 T2 在 T3 写入前后都读取 X,快照保持稳定。
  3. 添加垃圾回收:移除 WriteTS < min(活动 TS) 的版本。
  4. 与加锁方式比较:在行为和并发性上有何不同?

测试用例

场景行为结果
读取者在写入者之前开始读取旧版本一致
写入者在读取者之前开始读取者仅在写入者提交后才能看到写入无脏读
并发写入新的版本链冲突检测
长时间运行的读取快照保持固定可重复读

复杂度

  • 时间:查找可见版本为 O(每个项目的版本数)
  • 空间:在垃圾回收前为 O(总版本数)

MVCC 就像一个时光旅行数据库,每个事务都拥有自己一致的世界。通过将冲突转化为共存,它为现代关系型和分布式数据库背后的高性能、非阻塞系统提供了动力。

806 乐观并发控制 (OCC)

乐观并发控制 (OCC) 假设冲突很少发生,因此事务可以在没有锁的情况下运行,只在最后验证阶段检查冲突。如果未发现冲突,事务提交;如果存在冲突,则回滚并重试。

我们要解决什么问题?

加锁(如 2PL)通过阻塞访问来防止冲突,但这意味着等待和死锁。在冲突不频繁的读密集型工作负载中,这种做法是浪费的。

OCC 翻转了思维方式:

"让每个人自由运行。我们将在终点线检查问题。"

这种方法通过将执行与验证分离,最大限度地提高了并发性,尤其是在低竞争系统中。

它是如何工作的(通俗解释)?

想象一个小组项目,每个人编辑自己的副本,最后老师比较笔记。如果没有两个人修改了同一部分,所有合并都成功;否则,有人必须重做。

OCC 让每个事务经历三个阶段:

阶段描述
1. 读阶段事务读取数据,制作本地副本,计算更改。
2. 验证阶段提交前,检查与已提交事务的冲突。
3. 写阶段如果验证通过,原子性地应用更新。否则,中止。

在本地读取或写入时不使用锁,只在提交前的验证检查决定成功与否。

示例演练

步骤事务阶段操作结果
1T1读取 X=5本地副本
2T2读取 X=5本地副本
3T1计算X=5+1本地更改 (6)
4T2计算X=5+2本地更改 (7)
5T1验证无冲突 (T2 未提交)提交 X=6
6T2验证冲突:自读取后 X 已更改中止并重试

两者并行工作;T2 必须重试,因为它的读集与已更改项重叠。

微型代码(概念模拟)

C (简单 OCC 示例)

c
#include <stdio.h>
#include <stdbool.h>

typedef struct {
    int value;
    int version;
} DataItem;

bool validate(DataItem *item, int readVersion) {
    return item->version == readVersion;
}

bool commit(DataItem *item, int *localValue, int readVersion) {
    if (!validate(item, readVersion)) {
        printf("Abort: 数据已被另一事务更改。\n");
        return false;
    }
    item->value = *localValue;
    item->version++;
    printf("Commit 成功。新值 = %d\n", item->value);
    return true;
}

int main() {
    DataItem X = {5, 1};
    int local = X.value;
    local += 1;
    commit(&X, &local, 1);  // 成功
    int local2 = X.value;
    local2 += 2;
    commit(&X, &local2, 1); // 中止 (版本已更改)
}

为什么它重要

  • 高并发性,执行期间无锁。
  • 无死锁,事务不会相互阻塞。
  • 非常适合读密集型工作负载,其中冲突很少。
  • 清晰的验证逻辑,易于推理正确性。

权衡:如果冲突很多,工作会浪费,事务可能频繁重复。

验证规则(简化版)

每个事务 T 有:

  • 读集 (RS) – 读取的项
  • 写集 (WS) – 写入的项
  • TS(T) – 时间戳

在提交时,如果对于每个已提交的 Tᵢ,满足以下条件之一,则 T 通过验证:

  • Tᵢ 在 T 开始前完成,或
  • RS(T) ∩ WS(Tᵢ) = ∅ (无重叠)

如果发现冲突 → 中止并重试。

自己动手试试

  1. 运行两个读取 X 的事务,两者都写入新值。
  2. 顺序验证,看看哪个通过。
  3. 添加第三个只读事务,应该总是通过。
  4. 改变读/写集之间的重叠以测试冲突检测。

测试用例

场景冲突结果
两个事务读取相同数据,一个写入两者都提交
两个写入相同数据一个中止
只读事务总是提交
高竞争度频繁多次重试

复杂度

  • 时间:验证期间为 O(#活跃事务)
  • 空间:每个事务为 O(#读/写集)

乐观并发控制是数据库领域的"信任但要核实",让事务先行,然后在敲定前进行复核。在竞争很少的工作负载中,OCC 以近乎无锁的性能和清晰的序列化结果而表现出色。

807 可序列化快照隔离 (SSI)

可序列化快照隔离 (SSI) 是一种混合并发控制方案,它结合了 MVCC 的速度和完全可序列化的安全性。它建立在快照隔离 (SI) 之上,每个事务都能看到一个一致的快照,并增加了冲突检测以防止仅靠 SI 无法捕获的异常。

我们要解决什么问题?

快照隔离 (如 MVCC 中) 避免了脏读和不可重复读,但它并非完全可序列化。 它仍然可能产生写偏斜异常,即两个事务读取重叠的数据,并写入互不相交但相互冲突的更新。

写偏斜示例:

  • T1: 读取 X, Y → 更新 X
  • T2: 读取 X, Y → 更新 Y 两者都认为条件成立并提交,但它们一起破坏了一个不变式 (例如,"X + Y ≥ 1")。

SSI 通过检测危险的依赖模式并中止那些会违反可序列化性的事务来修复此问题。

它是如何工作的 (通俗解释)?

想象每个事务都戴着快照眼镜在数据库中行走。 它们看到的是它们开始时的世界。如果两个行走者进行的更改在任何实际顺序中都无法共存,其中一个就会被拦在门口。

步骤:

  1. 读阶段 – 事务从其快照中读取并记录依赖关系。
  2. 写阶段 – 暂定写入被存储,仅在验证后可见。
  3. 验证阶段 – 检测"危险结构" (例如,T1 → T2 → T3 循环)。
  4. 提交 – 仅当未发现序列化冲突时。

因此,每个事务都在其自己的版本化世界中操作,但 SSI 确保这些世界可以排列成一个无冲突的串行顺序。

示例演练

步骤事务操作说明
1T1 (开始@5)读取 X=10, Y=10快照视图
2T2 (开始@6)读取 X=10, Y=10快照视图
3T1更新 X=0暂定写入
4T2更新 Y=0暂定写入
5T1 提交成功无先前冲突
6T2 提交检测到冲突 (写偏斜)中止

两者都试图更新不相交的数据,但一起违反了不变式 → SSI 中止其中一个。

微型代码 (概念性说明)

C (简化的冲突检查)

c
#include <stdio.h>
#include <stdbool.h>

typedef struct {
    bool readX, readY;
    bool writeX, writeY;
} Txn;

bool conflict(Txn a, Txn b) {
    if ((a.readX && b.writeX) || (a.readY && b.writeY))
        return true;
    return false;
}

int main() {
    Txn T1 = {.readX = true, .readY = true, .writeX = true};
    Txn T2 = {.readX = true, .readY = true, .writeY = true};

    if (conflict(T1, T2))
        printf("检测到冲突:中止一个事务。\n");
    else
        printf("无冲突:可以提交两者。\n");
}

输出: 检测到冲突:中止一个事务。

为何重要

  • 可序列化 – 保证真正的可序列化行为。
  • 基于 MVCC – 仍然是非阻塞读取。
  • 无异常 – 防止写偏斜、幻读和危险循环。
  • 被 PostgreSQL 使用 – 是 SERIALIZABLE 隔离级别的默认设置。

权衡:需要依赖跟踪和冲突分析,这会增加开销。

SSI 依赖类型

依赖类型描述
rw-conflictT1 读取 X,T2 稍后写入 X
wr-conflictT1 写入 X,T2 稍后读取 X
ww-conflict两者都写入相同的 X
SSI 寻找 rw-cycles (T1 → T2 → T3 → T1) 作为非可序列化性的标志。

亲自尝试

  1. 模拟两个事务读取重叠数据并写入不相交的更新。
  2. 检查在两者都提交后,不变式 (例如,X + Y ≥ 1) 是否仍然成立。
  3. 添加冲突检测逻辑,当发现循环时中止一个事务。
  4. 与普通 SI 比较,观察在 SSI 下异常消失。

测试用例

场景隔离级别结果
写偏斜 (X+Y ≥ 1)SI违反
写偏斜 (X+Y ≥ 1)SSI阻止 (中止)
仅并发读取器SSI无中止
重叠写入SSI冲突 → 中止一个

复杂度

  • 时间:每次验证 O(#dependencies)
  • 空间:O(#active transactions × #reads/writes)

可序列化快照隔离就像给每个事务一个时间气泡,然后检查这些气泡是否能够以不非法重叠的方式排列起来。它用快照的性能提供了可序列化的安全性,是现代数据库两全其美的解决方案。

808 无锁算法

无锁算法是并发编程中的超级英雄,它们在不使用锁的情况下协调线程,避免了死锁、优先级反转和上下文切换的开销。它们不依赖互斥,而是依靠原子操作(如比较并交换)来确保正确性,即使许多线程同时竞争前进。

我们要解决什么问题?

在传统的并发编程中,锁被用来保护共享数据。但锁可能导致:

  • 死锁 —— 线程永远互相等待
  • 饥饿 —— 某些线程永远得不到机会
  • 阻塞延迟 —— 一个缓慢或暂停的线程会拖慢所有人

无锁算法通过确保系统始终有进展来解决这个问题,即无论其他线程做什么,至少有一个线程总能向前推进。

系统永远不会冻结,它会持续前进。

它是如何工作的(通俗解释)?

无锁算法不是锁定资源,而是使用原子原语乐观地更新共享数据。 如果发生冲突,线程会重试,无需等待,也不会阻塞。

关键原语:比较并交换

CAS(地址, 期望值, 新值)

原子性地检查 *地址 == 期望值 是否成立。 如果成立 → 用 新值 替换并返回 true。 如果不成立 → 返回 false(说明已被其他线程更改)。

线程会不断循环直到其 CAS 操作成功,这就是“无锁之舞”。

示例:无锁栈

步骤线程 A线程 B栈顶说明
1读取 top = XXA 计划 push(Y)
2读取 top = XXB 计划 push(Z)
3A CAS(X, Y)成功YY → X
4B CAS(X, Z)失败栈顶已改变
5B 重试CAS(Y, Z) 成功ZZ → Y → X

没有锁,两个线程通过原子重试安全地完成了入栈操作。

微型代码(使用 CAS 的 C 语言示例)

C(无锁栈 Push 操作)

c
#include <stdatomic.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>

typedef struct Node {
    int value;
    struct Node* next;
} Node;

_Atomic(Node*) top = NULL;

void push(int val) {
    Node* new_node = malloc(sizeof(Node));
    new_node->value = val;
    Node* old_top;
    do {
        old_top = atomic_load(&top);
        new_node->next = old_top;
    } while (!atomic_compare_exchange_weak(&top, &old_top, new_node));
    printf("已入栈 %d\n", val);
}

int main() {
    push(10);
    push(20);
}

每次 push 操作:

  • 加载当前栈顶
  • 将新节点的 next 指向它
  • 尝试 CAS 操作以安装新的栈顶
  • 如果其他线程更改了 top,则重试

为什么它很重要

  • 无死锁 —— 进展得到保证
  • 无阻塞 —— 慢线程不会阻塞其他线程
  • 高性能 —— 更少的上下文切换
  • 可扩展性 —— 非常适合多核系统

应用场景:

  • 并发队列、栈、哈希映射
  • 内存分配器、垃圾收集器
  • 高性能数据库和内核

进展保证

保证类型含义
无等待每个线程在有限步数内完成
无锁系统有进展(至少一个线程成功)
无障碍若无干扰则有进展

无锁算法处于中间位置,是安全性和性能之间的良好平衡。

动手尝试

  1. 使用 atomic_compare_exchange_weak 实现一个无锁栈或计数器。
  2. 添加两个线程递增一个共享计数器,观察 CAS 重试的实际过程。
  3. 与基于互斥锁的版本进行比较,注意 CPU 使用率和公平性。
  4. 模拟干扰,确保至少有一个线程始终向前推进。

测试用例

场景描述结果
单线程 push无冲突成功
双线程 pushCAS 重试循环两者都成功
CAS 失败通过比较检测到重试
暂停一个线程另一个继续有进展

复杂度分析

  • 时间复杂度:平均每次操作 O(1)(包含重试)
  • 空间复杂度:数据结构 O(n)

无锁算法是并发编程中乐观主义的艺术,无需等待,无需加锁,只有原子协作。它们在需要高吞吐量、低延迟的系统中大放异彩,在这些系统中,速度和活性比简单性更重要。

809 Wait-Die / Wound-Wait

Wait-Die(等待-死亡)和 Wound-Wait(伤害-等待)方案是基于时间戳的并发控制中经典的死锁预防策略。它们使用事务时间戳来决定谁等待、谁中止,从而保持系统运行并完全避免循环等待。

我们要解决什么问题?

当多个事务竞争同一资源时,可能会发生死锁:

  • T1 锁定了 X 并想要 Y
  • T2 锁定了 Y 并想要 X → 两者永远等待

我们需要一个规则,在这些循环形成之前就打破它们。

诀窍是什么?给每个事务分配一个时间戳(它的"年龄"),并用它来确定性解决冲突,没有循环,无需猜测。

它是如何工作的(通俗解释)?

每个事务 T 在启动时获得一个时间戳 TS(T)。 每当 T 请求被另一个事务 U 持有的锁时,我们应用以下两种策略之一:

方案规则直观理解
Wait-Die如果 T 比 U 老 → 等待;否则(更年轻)→ 中止(死亡)老的等待,年轻的重新启动
Wound-Wait如果 T 比 U 老 → 伤害(中止 U);否则(更年轻)→ 等待老的抢占,年轻的等待

因为时间戳永不改变,所以循环无法形成,总有一个方向会胜出。

示例演练

设 TS(T1)=5(更老),TS(T2)=10(更年轻)

场景方案结果
T1(老)想要 T2 持有的锁Wait-Die: T1 等待
Wound-Wait: T2 中止
两种方式都安全
T2(年轻)想要 T1 持有的锁Wait-Die: T2 中止
Wound-Wait: T2 等待
两种方式都安全

不可能形成循环,所有的等待都是从老事务到年轻事务,或者中止年轻事务,从而打破循环。

微型代码(概念模拟)

C (Wait-Die 逻辑)

c
#include <stdio.h>

typedef struct {
    int id;
    int ts; // 时间戳
} Txn;

void wait_die(Txn T, Txn U) {
    if (T.ts < U.ts)
        printf("T%d 等待 T%d\n", T.id, U.id);
    else
        printf("T%d 中止 (比 T%d 年轻)\n", T.id, U.id);
}

int main() {
    Txn T1 = {1, 5};
    Txn T2 = {2, 10};
    wait_die(T1, T2); // T1 等待
    wait_die(T2, T1); // T2 中止
}

输出:

T1 等待 T2  
T2 中止 (比 T1 年轻)

为什么重要

  • 无死锁,永远不会形成循环等待
  • 公平,优先处理更老的事务
  • 可预测,基于时间戳的简单规则
  • 与 2PL 兼容,可作为插件式死锁预防

权衡:在高竞争系统中,年轻事务可能会频繁中止。

对比

特性Wait-DieWound-Wait
老事务想要锁等待中止年轻事务
年轻事务想要锁中止等待
饥饿年轻事务可能发生罕见
攻击性保守激进
实现更容易稍复杂

亲自尝试

  1. 分配时间戳 T1=5, T2=10。
    • T1 想要 T2 的锁 → 比较规则。
    • T2 想要 T1 的锁 → 比较规则。
  2. 添加第三个事务 T3=15 并模拟冲突。
  3. 观察顺序如何总是从老到年轻流动,永远不会形成循环。
  4. 尝试与 2PL 集成:在获取锁之前应用规则。

测试用例

冲突Wait-DieWound-Wait结果
老事务想要年轻事务的锁等待中止年轻事务无死锁
年轻事务想要老事务的锁中止等待无死锁
时间戳相等选择顺序选择顺序确定性
多个等待由时间戳引导由时间戳引导无环图

复杂度

  • 时间:每次冲突检查 O(1)(比较时间戳)
  • 空间:O(#活动事务) 用于时间戳表

Wait-Die 和 Wound-Wait 是优雅的时间戳规则,将潜在的死锁转化为快速决策,老事务保持其尊严,年轻事务礼貌地重试。

810 死锁检测(等待图)

死锁检测是并发控制的“看门狗”。它不预先防止死锁,而是允许死锁发生,然后自动检测并解决它们。这种策略非常适合那些死锁罕见但可能发生,并且需要尽可能保持高并发性的系统。

我们要解决什么问题?

当多个事务竞争共享资源时,它们可能进入一种循环等待状态,导致进展完全停滞。

示例:

  • T₁ 锁住 X,然后请求 Y
  • T₂ 锁住 Y,然后请求 X

现在两者都无法继续。它们都在互相等待,形成了死锁。

如果我们无法提前避免这种模式,就必须动态地检测它们,并通过中止其中一个事务来恢复。

它是如何工作的(通俗解释)

我们将系统中的等待关系表示为等待图:

  • 每个节点代表一个事务。
  • 一条有向边 $T_i \rightarrow T_j$ 表示“事务 $T_i$ 正在等待 $T_j$”释放一个资源。
  • 图中的环意味着死锁。

检测算法:

  1. 根据当前的锁表构建等待图。
  2. 运行环检测算法(例如 DFS 或 Tarjan 的 SCC 算法)。
  3. 如果存在环,则中止一个事务(称为牺牲者)。
  4. 释放其锁,允许其他事务继续。

这保证了系统的活性,死锁永远不会无限期持续。

示例演练

步骤事务持有的锁等待的锁图边
1T₁ 锁住 XX
2T₂ 锁住 YY
3T₁ 请求 Y被 T₂ 持有Y$T₁ \rightarrow T₂$
4T₂ 请求 X被 T₁ 持有X$T₂ \rightarrow T₁$

生成的等待图包含一个环:

$$ T_1 \rightarrow T_2 \rightarrow T_1 $$

死锁已经形成。检测器中止一个事务(例如,最年轻的事务)来打破这个环。

微型代码(概念示例)

c
#include <stdio.h>
#include <stdbool.h>

#define N 3 // 事务数量

int graph[N][N];     // 邻接矩阵
bool visited[N], stack[N];

bool dfs(int v) {
    visited[v] = stack[v] = true;
    for (int u = 0; u < N; u++) {
        if (graph[v][u]) {
            if (!visited[u] && dfs(u)) return true;
            else if (stack[u]) return true; // 发现环
        }
    }
    stack[v] = false;
    return false;
}

bool has_cycle() {
    for (int i = 0; i < N; i++) visited[i] = stack[i] = false;
    for (int i = 0; i < N; i++)
        if (!visited[i] && dfs(i)) return true;
    return false;
}

int main() {
    graph[0][1] = 1; // T1 -> T2
    graph[1][0] = 1; // T2 -> T1
    if (has_cycle()) printf("检测到死锁。\n");
    else printf("无死锁。\n");
}

输出:

检测到死锁。

为什么它重要

  • 检测所有死锁,包括多事务循环
  • 最大化并发性,因为没有锁被预先保留
  • 通过选择并中止一个牺牲者来确保进展
  • 用于并发性复杂的数据库和操作系统

权衡:死锁必须在解决之前实际发生,这可能会浪费部分工作。

死锁解决策略

  1. 牺牲者选择 根据以下标准选择一个事务来中止:

    • 年龄(最年轻的优先)
    • 成本(完成工作最少的)
    • 优先级(最低的优先)
  2. 回滚 中止牺牲者并释放其锁。

  3. 重启 短暂延迟后重试被中止的事务。

一个温和的证明(为什么它有效)

设等待图为 $G = (V, E)$,其中:

  • $V$ = 活跃事务的集合
  • $E$ = 边 $(T_i, T_j)$ 的集合,表示 $T_i$ 等待 $T_j$

当且仅当 $G$ 中存在环时,存在死锁。

证明概要:

  • (如果)假设存在一个环: $$ T_1 \rightarrow T_2 \rightarrow \cdots \rightarrow T_k \rightarrow T_1 $$ 环中的每个事务都在等待下一个。由于每个事务都持有另一个事务需要的资源,因此没有事务可以继续。所以它们都被阻塞了,形成了死锁。

  • (仅当)反之,如果一组事务陷入死锁,每个事务必须都在等待该集合中的另一个事务。为这些等待关系构建边就形成了一个环。

因此,检测 $G$ 中的环等价于检测死锁。 一旦发现环,移除任意顶点 $T_v$(中止一个事务)即可打破该环:

$$ G' = G - {T_v} $$

并允许恢复进展。

自己动手试试

  1. 构建一个图: $$ T_1 \rightarrow T_2, \quad T_2 \rightarrow T_3, \quad T_3 \rightarrow T_1 $$ 使用 DFS 检测环。

  2. 中止 $T_3$,移除其边,并验证没有环残留。

  3. 与 Wait-Die 和 Wound-Wait 比较:

    • 那些方法防止环的形成。
    • 这种方法在事后检测并打破环。
  4. 尝试不同的牺牲者选择规则,并测量系统吞吐量。

测试用例

等待关系环?死锁?动作
$T_1 \rightarrow T_2, T_2 \rightarrow T_1$2 个节点中止一个
$T_1 \rightarrow T_2, T_2 \rightarrow T_3$3 个节点继续
$T_1 \rightarrow T_2, T_2 \rightarrow T_3, T_3 \rightarrow T_1$3 个节点中止一个
无边空图继续

复杂度

设 $V$ 为事务数量,$E$ 为边数量。

  • 时间复杂度: $$ O(V + E) $$ (使用深度优先搜索)

  • 空间复杂度: $$ O(V^2) $$ (对于邻接矩阵表示)

死锁检测充当运行时安全网。它接受在高并发系统中可能出现死锁,并通过识别等待图中的环并移除一个事务来确保死锁不会持续。这使得系统保持活跃、响应灵敏,并随着时间的推移保持无死锁状态。

第 82 节 日志、恢复与提交协议

811 预写日志 (WAL)

预写日志 (WAL) 是可靠存储系统的基础。它确保数据的更新在记录之前永远不会被应用,从而允许在崩溃后进行恢复。WAL 的黄金法则:先写日志,后写数据。

我们要解决什么问题?

在任何持久化的数据库或文件系统中,故障可能发生在写入过程中。如果没有预防措施,我们可能会得到部分应用的更新,从而损坏数据。

示例:

  • T₁ 更新记录 X
  • 系统在将 X 写入磁盘之前崩溃

重启后,我们必须重放或撤销更改以恢复一致性。WAL 提供了实现这一目标的结构。

通过在应用更改之前将意图写入日志,WAL 保证了每个更新都是可重现或可逆的。

它是如何工作的(通俗解释)

WAL 在稳定存储上维护一个顺序日志。每个日志记录描述:

  • 事务 ID
  • 旧值(用于撤销)
  • 新值(用于重做)

WAL 协议强制执行两个关键规则:

  1. 预写规则: 在修改磁盘上的任何数据页之前,其日志记录必须被刷新到日志中。

  2. 提交规则: 只有在事务的所有日志记录都安全地写入磁盘后,该事务才被视为已提交。

因此,即使发生崩溃,日志也可以重放所有已完成的操作。

示例演练

步骤事务操作日志记录数据
1T₁开始[BEGIN T₁]
2T₁更新 X: 10 → 20[T₁, X, old=10, new=20]内存中
3T₁刷新日志[T₁, X, old=10, new=20]已持久化
4T₁将 X=20 写入磁盘已更新
5T₁提交[COMMIT T₁]持久化

如果发生崩溃:

  • 步骤 3 之前 → 无日志记录 → 无操作
  • 步骤 3 之后 → 日志指示了要重做的内容 → 恢复过程重放 X=20

微型代码(概念性示例)

c
#include <stdio.h>

typedef struct {
    int old_val, new_val;
    char *record;
} LogEntry;

void write_ahead(LogEntry log) {
    printf("Write log: old=%d, new=%d\n", log.old_val, log.new_val);
}

void apply_update(int *x, LogEntry log) {
    *x = log.new_val;
    printf("Apply: X=%d\n", *x);
}

int main() {
    int X = 10;
    LogEntry log = {10, 20, "T1-X"};
    write_ahead(log); // 步骤 1: 写日志
    apply_update(&X, log); // 步骤 2: 写数据
    printf("Commit.\n");
}

输出:

Write log: old=10, new=20
Apply: X=20
Commit.

为什么它很重要

  • 持久性 (ACID 中的 D): 已提交的更改永远不会丢失。

  • 原子性 (ACID 中的 A): 未提交的更改可以通过日志撤销。

  • 崩溃恢复: 重放已提交的更新(重做),回滚未提交的更新(撤销)。

  • 效率: 顺序的日志写入比随机的数据写入更快。

一个温和的证明(为什么它有效)

假设 $L$ 是日志,$D$ 是数据页,$T_i$ 是一个事务。

对于每个更新 $u$:

  1. 日志记录 $r(u)$ 在 $u$ 应用到 $D$ 之前被刷新到 $L$。 $$ \text{write}(L, r(u)) \Rightarrow \text{write}(D, u) $$
  2. 提交时,$L$ 包含 $T_i$ 的每个记录 $r(u)$。
  3. 如果系统崩溃:
  • 对于已提交的 $T_i$:从 $L$ 重做所有 $r(u)$。
  • 对于未提交的 $T_i$:使用 $L$ 中的旧值进行撤销。

因此,恢复后: $$ D' = \text{REDO(committed)} + \text{UNDO(uncommitted)} $$ 确保数据库匹配一个有效的串行状态。

自己动手试试

  1. 模拟一个更新 $X=10 \to 20$ 的事务。
  • 先写日志后写数据 → 崩溃 → 通过重做恢复。
  1. 尝试相反的顺序(先写数据后写日志)。观察恢复失败。
  2. 添加 [BEGIN, UPDATE, COMMIT] 记录并测试恢复逻辑。
  3. 实验撤销日志与重做日志。

测试用例

场景日志状态恢复操作
写日志前崩溃忽略(无记录)
写日志后、写数据前崩溃[T₁, X, 10, 20]重做
提交前崩溃[BEGIN, UPDATE]撤销
提交后崩溃[BEGIN, UPDATE, COMMIT]重做

复杂度

  • 时间:每次恢复 $O(n)$(扫描日志)
  • 空间:每个事务 $O(n)$ 条日志记录

预写日志是数据库中的真相记录本。每个更改在发生之前都被记录下来,确保即使在崩溃之后,系统也总能找到回到一致状态的方法。

812 ARIES 恢复(利用语义的恢复与隔离算法)

ARIES 是数据库恢复算法的黄金标准。它建立在预写日志(WAL)之上,并结合了重做、撤销和检查点,即使在面对崩溃时也能保证原子性和持久性。ARIES 为 DB2、SQL Server 和 PostgreSQL 变体等主要系统提供支持。

我们要解决什么问题?

当数据库崩溃时,我们面临三个任务:

  1. 重做已提交的工作(以确保持久性)。
  2. 撤销未提交的工作(以保持原子性)。
  3. 从最近的检查点恢复(以避免扫描整个日志)。

早期的系统常常不得不在效率和正确性之间做出选择。ARIES 通过结合三个原则来解决这个问题:

  1. 预写日志(WAL),在数据写入前记录日志。
  2. 重复历史,重做自上次检查点以来的所有操作。
  3. 生理日志,在页面级别记录更改,而不仅仅是逻辑或物理更改。

它是如何工作的(通俗解释)

可以把 ARIES 想象成数据库的时间机器。崩溃后,它会精确地按照发生过的顺序重放过去,然后回退未提交的更改。

ARIES 恢复过程分三个阶段运行:

  1. 分析阶段

    • 从最后一个检查点开始向前扫描日志。
    • 重建事务表(TT)和脏页表(DPT)。
    • 识别在崩溃时处于活动状态的事务。
  2. 重做阶段

    • 重新应用 DPT 中最早日志序列号(LSN)开始的所有更新。
    • 重复历史,将数据库恢复到崩溃前的精确状态。
  3. 撤销阶段

    • 使用补偿日志记录(CLR)回滚未提交的事务。
    • 每次撤销都会写入一个 CLR,以实现幂等恢复。

撤销完成后,数据库仅反映已提交的工作。

示例演练

步骤日志记录描述
1[BEGIN T₁]事务开始
2[T₁, X, old=10, new=20]更新已记录
3[BEGIN T₂]第二个事务
4[T₂, Y, old=5, new=9]更新已记录
5[COMMIT T₁]T₁ 已提交
6发生崩溃T₂ 未提交

恢复运行:

  • 分析:发现 T₁ 已提交,T₂ 活动
  • 重做:重放两个更新(重复历史)
  • 撤销:使用 CLR 回滚 T₂

最终状态:

  • X = 20 (T₁ 已提交)
  • Y = 5 (T₂ 已撤销)

微型代码(概念模拟)

c
#include <stdio.h>

typedef struct {
    char* txn;
    char* action;
    int old_val;
    int new_val;
} LogRecord;

void redo(LogRecord log) {
    printf("重做: %s 设置值为 %d\n", log.txn, log.new_val);
}

void undo(LogRecord log) {
    printf("撤销: %s 恢复值为 %d\n", log.txn, log.old_val);
}

int main() {
    LogRecord L1 = {"T1", "UPDATE", 10, 20};
    LogRecord L2 = {"T2", "UPDATE", 5, 9};
    printf("分析阶段: T1 已提交, T2 活动\n");
    printf("重做阶段:\n");
    redo(L1); redo(L2);
    printf("撤销阶段:\n");
    undo(L2);
}

输出:

分析阶段: T1 已提交, T2 活动
重做阶段:
重做: T1 设置值为 20
重做: T2 设置值为 9
撤销阶段:
撤销: T2 恢复值为 5

为什么它很重要

  • 快速恢复,无需完整日志扫描;从最后一个检查点开始。
  • 可重复且幂等,恢复期间崩溃?只需重新启动。
  • 支持细粒度日志记录,每页 LSN 确保正确性。
  • 行业验证,用于企业数据库。

一个温和的证明(为什么它有效)

令 $LSN(p)$ 为应用于页面 $p$ 的最后一个更新的日志序列号。

在重做期间:

  • 对于每个日志记录 $r$,其 $LSN(r) \ge \min(LSN(p))$, 仅当 $LSN(r) > LSN(p)$ 时才重做(以避免重复应用)。

在撤销期间:

  • 对于每个未提交的事务 $T$,按相反顺序撤销记录。
  • 每次撤销都会写入一个补偿日志记录(CLR),以便恢复保持幂等性: $$ \text{UNDO}(r) \implies \text{WRITE(CLR)} \land \text{APPLY(old_val)} $$ 因此,ARIES 确保恢复后: $$ \text{数据库状态} = \text{重做(已提交)} + \text{撤销(未提交)} $$ 这满足了原子性和持久性。

自己动手试试

  1. 模拟两个事务 $T_1$ 和 $T_2$,一个已提交,一个未提交。
  2. 添加日志记录 [BEGIN][UPDATE][COMMIT][END]
  3. 在 $T_2$ 提交前触发崩溃。
  4. 运行 ARIES:分析 → 重做 → 撤销。
  5. 确认只有 $T_1$ 的更新持久存在。

测试用例

场景日志操作结果
提交后崩溃[COMMIT T₁]仅重做T₁ 的数据已恢复
提交前崩溃[UPDATE T₂]重做 + 撤销T₂ 已撤销
恢复期间崩溃混合重新启动幂等恢复
多个活动事务混合重建 TT/DPT全部安全

复杂度

  • 时间:$O(n)$(从检查点开始扫描)
  • 空间:$O(\text{#活动事务数})$(TT + DPT)

ARIES 是韧性的架构,它精心重放过去,修复现在,并保存未来。通过融合重做、撤销和检查点,它保证了每次崩溃带来的不是混乱,而是恢复的一致性。

813 影子分页

影子分页是一种写时复制恢复技术,它消除了对日志的需求。它不直接写入现有页面,而是创建新的副本(影子),并在提交时原子性地切换到它们。如果在切换前发生崩溃,旧页面将保持不变,从而保证了一致性。

我们要解决什么问题?

传统的恢复方法(如 WAL 和 ARIES)维护日志以重做或撤销更改。这增加了开销和复杂性。

影子分页提供了一个更简单的替代方案:

  • 无需撤销或重做阶段
  • 无需日志
  • 提交 = 指针交换

通过使用页面版本控制,它确保未提交的更改永远不会覆盖稳定数据。

它是如何工作的(通俗解释)

想象数据库是一个页面树,其中根页面指向所有其他页面。每次修改不是就地更新,而是创建一个新的副本(影子页面)。事务私下更新指针,当准备提交时,它原子性地替换根节点。

步骤:

  1. 维护一个页表(映射逻辑页面 → 物理页面)。
  2. 更新时,复制目标页面,修改副本,并更新页表。
  3. 提交时,原子性地更新根指针指向新的页表。
  4. 如果在提交前崩溃,旧根仍然有效 → 自动回滚。

无需日志重放,无需撤销,无需重做,只需指针交换。

示例演练

假设我们有一个页表:

逻辑页面物理页面
A1
B2

事务想要更新 B:

  1. 复制页面 2 → 页面 3
  2. 更新页面 3
  3. 更新表,使 B → 3
  4. 提交时,用新表替换旧根指针

如果在提交前发生崩溃,系统使用旧根 → B=2(旧版本)。 如果提交成功,根现在指向 B=3 的表(新版本)。

微型代码(概念模拟)

c
#include <stdio.h>
#include <string.h>

typedef struct {
    int A;
    int B;
} PageTable;

PageTable stable = {1, 2};
PageTable shadow;

void update(PageTable *pt, char page, int new_val) {
    if (page == 'A') pt->A = new_val;
    else if (page == 'B') pt->B = new_val;
}

int main() {
    // 将当前表复制到影子表
    memcpy(&shadow, &stable, sizeof(PageTable));
    
    // 更新影子副本
    update(&shadow, 'B', 3);
    printf("影子表: A=%d, B=%d\n", shadow.A, shadow.B);
    
    // 提交:原子性地交换根指针
    stable = shadow;
    printf("已提交的表: A=%d, B=%d\n", stable.A, stable.B);
}

输出:

影子表: A=1, B=3
已提交的表: A=1, B=3

为什么它重要

  • 设计上具有崩溃安全性,提交是原子的
  • 无需撤销/重做
  • 恢复简单,旧根 = 一致状态
  • 适用于基于不可变性的系统

权衡:

  • 复制开销(尤其是大型树)
  • 多个版本导致碎片化
  • 更难支持并发和部分更新

使用场景:

  • 早期 DBMS(例如,System R 变体)
  • 文件系统,如 ZFS 和 WAFL

一个温和的证明(为什么它有效)

令 $R_0$ 为事务前的根指针,$R_1$ 为提交后的根指针。

在更新期间:

  • 所有更改都发生在影子页面中,保持 $R_0$ 不变。
  • 提交 = 原子指针交换: $$ R \leftarrow R_1 $$ 如果发生崩溃:
  • 如果在交换前 → $R = R_0$(旧的一致状态)
  • 如果在交换后 → $R = R_1$(新的一致状态)

因此不变量成立: $$ R \in { R_0, R_1 } $$ 中间状态永远不会可见,确保了原子性和持久性。

自己动手试试

  1. 构建一个映射 {A, B, C} 的页表。
  2. 更新时执行影子复制。
  3. 模拟提交前和提交后的崩溃,验证恢复。
  4. 扩展到多级(根 → 分支 → 叶子)。
  5. 与 WAL 比较性能:写放大 vs 简单性。

测试用例

场景操作结果
更新页面,提交前崩溃根未交换旧数据可见
更新页面,提交成功根已交换新数据可见
交换期间部分写入交换是原子的一个有效根
多次更新全部或无原子提交

复杂度

  • 时间:$O(n)$(复制修改过的页面)
  • 空间:$O(\text{#updated pages})$(新副本)

影子分页是写时复制恢复的简化实现。通过将更新视为新版本并使用原子根交换,它将复杂的恢复逻辑转化为指针运算,这是一条通向一致性的简洁、优雅的路径。

814 两阶段提交(2PC)

两阶段提交(2PC)协议确保分布式系统中的原子提交。它协调多个参与者,使得要么全部提交,要么全部中止,即使在节点故障时也能保持原子性。

它是数据库、消息队列和微服务中分布式事务的基石。

我们要解决什么问题?

在一个分布式事务中,多个节点(参与者)必须就一个单一结果达成一致。 如果一个提交而另一个中止,就会导致全局不一致。

我们需要一个协调协议来确保:

  • 要么全部成功,要么全部失败的结果
  • 即使发生故障也能达成一致
  • 决策的持久化记录

两阶段提交协议通过引入一个协调者来管理所有参与者之间的两步握手,从而实现这一目标。

它是如何工作的(通俗解释)

想象一个指挥家指挥一个管弦乐队:

  1. 首先,他询问每位乐手:“你准备好了吗?”
  2. 只有当所有人都说“是”时,指挥家才会发出*“开始演奏!”*的信号。

如果有任何乐手说*“不”*,演出就会停止。

类似地,2PC 分两个阶段进行:

阶段 1:准备(投票阶段)
  • 协调者向所有参与者发送 PREPARE
  • 每个参与者:
    • 将其准备记录写入磁盘(以实现持久性)。
    • 如果准备好提交,则回复 YES;否则回复 NO
阶段 2:提交(决策阶段)
  • 如果所有投票都是 YES
    • 协调者写入 COMMIT 记录并向所有人发送 COMMIT
  • 如果有任何投票是 NO 或超时:
    • 协调者写入 ABORT 记录并向所有人发送 ABORT

每个参与者都遵循协调者的最终决定。

示例演练

步骤协调者参与者 1参与者 2备注
1发送 PREPARE等待等待协调者开始投票
2等待投票 YES投票 YES参与者已就绪
3收集投票全部为 YES
4发送 COMMIT提交提交全局提交
5完成完成完成原子性结果

如果有一个投票 NO

  • 协调者向所有人发送 ABORT
  • 每个参与者回滚

微型代码(概念模拟)

c
#include <stdio.h>
#include <stdbool.h>

bool participant_vote(const char* name, bool can_commit) {
    printf("%s 投票 %s\n", name, can_commit ? "YES" : "NO");
    return can_commit;
}

int main() {
    bool p1 = participant_vote("P1", true);
    bool p2 = participant_vote("P2", true);

    if (p1 && p2) {
        printf("协调者:全部 YES → COMMIT\n");
    } else {
        printf("协调者:投票失败 → ABORT\n");
    }
}

输出(全部 YES):

P1 投票 YES
P2 投票 YES
协调者:全部 YES → COMMIT

为何重要

  • 确保跨多个系统的原子提交
  • 通过持久化日志实现故障恢复
  • 分布式数据库和中间件的标准

然而,2PC 有一个弱点: 如果协调者在 PREPARE 之后、COMMIT 之前崩溃,参与者将被阻塞,等待决策。

这促使了三阶段提交(3PC)以及基于共识的替代方案,如 Paxos Commit 和 Raft。

一个温和的证明(为何有效)

设 $P_1, P_2, \dots, P_n$ 为参与者,$C$ 为协调者。

每个参与者记录:

  • 在投票 YES 之前记录 $\text{prepare}(T)$
  • 在收到决策后记录 $\text{commit}(T)$ 或 $\text{abort}(T)$

协调者记录:

  • $\text{decision}(T) \in {\text{commit}, \text{abort}}$

在提交时:

  1. 所有参与者都有 $\text{prepare}(T)$
  2. 协调者有 $\text{commit}(T)$
  3. 消息确保全部提交或全部中止: $$ \forall i, j:; \text{state}(P_i) = \text{state}(P_j) $$

因此,即使在部分故障下,原子性和一致性也得以保持。

亲自尝试

  1. 模拟两个参与者和一个协调者。
    • 全部 YES → 提交
    • 一个 NO → 中止
  2. 在协调者的阶段 2 之前添加超时。
    • 会发生什么?(参与者阻塞)
  3. 添加日志记录:[BEGIN, PREPARE, COMMIT]
    • 在崩溃后恢复协调者,重新发出最终决策。
  4. 与 3PC(非阻塞变体)比较行为。

测试用例

场景投票结果备注
全部 YESYES, YES, YES提交全部同意
一个 NOYES, NO, YES中止原子性中止
超时(无响应)YES, –, YES中止安全性优先于进展
准备后崩溃全部 YES阻塞等待协调者

复杂度

  • 消息复杂度:每阶段 $O(n)$
  • 日志写入:每阶段 1 次(准备,提交)
  • 阻塞:如果协调者在准备后失败,可能发生

两阶段提交是分布式系统的原子握手,一个简单而严格的保证:所有参与者要么一起前进,要么全部停止。它为后来出现的容错共识协议奠定了基础。

815 三阶段提交(3PC)

三阶段提交(3PC)协议扩展了两阶段提交(2PC),以避免其主要弱点——阻塞。它确保即使协调者崩溃,也没有参与者会一直卡住等待决定。3PC 通过插入一个预提交阶段,将达成一致执行分离开来,从而实现了这一点。

我们要解决什么问题?

2PC 保证了原子性,但没有保证活性。 如果协调者在所有参与者都投票 YES 后失败,每个人都会无限期地等待,系统就会停滞。

3PC 通过确保以下内容来修复这个问题:

  • 所有参与者都经历相同的状态序列
  • 崩溃后没有状态是模糊的
  • 超时总是能导致安全的进展(中止或提交)

这使得 3PC 成为一个非阻塞的原子提交协议,前提是假设没有网络分区且消息延迟有界。

它是如何工作的(通俗解释)

3PC 将提交过程分为三个阶段,在最终提交之前增加了一个预提交握手。

阶段名称描述
1CanCommit?协调者询问参与者是否可以提交。
2PreCommit如果所有人都投票 YES,协调者发送预提交(承诺)。参与者准备提交并确认。
3DoCommit协调者发送最终的提交。参与者完成并确认。

如果任何参与者或协调者在等待消息时超时,它可以根据其最后已知的状态安全地做出决定(提交或中止)。

示例流程

步骤协调者参与者 1参与者 2备注
1发送 CanCommit?等待等待请求投票
2等待回复投票 YES投票 YES准备就绪
3发送 PreCommit准备准备无法回头
4等待确认确认确认所有人就绪
5发送 DoCommit提交提交全部完成

如果有人在阶段中途失败:

  • 其他人使用超时机制来做出一致的选择
  • 没有人会永远阻塞

状态转换

参与者同步地经历状态转换:

$$ \text{INIT} \rightarrow \text{WAIT} \rightarrow \text{PRECOMMIT} \rightarrow \text{COMMIT} $$

如果发生超时:

  • 在 WAIT 状态,安全中止
  • 在 PRECOMMIT 状态,安全提交

崩溃后不存在模糊状态。

微型代码(概念性模拟)

c
#include <stdio.h>
#include <stdbool.h>

typedef enum {INIT, WAIT, PRECOMMIT, COMMIT, ABORT} State;

void transition(State *s, const char *msg) {
    if (*s == INIT && msg == "CanCommit?") *s = WAIT;
    else if (*s == WAIT && msg == "PreCommit") *s = PRECOMMIT;
    else if (*s == PRECOMMIT && msg == "DoCommit") *s = COMMIT;
}

int main() {
    State P = INIT;
    printf("State: INIT\n");
    transition(&P, "CanCommit?");
    printf("State: WAIT\n");
    transition(&P, "PreCommit");
    printf("State: PRECOMMIT\n");
    transition(&P, "DoCommit");
    printf("State: COMMIT\n");
}

输出:

State: INIT
State: WAIT
State: PRECOMMIT
State: COMMIT

为什么它很重要

  • 非阻塞:参与者永远不会永远等待
  • 协调安全:不会出现混合的提交/中止结果
  • 容错:恢复后状态转换安全

与 2PC 相比,3PC 提高了可用性,但需要同步假设(有界延迟)。在存在分区的现实网络环境中,更倾向于使用 Paxos Commit 或 Raft。

一个温和的证明(为什么它有效)

令 $P_i$ 为在时间 $t$ 处于状态 $s_i(t)$ 的参与者。

不变量: $$ \forall i, j:\ s_i(t) \neq \text{COMMIT} \land s_j(t) = \text{ABORT} $$ 也就是说,没有进程会在另一个进程中止时提交。

  • 在 WAIT 状态,如果发生超时 → 中止(安全)。
  • 在 PRECOMMIT 状态,所有参与者都已确认 → 所有人都可以安全提交。
  • 因此,不会出现不确定或矛盾的结果。

每个状态都意味着一个安全的本地决定: $$ \begin{cases} \text{WAIT} \implies \text{ABORT} \ \text{PRECOMMIT} \implies \text{COMMIT} \end{cases} $$ 因此,即使发生超时,全局一致性也能得到保持。

自己动手试试

  1. 模拟所有参与者投票 YES,系统提交。
  2. 让一个参与者投票 NO,全部中止。
  3. 在 PRECOMMIT 阶段让协调者崩溃,参与者安全提交。
  4. 与 2PC 比较,阻塞会发生在哪里?

测试用例

场景投票故障结果
全部 YES提交
一个 NOP2中止
在 WAIT 阶段崩溃超时中止安全
在 PRECOMMIT 阶段崩溃超时提交安全
网络延迟(有界)非阻塞

复杂度

  • 阶段:3 轮消息
  • 消息复杂度:每阶段 $O(n)$
  • 时间:比 2PC 多一个阶段,但没有阻塞

三阶段提交是 2PC 的非阻塞演进,通过插入预提交握手,它确保了一致与行动是分离的,因此故障永远不会让系统在不确定的状态下等待。

816 检查点

检查点是定期保存系统状态一致快照的过程,以便在崩溃后可以从检查点恢复,而不是从头开始。它是数据库、操作系统和分布式系统中快速恢复的支柱。

我们正在解决什么问题?

如果没有检查点,崩溃后的恢复需要重放整个日志,这可能既缓慢又低效。 想象一个拥有数百万次操作的数据库,从头开始重启将耗费极长的时间。

通过创建检查点,我们在日志中标记安全位置,以便:

  • 只需要重做或撤销最后一个检查点之后的操作
  • 恢复时间是有界且可预测的
  • 系统在故障后可以更快地恢复

检查点以少量的运行时开销为代价,换取了恢复速度的巨大提升。

它是如何工作的(通俗解释)

检查点捕获所有必要恢复信息的快照:

  • 事务表(TT)(活跃事务)
  • 脏页表(DPT)(尚未写入磁盘的页)
  • 标记恢复可以继续的日志位置

在正常操作期间:

  1. 系统运行并追加日志记录(如 WAL)。

  2. 定期写入一个检查点:

    • [BEGIN CHECKPOINT]
    • 快照 TT 和 DPT
    • [END CHECKPOINT]

在恢复期间:

  • 从最后一个检查点开始扫描日志,而不是从头开始。
  • 只重做或撤销之后发生的事情。

示例演练

步骤日志条目描述
1[BEGIN T₁]事务开始
2[UPDATE T₁, X, 10→20]修改数据
3[BEGIN CHECKPOINT]捕获快照
4{TT: {T₁}, DPT: {X}}写入表状态
5[END CHECKPOINT]完成检查点
6[UPDATE T₁, Y, 5→7]继续操作

如果在步骤 6 之后发生崩溃,恢复将从步骤 3 之后开始,而不是步骤 1。

检查点类型

类型描述示例
一致性检查点暂停所有事务更简单,更慢
模糊检查点在系统运行时进行用于 ARIES
协调检查点分布式系统中的全局同步快照算法
非协调检查点每个节点独立进行存在状态不一致的风险

大多数现代系统使用模糊检查点,无需全局暂停,只需元数据一致性。

微型代码(概念模拟)

c
#include <stdio.h>

typedef struct {
    int txn_count;
    int dirty_pages;
} Checkpoint;

void checkpoint_write(Checkpoint c) {
    printf("BEGIN CHECKPOINT\n");
    printf("TT: %d, DPT: %d\n", c.txn_count, c.dirty_pages);
    printf("END CHECKPOINT\n");
}

int main() {
    Checkpoint c = {1, 2};
    checkpoint_write(c);
}

输出:

BEGIN CHECKPOINT
TT: 1, DPT: 2
END CHECKPOINT

为什么它很重要

  • 更快的恢复,跳过日志中不相关的部分
  • 稳定的还原点,已知的一致状态
  • 减少 I/O,只重做最近的更新
  • 与 WAL 和 ARIES 配合使用,是恢复的关键构建块

权衡:

  • 检查点期间的额外开销
  • 额外的磁盘写入
  • 必须确保快照一致性

一个温和的证明(为什么它有效)

令 $L = [r_1, r_2, \ldots, r_n]$ 为日志,$C_k$ 为记录 $r_k$ 之后的一个检查点。

恢复规则:

  • 重做 $C_k$ 之后的所有日志记录
  • 撤销从 $C_k$ 开始的不完整事务

由于 $C_k$ 捕获了所有先前已提交的状态,我们有: $$ \text{state}(C_k) = \text{apply}(r_1, \ldots, r_k) $$

因此在崩溃后: $$ \text{Recover} = \text{Redo}(r_{k+1}, \ldots, r_n) $$

检查点确保我们永远不需要再次访问 $r_1, \ldots, r_k$。

自己动手试试

  1. 模拟一个包含 10 次更新和 2 个检查点的日志。

    • 从最后一个检查点开始恢复。
  2. 与完整日志重放比较运行时间。

  3. 将脏页添加到检查点,只重做受影响的页。

  4. 实现模糊检查点(无暂停,捕获快照元数据)。

测试用例

场景操作恢复起点结果
无检查点重放所有记录$r_1$
一个检查点从检查点之后开始$r_k$更快
多个检查点使用最后一个$r_m$最快
模糊检查点无暂停$r_m$高效

复杂度

  • 检查点时间:$O(\text{#脏页数})$
  • 恢复时间:$O(\text{检查点后的日志长度})$
  • 空间:少量元数据开销

检查点是恢复的暂停按钮,是一个安全快照,让系统能够快速恢复。通过记住上次站稳的位置,数据库可以自信地重启,跳过过去,直接投入当下。

817 撤销日志

撤销日志是数据库系统中最早期、最简单的恢复机制之一。其核心思想非常直接:在将旧版本保存到日志之前,绝不覆盖任何值。系统崩溃后,可以利用保存的旧值撤销任何未提交的更改。

我们要解决什么问题?

在直接在磁盘上修改数据(就地更新)的系统中,一次崩溃可能会留下不完整或不一致的数据。 我们需要一种方法来安全地回滚未提交的事务,并将数据库恢复到之前的一致状态。

撤销日志通过“先写日志”来解决这个问题,确保旧值总是可以恢复。

这一原则被称为预写规则

在将任何更改写入数据库之前,必须将旧值写入日志。

它是如何工作的(通俗解释)

撤销日志为每次更新维护一个旧值日志。

日志记录格式:

<T, X, old_value>

其中:

  • T = 事务 ID
  • X = 数据项
  • old_value = 修改前的值
协议规则
  1. 预写规则 在写入磁盘之前,先将旧值记录到日志。

  2. 提交规则 只有在所有写入都刷新到磁盘后,事务才能提交。

如果发生崩溃:

  • 已提交的事务保持不变。
  • 未提交的事务使用日志中的旧值进行撤销。

示例演练

步骤操作日志数据
1<START T₁>开始事务
2<T₁, X, 10>记录旧值
3X = 20写入新值20
4<COMMIT T₁>记录提交20

如果在 <COMMIT T₁> 之前发生崩溃:

  • 恢复过程向后扫描日志
  • 找到 <T₁, X, 10>
  • X 恢复为 10

恢复算法(向后扫描)

  1. 向后扫描日志。
  2. 对于每个未提交的事务 $T$:
    • 对于每条记录 <T, X, old_value>: 恢复 $X \leftarrow old_value$
  3. 为每个被撤销的事务写入 <END T>

所有已提交的事务保持不变。

微型代码(概念性模拟)

c
#include <stdio.h>

typedef struct {
    char* txn;
    char* var;
    int old_val;
    int new_val;
} LogEntry;

int main() {
    int X = 10;
    LogEntry L = {"T1", "X", 10, 20};

    printf("<START %s>\n", L.txn);
    printf("<%s, %s, %d>\n", L.txn, L.var, L.old_val);

    X = L.new_val;
    printf("X = %d (updated)\n", X);

    // 模拟在提交前崩溃
    printf("Crash! Rolling back...\n");

    // 撤销
    X = L.old_val;
    printf("Undo: X restored to %d\n", X);
}

输出:

<START T1>
<T1, X, 10>
X = 20 (updated)
Crash! Rolling back...
Undo: X restored to 10

为什么它很重要

  • 简单有效的回滚机制
  • 保证原子性,未提交的更新永远不会持久化
  • 用于早期的 DBMS 和事务性文件系统

权衡:

  • 写入必须是就地更新(无影子副本)
  • 每次写入前都需要刷新日志 → 较慢
  • 没有内置的重做功能(无法恢复已提交的更新)

一个温和的证明(为什么它有效)

令 $L$ 为日志序列,$D$ 为数据库。

不变式: 在任何更新 $(X \leftarrow v_{\text{new}})$ 之前, 其旧值 $v_{\text{old}}$ 已被记录到日志: $$ \text{write}(L, \langle T, X, v_{\text{old}} \rangle) \Rightarrow \text{write}(D, X = v_{\text{new}}) $$

崩溃后:

  • 对于任何没有 <COMMIT T> 的事务 $T$, 向后扫描,恢复每个 $X$: $$ X \leftarrow v_{\text{old}} $$
  • 对于已提交的 $T$,不执行撤销操作。

因此: $$ D_{\text{恢复后}} = D_{\text{未提交更新前}} $$

确保了原子性和一致性。

动手试试

  1. 在一个事务下更新两个变量(X, Y)。
  2. <COMMIT> 前崩溃,回滚两者。
  3. <COMMIT> 后崩溃,不回滚。
  4. 扩展 <END T> 记录以进行清理。

测试用例

日志操作结果
<START T₁>, <T₁, X, 10>, crash未提交撤销 X=10
<START T₁>, <T₁, X, 10>, <COMMIT T₁>已提交无操作
多个事务,一个未提交部分撤销仅回滚活跃事务

复杂度

  • 时间:$O(n)$(向后扫描日志)
  • 空间:$O(\text{#updates})$(日志大小)

撤销日志是旧值的守护者,总是在触碰现在之前写下过去。 如果系统失足,日志便成为它一步步、一次次撤销,安全返回的地图。

818 重做日志

重做日志是撤销日志的对偶方案。它不是记录用于回滚的旧值,而是记录新值,以便在崩溃后,系统可以重新应用(重做)所有已提交的更新。它通过仅重放那些达到提交点的操作来确保持久性。

我们要解决什么问题?

如果系统在将数据刷新到磁盘之前崩溃,一些已提交的事务可能只存在于内存中。如果没有保护措施,它们的更新将会丢失,这违反了持久性。

重做日志通过在提交前记录所有新值来解决这个问题,这样即使数据页从未写入,恢复过程也能在之后重建它们。

规则很简单:

在事务的所有新值被记录到稳定存储之前,绝不宣布该事务已提交。

它是如何工作的(通俗解释)

重做日志保存每次更新的新值记录。

日志记录格式:

<T, X, new_value>

其中:

  • T = 事务 ID
  • X = 数据项
  • new_value = 修改后的值
重做日志规则
  1. 提交前记录日志 每个 <T, X, new_value> 必须在 <COMMIT T> 之前写入。

  2. 提交后写入数据 实际的数据页可以在事务提交后写入。

  3. 恢复时重做 如果已提交事务的更改未应用到磁盘,则重新应用它们。

未提交的事务被忽略,它们的更改永远不会到达数据库。

示例演练

步骤操作日志数据
1<START T₁>开始事务
2<T₁, X, 20>记录新值
3<COMMIT T₁>提交
4X = 20写入数据20

如果在提交之后、写入 X 之前发生崩溃, 恢复过程将从日志重做更新:X = 20

恢复算法(前向扫描)

  1. 从头开始前向扫描日志。
  2. 识别已提交的事务。
  3. 对于每个已提交的事务 $T$:
    • 对于每条记录 <T, X, new_value> 重新应用 $X \leftarrow new_value$
  4. 写入 <END T> 标记完成。

无需撤销,未提交的更新永远不会被写入。

微型代码(概念模拟)

c
#include <stdio.h>

typedef struct {
    char* txn;
    char* var;
    int new_val;
} LogEntry;

int main() {
    int X = 10;
    LogEntry L = {"T1", "X", 20};

    printf("<START %s>\n", L.txn);
    printf("<%s, %s, %d>\n", L.txn, L.var, L.new_val);
    printf("<COMMIT %s>\n", L.txn);

    // 数据写入前崩溃
    printf("崩溃!正在恢复...\n");

    // 重做
    X = L.new_val;
    printf("重做: X = %d\n", X);
}

输出:

<START T1>
<T1, X, 20>
<COMMIT T1>
崩溃!正在恢复...
重做: X = 20

为什么它很重要

  • 确保持久性(ACID 中的 D),已提交的更新永不丢失
  • 恢复简单,仅需重新应用已提交的更新
  • 可以安全地延迟写入,数据惰性写入
  • 用于采用延迟写入的系统

权衡:

  • 需要前向恢复扫描
  • 无法撤销,假定未提交的更新从未刷新
  • 必须在宣布成功前强制写入 <COMMIT> 记录

一个温和的证明(为什么它有效)

令 $L$ 为日志,$D$ 为数据页。

不变量:

  • 提交前: $$ \forall (T, X, v_{\text{new}}) \in L,\ \text{no write to } D $$
  • 提交时: $$ \text{write}(L, \langle \text{COMMIT } T \rangle) \Rightarrow \text{all new values in } L $$

恢复时:

  • 对于已提交的 $T$,重新应用更新: $$ X \leftarrow v_{\text{new}} $$
  • 对于未提交的 $T$,跳过(没有更改被持久化)

因此最终状态是: $$ D_{\text{after recovery}} = D_{\text{after committed txns}} $$ 确保了原子性和持久性。

自己动手试试

  1. 模拟 $T_1$:更新 $X=10 \to 20$,在写入 $X$ 之前崩溃。
  2. 重放日志 → 验证 $X=20$。
  3. 添加未提交的 $T_2$,不应用重做。
  4. 与检查点结合,跳过旧事务。

测试用例

日志操作结果
<START T₁>, <T₁, X, 20>, <COMMIT T₁>重做X=20
<START T₁>, <T₁, X, 20> (无提交)跳过X 不变
两个事务,一个已提交重做一个仅应用 T₁
缺少提交记录跳过安全恢复

复杂度

  • 时间:$O(n)$(前向扫描)
  • 空间:$O(\text{#updates})$(日志大小)

重做日志是恢复的重演艺术家,总是向前看,总是恢复你本意中的未来。 通过在提交前保存每一个新值,它确保任何崩溃都无法抹去已承诺的内容。

819 法定人数提交(Quorum Commit)

法定人数提交是一种分布式提交协议,它通过要求多数(法定人数)副本达成一致,才能将事务视为已提交,从而确保一致性。它是 Dynamo、Cassandra 和基于 Paxos 的数据库等容错复制系统的基础。

与一个协调者强制所有参与者提交(如 2PC)不同,法定人数提交将控制权分散到各个副本,只有当足够多的节点同意时,提交才会发生。

我们要解决什么问题?

在复制系统中,数据存储在多个节点上以实现容错。 我们需要一种方法来确保:

  • 持久性:数据在节点故障后依然存在
  • 一致性:所有副本最终收敛
  • 可用性:即使在部分故障时也能继续进展

如果我们要求所有副本都确认每次写入,那么一个崩溃的节点就会阻塞进展。 法定人数提交通过只要求多数来解决这个问题: $$ W + R > N $$ 其中:

  • $N$ = 副本总数
  • $W$ = 写入法定人数大小
  • $R$ = 读取法定人数大小

这确保了每次读取都与最新的已提交写入有重叠。

它是如何工作的(通俗解释)

每个事务(或写入)被发送到 N 个副本。 协调者等待收到 W 个确认后,才宣布提交成功。

在读取时,客户端查询 R 个副本并合并响应。 因为 $W + R > N$,所以在任何读取法定人数中,至少有一个副本拥有最新的值。

写入步骤:
  1. 将写入发送给所有 $N$ 个副本。
  2. 等待 $W$ 个确认。
  3. 如果达到 $W$ → 提交成功。
  4. 如果少于 $W$ → 中止或重试。
读取步骤:
  1. 查询 $R$ 个副本。
  2. 收集响应。
  3. 选择具有最新时间戳/版本的值。

根据 $W$ 和 $R$ 的设置,此模型支持最终一致性或强一致性。

示例演练

参数
$N = 3$副本总数
$W = 2$写入法定人数
$R = 2$读取法定人数

写入 "X=10":

  • 发送给副本 A, B, C
  • A 和 B 响应 → 达到法定人数 → 提交
  • C 可能延迟,稍后会赶上

读取 "X":

  • 查询 A, C
  • A 有 X=10,C 有 X=旧值
  • 选择最新的(A 胜出)

因为 $W + R = 4 > 3$,重叠保证了正确性。

微型代码(概念模拟)

c
#include <stdio.h>

int main() {
    int N = 3, W = 2, ack = 0;
    int replicas[] = {1, 1, 0}; // A, B 确认;C 失败

    for (int i = 0; i < N; i++) {
        if (replicas[i]) ack++;
    }

    if (ack >= W)
        printf("提交成功 (确认数=%d, 法定人数=%d)\n", ack, W);
    else
        printf("提交失败 (确认数=%d, 法定人数=%d)\n", ack, W);
}

输出:

提交成功 (确认数=2, 法定人数=2)

为什么它很重要

  • 容错性:可承受 $N - W$ 个副本故障
  • 低延迟:避免等待所有节点
  • 一致性-可用性权衡:可通过 $W$ 和 $R$ 调节
  • Dynamo、Cassandra、Riak、CockroachDB 的基础

权衡:

  • 如果副本延迟,则为最终一致性
  • 比单节点提交需要更高的协调成本
  • 并发写入时可能冲突(可通过版本控制解决)

一个温和的证明(为什么它有效)

令:

  • $N$ = 副本总数
  • $W$ = 写入法定人数大小
  • $R$ = 读取法定人数大小

为确保每次读取都能看到最新的已提交写入: $$ W + R > N $$

证明概要:

  • 写入在 $W$ 个副本存储更新后完成。
  • 读取查询 $R$ 个副本。
  • 由于 $W + R > N$, 必须至少存在一个副本 $p$ 同时收到了两者。 因此,每次读取都与最新的写入有重叠。

所以每次读取都与已写入的副本集合相交,从而确保了一致性。

自己动手试试

  1. 设置 $N=5$, $W=3$, $R=3$ → 检查重叠。
  2. 模拟副本故障:
    • 如果 1 个故障 → 仍然可以提交(5 个中的 3 个)。
    • 如果 3 个故障 → 无法达到法定人数 → 中止。
  3. 将 $W$ 减少到 1 → 更快但一致性更弱。
  4. 可视化 $W+R > N$ 的重叠条件。

测试用例

$N$$W$$R$$W + R > N$一致?结果
3224 > 3强一致性
3213 = 3可以但脆弱
3112 < 3不一致
5336 > 5安全重叠

复杂度

  • 写入延迟:等待 $W$ 个确认 → $O(W)$
  • 读取延迟:等待 $R$ 个响应 → $O(R)$
  • 容错性:最多可承受 $N - W$ 个故障

法定人数提交是分布式数据的投票系统,决策由多数而非全体一致做出。 通过调整 $W$ 和 $R$,你可以引导系统趋向强一致性、高可用性或低延迟,一次一个法定人数。

820 共识提交

共识提交将两阶段提交(2PC)的原子提交协议与 Paxos 或 Raft 等共识算法的容错能力相结合。它确保分布式事务即使在协调者或参与者崩溃的情况下,也能达成持久、一致的决策(提交或中止)。

这就是现代分布式数据库(例如 Spanner、CockroachDB、Calvin)在出现故障时实现原子性和一致性的方式。

我们要解决什么问题?

传统的两阶段提交(2PC)确保原子性,但存在阻塞问题:如果协调者在所有参与者准备之后发生故障,它们可能会永远等待。

共识提交通过用共识组替代协调者这个单点故障来解决这个问题。提交决策通过多数派同意达成,因此即使某些节点发生故障,其他节点也可以继续。

简而言之:

  • 2PC:原子但阻塞
  • 共识提交:原子、容错、非阻塞

它是如何工作的(通俗解释)

共识提交将 2PC 包装在一个共识层(如 Paxos 或 Raft)内部。 不是由单个协调者做决定,而是提交决策本身被复制并达成一致。

步骤:

  1. 准备阶段

    • 每个参与者投票 YES/NO(类似 2PC)。
    • 投票发送给领导者节点。
  2. 共识阶段

    • 领导者提出最终决策(提交/中止)。
    • 决策通过共识协议(Paxos/Raft)进行复制。
    • 多数派接受 → 决策持久化。
  3. 提交阶段

    • 决策广播给所有参与者。
    • 每个参与者在本地应用提交/中止。
    • 即使领导者崩溃,决策也可以被恢复。

因此,决策本身被冗余存储在各个节点上,消除了协调者故障导致的单点阻塞问题。

示例演练

步骤角色操作
1领导者从参与者收集投票
2参与者投票 YES / NO
3领导者提议最终决策 = COMMIT
4副本通过 Raft/Paxos 达成共识
5多数派同意 → 决策持久化
6所有节点在本地应用 COMMIT

如果领导者在步骤 4 之后失败,新的领导者会读取复制的日志并从相同的决策继续执行。

可视化总结

$$ \text{共识提交} = \text{2PC} + \text{共识复制} $$

确保:

  • 原子性(通过 2PC 投票)
  • 持久性(通过共识日志)
  • 容错性(通过法定人数协议)

微型代码(概念模拟)

c
#include <stdio.h>
#include <stdbool.h>

bool participant_vote(const char* name, bool can_commit) {
    printf("%s 投票 %s\n", name, can_commit ? "YES" : "NO");
    return can_commit;
}

bool consensus_replicate(bool decision) {
    printf("共识组同意: %s\n", decision ? "COMMIT" : "ABORT");
    return decision;
}

int main() {
    bool p1 = participant_vote("P1", true);
    bool p2 = participant_vote("P2", true);

    bool decision = (p1 && p2);
    consensus_replicate(decision);

    printf("最终决策: %s\n", decision ? "COMMIT" : "ABORT");
}

输出:

P1 投票 YES
P2 投票 YES
共识组同意: COMMIT
最终决策: COMMIT

为什么它很重要

  • 原子性:跨节点的全有或全无提交
  • 非阻塞:决策被复制,而非集中化
  • 崩溃安全:领导者可能失败,新领导者恢复状态
  • 应用于:Google Spanner、CockroachDB、YugabyteDB

权衡:

  • 比 2PC 需要更多消息轮次
  • 共识开销($O(\text{日志复制})$)
  • 协调多个共识组的复杂性

一个温和的证明(为什么它有效)

令:

  • $P_i$ = 参与者节点
  • $L$ = 领导者
  • $Q$ = 多数派法定人数

关键特性:

  1. 一致性: 提交决策被复制到多数派 $Q$。 任何新领导者必须从 $Q$ 读取,因此所有领导者共享相同的决策: $$ \forall L_1, L_2:\ \text{决策}(L_1) = \text{决策}(L_2) $$

  2. 原子性: 所有参与者应用相同的决策: $$ \forall i, j:\ \text{状态}(P_i) = \text{状态}(P_j) $$

  3. 持久性: 一旦法定人数存储了决策,即使某些副本失败,决策也不会丢失。

因此共识提交保证: $$ \text{原子性} + \text{一致性} + \text{容错性} $$

自己动手试试

  1. 模拟 3 个副本($N=3$),2 个参与者。
  2. 一个副本在提交提议后崩溃 → 共识仍然成功。
  3. 重启崩溃的节点 → 从共识日志重放。
  4. 与 2PC 比较:阻塞发生在哪里?
  5. 测试混合投票,一个 NO → 系统中止。

测试用例

投票共识结果说明
YES, YESCOMMIT提交正常情况
YES, NOABORT中止原子中止
领导者崩溃多数派存在提交继续非阻塞
多数派丢失等待无进展(安全性 > 活性)

复杂度

  • 消息轮次: 2PC(2 轮) + 共识(2–3 轮)
  • 时间:$O(\text{共识延迟})$
  • 容错性:在 $2f+1$ 个副本中可承受 $f$ 个故障

共识提交是具有智慧的原子提交,融合了 2PC 的简单性和共识的弹性。 它保证即使在故障风暴中,每个节点看到的决策都是相同的,并且该决策是最终的、持久的,并由多数派同意。

第 83 节 调度

821 先来先服务(FCFS)

先来先服务(FCFS)是最简单的调度算法,它严格按照任务到达的顺序进行处理。没有抢占,没有优先级,只是一个公平的队列:先到的作业先执行。

它被用于操作系统、作业调度器,甚至在那些公平性比响应性更重要的 I/O 队列中。

我们要解决什么问题?

当多个作业竞争一个共享资源(CPU、磁盘、打印机)时,我们需要一个策略来决定哪个先运行。

FCFS 策略通过提供以下特性来解决这个问题:

  • 公平性:每个作业都有机会运行
  • 简单性:易于实现
  • 可预测性:执行顺序透明

但由于作业以非抢占方式运行,长作业可能会阻塞短作业,这种现象被称为护航效应。

它是如何工作的(通俗解释)

作业按到达时间排序。调度器总是选择等待时间最早的作业。

步骤:

  1. 维护一个就绪作业的 FIFO 队列。
  2. 当 CPU 空闲时,从队列中取出第一个作业。
  3. 运行它直到完成(不中断)。
  4. 对队列中的下一个作业重复此过程。

没有优先级。没有时间片。只是按时间顺序的公平性。

示例演练

假设有 3 个作业到达:

作业到达时间执行时间
J₁05
J₂13
J₃28

执行顺序:J₁ → J₂ → J₃

作业开始时间完成时间周转时间等待时间
J₁0550
J₂5874
J₃816146

平均等待时间 = $(0 + 4 + 6)/3 = 3.33$

作业到达得越晚,它可能等待的时间就越长,尤其是在长任务后面时。

微型代码(概念模拟)

c
#include <stdio.h>

typedef struct {
    int id;
    int burst;
} Job;

int main() {
    Job q[] = {{1, 5}, {2, 3}, {3, 8}};
    int n = 3, time = 0;

    for (int i = 0; i < n; i++) {
        printf("作业 %d 在时间 %d 开始,运行 %d 个单位时间\n",
               q[i].id, time, q[i].burst);
        time += q[i].burst;
        printf("作业 %d 在时间 %d 完成\n", q[i].id, time);
    }
}

输出:

作业 1 在时间 0 开始,运行 5 个单位时间
作业 1 在时间 5 完成
作业 2 在时间 5 开始,运行 3 个单位时间
作业 2 在时间 8 完成
作业 3 在时间 8 开始,运行 8 个单位时间
作业 3 在时间 16 完成

为什么它重要

  • 公平性:所有作业被平等对待
  • 简单性:实现起来微不足道
  • 当任务相似时,吞吐量良好

但是:

  • 护航效应:一个长作业会延迟所有其他作业
  • 响应性差:对 I/O 密集型作业没有抢占
  • 不适合交互式系统

一个温和的证明(为什么它有效)

设 $n$ 个作业在时间 $a_i$ 到达,执行时间为 $b_i$(按到达时间排序)。

在 FCFS 中,每个作业在前一个作业完成后开始: $$ S_1 = a_1, \quad S_i = \max(a_i, F_{i-1}) $$ $$ F_i = S_i + b_i $$

周转时间: $$ T_i = F_i - a_i $$

等待时间: $$ W_i = T_i - b_i $$

因为顺序是固定的,FCFS 最小化了上下文切换开销,但不一定最小化等待时间。

自己动手试试

  1. 模拟 5 个具有不同执行时间的作业。
  2. 计算平均周转时间和平均等待时间。
  3. 先添加一个非常长的作业,观察护航效应。
  4. 与最短作业优先(SJF)比较,注意差异。

测试用例

作业数到达时间执行时间调度顺序平均等待时间
30,1,25,3,8FCFS3.33
30,2,42,2,2FCFS2.0
30,1,210,1,1FCFS高(护航效应)

复杂度

  • 时间:$O(n)$(线性扫描队列)
  • 空间:$O(n)$(队列大小)

FCFS 是调度算法中温和的巨人,适应缓慢,但稳定且公平。 它的简单性使其成为批处理系统和 FIFO 队列的理想选择,尽管现代调度器通常添加优先级和抢占来抑制其护航效应。

822 最短作业优先(SJF)

最短作业优先(SJF)调度算法总是选择下一个执行时间最短的作业。 它是最小化平均等待时间的最优调度算法,短任务永远不会被长任务阻塞。

它有两种变体:

  • 非抢占式 SJF:一旦作业开始,就运行到完成。
  • 抢占式 SJF(最短剩余时间优先,SRTF):如果新到达的作业剩余时间更短,则抢占当前正在运行的作业。

我们要解决什么问题?

先来先服务(FCFS)可能导致护航效应,短任务需要等待长任务完成。 SJF 通过优先处理较短的任务来修复这个问题,从而改善平均周转时间和响应性。

其核心思想是:

“总是先做最简单的事情。”

这反映了现实世界中的排队情况:先服务快速的客户以最小化平均等待时间。

它是如何工作的(通俗解释)

  1. 维护一个就绪作业列表。
  2. 当 CPU 空闲时,选择突发时间最短的作业。
  3. 运行它(非抢占式),或者如果到达了更短的作业则切换(抢占式)。
  4. 重复直到所有作业完成。

如果突发时间是已知的(例如,通过指数平均法预测),SJF 可以提供可证明的最小等待时间。

示例演练(非抢占式)

作业到达时间突发时间
J₁07
J₂14
J₃21
J₄34

执行顺序:

  • 在 $t=0$ 时,只有 J₁ (7) → 运行 J₁
  • 当 J₁ 在 $t=7$ 结束时,从 {J₂(4), J₃(1), J₄(4)} 中选择最短的 → J₃
  • 然后 J₂ → J₄
作业开始时间结束时间周转时间等待时间
J₁0770
J₃7865
J₂812117
J₄1216139

平均等待时间 = $(0 + 5 + 7 + 9)/4 = 5.25$

示例演练(抢占式 / SRTF)

作业到达时间突发时间
J₁08
J₂14
J₃22

时间线:

  • $t=0$: J₁ 开始
  • $t=1$: J₂ 到达 (4 < 7 剩余) → 抢占 J₁
  • $t=2$: J₃ 到达 (2 < 3 剩余) → 抢占 J₂
  • 执行 J₃ → J₂ → J₁

总等待时间小于 FCFS 或非抢占式 SJF。

微型代码(概念模拟)

c
#include <stdio.h>

typedef struct {
    int id, burst; // 作业ID,突发时间
} Job;

void sjf(Job jobs[], int n) {
    // 简单的选择排序式调度
    for (int i = 0; i < n - 1; i++) {
        int min = i;
        for (int j = i + 1; j < n; j++)
            if (jobs[j].burst < jobs[min].burst)
                min = j;
        Job temp = jobs[i];
        jobs[i] = jobs[min];
        jobs[min] = temp;
    }

    int time = 0;
    for (int i = 0; i < n; i++) {
        printf("作业 %d 在时间 %d 开始,突发时间 = %d\n",
               jobs[i].id, time, jobs[i].burst);
        time += jobs[i].burst;
    }
}

int main() {
    Job jobs[] = {{1, 7}, {2, 4}, {3, 1}, {4, 4}};
    sjf(jobs, 4);
}

输出:

作业 3 在时间 0 开始,突发时间 = 1
作业 2 在时间 1 开始,突发时间 = 4
作业 4 在时间 5 开始,突发时间 = 4
作业 1 在时间 9 开始,突发时间 = 7

为什么它重要

  • 最小化平均等待时间,可证明是最优的
  • 对短作业公平,但可能使长作业饥饿
  • 是基于优先级和多级反馈调度器的基础

权衡:

  • 需要知道或估计突发时间
  • 不适用于不可预测的工作负载
  • 可能导致饥饿(长作业永远得不到调度)

一个温和的证明(为什么它有效)

假设作业的突发时间为 $b_1, b_2, \dots, b_n$,按升序排序。 总等待时间: $$ W = \sum_{i=1}^{n-1} (n - i) b_i $$ SJF 最小化了 $W$,因为将任何较长的作业提前交换都会增加总等待时间。 因此,根据最短处理时间优先(SPT) 原则,SJF 在最小化平均等待时间方面是最优的。

亲自尝试

  1. 模拟突发时间为 7, 4, 1, 4 的 4 个作业。
  2. 为非抢占式 SJF 绘制甘特图。
  3. 现在添加抢占(SRTF),比较结果。
  4. 尝试一个长作业提前到达,观察饥饿现象。
  5. 比较平均等待时间与 FCFS。

测试用例

作业数突发时间顺序平均等待时间说明
35, 2, 13→2→12SJF 最优
47,4,1,43→2→4→15.25与示例匹配
32,2,2任意顺序2平局时安全

复杂度

  • 排序:$O(n \log n)$
  • 调度:$O(n)$
  • 空间:$O(n)$

SJF 是调度器中的战略家,总是选择最短的路径来减少等待。 它在理论上很优雅,但在实践中要求很高,当突发时间已知或可预测时,它表现出色。

824 优先级调度

优先级调度根据作业的优先级值来选择下一个作业,而非其到达顺序或长度。 高优先级作业先运行;低优先级作业等待。它是 SJF(其中优先级 = $1/\text{突发时间}$)的推广,并且是许多现实世界调度器(如 Linux、Windows 和数据库中的调度器)的基础。

主要有两种模式:

  • 非抢占式:一旦开始,作业将运行至完成。
  • 抢占式:如果更高优先级的作业到达,它会中断当前作业。

我们要解决什么问题?

在真实系统中,并非所有作业都是平等的:

  • 有些是时间关键的(例如,中断、实时任务)
  • 其他则是低紧急度的(例如,后台作业)

我们需要一种能反映重要性或紧急性的机制,通过优先级而非公平性或顺序来调度。

它是如何工作的(通俗解释)

每个作业都有一个优先级编号(数值越高表示越紧急)。

算法步骤:

  1. 将到达的作业插入就绪队列,按优先级排序。
  2. 选取优先级最高的作业。
  3. 运行(直到完成或被抢占)。
  4. 在完成或抢占后,选择下一个最高优先级的作业。

优先级可以是:

  • 静态:一次性分配
  • 动态:可更新(例如,老化、反馈)

示例演练(非抢占式)

作业到达时间突发时间优先级
J₁052
J₂134
J₃213

选择顺序:

  • $t=0$:J₁ 开始(唯一作业)
  • $t=1$:J₂ 到达(优先级 4 > 2),但 J₁ 是非抢占式的
  • $t=5$:J₂ (4),然后 J₃ (3)

执行顺序:J₁ → J₂ → J₃

作业开始时间完成时间周转时间等待时间
J₁0550
J₂5874
J₃8976

示例演练(抢占式)

相同的作业,抢占模式:

  • $t=0$:运行 J₁ (p=2)
  • $t=1$:J₂ 到达 (p=4) → 抢占 J₁
  • $t=1–4$:运行 J₂(完成)
  • $t=4$:J₁ 恢复运行
  • $t=5$:J₃ 到达 (p=3) → 再次抢占 J₁
  • $t=5–6$:运行 J₃
  • $t=6–9$:完成 J₁

执行顺序:J₁ → J₂ → J₁ → J₃ → J₁

微型代码(概念模拟)

c
#include <stdio.h>

typedef struct {
    int id, burst, priority;
} Job;

void sort_by_priority(Job jobs[], int n) {
    for (int i = 0; i < n - 1; i++)
        for (int j = i + 1; j < n; j++)
            if (jobs[j].priority > jobs[i].priority) {
                Job t = jobs[i]; jobs[i] = jobs[j]; jobs[j] = t;
            }
}

int main() {
    Job jobs[] = {{1, 5, 2}, {2, 3, 4}, {3, 1, 3}};
    int n = 3, time = 0;
    sort_by_priority(jobs, n);
    for (int i = 0; i < n; i++) {
        printf("作业 %d (优先级=%d) 从 %d 运行到 %d\n",
               jobs[i].id, jobs[i].priority, time, time + jobs[i].burst);
        time += jobs[i].burst;
    }
}

输出:

作业 2 (优先级=4) 从 0 运行到 3
作业 3 (优先级=3) 从 3 运行到 4
作业 1 (优先级=2) 从 4 运行到 9

为什么它重要

  • 直接表达紧急性
  • 用于操作系统内核、设备驱动程序、实时系统
  • 实现服务区分(前台与后台)

权衡:

  • 可能出现饥饿(低优先级作业可能永远无法运行)
  • 需要老化或动态优先级来确保公平性
  • 可能出现优先级反转(低优先级阻塞高优先级)

一个温和的证明(为什么它有效)

设每个作业 $J_i$ 的优先级为 $p_i$。 调度器选择作业 $J_k$,其中: $$ p_k = \max_i(p_i) $$

为确保有界等待,使用老化: $$ p_i(t) = p_i(0) + \alpha t $$ 其中 $\alpha$ 是每单位时间的小增量。 最终,每个作业的优先级都会提升到足以执行,防止饥饿。

自己动手试试

  1. 模拟优先级为 5, 3, 1 的作业 → 观察顺序。
  2. 添加老化:每个时间单位,增加等待作业的优先级。
  3. 比较抢占式与非抢占式运行。
  4. 添加高优先级的 I/O 密集型作业,注意响应性。

测试用例

作业优先级模式顺序饥饿?
4, 3, 2非抢占式1→2→3
5, 1, 1抢占式1→2/3可能
启用老化抢占式公平轮转

复杂度

  • 基于排序:每次调度决策 $O(n \log n)$
  • 动态老化:每次时钟滴答更新 $O(n)$
  • 抢占开销:取决于频率

优先级调度是执行调度器,将 CPU 分配给“喊声最大”的作业。 它功能强大但带有“政治性”:没有老化机制,“安静”的作业可能永远不被听到。

825 多级队列调度

多级队列调度将就绪队列划分为多个子队列,每个子队列专用于一类进程,例如系统进程、交互进程、批处理进程或后台进程。 每个队列都有自己的调度策略,而队列本身则使用固定优先级或时间片进行调度。

这种设计反映了真实操作系统的情况,即并非所有进程都是平等的,有些需要立即关注(如键盘中断),而另一些则可以等待(如备份)。

我们正在解决什么问题?

在单队列调度器(如 FCFS 或 RR)中,所有进程一起竞争。 但真实系统需要区分:

  • 前台(交互式)作业需要快速响应。
  • 后台(批处理)作业需要高吞吐量。
  • 系统任务需要即时服务。

多级队列通过分类 + 专业化来解决这个问题:

  • 不同的作业类型 → 不同的队列
  • 不同的队列 → 不同的策略

它是如何工作的(通俗解释)

  1. 将进程划分为不同的类别(例如,系统、交互、批处理)。
  2. 将每个类别分配到一个队列。
  3. 每个队列都有自己的调度算法(RR、FCFS、SJF 等)。
  4. 队列选择策略:
    • 固定优先级:始终优先服务较高优先级的队列。
    • 时间片:在队列之间共享 CPU(例如,80% 给用户,20% 给后台)。

示例队列设置

队列类型策略优先级
Q₁系统FCFS1(最高)
Q₂交互式RR2
Q₃批处理SJF3(最低)

CPU 选择顺序:

  1. 始终首先检查 Q₁。
  2. 如果 Q₁ 为空,则检查 Q₂。
  3. 如果 Q₂ 为空,则检查 Q₃。

每个队列独立运行其自己的本地调度器。

示例演练

假设:

  • Q₁: 系统 → J₁(3), J₂(2)
  • Q₂: 交互式 → J₃(4), J₄(3)
  • Q₃: 批处理 → J₅(6)

固定优先级方案:

  • 首先运行所有 Q₁ 作业(J₁, J₂)(FCFS)
  • 然后是 Q₂ 作业(RR)
  • 最后是 Q₃ 作业(SJF)

结果:系统响应性得到保证,后台作业被延迟。

时间片方案(例如,50%-30%-20%):

  • Q₁ 获得 50% 的 CPU,Q₂ 获得 30%,Q₃ 获得 20%
  • 调度器按比例在队列之间轮转

微型代码(概念模拟)

c
#include <stdio.h>

typedef struct {
    int id, burst, queue;
} Job;

void run_queue(Job jobs[], int n, const char* name) {
    printf("Running %s queue:\n", name);
    for (int i = 0; i < n; i++)
        printf("  Job %d (burst=%d)\n", jobs[i].id, jobs[i].burst);
}

int main() {
    Job sys[] = {{1, 3, 1}, {2, 2, 1}};
    Job inter[] = {{3, 4, 2}, {4, 3, 2}};
    Job batch[] = {{5, 6, 3}};

    run_queue(sys, 2, "System (FCFS)");
    run_queue(inter, 2, "Interactive (RR)");
    run_queue(batch, 1, "Batch (SJF)");
}

输出:

Running System (FCFS):
  Job 1 (burst=3)
  Job 2 (burst=2)
Running Interactive (RR):
  Job 3 (burst=4)
  Job 4 (burst=3)
Running Batch (SJF):
  Job 5 (burst=6)

为什么它很重要

  • 支持作业区分(系统 vs 用户 vs 批处理)
  • 为混合工作负载结合多种策略
  • 为高优先级队列提供可预测的服务

权衡:

  • 严格的分离:进程不能在队列之间移动
  • 饥饿:低优先级队列可能永远无法运行(在固定优先级下)
  • 复杂的调优:平衡队列份额需要小心处理

一个温和的证明(为什么它有效)

设队列 $Q_1, Q_2, \ldots, Q_k$ 的优先级为 $P_1 > P_2 > \ldots > P_k$。

对于固定优先级:

  • CPU 总是服务具有最高 $P_i$ 的非空队列。
  • 因此,系统任务(较高的 $P_i$)永远不会被用户任务阻塞。

对于时间片:

  • 每个队列获得 CPU 份额 $s_i$,且 $\sum s_i = 1$。
  • 在时间 $T$ 内,每个队列执行 $s_i T$,确保跨类别的公平分配。

这确保了有限的延迟和确定性的控制。

自己动手试试

  1. 创建 3 个队列:系统(FCFS)、交互式(RR)、批处理(SJF)。
  2. 模拟固定优先级与时间片调度。
  3. 添加一个长时间运行的批处理作业 → 观察饥饿现象。
  4. 切换到时间片 → 比较公平性。
  5. 尝试不同的份额比例。

测试用例

队列数策略模式结果
2FCFS, RR固定优先级系统作业快速执行
3FCFS, RR, SJF时间片平衡的公平性
2RR, SJF固定优先级饥饿风险

复杂度

  • 调度:$O(k)$(选择队列)+ 本地队列策略
  • 空间:$O(\sum n_i)$(所有队列中的作业总数)

多级队列调度就像一个分层的城市,紧急事务走快车道,稳定事务走辅路。 每个层级按照自己的节奏运行,但市长(调度器)决定谁接下来获得 CPU。

826 最早截止时间优先 (EDF)

最早截止时间优先 (EDF) 是一种动态优先级调度算法,主要用于实时系统。 在任何调度决策点,它都会选择截止时间最近的任务。如果新到达的任务具有更早的截止时间,它可以抢占当前正在运行的任务。

对于单处理器实时系统,EDF 是最优的。如果存在可行的调度方案,EDF 就能找到它。

我们要解决什么问题?

在实时系统中,时间是关键。错过截止时间可能意味着失败(例如,错过传感器读数或控制信号延迟)。

我们需要一种调度策略,它能够:

  • 始终满足截止时间(如果可能)
  • 适应动态任务到达
  • 在时间约束下确保可预测性

EDF 通过让最紧急的任务优先运行来实现这一点,紧急程度由截止时间的远近衡量。

它是如何工作的(通俗解释)

每个任务 $T_i$ 具有:

  • 执行时间:$C_i$
  • 周期(或到达时间):$P_i$
  • 绝对截止时间:$D_i$

在每一时刻:

  1. 收集所有就绪任务。
  2. 选择其中截止时间最早的任务。
  3. 运行它(如果另一个任务到达且其 $D_i$ 更早,则抢占)。
  4. 随着新任务到达,重复此过程。

CPU 总是优先运行最紧急的任务。

示例演练

任务到达时间执行时间 ($C$)截止时间 ($D$)
T₁025
T₂113
T₃227

时间线:

  • $t=0$: T₁ 就绪 (D=5) → 运行 T₁
  • $t=1$: T₂ 到达 (D=3 < 5) → 抢占 T₁,运行 T₂
  • $t=2$: T₂ 完成 → 恢复运行 T₁
  • $t=4$: T₁ 完成 → 运行 T₃

执行顺序:T₁ (0–1), T₂ (1–2), T₁ (2–4), T₃ (4–6)

所有截止时间都满足 ✅

可行性条件

对于具有执行时间 $C_i$ 和周期 $P_i$ 的周期性任务:

$$ U = \sum_{i=1}^{n} \frac{C_i}{P_i} $$

如果满足以下条件,EDF 能保证所有截止时间:

$$ U \le 1 $$

即,总的 CPU 利用率 ≤ 100%。

微型代码(概念性模拟)

c
#include <stdio.h>

typedef struct {
    int id, exec, deadline;
} Task;

void edf(Task t[], int n) {
    int time = 0;
    while (n > 0) {
        int min = 0;
        for (int i = 1; i < n; i++)
            if (t[i].deadline < t[min].deadline) min = i;

        printf("时间 %d%d: 任务 %d (D=%d)\n",
               time, time + t[min].exec, t[min].id, t[min].deadline);
        time += t[min].exec;

        for (int j = min; j < n - 1; j++) t[j] = t[j + 1];
        n--;
    }
}

int main() {
    Task tasks[] = {{1, 2, 5}, {2, 1, 3}, {3, 2, 7}};
    edf(tasks, 3);
}

输出:

时间 0–1: 任务 2 (D=3)
时间 1–3: 任务 1 (D=5)
时间 3–5: 任务 3 (D=7)

为什么它很重要

  • 对单 CPU 是最优的,如果可行则满足所有截止时间
  • 动态优先级:随着截止时间临近而调整
  • 广泛应用于实时操作系统和嵌入式系统

权衡:

  • 开销:频繁的优先级更新和抢占
  • 需要准确的截止时间和执行时间
  • 在过载情况下可能发生颠簸(错过多个截止时间)

一个温和的证明(为什么它有效)

假设 EDF 未能满足一个截止时间,而存在一个可行的调度方案。 那么在错过的截止时间 $D_i$ 处,一定是某个截止时间 $D_j > D_i$ 的任务 $T_j$ 在运行。 交换它们的执行顺序会使 $T_i$ 更早执行,而不会使 $T_j$ 延迟超过 $D_j$,这与最优性相矛盾。

因此,如果存在可行的调度方案,EDF 总能找到它。

可行性: $$ \sum_{i=1}^{n} \frac{C_i}{P_i} \le 1 \implies \text{所有截止时间都满足。} $$

自己动手试试

  1. 创建 3 个任务,参数为 $(C, D)$ = (2, 5), (1, 3), (2, 7)。
  2. 运行 EDF,绘制甘特图。
  3. 添加过载任务:$(C, D)$ = (3, 4), (3, 5)。
  4. 观察如果 $U > 1$ 时错过的截止时间。
  5. 与单调速率调度 (RMS) 进行比较。

测试用例

任务$C_i$$D_i$$U$可行?结果
(2,5),(1,3),(2,7)0.9所有截止时间都满足
(3,4),(3,5)1.35错过截止时间
(1,4),(1,5),(2,10)0.9可行

复杂度

  • 调度决策:每一步 $O(n)$
  • 抢占成本:高(动态)
  • 空间:$O(n)$ 任务列表

EDF 是调度器中的钟表匠,总是关注着下一个即将响起的时钟。 当任务有精确的截止时间时,它是确保每一秒都井然有序的最可靠指南。

827 单调速率调度(RMS)

单调速率调度(RMS)是一种固定优先级的实时调度算法。 它根据任务频率(速率)分配优先级:周期越短,优先级越高。 RMS 是所有固定优先级调度器中最优的,如果一组周期性任务无法被 RMS 调度,那么其他任何固定优先级策略也无法做得更好。

我们要解决什么问题?

在实时系统中,任务周期性重复,并且必须在截止时间前完成。 我们需要一个静态、可预测的调度器,具备:

  • 低运行时开销(固定优先级)
  • 确定性时序
  • 在特定 CPU 负载下保证可行性

EDF(动态优先级)是最优的,但维护成本高。RMS 牺牲了一点灵活性,换来了简单性和确定性。

它是如何工作的(通俗解释)

每个周期性任务 $T_i$ 由以下特征描述:

  • 周期 $P_i$(两次释放之间的间隔)
  • 计算时间 $C_i$(每个周期的执行时间)
  • 截止时间 = 周期结束 ($D_i = P_i$)

RMS 规则:

将更高的优先级分配给周期 $P_i$ 更小的任务(频率更高)。

调度器步骤:

  1. 按周期递增排序所有任务(周期越短,优先级越高)。
  2. 运行最高优先级的就绪任务。
  3. 必要时抢占较低优先级的任务。
  4. 每个周期重复。

示例详解

任务执行时间 $C_i$周期 $P_i$优先级
T₁14
T₂25
T₃310

时间线:

  • $t=0$: 运行 T₁(1 个单位)
  • $t=1$: 运行 T₂(2 个单位)
  • $t=3$: 运行 T₃(3 个单位)
  • $t=4$: T₁ 再次释放 → 抢占 T₃

CPU 总是选择频率最高的就绪任务。

利用率界限(可行性测试)

对于具有周期 $P_i$ 和执行时间 $C_i$ 的 $n$ 个周期性任务, 如果总利用率满足以下条件,RMS 保证所有截止时间:

$$ U = \sum_{i=1}^{n} \frac{C_i}{P_i} \le n \cdot (2^{1/n} - 1) $$

当 $n \to \infty$ 时, $$ U \to \ln 2 \approx 0.693 $$

因此,最高 69.3% 的 CPU 利用率是保证安全的。 超过这个值,可调度性取决于特定的任务对齐情况。

示例

任务$C_i$$P_i$$C_i / P_i$
T₁140.25
T₂150.20
T₃2100.20

$$ U = 0.25 + 0.2 + 0.2 = 0.65 \le 3(2^{1/3}-1) \approx 0.78 $$

✅ 在 RMS 下可行

微型代码(概念性模拟)

c
#include <stdio.h>

typedef struct {
    int id, exec, period, remaining; // id, 执行时间, 周期, 剩余时间
} Task;

int main() {
    Task tasks[] = {{1, 1, 4, 1}, {2, 1, 5, 1}, {3, 2, 10, 2}};
    int n = 3, time = 0, end = 20;

    while (time < end) {
        int sel = -1;
        for (int i = 0; i < n; i++) {
            if (time % tasks[i].period == 0)
                tasks[i].remaining = tasks[i].exec;
            if (tasks[i].remaining > 0 &&
                (sel == -1 || tasks[i].period < tasks[sel].period))
                sel = i;
        }
        if (sel != -1) {
            printf("t=%d: Run T%d\n", time, tasks[sel].id);
            tasks[sel].remaining--;
        } else printf("t=%d: Idle\n", time);
        time++;
    }
}

输出(节选):

t=0: Run T1
t=1: Run T2
t=2: Run T2
t=3: Run T3
t=4: Run T1
...

为什么它重要

  • 固定优先级 = 简单、可预测
  • 在固定优先级调度器中被证明是最优的
  • 用于 RTOS、嵌入式控制循环、航空电子设备

权衡:

  • 静态优先级 → 不如 EDF 灵活
  • 利用率界限 < 100% → 可能让 CPU 闲置
  • 过载可能导致低优先级任务错过截止时间

一个温和的证明(为什么它有效)

对于具有调和周期($P_i$ 能整除 $P_j$)的任务, $$ U = \sum_{i=1}^{n} \frac{C_i}{P_i} \le 1 $$ 既是必要条件也是充分条件。

一般情况下:

  • 如果 $U \le n(2^{1/n}-1)$ → RMS 保证截止时间。
  • 否则,可能仍然可行(但不保证)。

证明概要:

任何截止时间错过都意味着一个低优先级任务延迟了一个高优先级任务,这在 RMS 中是不可能的,因为高优先级任务总是可以抢占。

因此,RMS 在固定优先级调度器中是最优的。

自己动手试试

  1. 创建任务:$(C,P) = (1,4),(1,5),(2,10)$ → 检查可行性。
  2. 将 $C_3$ 增加到 3 → 重新计算 $U$,观察错过。
  3. 在同一组任务上比较 RMS 和 EDF。
  4. 添加调和任务($P_2 = 2P_1$)→ 调度更容易。

测试用例

任务$U$界限可行?
(1,4),(1,5),(2,10)0.650.78
(2,5),(2,7),(2,10)0.860.78⚠️ 可能
(1,4),(2,5),(3,10)0.950.78❌ 很可能错过

复杂度

  • 决策时间:$O(n)$(选择周期 $P_i$ 最小的就绪任务)
  • 空间:$O(n)$ 任务表
  • 开销:低(静态优先级)

RMS 是实时调度的节拍器,简单、有节奏且固定。 每个任务都知道自己的位置,CPU 随着它们周期的节拍起舞。

828 彩票调度

彩票调度将概率公平性引入 CPU 调度。 每个进程持有一定数量的彩票,在每次调度决策时,调度器随机抽取一张彩票,其所有者获得 CPU。 随着时间的推移,每个进程获得的 CPU 时间比例近似于其彩票份额。

这就像为 CPU 时间举办一场抽奖:彩票越多 → 中奖机会越大 → 获得的 CPU 时间越多。

我们要解决什么问题?

传统调度器(如 FCFS 或优先级调度)是确定性的,但很僵化。它们可能导致:

  • 低优先级进程饥饿
  • 工作负载变化时适应性差
  • 静态分配(难以动态调整)

彩票调度提供了:

  • 长期公平性
  • 动态可调性(可随时更改彩票数量)
  • 进程间的比例共享

它是如何工作的(通俗解释)

每个进程 $P_i$ 持有 $t_i$ 张彩票。 总池为 $T = \sum_i t_i$。

在每个调度步骤:

  1. 抽取一个随机数 $r \in [1, T]$。
  2. 找到其累积彩票范围包含 $r$ 的进程。
  3. 运行该进程一个时间片。
  4. 重复。

经过多次抽取,进程 $P_i$ 运行次数大约为:

$$ \text{时间占比} = \frac{t_i}{T} $$

因此,预期的 CPU 份额与其彩票数量成正比。

示例演练

进程彩票预期份额
P₁5050%
P₂3030%
P₃2020%

经过 1000 个时间片:

  • P₁ 运行约 500 次
  • P₂ 运行约 300 次
  • P₃ 运行约 200 次

会出现小的波动(随机性),但长期公平性得以保持。

示例彩票范围

进程彩票范围
P₁501–50
P₂3051–80
P₃2081–100

如果 $r = 73$ → P₂ 胜出 如果 $r = 12$ → P₁ 胜出 如果 $r = 95$ → P₃ 胜出

微型代码(概念模拟)

c
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

typedef struct {
    int id, tickets;
} Proc;

int pick_winner(Proc p[], int n) {
    int total = 0;
    for (int i = 0; i < n; i++) total += p[i].tickets;
    int draw = rand() % total + 1;
    int sum = 0;
    for (int i = 0; i < n; i++) {
        sum += p[i].tickets;
        if (draw <= sum) return i;
    }
    return 0;
}

int main() {
    srand(time(NULL));
    Proc p[] = {{1, 50}, {2, 30}, {3, 20}};
    int n = 3;
    for (int i = 0; i < 10; i++) {
        int w = pick_winner(p, n);
        printf("时间片 %d: 进程 %d 运行\n", i + 1, p[w].id);
    }
}

输出(示例):

时间片 1: 进程 1 运行
时间片 2: 进程 2 运行
时间片 3: 进程 1 运行
时间片 4: 进程 3 运行
...

为什么它重要

  • 比例公平性:按权重共享 CPU
  • 动态控制:只需调整彩票数量
  • 无饥饿:每个进程都有机会
  • 概率实现简单

权衡:

  • 近似公平性:短期不精确
  • 随机性:小规模不可预测
  • 开销:随机数生成,累积扫描

一个温和的证明(为什么它有效)

设 $t_i$ 为进程 $i$ 的彩票数,$T$ 为总彩票数。 每次抽取在 $[1, T]$ 上均匀随机。

进程 $P_i$ 获胜的概率: $$ P(\text{胜出}_i) = \frac{t_i}{T} $$

经过 $N$ 个时间片,预期胜出次数: $$ E[\text{运行次数}_i] = N \cdot \frac{t_i}{T} $$

根据大数定律,当 $N \to \infty$ 时,实际运行次数收敛于 $E[\text{运行次数}_i]$: $$ \frac{\text{运行次数}_i}{N} \to \frac{t_i}{T} $$

因此,长期比例公平性得到保证。

亲自尝试

  1. 分配彩票:(50, 30, 20)。运行 100 个时间片,测量分布。
  2. 增加 P₃ 的彩票,观察其份额上升。
  3. 在运行中移除彩票,进程消失。
  4. 结合补偿彩票处理 I/O 密集型作业。
  5. 与轮转调度(彩票数相等)进行比较。

测试用例

进程彩票预期比例观测值(近似)
(50, 30, 20)1005:3:2502:303:195
(1, 1, 1)31:1:1~相等
(90, 10)29:1~9:1

复杂度

  • 抽取:$O(n)$(线性扫描)或 $O(\log n)$(树)
  • 空间:$O(n)$
  • 开销:每个时间片开销小

彩票调度是调度器中的赌场,长期公平,短期充满变数。 它不依赖僵化的规则,而是信任概率来平衡负载,给每个进程一张彩票,一个机会。

829 多级反馈队列 (MLFQ)

多级反馈队列 (MLFQ) 是最灵活、最具适应性的 CPU 调度算法之一。 与多级队列调度(进程被固定在一个队列中)不同,MLFQ 允许进程在队列之间移动,根据观察到的行为获得或失去优先级。

它融合了优先级调度、轮转调度和反馈适应机制,奖励交互式作业以快速服务,并降低 CPU 密集型作业的优先级。

我们要解决什么问题?

在现实世界的系统中,进程各不相同:

  • 有些是交互式的(短时突发,频繁 I/O)
  • 其他是 CPU 密集型的(长时间计算)

我们并不总能事先知道这一点。 MLFQ 动态学习:

  • 如果一个进程使用了太多 CPU → 降低其优先级
  • 如果一个进程频繁让出 CPU(I/O 密集型) → 提高其优先级

因此,MLFQ 自动适应,在响应性和吞吐量之间取得平衡。

它是如何工作的(通俗解释)

  1. 维护多个就绪队列,每个队列具有不同的优先级。
  2. 每个队列有自己的时间片(优先级越高 → 时间片越短)。
  3. 新作业进入最高优先级队列。
  4. 如果它用完了整个时间片 → 降级到较低优先级队列。
  5. 如果它提前让出 CPU(I/O 等待) → 保持原队列或升级。
  6. 总是从最高优先级的非空队列调度。
  7. 定期将所有进程重置到最高优先级队列(老化机制)。

示例设置

队列优先级时间片调度策略
Q₁1 单位RR
Q₂2 单位RR
Q₃4 单位FCFS

规则:

  • 总是优先调度 Q₁ 而非 Q₂,优先 Q₂ 而非 Q₃。
  • 使用的时间片长度等于当前队列的时间片。
  • 如果进程提前结束 → 保持原队列。
  • 如果用完了整个时间片 → 降级。

示例演练

作业:

作业突发时间行为
J₁8CPU 密集型
J₂2I/O 密集型

时间线:

  • $t=0$: J₁ (Q₁, 时间片 1) → 用完整个时间片 → 降级到 Q₂
  • $t=1$: J₂ (Q₁, 时间片 1) → 提前让出 → 保持原队列
  • $t=2$: J₂ (Q₁) → 完成
  • $t=3$: J₁ (Q₂, 时间片 2) → 用完整个时间片 → 降级到 Q₃
  • $t=5$: J₁ (Q₃, FCFS) → 完成

结果:

  • J₂(交互式)快速完成
  • J₁(CPU 密集型)获得公平份额,但优先级较低

微型代码(概念性模拟)

c
#include <stdio.h>

typedef struct {
    int id, burst, queue;
} Job;

int main() {
    Job jobs[] = {{1, 8, 1}, {2, 2, 1}};
    int time = 0;
    while (jobs[0].burst > 0 || jobs[1].burst > 0) {
        for (int i = 0; i < 2; i++) {
            if (jobs[i].burst <= 0) continue;
            int q = jobs[i].queue;
            int quantum = (q == 1) ? 1 : (q == 2) ? 2 : 4;
            int run = jobs[i].burst < quantum ? jobs[i].burst : quantum;
            printf("t=%d: Job %d (Q%d) runs for %d\n",
                   time, jobs[i].id, q, run);
            time += run;
            jobs[i].burst -= run;
            if (jobs[i].burst > 0 && run == quantum && q < 3)
                jobs[i].queue++;
        }
    }
}

输出:

t=0: Job 1 (Q1) runs for 1
t=1: Job 2 (Q1) runs for 1
t=2: Job 2 (Q1) runs for 1
t=3: Job 1 (Q2) runs for 2
t=5: Job 1 (Q3) runs for 4
t=9: Job 1 (Q3) runs for 1

为什么它很重要

  • 适应性:无需事先知道作业长度
  • 公平性:所有作业最终都会获得 CPU 时间
  • 响应性:有利于短时和 I/O 密集型任务
  • 广泛应用:UNIX 和现代操作系统调度器的基础

权衡:

  • 复杂调优:许多参数(时间片、队列数量)
  • 开销:管理升级/降级
  • 饥饿风险:如果不定期提升优先级

一个温和的证明(为什么它有效)

随着时间的推移,CPU 密集型作业会下降到较低优先级队列,将最高优先级队列留给短时、交互式的作业。 给定足够的重置周期,每个作业最终都会回到最高优先级。

因此,MLFQ 保证了 I/O 密集型任务的最终进展和低延迟。

设队列 $Q_1, Q_2, \ldots, Q_k$ 及其时间片 $q_1 < q_2 < \cdots < q_k$。 一个每次都用完整个时间片的作业,经过 $k-1$ 步后移动到队列 $Q_k$。 定期重置恢复了公平性,防止了无限期饥饿。

亲自尝试

  1. 模拟 3 个作业:CPU 密集型、短时突发、交互式。
  2. 分配时间片:1, 2, 4。
  3. 观察升级/降级。
  4. 添加定期重置 → 验证公平性。
  5. 与轮转调度和优先级调度进行比较。

测试用例

作业数突发时间行为结果
28, 2CPU vs I/OI/O 作业提前完成
35, 3, 2混合CPU 作业被降级
110CPU 密集型在低优先级队列结束

复杂度

  • 调度决策:每次决策 $O(\text{#queues})$
  • 空间:$O(\sum n_i)$
  • 开销:升级、降级、重置

MLFQ 是调度器中的变色龙,不断适应,实时学习作业行为。 它同时奖励耐心和响应性,在时间跨度上编排公平。

830 公平队列(FQ)

公平队列(FQ)是一种网络调度算法,它公平地在各个流之间分配带宽。 它将每个流视为拥有自己的私有队列,并按比例平滑地分配传输机会,防止任何一个流独占链路。

用 CPU 调度的术语来说,FQ 是数据包的加权轮询调度,每个流都有机会,但权重更大的流按比例获得更多带宽。

我们要解决什么问题?

在共享系统(如路由器或 CPU 调度器)中,多个流或进程竞争单一资源。 如果没有公平性,一个贪婪的流可能会垄断容量,导致其他流饥饿或产生抖动。

公平队列确保:

  • 公平的带宽共享
  • 流之间的隔离
  • 行为良好的流量的平滑延迟

广泛应用于网络路由器、I/O 调度器和虚拟化系统。

它是如何工作的(通俗解释)

每个流维护自己的数据包(或任务)FIFO 队列。 从概念上讲,调度器模拟在所有活动流之间进行逐比特的轮询调度, 然后按照相同的顺序传输完整的数据包。

诀窍在于为每个数据包分配一个虚拟完成时间,并且总是发送在完美公平情况下最早完成的数据包。

关键思想:虚拟完成时间

对于每个数据包 $p_{i,k}$(流 $i$ 的第 $k$ 个数据包):

  • 到达时间:$A_{i,k}$
  • 长度:$L_{i,k}$
  • 开始时间: $$ S_{i,k} = \max(F_{i,k-1}, V(A_{i,k})) $$
  • 完成时间: $$ F_{i,k} = S_{i,k} + \frac{L_{i,k}}{w_i} $$

其中:

  • $F_{i,k-1}$ = 流 $i$ 中前一个数据包的完成时间
  • $V(t)$ = 在 $t$ 时刻的系统虚拟时间
  • $w_i$ = 流 $i$ 的权重(优先级)

调度器总是接下来选择 $F_{i,k}$ 最小的数据包。

如果 $w_i$ 不同,这模拟了加权公平队列(WFQ)。

示例演练

假设有两个流:

数据包长度权重
F₁100 字节1
F₂50 字节1

如果两者都处于活动状态:

  • F₁ 的数据包在时间 100 完成
  • F₂ 的在时间 50 完成 → 先发送 F₂
  • 然后是 F₁ → 总带宽 50/50 共享

如果 F₂ 发送更多,F₁ 在一段时间后仍能获得相等的份额。

微型代码(概念性模拟)

c
#include <stdio.h>

typedef struct {
    int flow, length, finish;
} Packet;

int main() {
    Packet p[] = {
        {1, 100, 100},
        {2, 50, 50},
        {1, 100, 200},
        {2, 50, 100}
    };
    int n = 4;

    // 按完成时间排序(简化版)
    for (int i = 0; i < n - 1; i++)
        for (int j = i + 1; j < n; j++)
            if (p[j].finish < p[i].finish) {
                Packet t = p[i]; p[i] = p[j]; p[j] = t;
            }

    for (int i = 0; i < n; i++)
        printf("发送来自流 %d 的数据包 (完成时间=%d)\n",
               p[i].flow, p[i].finish);
}

输出:

发送来自流 2 的数据包 (完成时间=50)
发送来自流 2 的数据包 (完成时间=100)
发送来自流 1 的数据包 (完成时间=100)
发送来自流 1 的数据包 (完成时间=200)

为什么它很重要

  • 带宽公平性:没有流能霸占链路
  • 延迟公平性:小数据包不会挨饿
  • 隔离性:行为不当的流不会损害其他流
  • 应用于:路由器、磁盘和操作系统进程调度器

权衡:

  • 计算成本:必须跟踪虚拟完成时间
  • 高速系统需要近似算法
  • 大量流会增加开销

一个温和的证明(为什么它有效)

公平队列近似于一个理想的流体系统,在该系统中,每个流连续获得 $$ \frac{w_i}{\sum_j w_j} $$ 的链路速率。

每个数据包 $p_{i,k}$ 都有一个完成时间: $$ F_{i,k} = \max(F_{i,k-1}, V(A_{i,k})) + \frac{L_{i,k}}{w_i} $$

通过按 $F_{i,k}$ 递增的顺序传输数据包, 我们确保在任何时刻,任意两个流之间的服务滞后都限制在一个数据包大小之内。

因此,没有流能获得超过其公平份额的资源。

自己动手试试

  1. 两个流,权重相等,模拟完成时间。
  2. 改变权重(1:2),检查按比例的带宽分配。
  3. 添加一个有大数据包的流,观察公平性。
  4. 与 FIFO 比较,注意在竞争情况下的差异。
  5. 在流传输过程中添加新流,验证平滑集成。

测试用例

流数权重数据包结果
21:1相等公平 50/50 分配
21:2相等按权重 1:2 分配
31:1:1大小不一随时间推移获得平等服务

复杂度

  • 决策时间:$O(\log n)$(按 $F_{i,k}$ 的优先队列)
  • 空间:$O(n)$ 个活动流
  • 准确性:在理想公平份额的一个数据包范围内

公平队列是调度器的平衡器,一位冷静的仲裁者,确保每个流都得到其应得的份额。 就像一位指挥家,它恰到好处地交错排列数据包,让所有声音和谐地共享线路。

第 84 节 缓存与替换

831 LRU(最近最少使用)

最近最少使用(LRU)是最经典的缓存替换策略之一。 当空间不足时,它会移除最近最少被访问的项,其假设是最近使用过的项很可能很快会再次被使用(时间局部性)。

它简单、直观,广泛应用于内存管理、CPU 缓存、Web 缓存和数据库。

我们要解决什么问题?

缓存空间有限。当缓存已满时,我们需要决定驱逐哪个项以便为新项腾出空间。

如果我们驱逐了错误的项,可能很快又需要它,从而导致缓存未命中。

LRU 基于最近性原则,选择驱逐最久未被使用的项:

“你最近没用过的东西,下次使用的可能性较低。”

这个策略能很好地适应时间局部性,即最近访问过的数据往往会被再次访问。

它是如何工作的(通俗解释)

跟踪访问顺序。 当缓存已满时:

  • 移除最旧(最近最少使用)的项。
  • 将新项插入到最近使用的位置。

每次访问一个项时:

  • 将其移动到最前面(最近使用的位置)。

可以将其想象成一个按最近使用时间排序的队列(或列表):

  • 最前面 = 最近最多使用
  • 最后面 = 最近最少使用

示例演练

假设缓存大小 = 3。

访问序列:A, B, C, A, D

步骤访问缓存(前 → 后)驱逐
1AA-
2BB A-
3CC B A-
4AA C B-
5DD A CB

解释

  • 再次访问 A 后,将 A 移到最前面。
  • 插入 D 时,缓存已满,驱逐最近最少使用的 B。

微型代码(C)

使用链表 + 哈希映射(实现 O(1) 查找):

c
#include <stdio.h>
#include <stdlib.h>

#define CAP 3

typedef struct Node {
    int key;
    struct Node *prev, *next;
} Node;

Node *head = NULL, *tail = NULL;

void move_to_front(Node *n) {
    if (n == head) return;
    if (n->prev) n->prev->next = n->next;
    if (n->next) n->next->prev = n->prev;
    if (n == tail) tail = n->prev;
    n->prev = NULL; n->next = head;
    if (head) head->prev = n;
    head = n;
}

void access(int key) {
    Node *n = head;
    while (n && n->key != key) n = n->next;
    if (n) {
        move_to_front(n);
        printf("访问 %d (命中)\n", key);
        return;
    }
    printf("访问 %d (未命中)\n", key);
    n = malloc(sizeof(Node));
    n->key = key; n->prev = NULL; n->next = head;
    if (head) head->prev = n;
    head = n;
    if (!tail) tail = n;
    int count = 0; Node *t = head;
    while (t) { count++; if (count > CAP) break; t = t->next; }
    if (count > CAP) {
        Node *evict = tail;
        tail = tail->prev;
        tail->next = NULL;
        free(evict);
    }
}

int main() {
    int seq[] = {1, 2, 3, 1, 4};
    for (int i = 0; i < 5; i++) access(seq[i]);
}

为什么它重要

  • 自适应:能很好地处理时间局部性
  • 简单有效:易于理解
  • 广泛应用:操作系统页面、数据库缓冲区、Web 缓存

权衡:

  • 开销:需要跟踪最近使用情况
  • 对于循环访问模式(例如,大于缓存的顺序扫描)并非最优
  • O(1) 的实现需要哈希表 + 链表

一个温和的证明(为什么它有效)

设 $S$ 为访问序列,$C$ 为缓存容量。

如果一个项 $x$ 在最近 $C$ 次不同的访问中都没有被使用, 那么添加一个新项意味着 $x$ 不会很快被重用(在大多数实际工作负载下)。

LRU 在栈属性下近似于 Belady 的最优策略:

在 LRU 下增加缓存大小永远不会增加未命中。

因此,LRU 的性能是单调的,这是一个罕见且有价值的特性。

亲自尝试

  1. 模拟容量为 3,序列为 A, B, C, D, A, B, E, A, B, C, D, E 的 LRU。
  2. 与 FIFO 和随机替换策略比较命中率。
  3. 观察时间局部性如何提高命中率。
  4. 尝试一个循环模式(A, B, C, D, A, B, C, D, ...),看看其弱点。
  5. 使用栈或有序映射实现 LRU。

测试用例

缓存大小序列命中数未命中数
3A B C A D14
3A B C A B C33
2A B A B A B42

复杂度

  • 访问:$O(1)$(使用哈希 + 链表)
  • 空间:$O(n)$(缓存 + 元数据)

LRU 是最近性的记忆,一个从你的习惯中学习的缓存。 你经常触碰的,它保持亲近;你遗忘的,它便放手。

832 LFU(最不经常使用)

最不经常使用(LFU)是一种缓存替换策略,它优先驱逐访问频率最低的项。 与关注最近使用时间(如 LRU)不同,LFU 关注的是频率,它假设经常被访问的项很可能再次被访问。

对于存在长期保持热度的热点项的工作负载,LFU 是一个很自然的选择。

我们要解决什么问题?

在许多系统中,有些数据会被重复使用(热点项),而另一些则很少需要。 如果我们使用 LRU,一次连续的突发访问可能会冲刷掉热门项,这种现象称为缓存污染。

LFU 通过跟踪访问计数来解决这个问题,因此频繁访问的项受到保护,不会被驱逐。

驱逐规则:移除访问频率最低的项。

它是如何工作的(通俗解释)

每次访问一个项时:

  • 增加其频率计数
  • 根据该计数重新排序或重新分类

当缓存已满时:

  • 驱逐频率最小的项

可以将其视为一个按访问频率排序的优先队列:

  • 经常出现的项上升到顶部
  • 很少访问的项逐渐下沉并被驱逐

示例演练

缓存容量 = 3 访问序列:A, B, C, A, B, A, D

步骤访问频率计数驱逐
1AA:1-
2BA:1, B:1-
3CA:1, B:1, C:1-
4AA:2, B:1, C:1-
5BA:2, B:2, C:1-
6AA:3, B:2, C:1-
7DA:3, B:2, C:1驱逐 C

驱逐:C(频率最低)

最终缓存:A (3), B (2), D (1)

微型代码(C)

c
#include <stdio.h>
#include <stdlib.h>

#define CAP 3

typedef struct {
    char key;
    int freq;
} Entry;

Entry cache[CAP];
int size = 0;

void access(char key) {
    for (int i = 0; i < size; i++) {
        if (cache[i].key == key) {
            cache[i].freq++;
            printf("访问 %c (命中, 频率=%d)\n", key, cache[i].freq);
            return;
        }
    }
    printf("访问 %c (未命中)\n", key);
    if (size < CAP) {
        cache[size++] = (Entry){key, 1};
        return;
    }
    // 驱逐 LFU
    int min_i = 0;
    for (int i = 1; i < size; i++)
        if (cache[i].freq < cache[min_i].freq)
            min_i = i;
    printf("驱逐 %c (频率=%d)\n", cache[min_i].key, cache[min_i].freq);
    cache[min_i] = (Entry){key, 1};
}

int main() {
    char seq[] = {'A','B','C','A','B','A','D'};
    for (int i = 0; i < 7; i++) access(seq[i]);
}

输出:

访问 A (未命中)
访问 B (未命中)
访问 C (未命中)
访问 A (命中, 频率=2)
访问 B (命中, 频率=2)
访问 A (命中, 频率=3)
访问 D (未命中)
驱逐 C (频率=1)

为什么它很重要

  • 保护长期保持热度的热点数据
  • 减少一次性扫描造成的缓存污染
  • 对于倾斜的工作负载(Zipfian 分布)非常有效

权衡:

  • 难以高效实现(需要按频率排序的优先级)
  • 旧的流行项可能永远驻留(没有老化机制)
  • 如果实现简单,簿记开销很大

像带老化的 LFU 或 TinyLFU 这样的变体,通过随时间衰减频率来解决陈旧性问题。

一个温和的证明(为什么它有效)

令 $f(x)$ 为项 $x$ 的频率计数。 LFU 在容量 $C$ 的限制下,将 $f(x)$ 最高的所有项保留在缓存中。

如果访问分布是稳定的,那么前 $C$ 个最频繁的项能最小化缓存未命中。

从概率角度来说,对于访问概率 $p(x)$, 最优的稳态缓存应持有 $p(x)$ 最大的 $C$ 个项, LFU 通过计数来近似这一点。

自己动手试试

  1. 运行序列 A B C A B A D(容量 3)。
  2. 尝试一个流式模式(A B C D E F ...),观察 LFU 性能下降。
  3. 添加老化机制(定期将所有频率除以 2)。
  4. 将结果与 LRU 和 Random 进行比较。
  5. 可视化随时间变化的计数,观察热点项的持久性。

测试用例

缓存大小序列驱逐项结果
3A B C A B A DCA(3), B(2), D(1)
2A B A B CCC 替换 A
3A B C D EA,B,C所有项频率为 1,任意驱逐

复杂度

  • 访问:$O(\log n)$(堆)或 $O(1)$(频率桶)
  • 空间:$O(n)$ 用于频率跟踪

LFU 是缓存的统计学家,它观察模式,忠实地计数, 并且只保留历史证明是流行的内容。

833 FIFO 缓存(先进先出)

先进先出(FIFO)是最简单的缓存替换策略之一。它会驱逐缓存中最旧的条目,即存在时间最长的条目,而不管它被使用的频率或最近是否被使用过。

它很容易用一个简单的队列来实现,但不考虑最近使用情况或频率,因此容易出现异常。

我们要解决什么问题?

当缓存已满时,我们必须移除一些内容来腾出空间。FIFO 用一个简单的规则来回答这个问题:

"驱逐最先进入的条目。"

当数据遵循流式模式(旧的条目不太可能被重用)时,这种方法效果很好,但当旧的条目仍然很热门(被频繁重用)时,它就会失效。

它主要用于那些更看重简单性而非精确性的场景。

它是如何工作的(通俗解释)

  1. 维护一个按插入顺序排列的条目队列。
  2. 当访问一个新条目时:
    • 如果它已经在缓存中 → 命中(什么也不做)
    • 如果不在 → 未命中,插入到队列尾部
  3. 如果缓存已满 → 驱逐队列头部的条目(最旧的)

与 LRU 不同,访问时不会发生重新排序。

示例演练

缓存容量 = 3 访问序列:A, B, C, A, D, B

步骤访问缓存(头部 → 尾部)驱逐
1AA-
2BA B-
3CA B C-
4AA B C-
5DB C D驱逐 A
6BB C D-

观察: 尽管 A 被再次使用,但 FIFO 还是驱逐了它,因为它是最旧的,而不是使用最少的。

微型代码(C语言)

c
#include <stdio.h>

#define CAP 3

char cache[CAP];
int size = 0;
int front = 0;

int in_cache(char key) {
    for (int i = 0; i < size; i++)
        if (cache[i] == key) return 1;
    return 0;
}

void access(char key) {
    if (in_cache(key)) {
        printf("访问 %c (命中)\n", key);
        return;
    }
    printf("访问 %c (未命中)\n", key);
    if (size < CAP) {
        cache[size++] = key;
    } else {
        printf("驱逐 %c\n", cache[front]);
        cache[front] = key;
        front = (front + 1) % CAP;
    }
}

int main() {
    char seq[] = {'A','B','C','A','D','B'};
    for (int i = 0; i < 6; i++) access(seq[i]);
}

输出:

访问 A (未命中)
访问 B (未命中)
访问 C (未命中)
访问 A (命中)
访问 D (未命中)
驱逐 A
访问 B (命中)

为什么它重要

  • 简单性:易于实现(只需一个队列)
  • 确定性:行为可预测
  • 适用于 FIFO 队列和流式数据

权衡:

  • 无最近使用感知:会驱逐最近使用的数据
  • Belady 异常:增加缓存大小可能会增加未命中次数
  • 处理时间局部性的能力差

一个温和的证明(为什么它有效)

FIFO 作为一个最近插入的滑动窗口运行。每次新的访问都会推出最旧的条目,而不管其使用情况。

形式上,在时间 $t$,缓存保存着最后 $C$ 个唯一的插入。如果一个重用条目的插入时间点位于该窗口之外,它就不在缓存中,因此对于重复模式性能较差。

亲自尝试

  1. 模拟容量 = 3,序列 A B C A B C A B C
  2. 注意第三个之后的每次访问都是未命中,没有捕获到重用。
  3. 与 LRU 进行比较,LRU 会重用过去的数据。
  4. 用流式数据(例如顺序块)测试,FIFO 表现出色。
  5. 逐步可视化队列的演变。

测试用例

缓存大小序列未命中次数备注
3A B C A D B4按年龄驱逐
3A B C A B C6Belady 异常
2A B A B A B2如果重用适合窗口则有效

复杂度

  • 访问:$O(1)$(队列操作)
  • 空间:$O(C)$(缓存数组)

FIFO 是缓存的流水线,它稳步向前移动,从不回头看看它正在放弃什么。

834 CLOCK 算法

CLOCK 算法是 LRU(最近最少使用)算法的一种高效近似。它不维护完整的最近使用列表,而是为每个页面维护一个循环缓冲区和一个使用位,从而以低得多的开销实现接近 LRU 的性能。

由于其简单性和速度,它被广泛应用于操作系统(例如,虚拟内存中的页面置换)。

我们要解决什么问题?

一个真正的 LRU 缓存需要跟踪精确的访问顺序,这可能代价高昂:

  • 每次访问时更新位置
  • 维护链表或时间戳

CLOCK 算法使用单个引用位来近似 LRU,像时钟指针一样旋转,惰性地查找牺牲页。

这减少了开销,同时保持了相似的命中率。

它是如何工作的?(通俗解释)

想象页面排列成一个圆圈,每个页面都有一个使用位(0 或 1)。一个时钟指针在圆圈上移动,指向可能被淘汰的候选页面。

当一个页面被访问时:

  • 将其使用位设置为 1

当缓存已满且需要淘汰页面时:

  1. 查看时钟指针指向的页面。
  2. 如果使用位 = 0,则淘汰它。
  3. 如果使用位 = 1,则将其设置为 0 并向前移动指针。
  4. 重复此过程,直到找到一个使用位为 0 的页面。

这确保了最近使用过的页面获得第二次机会。

示例演练

缓存容量 = 3

步骤访问缓存使用位指针淘汰
1AA[1]→A-
2BA B[1 1]→A-
3CA B C[1 1 1]→A-
4DA B C[0 1 1]→B淘汰 A
5BA B C[0 1 1]→B-
6ED B C[1 1 1]→D淘汰下一个 0

详细步骤:

  • 当缓存已满时,D 到达。指针指向 A,使用位=1 → 设为 0 → 移动 → B (1) → 设为 0 → 移动 → C (1) → 设为 0 → 移动 → 回到 A (0) → 淘汰 A → 插入 D。

微型代码 (C)

c
#include <stdio.h>

#define CAP 3

typedef struct {
    char key;
    int use;
} Page;

Page cache[CAP];
int size = 0, hand = 0;

int find(char key) {
    for (int i = 0; i < size; i++)
        if (cache[i].key == key) return i;
    return -1;
}

void access(char key) {
    int i = find(key);
    if (i != -1) {
        printf("访问 %c (命中)\n", key);
        cache[i].use = 1;
        return;
    }
    printf("访问 %c (未命中)\n", key);
    if (size < CAP) {
        cache[size++] = (Page){key, 1};
        return;
    }
    // CLOCK 淘汰
    while (1) {
        if (cache[hand].use == 0) {
            printf("淘汰 %c\n", cache[hand].key);
            cache[hand] = (Page){key, 1};
            hand = (hand + 1) % CAP;
            break;
        }
        cache[hand].use = 0;
        hand = (hand + 1) % CAP;
    }
}

int main() {
    char seq[] = {'A','B','C','D','B','E'};
    for (int i = 0; i < 6; i++) access(seq[i]);
}

为什么它很重要?

  • 类似 LRU 的性能,数据结构更简单
  • 使用循环指针实现常数时间淘汰
  • 操作系统标准:用于 UNIX、Linux、Windows 虚拟内存

权衡:

  • 是近似算法,不是完美的 LRU
  • 可能偏向于访问频繁但间隔时间长的页面
  • 仍然需要为每个页面存储一个位

一个温和的证明(为什么它有效)

设每个页面有一个使用位 $u_i \in {0, 1}$。 当一个页面被引用时,$u_i = 1$。 指针循环遍历页面,如果 $u_i$ 为 1 则将其清零为 0。

一个页面只有在被访问过的情况下才能存活一整轮循环, 因此最终只有未被引用的页面会被替换。

因此,CLOCK 保证了: $$ \text{淘汰顺序近似于最近使用顺序} $$ 并且维护成本为 $O(1)$。

亲自尝试

  1. 模拟访问序列 A B C D A B E
  2. 跟踪使用位和指针。
  3. 与 LRU 结果比较,通常淘汰结果相同。
  4. 增加容量,验证相似的趋势。
  5. 扩展到 Second-Chance FIFO(CLOCK = 改进的 FIFO)。

测试用例

缓存大小序列淘汰的页面结果
3A B C D B EA, C与 LRU 匹配
2A B A C AB与 LRU 相似
3A A B C DA, B保留了最近的 A

复杂度

  • 访问:$O(1)$
  • 淘汰:$O(1)$(摊销)
  • 空间:$O(C)$

CLOCK 算法是温和的 LRU,它静静地旋转,给予第二次机会, 并且只淘汰那些真正被遗忘的页面。

835 ARC(自适应替换缓存)

自适应替换缓存(Adaptive Replacement Cache,ARC)是一种自调优的缓存算法,能动态平衡最近使用和频繁使用。 它结合了 LRU(最近使用项)和 LFU(频繁使用项)的优点,并在访问模式变化时自动适应

ARC 由 IBM 研究院提出,用于 ZFS 等系统,以应对混合工作负载下的智能缓存。

我们要解决什么问题?

传统缓存采用固定策略:

  • LRU 偏好最近使用,但在循环扫描下表现不佳。
  • LFU 偏好频繁使用,但会遗忘最近使用。

现实世界的工作负载是波动的,有时新项更重要,有时重用项更重要。

ARC 通过同时跟踪两者,并根据哪一方能带来更多命中来动态适应,从而解决这个问题。

"如果最近性有帮助,就偏向 LRU。 如果频率性有帮助,就偏向 LFU。"

它是如何工作的(通俗解释)

ARC 维护四个列表:

列表含义
T₁见过一次的最近项(LRU)
T₂见过两次及以上的频繁项(LFU)
B₁最近从 T₁ 中淘汰项的幽灵列表
B₂最近从 T₂ 中淘汰项的幽灵列表

幽灵列表不存储数据,只存储键,用于记住被淘汰的内容。

ARC 动态调整一个目标大小 p:

  • 如果在 B₁ 中发生幽灵命中,则最近性分配不足 → 增加 p(偏向 T₁)
  • 如果在 B₂ 中发生幽灵命中,则频率性分配不足 → 减少 p(偏向 T₂)

因此,ARC 在线学习如何在最近性(T₁)和频率性(T₂)之间划分缓存。

示例流程

缓存大小 = 4

  1. 访问 A B C D → 填满 T₁ = [D C B A]
  2. 再次访问 A → 将 A 从 T₁ 移至 T₂
  3. 访问 E → 从 T₁ 中淘汰最近最少使用的项(D)→ 记录到 B₁
  4. 稍后,再次访问 D → 在 B₁ 中发生幽灵命中 → 增加 p → 偏向 T₁

ARC 利用这些幽灵信号持续平衡最近性与频率性。

微型代码(伪代码)

c
on access(x):
    if x in T1 or x in T2:
        // 缓存命中
        move x to front of T2
    else if x in B1:
        // 最近性幽灵命中
        increase p
        replace(x)
        move x to front of T2
    else if x in B2:
        // 频率性幽灵命中
        decrease p
        replace(x)
        move x to front of T2
    else:
        // 新项
        if T1.size + B1.size == c:
            if T1.size < c:
                remove oldest from B1
                replace(x)
            else remove oldest from T1
        else if total size >= c:
            if total size == 2c:
                remove oldest from B2
        insert x to front of T1

为什么它很重要

  • 自调优:自动在 LRU 和 LFU 之间调整
  • 工作负载自适应:优雅处理扫描和热点项
  • 无需手动调优:p 在线更新

权衡:

  • 更复杂的簿记工作
  • 更高的元数据开销(四个列表)
  • 已申请专利(IBM;在 ZFS 中需许可使用)

一个温和的证明(为什么它有效)

ARC 近似于 LRU 和 LFU 的最优自适应混合。

令:

  • $c$ = 总缓存大小
  • $p$ = 最近性(T₁)的分区目标

在任何时刻:

  • T₁ 存储 $\min(p, c)$ 个最近使用的项
  • T₂ 存储剩余的 $\max(0, c - p)$ 个最频繁使用的项

幽灵列表(B₁, B₂)充当反馈通道。 如果 $\vert B₁\vert > \vert B₂\vert $,则最近性命中更多 → 增加 $p$(偏向 LRU)。 如果 $\vert B₂\vert > \vert B₁\vert $,则频率性命中更多 → 减少 $p$(偏向 LFU)。

因此,对于给定的访问分布,ARC 收敛到接近最优策略的策略。

自己试试

  1. 模拟序列 A B C D A B E A B C D(容量为 4)。
  2. 观察幽灵命中发生时 p 的移动。
  3. 与纯 LRU 和 LFU 比较,ARC 适应得更好。
  4. 引入一个长扫描(A...Z)→ ARC 转向频率性。
  5. 重放混合工作负载,ARC 智能地振荡。

测试用例

序列容量LRU 未命中数LFU 未命中数ARC 未命中数胜者
重复 (A,B,C,A,B,C)3平局
扫描 (A,B,C,D,E)3中等ARC
混合热点+扫描4中等ARC

复杂度

  • 访问:$O(1)$ 摊销(列表操作)
  • 空间:$O(2C)$(实际列表 + 幽灵列表)
  • 自适应参数:$p \in [0, C]$

ARC 是聪明的混合体,同时关注历史和频率, 学习哪种模式在当下占主导,并像一个生命系统一样调整其平衡。

836 双队列(2Q)

双队列(2Q)缓存是对朴素 LRU 的一种巧妙且轻量级的增强。它将最近访问的页面与频繁重用的页面分离开来,减少了由一次性访问引起的缓存污染,这是单独使用 LRU 难以解决的问题。

你可以将 2Q 视为 ARC 的一个简化、实用的“表亲”,理念相同,但组件更少。

我们要解决什么问题?

LRU 会淘汰最近最少使用的页面,但在存在大规模顺序扫描的工作负载中,那些新看到的页面可能会淘汰掉仍然需要的热点页面。

2Q 通过首先将新页面保留在一个“试用”队列中来防止这种情况。只有被访问过两次的项才会移入主队列。

“新项必须在主缓存中赢得自己的位置。”

当存在一次性数据和重复数据的混合访问时,这极大地提高了性能。

它是如何工作的(通俗解释)

2Q 维护两个 LRU 队列:

队列含义行为
A1 (In)最近被访问过一次临时存放区
Am (Main)至少被访问过两次长期缓存

访问流程:

  1. 未命中时:将页面插入 A1(如果未满)。
  2. 在 A1 中命中:将该页面提升到 Am。
  3. 在 Am 中命中:将其移到 MRU(最前端)。
  4. 如果 A1 已满 → 从 A1 中淘汰最旧的(LRU)项。
  5. 如果 Am 已满 → 从 Am 中淘汰最旧的项。

示例演练

缓存容量 = 4 (A1 = 2, Am = 2) 访问序列:A, B, C, A, D, B, E, A

步骤访问A1 (Recent)Am (Frequent)淘汰项
1AA--
2BB A--
3CC B-淘汰 A (A1中最旧)
4ABA将 A 提升到 Am
5DD BA-
6BDA B将 B 提升到 Am
7EE DA B淘汰 A1 中最旧的项
8AE DA B在 Am 中命中

结果:热点页面 A, B 得以保留;冷扫描数据被无害地冲刷掉。

微型代码(C)

c
#include <stdio.h>
#include <string.h>

#define A1_SIZE 2
#define AM_SIZE 2

char A1[A1_SIZE][2], Am[AM_SIZE][2];
int a1_len = 0, am_len = 0;

int in_list(char list[][2], int len, char k) {
    for (int i = 0; i < len; i++)
        if (list[i][0] == k) return i;
    return -1;
}

void access(char k) {
    int i;
    if ((i = in_list(Am, am_len, k)) != -1) {
        printf("访问 %c (在 Am 中命中)\n", k);
        return;
    }
    if ((i = in_list(A1, a1_len, k)) != -1) {
        printf("访问 %c (提升到 Am)\n", k);
        // 从 A1 中移除
        memmove(&A1[i], &A1[i+1], (a1_len - i - 1) * 2);
        a1_len--;
        // 插入到 Am
        if (am_len == AM_SIZE) {
            printf("从 Am 淘汰 %c\n", Am[AM_SIZE-1][0]);
            am_len--;
        }
        memmove(&Am[1], &Am[0], am_len * 2);
        Am[0][0] = k; am_len++;
        return;
    }
    printf("访问 %c (未命中,插入到 A1)\n", k);
    if (a1_len == A1_SIZE) {
        printf("从 A1 淘汰 %c\n", A1[A1_SIZE-1][0]);
        a1_len--;
    }
    memmove(&A1[1], &A1[0], a1_len * 2);
    A1[0][0] = k; a1_len++;
}

int main() {
    char seq[] = {'A','B','C','A','D','B','E','A'};
    for (int i = 0; i < 8; i++) access(seq[i]);
}

为什么它很重要

  • 缓解了 LRU 的弱点:避免在扫描期间冲刷缓存
  • 轻量级:比 ARC 更简单,易于实现
  • 自动适应重用频率
  • 用于:PostgreSQL、InnoDB 和操作系统缓存

权衡:

  • 需要调整两个参数(A1 和 Am 的大小)
  • 比朴素 LRU 略多一些元数据开销
  • 不能像 LFU 那样完全捕获长期频率

一个温和的证明(为什么它有效)

令:

  • $A_1$ 跟踪被访问过一次的项
  • $A_m$ 跟踪至少被访问过两次的项

假设访问概率为 $p_i$。 在稳态下:

  • $A_1$ 过滤一次性访问项(低 $p_i$)
  • $A_m$ 持有高 $p_i$ 的项 因此,2Q 以线性开销近似实现了一个频率感知策略。

形式上,当 $p(\text{一次性访问项}) > 0$ 时,2Q 的未命中率低于 LRU,因为此类页面在污染主缓存之前就被快速循环淘汰了。

自己动手试试

  1. 使用混合了热点和冷页面的序列进行模拟。
  2. 在长顺序扫描下比较 LRU 和 2Q。
  3. 调整 A1/Am 的比例(例如,25/75, 50/50),观察敏感性。
  4. 在冷项中添加重复的热点项,观察 2Q 的适应情况。
  5. 测量命中率并与 LRU 和 LFU 进行比较。

测试用例

序列容量策略命中率胜出者
A B C D A B C D4LRU-
A B C D A B42Q更高2Q
热点 + 冷点混合42Q更好的稳定性2Q

复杂度

  • 访问:$O(1)$(使用链表或队列)
  • 空间:$O(C)$(两个队列)
  • 适应性:静态比例(手动调整)

2Q 是精明的 LRU,它不会立刻信任新页面。新页面必须先证明自己的价值,才能加入受信任的、频繁使用的数据的核心圈子。

837 LIRS(低最近访问间隔集合)

LIRS(低最近访问间隔集合)是一种高性能的缓存替换算法,它通过区分真正的热页和暂时流行的页来改进 LRU。 它衡量的是重用距离,而不仅仅是最近访问时间,从而捕捉访问行为中更深层的时间模式。

由 Jiang 和 Zhang(2002)发明,LIRS 在具有不规则重用模式的工作负载中,比 LRU 和 ARC 实现了更低的未命中率。

我们要解决什么问题?

LRU 根据自上次访问以来的时间对页面进行排序,假设最近使用的页面很快会被再次使用。 但当某些页面在很长的时间间隔内只被访问一次时,这种方法就会失效,它们看起来仍然"最近",但实际上并不热。

LIRS 通过根据页面的重用距离进行排序来改进这一点:

"与其他页面相比,这个页面最近被重用的频率如何?"

重用距离越小,页面再次被重用的可能性就越大。

它是如何工作的(通俗解释)

LIRS 维护两个集合:

集合含义内容
LIR(低最近访问间隔)频繁重用的页面保留在缓存中
HIR(高最近访问间隔)很少重用或新的页面一部分驻留,大部分仅记录

所有页面(无论是否驻留)都在一个按最近访问时间排序的栈 S 中进行跟踪。 S 的一个子集 Q 包含驻留的 HIR 页面。

当一个页面被访问时:

  1. 如果它在 LIR 中 → 将其移动到栈顶(最近访问)。
  2. 如果它在 HIR 中(驻留) → 将其提升为 LIR;将最近最少访问的 LIR 降级为 HIR。
  3. 如果它未驻留 → 将其添加为 HIR;如果需要,则驱逐最老的驻留 HIR。
  4. 修剪栈 S 的底部以移除过时的条目(非驻留且未被引用的)。

因此,LIRS 是基于实际的重用距离动态调整的,而不仅仅是基于最近一次访问。

示例演练

缓存大小 = 3 访问序列:A, B, C, D, A, B, E, A, B, C

步骤访问LIRHIR(驻留)驱逐
1AA--
2BA B--
3CA B C--
4DB CD驱逐 A
5AC DA驱逐 B
6BD AB驱逐 C
7EA BE驱逐 D
8AB EA无驱逐(A 被重用)
9BE AB-
10CA BC驱逐 E

热页(A,B)稳定地保持在 LIR 中;冷页则在 HIR 中循环。

微型代码(概念性模拟)

c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define CAP 3

typedef struct Page {
    char key;
    int isLIR, resident;
} Page;

Page cache[CAP];
int size = 0;

void access(char key) {
    int i;
    for (i = 0; i < size; i++)
        if (cache[i].key == key) break;

    if (i < size) {
        printf("访问 %c (命中)\n", key);
        cache[i].isLIR = 1;
        return;
    }

    printf("访问 %c (未命中)\n", key);
    if (size < CAP) {
        cache[size++] = (Page){key, 0, 1};
        return;
    }
    int victim = 0;
    for (i = 1; i < size; i++)
        if (!cache[i].isLIR) { victim = i; break; }
    printf("驱逐 %c (HIR)\n", cache[victim].key);
    cache[victim] = (Page){key, 0, 1};
}

int main() {
    char seq[] = {'A','B','C','D','A','B','E','A','B','C'};
    for (int i = 0; i < 10; i++) access(seq[i]);
}

(简化逻辑;真实的 LIRS 会显式维护栈 S 和集合 Q。)

为什么它很重要

  • 对于混合工作负载,命中率比 LRU 更高
  • 优雅地处理扫描(避免 LRU 抖动)
  • 动态适应,无需手动调整
  • 用于:数据库缓冲池、SSD 缓存、操作系统内核

权衡:

  • 更多的簿记开销(栈和集合)
  • 比 LRU 略高的开销
  • 从头实现更困难

一个温和的证明(为什么它有效)

令 $r(p)$ 表示页面 $p$ 的最近访问间隔(在两次使用 $p$ 之间访问的不同页面的数量)。 LIRS 保留具有低 $r(p)$(频繁重用)的页面。

随着时间的推移,该算法维护:

  • LIR 集合 = 具有小 $r(p)$ 的页面
  • HIR 集合 = 具有大 $r(p)$ 的页面

由于低 $r$ 的页面能最小化预期未命中数,LIRS 比 LRU 更接近地近似于 OPT(Belady 最优算法)。

形式上,LIR 集合近似于: $$ \text{LIR} = \arg\min_{|S| = C} \sum_{p \in S} r(p) $$

自己动手试试

  1. 在序列 A B C D A B E A B C 上模拟 LIRS。
  2. 与 LRU 比较,LIRS 避免了过多的驱逐。
  3. 添加一个大的扫描(A B C D E F G A),注意未命中数更少。
  4. 可视化重用距离,观察 LIR 如何稳定下来。
  5. 尝试不同的缓存大小。

测试用例

序列策略未命中率胜者
重复(A,B,C,A,B,C)LRU平局
扫描(A,B,C,D,E)LRULIRS
混合重用LIRS最低LIRS

复杂度

  • 访问:$O(1)$(摊销,包含栈修剪)
  • 空间:$O(C)$(缓存 + 元数据)
  • 适应性:自动

LIRS 是缓存算法中的战略家,它不仅仅记住你何时使用了某物, 它还记住你定期回来使用它的频率,并据此进行优先级排序。

838 TinyLFU(微型最不常用)

TinyLFU 是一种现代的概率性缓存准入策略,它使用紧凑的计数器来跟踪项目频率,而不是存储完整的历史记录。 它决定哪些项目值得进入缓存,而不是直接决定驱逐哪些项目,通常与 LRU 或 ARC 等其他策略结合使用。

TinyLFU 为 Caffeine(Java 缓存)和现代 Web 缓存层等实际系统提供支持,以最小的内存实现接近最优的命中率。

我们要解决什么问题?

经典 LFU 需要为每个缓存项存储一个频率计数器,这非常占用空间。 此外,它甚至为即将被驱逐的项目更新计数器,浪费内存和 CPU。

TinyLFU 通过使用固定大小的草图(sketch)进行近似计数来记录最近访问项目的频率,从而解决了这两个问题。 它以概率方式做出准入决策,只保留比被驱逐项目更受欢迎的项目。

"不仅要明智地驱逐,更要明智地准入。"

它是如何工作的(通俗解释)

TinyLFU 包含两个核心思想:

  1. 频率草图

    • 一个紧凑的计数器结构(如计数最小草图)跟踪项目在滑动窗口中被访问的频率。
    • 旧的频率会定期衰减。
  2. 准入策略

    • 当缓存已满且新项目到达时:
      • 估计其频率 f_new
      • 与牺牲者(victim)的频率 f_victim 进行比较
      • 如果 f_new > f_victim,则准入新项目
      • 否则,拒绝它(保留旧项目)

因此,TinyLFU 不会盲目替换,它会问:

"这个新来者真的比当前的租户更受欢迎吗?"

示例演练

缓存容量 = 3 访问序列:A, B, C, D, A, E, A, F

步骤访问频率估计决策
1AA:1准入
2BB:1准入
3CC:1准入
4DD:1, 牺牲者 C:1拒绝(相等)
5AA:2保留
6EE:1, 牺牲者 B:1拒绝
7AA:3保留
8FF:1, 牺牲者 B:1拒绝

结果:缓存稳定为 A (3), B (1), C (1)。 最频繁的项目 A 占主导地位,内存使用高效。

微型代码(概念性伪代码)

c
if (cache.contains(x)) {
    // 命中:增加频率
    sketch.increment(x);
} else {
    // 未命中:估计频率
    int f_new = sketch.estimate(x);
    int f_victim = sketch.estimate(victim);
    if (f_new > f_victim) {
        evict(victim);
        cache.insert(x);
    }
    sketch.increment(x);
}

这里的草图是一个哈希计数器的固定大小表, 通过概率性更新以节省空间。

为什么它很重要

  • 空间高效:近似计数器,无需逐项状态
  • 高性能:对于动态工作负载,命中率接近最优
  • 可与其他策略协作:常与 LRU 或 CLOCK 配对以跟踪近期性
  • 抗扫描污染:忽略罕见或一次性项目

权衡:

  • 概率性误差:计数器冲突导致小的不准确性
  • 额外计算:需要哈希和频率比较
  • 无严格排序:依赖于近似受欢迎程度

一个温和的证明(为什么它有效)

TinyLFU 估计大小为 $W$ 的最近访问窗口内的频率。 每个计数器代表计数最小草图中的一个桶, 因此对于项目 $x$,频率近似为:

$$ \hat{f}(x) = \min_{i} C[h_i(x)] $$

其中 $C$ 是计数器,$h_i$ 是哈希函数。

新项目仅在以下情况下被准入:

$$ \hat{f}(x_\text{new}) > \hat{f}(x_\text{victim}) $$

这确保了缓存倾向于频率最优的内容,同时将空间复杂度保持在 $O(\log n)$ 而不是 $O(n)$。

由于频率会定期衰减,草图自然会遗忘旧数据,将重点保持在最近的受欢迎程度上。

自己动手试试

  1. 实现一个计数最小草图(4 个哈希函数,1024 个计数器)。
  2. 运行序列 A B C D A E A F
  3. 观察哪些项目被准入,哪些被拒绝。
  4. 与 LFU 比较,看看 TinyLFU 如何用 1% 的空间模拟它。
  5. 与 LRU 集成进行驱逐,即 "W-TinyLFU"。

测试用例

序列策略未命中率评论
A B C D A E A FLFU0.50基线
A B C D A E A FTinyLFU0.33更好的命中率
流式 + 热点TinyLFU最低自适应过滤

复杂度

  • 访问:$O(1)$(哈希 + 计数器操作)
  • 空间:草图 $O(k \log W)$
  • 驱逐:与 LRU 或 CLOCK 配对

TinyLFU 是现代缓存的守门员, 小巧、快速且足够智能,能够记住真正重要的东西, 只让值得的项目进入内存。

839 随机替换

随机替换是能想象到的最简单的缓存淘汰策略。 当缓存已满且新项目到达时,它会随机选择一个现有项目进行淘汰。 不考虑最近使用情况,不考虑访问频率,全凭运气。

这听起来很天真,但令人惊讶的是,对于某些工作负载,随机替换表现得相当不错,并且可以作为评估更智能策略(如 LRU 或 ARC)的基准。

我们要解决什么问题?

在受限环境(硬件缓存、嵌入式系统或高速交换机)中,为 LRU 或 LFU 维护详细的元数据可能成本太高。 每次命中都更新访问时间戳或维护链表会消耗时间和内存。

随机替换消除了这种成本。 它用精确性换取了简单性和速度。

"当你无法决定淘汰谁时,就让随机性来决定。"

它是如何工作的(通俗解释)

  1. 每次访问时:

    • 如果项目在缓存中,则为命中。
    • 如果不在,则为未命中。
  2. 如果缓存已满:

    • 随机选择一个槽位。
    • 用新项目替换该槽位的项目。
  3. 否则,直接插入新项目。

无需排序,无需跟踪,无需统计。

示例演练

缓存容量 = 3 访问序列:A, B, C, D, B, A, E

步骤访问缓存内容淘汰项
1AA-
2BA B-
3CA B C-
4DB C D随机淘汰 (A)
5BB C D-
6AC D A随机淘汰 (B)
7ED A E随机淘汰 (C)

每次淘汰都是均匀随机的,因此每次运行的行为会有所不同,但总体而言,随机替换能使缓存中近期和较旧的项目保持大致平衡的比例。

微型代码 (C)

c
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

#define CAP 3

char cache[CAP];
int size = 0;

int in_cache(char key) {
    for (int i = 0; i < size; i++)
        if (cache[i] == key) return 1;
    return 0;
}

void access(char key) {
    if (in_cache(key)) {
        printf("访问 %c (命中)\n", key);
        return;
    }
    printf("访问 %c (未命中)\n", key);
    if (size < CAP) {
        cache[size++] = key;
    } else {
        int victim = rand() % CAP;
        printf("淘汰 %c (随机)\n", cache[victim]);
        cache[victim] = key;
    }
}

int main() {
    srand(time(NULL));
    char seq[] = {'A','B','C','D','B','A','E'};
    for (int i = 0; i < 7; i++) access(seq[i]);
}

为何重要

  • 无元数据开销,仅需一个简单数组
  • 所有操作都是常数时间
  • 测试缓存效率的有用基准
  • 在均匀工作负载下表现稳健

权衡:

  • 局部性捕获能力差,忽略最近使用情况和访问频率
  • 性能不可预测
  • 可能意外淘汰热点页面

尽管如此,在简单性和速度至关重要的硬件缓存(如 TLB)中,随机替换仍然很常见。

一个温和的证明(为何有效)

在稳态下,缓存中的每个项目在每次未命中时都有相等的 $1/C$ 概率被淘汰。 对于具有均匀访问概率的项目,命中率大致为:

$$ H = 1 - \frac{1}{C + 1} $$

这意味着对于均匀随机访问,随机替换能达到与 LRU 大致相同的命中率, 但在时间局部性下表现更差。

随着缓存大小增加,方差会平滑,命中率趋于收敛。

亲自尝试

  1. 模拟 100 个随机访问序列(例如,在 10 个项目上,缓存大小为 3)。
  2. 测量平均命中率。
  3. 与 LRU 和 LFU 比较,注意随机替换如何保持一致性但从不最优。
  4. 尝试使用偏斜的访问模式(Zipfian),随机替换会落后。
  5. 测量标准差,在大型系统中随机替换的方差很小。

测试用例

序列策略缓存大小未命中率胜出者
均匀随机访问随机3≈LRU
热点+冷点混合随机3较高LRU
顺序扫描随机3相似FIFO

复杂度

  • 访问:$O(1)$
  • 空间:$O(C)$
  • 无簿记开销

随机替换是缓存的抛硬币决策, 它不记忆也不预测,却出奇地稳定。 当简单性和速度至上时,随机性就足够了。

840 Belady 最优算法(OPT)

Belady 最优算法,通常称为 OPT,是缓存替换理论的黄金标准。 它知晓未来:在每次淘汰时,它移除未来最长时间内不会被使用的项。

没有真实的缓存能够完美实现它(因为我们无法预知未来),但 OPT 作为基准,所有实用算法(LRU、LFU、ARC 等)都以其为衡量标准。

我们要解决什么问题?

每个缓存替换策略都试图最小化缓存未命中。 但所有实用算法仅依赖于过去和现在的访问模式。

Belady 的 OPT 假设完美的预知能力,它能看到整个未来的访问序列,从而在每一步都做出全局最优的选择。

"如果你知道接下来会发生什么,你就永远不会做出错误的淘汰决策。"

对于任何给定的访问轨迹和缓存大小,它提供了可能的最低未命中率。

它是如何工作的(通俗解释)

当缓存已满且必须插入新项时:

  1. 向前查看未来的访问序列。
  2. 对于缓存中当前的每个项,找出它下一次被使用的时间。
  3. 淘汰下一次使用时间最远的项。
    • 如果一个项永远不会再被使用,它就是完美的淘汰对象。

就是这样,概念简单,实践中不可能实现。

示例演练

缓存容量 = 3 访问序列:A, B, C, D, A, B, E, A, B, C, D, E

步骤访问缓存(之前)未来使用情况淘汰项缓存(之后)
1A---A
2BA--A B
3CA B--A B C
4DA B CA(5), B(6), C(9)C(最远)A B D
5AA B DB(6), D(10)-A B D
6BA B DA(8), D(10)-A B D
7EA B DA(8), B(9), D(10)D(最远)A B E
8AA B EB(9), E(12)-A B E
9BA B EA(10), E(12)-A B E
10CA B EA(10), B(11), E(12)E(最远)A B C
11DA B CA(10), B(11), C(-)C(永不重用)A B D
12EA B DA(-), B(-), D(-)任意A B E

结果:可能的最小未命中数,没有其他策略可以做得更好。

微型代码(C语言,用于模拟)

c
#include <stdio.h>

#define CAP 3
#define SEQ_LEN 12

char seq[] = {'A','B','C','D','A','B','E','A','B','C','D','E'};
char cache[CAP];
int size = 0;

int find(char key) {
    for (int i = 0; i < size; i++)
        if (cache[i] == key) return i;
    return -1;
}

int next_use(char key, int pos) {
    for (int i = pos + 1; i < SEQ_LEN; i++)
        if (seq[i] == key) return i;
    return 999; // 无穷大(永不再用)
}

void access(int pos) {
    char key = seq[pos];
    if (find(key) != -1) {
        printf("访问 %c (命中)\n", key);
        return;
    }
    printf("访问 %c (未命中)\n", key);
    if (size < CAP) { cache[size++] = key; return; }
    // OPT 淘汰
    int victim = 0, farthest = -1;
    for (int i = 0; i < size; i++) {
        int next = next_use(cache[i], pos);
        if (next > farthest) { farthest = next; victim = i; }
    }
    printf("淘汰 %c\n", cache[victim]);
    cache[victim] = key;
}

int main() {
    for (int i = 0; i < SEQ_LEN; i++) access(i);
}

为什么它重要

  • 定义了缓存未命中的理论下界
  • 评估其他策略的基准
  • 有助于证明最优性差距(LRU 或 ARC 有多接近)
  • 概念简单,分析能力强

权衡:

  • 需要完整的未来知识(在真实系统中不可能)
  • 仅限离线使用,但对于模拟和研究至关重要

一个温和的证明(为什么它有效)

设未来访问序列为 $S = [s_1, s_2, \dots, s_n]$,缓存容量为 $C$。

在任何时刻 $t$,OPT 淘汰页面 $p$,其下一次引用 $r(p)$ 满足:

$$ r(p) = \max_{x \in \text{cache}} \text{next}(x) $$

意思是:未来使用时间最远的项。

通过对序列进行归纳:

  • 在每一步,OPT 最小化前缀 $S_t$ 的未命中。
  • 淘汰任何其他页面不会导致更少的未来未命中,因为该页面会更早被需要。

因此,在所有可能的策略中,OPT 产生最小的未命中次数。

自己动手试试

  1. 模拟序列 A B C D A B E A B C D E
  2. 比较 LRU、FIFO 和 OPT 的未命中率。
  3. 观察没有策略能胜过 OPT。
  4. 增加缓存大小 → 测量收益递减情况。
  5. 使用 OPT 作为评估自定义算法的基准。

测试用例

缓存大小序列LRU 未命中FIFO 未命中OPT 未命中
3A B C D A B E A B C D E9107
2A B A B A B222
4A B C D A B C D444

复杂度

  • 访问:$O(C)$(缓存线性扫描)
  • 下次使用查找:每次访问 $O(N)$(可以预计算)
  • 总体:$O(NC)$ 模拟

Belady 的 OPT 是缓存的先知,事后看来完美无缺,事前看来无法实现。 每个实用算法都努力接近它的智慧,一次一个缓存未命中。

第 85 节 网络

841 Dijkstra(迪杰斯特拉)路由算法

Dijkstra 算法用于在边权非负的图中,找到从源节点到所有其他节点的最短路径。 它构成了链路状态路由协议(如 OSPF,开放最短路径优先)的基础,该协议在现代计算机网络中用于确定数据包的高效路由。

我们要解决什么问题?

在一个路由器网络中,每条链路都有一个成本,代表延迟、拥塞或距离。 目标是计算从一个路由器到所有其他路由器的最短(成本最低)路径,以确保数据包高效传输。

形式化地说,给定一个图 $G = (V, E)$,其边权重 $w(u, v) \ge 0$,找出从源节点 $s$ 到每个顶点 $v \in V$ 的最小总成本。

"我们需要知道如何去往任何地方,以及实际距离有多远。"

它是如何工作的(通俗解释)

Dijkstra 算法从源节点开始,逐步生长出一棵最短路径树。 在每一步,它扩展距离最近的未访问节点,并对其邻居进行松弛操作。

  1. 从源节点 $s$ 开始,距离 $d[s] = 0$。

  2. 所有其他节点初始距离设为 $d[v] = \infty$。

  3. 重复以下步骤:

    • 选取具有最小临时距离的节点 $u$。

    • 将其标记为 已访问(其距离现在已确定)。

    • 对于 $u$ 的每个邻居 $v$:

      • 如果 $d[v] > d[u] + w(u, v)$,则更新 $d[v]$。

持续进行直到所有节点都被访问。

示例演练

图:

权重
A → B4
A → C2
B → C3
B → D2
C → D4
D → E1

起点:A

步骤已访问节点临时距离 (A, B, C, D, E)选择的节点
1-(0, ∞, ∞, ∞, ∞)A
2A(0, 4, 2, ∞, ∞)C
3A, C(0, 4, 2, 6, ∞)B
4A, C, B(0, 4, 2, 6, ∞)D
5A, C, B, D(0, 4, 2, 6, 7)E

从 A 出发的最短路径:

  • A→C = 2
  • A→B = 4
  • A→D = 6
  • A→E = 7

微型代码(C语言)

c
#include <stdio.h>
#include <limits.h>
#define V 5

int minDistance(int dist[], int visited[]) {
    int min = INT_MAX, min_index = -1;
    for (int v = 0; v < V; v++)
        if (!visited[v] && dist[v] <= min)
            min = dist[v], min_index = v;
    return min_index;
}

void dijkstra(int graph[V][V], int src) {
    int dist[V], visited[V] = {0};
    for (int i = 0; i < V; i++) dist[i] = INT_MAX;
    dist[src] = 0;

    for (int count = 0; count < V - 1; count++) {
        int u = minDistance(dist, visited);
        visited[u] = 1;
        for (int v = 0; v < V; v++)
            if (!visited[v] && graph[u][v] &&
                dist[u] + graph[u][v] < dist[v])
                dist[v] = dist[u] + graph[u][v];
    }

    for (int i = 0; i < V; i++)
        printf("A -> %c = %d\n", 'A' + i, dist[i]);
}

为什么它很重要

  • 是 OSPF 和 IS-IS 等路由协议的基础
  • 保证在非负权重下获得最优路径
  • 可预测且高效,运行时间为多项式级别
  • 应用超出网络领域:地图、物流、游戏、规划

权衡:

  • 对于大型图(不使用堆)较慢
  • 要求所有边都具有非负权重
  • 需要全局拓扑知识(每个路由器必须知道网络地图)

一个温和的证明(为什么它有效)

当一个节点是当前可能的最接近的未访问节点时,它就被永久标记(最终确定)。 假设存在一条通过另一个未访问节点的更短路径,这将与我们选择了最小值这一事实相矛盾。

形式化地,如果 $d[u]$ 被最终确定,那么:

$$ d[u] = \min_{P: s \to u} \text{cost}(P) $$

这个不变量得以保持,因为每次松弛操作都始终使 $d[v]$ 保持为已知的最小距离。

因此,当所有节点都被处理完后,$d[v]$ 就保存了真正的最短距离。

亲自尝试

  1. 在以下图上运行 Dijkstra 算法:

    • A→B=1, A→C=4, B→C=2, C→D=1
    • 找出从 A 出发的所有最短路径。
  2. 修改一条边的权重以测试算法的稳定性。

  3. 添加一条负权边(例如 -2),观察算法失效,它不再有效。

  4. 在同一个图上与 Bellman–Ford 算法(842)进行比较。

测试用例

源节点结果(最短距离)
A→B=4, A→C=2, B→D=2, C→D=4, D→E=1AA:(0), B:(4), C:(2), D:(6), E:(7)
A→B=1, B→C=1, C→D=1AA:(0), B:(1), C:(2), D:(3)
A→B=3, A→C=1, C→B=1AA:(0), B:(2), C:(1)

复杂度

  • 时间复杂度:
    • 使用数组:$O(V^2)$
    • 使用优先队列(堆):$O((V + E)\log V)$
  • 空间复杂度:$O(V)$(用于存储距离和已访问集合)

Dijkstra 算法是网络世界的导航员, 它绘制出每一条可能的路线,找到最快的那一条, 并证明效率不过是井井有条的探索。

842 Bellman–Ford 路由算法

Bellman–Ford 是距离向量路由协议(如 RIP,路由信息协议)的基础。 它计算从单个源节点到所有其他节点的最短路径,即使边权重为负时也能处理,这是 Dijkstra 算法无法做到的。 它通过迭代松弛来实现这一点,通过网络传播距离估计值直到收敛。

我们要解决什么问题?

在网络中,路由器通常只知道其直接邻居,而不知道整个拓扑结构。 它们需要一种分布式的方法来发现路由,并在成本变化时(即使某些链路变得“负”,例如成本降低或激励路径)进行适应。

Bellman–Ford 允许每个路由器仅通过与邻居的本地通信来找到最小路径成本。

正式地说,给定一个带权图 $G = (V, E)$ 和一个源节点 $s$, 对于所有 $v \in V$,找到 $d[v] = \min \text{cost}(s \to v)$, 即使某些 $w(u, v) < 0$,只要没有负环。

它是如何工作的(通俗解释)

Bellman–Ford 反复更新所有边,对它们进行“松弛”,直到无法进一步改进为止。

  1. 初始化所有距离: $d[s] = 0$,对于所有其他 $v$,$d[v] = \infty$。

  2. 重复 $V - 1$ 次:

    • 对于每条权重为 $w(u, v)$ 的边 $(u, v)$:

      • 如果 $d[v] > d[u] + w(u, v)$,则更新 $d[v] = d[u] + w(u, v)$。
  3. 经过 $V - 1$ 次迭代后,所有最短路径都已找到。

  4. 再进行一次遍历可以检测负环,如果任何边仍然可以松弛,则存在环。

示例演练

图:

权重
A → B4
A → C5
B → C-2
C → D3

起点:A

初始化: $d[A]=0$, $d[B]=\infty$, $d[C]=\infty$, $d[D]=\infty$

迭代次数松弛的边更新后的距离 $(A,B,C,D)$
1A→B, A→C(0, 4, 5, ∞)
B→C(0, 4, 2, ∞)
C→D(0, 4, 2, 5)
2无变化(0, 4, 2, 5)
3无变化(0, 4, 2, 5)

从 A 出发的最短路径: A→B=4, A→C=2, A→D=5

微型代码 (C)

c
#include <stdio.h>
#include <limits.h>

#define V 4
#define E 4

struct Edge { int u, v, w; };

void bellmanFord(struct Edge edges[], int src) {
    int dist[V];
    for (int i = 0; i < V; i++) dist[i] = INT_MAX;
    dist[src] = 0;

    for (int i = 0; i < V - 1; i++)
        for (int j = 0; j < E; j++) {
            int u = edges[j].u, v = edges[j].v, w = edges[j].w;
            if (dist[u] != INT_MAX && dist[u] + w < dist[v])
                dist[v] = dist[u] + w;
        }

    for (int j = 0; j < E; j++) {
        int u = edges[j].u, v = edges[j].v, w = edges[j].w;
        if (dist[u] != INT_MAX && dist[u] + w < dist[v]) {
            printf("检测到负环\n");
            return;
        }
    }

    for (int i = 0; i < V; i++)
        printf("A -> %c = %d\n", 'A' + i, dist[i]);
}

int main() {
    struct Edge edges[E] = {{0,1,4},{0,2,5},{1,2,-2},{2,3,3}};
    bellmanFord(edges, 0);
}

为什么它很重要

  • 与 Dijkstra 不同,可以处理负权重
  • 是距离向量路由协议(RIP)的核心
  • 易于分布式实现,每个节点只需要邻居信息
  • 检测路由环路(负环)

权衡:

  • 比 Dijkstra 慢 ($O(VE)$)
  • 如果更新是异步的,在不稳定的网络中可能会振荡
  • 对节点间延迟或不一致的更新敏感

一个温和的证明(为什么它有效)

Bellman–Ford 的每次迭代都保证,经过 $k$ 次松弛后,所有最多包含 $k$ 条边的最短路径都能被正确计算。 由于任何最短路径最多可以包含 $V-1$ 条边(在具有 $V$ 个顶点的图中), 经过 $V-1$ 次迭代后,所有距离都是最终的。

正式地:

$$ d^{(k)}[v] = \min_{(u,v) \in E}(d^{(k-1)}[u] + w(u,v)) $$

经过 $V-1$ 次迭代后, 对于所有 $v$,$d[v] = \min_{P: s \to v} \text{cost}(P)$。

如果之后存在更短的路径,它必须包含一个环,并且如果该环降低了成本,那么它就是负环。

自己动手试试

  1. 构建你自己的图并手动运行 Bellman–Ford。
  2. 引入一条负边(例如,B→A = -5)并观察它如何更新。
  3. 添加一个负环(A→B=-2, B→A=-2),观察它检测到不稳定性。
  4. 与 Dijkstra 算法的松弛次数进行比较。
  5. 实现分布式版本(用于 RIP)。

测试用例

负边负环结果
A→B=4, A→C=5, B→C=-2, C→D=3成功(找到最短路径)
A→B=3, B→C=4, C→A=-8检测到负环
A→B=2, A→C=1, C→D=3正常工作

复杂度

  • 时间:$O(VE)$
  • 空间:$O(V)$
  • 对于稠密图,比 Dijkstra 慢; 对于稀疏图,仍然高效。

Bellman–Ford 是网络中有耐心的信使, 它不急于求成,只是不断更新,直到所有人都知道真相。

843 链路状态路由(OSPF)

链路状态路由是现代路由协议(如 OSPF(开放式最短路径优先)和 IS-IS)背后的核心原理。 与距离矢量协议(例如 RIP)不同,在距离矢量协议中路由器仅与邻居共享距离信息,而链路状态协议共享整个拓扑,每个节点都知道完整的网络地图,并使用 Dijkstra(迪杰斯特拉)算法计算最优路径。

我们要解决什么问题?

路由器必须在大型、不断变化的网络中找到通往其他每个路由器的最有效路径。 距离矢量路由(如 Bellman–Ford(贝尔曼-福特)算法)可以工作,但收敛速度慢且容易产生环路。

链路状态路由通过让每个路由器都拥有一个完整且一致的网络拓扑视图来解决这个问题,因此每个路由器都可以独立计算最短路径。

"每个路由器都持有一份世界地图,而不是仅仅传播距离信息。"

它是如何工作的(通俗解释)

每个路由器执行五个关键步骤:

  1. 发现邻居: 向直接连接的路由器发送 "hello" 数据包。

  2. 测量链路成本: 确定到每个邻居的延迟或成本。

  3. 构建链路状态数据包: 描述所有邻居和链路成本(类似于一个小的邻接表)。

  4. 通过网络泛洪 LSP: 每个路由器转发其他路由器的 LSP,以确保每个人都拥有相同的地图。

  5. 运行 Dijkstra 算法: 计算从自身到所有目的地的最短路径。

当网络发生变化(链路故障或成本变化)时,只有更新后的 LSP 会被泛洪,从而保持快速可靠的收敛。

示例演练

假设我们有一个小型网络:

A --1-- B --2-- C
 \      |
  4     3
   \    |
     D--E

每个路由器通告其本地链路:

路由器通告的链路
AA–B(1), A–D(4)
BB–A(1), B–C(2), B–E(3)
CC–B(2)
DD–A(4), D–E(1)
EE–B(3), E–D(1)

交换 LSP 后,所有路由器都拥有相同的全局地图。

然后路由器 A 运行 Dijkstra 算法计算最短路径:

目的地最短路径总成本
BA→B1
CA→B→C3
DA→D4
EA→B→E4

微型代码(Python 概念模拟)

python
import heapq

def dijkstra(graph, src):
    # 初始化距离字典,所有节点距离为无穷大
    dist = {v: float('inf') for v in graph}
    # 源节点距离为 0
    dist[src] = 0
    # 使用优先队列(最小堆)
    pq = [(0, src)]
    while pq:
        d, u = heapq.heappop(pq)
        # 如果当前距离大于已知最短距离,跳过
        if d > dist[u]:
            continue
        # 遍历邻居节点
        for v, w in graph[u]:
            # 如果找到更短路径,更新距离并加入优先队列
            if dist[v] > d + w:
                dist[v] = d + w
                heapq.heappush(pq, (dist[v], v))
    return dist

graph = {
    'A': [('B',1), ('D',4)],
    'B': [('A',1), ('C',2), ('E',3)],
    'C': [('B',2)],
    'D': [('A',4), ('E',1)],
    'E': [('B',3), ('D',1)]
}

print(dijkstra(graph, 'A'))

输出: {'A': 0, 'B': 1, 'C': 3, 'D': 4, 'E': 4}

为什么它很重要

  • 快速收敛:所有路由器基于一致的拓扑即时更新
  • 无环路路径:Dijkstra 算法确保确定性的路由
  • 可扩展性:支持分层路由(OSPF 中的区域)
  • 灵活性:适应距离以外的成本度量(延迟、负载、可靠性)

权衡:

  • 需要更多内存来存储全局拓扑
  • 大型网络的泛洪开销
  • 管理链路状态数据库的复杂性

一个温和的证明(为什么它能工作)

每个路由器最终都会收到一组相同的链路状态通告,形成相同的图 $G=(V,E)$。 从其自身节点 $s$ 运行 Dijkstra 算法得到:

$$ d[s,v] = \min_{P: s \to v} \sum_{(u,w) \in P} c(u,w) $$

因为每个路由器都基于相同的一致地图进行计算, 所以生成的路由表是无环路的且全局一致。

如果链路成本发生变化,只有本地 LSP 被更新,收敛时间与网络直径成正比,而不是路径长度。

自己动手试试

  1. 创建一个 5 节点的网络,并手动泛洪链路状态消息。
  2. 从每个路由器的角度构建一个小的邻接表。
  3. 从每个路由器运行 Dijkstra 算法。
  4. 模拟链路故障,例如移除 A–B 链路,并重新计算路由。
  5. 观察网络重新稳定的速度。

测试用例

网络协议收敛速度无环路
5 个路由器,全网状连接OSPF
10 个路由器,单点故障OSPF非常快
5 个路由器,RIP较慢可能否

复杂度

  • 泛洪:链路传播为 $O(E)$
  • 计算:$O(V^2)$(或使用堆时为 $O((V+E)\log V)$)
  • 内存:拓扑存储为 $O(V + E)$

链路状态路由是互联网的制图师, 每个路由器都保存一份世界地图的副本,并在网络环境发生变化时立即重新计算其最佳路径。

844 距离向量路由(RIP)

距离向量路由是最早、最简单的分布式路由算法之一,构成了 RIP(路由信息协议)的基础。 每个路由器与邻居共享其当前对网络的认知,即其"距离向量",它们迭代地更新自己的距离,直到所有路由器就最短路径达成一致。

我们要解决什么问题?

在大型、去中心化的网络中,路由器无法看到整个拓扑结构。 它们只知道邻居有多远,以及到达邻居的成本有多高。

问题是:

"如果我只知道我的邻居及其距离,我还能找到到达所有目的地的最短路径吗?"

距离向量路由的回答是肯定的,通过重复的本地更新,路由器最终会就全局最短路径达成一致。

它是如何工作的(通俗解释)

每个路由器维护一个距离向量 $D[v]$,其中保存了它当前对到达其他每个节点的最短路径成本的估计。

  1. 初始化:

    • 对于自身,$D[s] = 0$,
    • 对于所有其他节点,$D[v] = \infty$。
  2. 每个路由器定期将其向量发送给所有邻居。

  3. 当路由器从邻居 $N$ 收到一个向量时,它会更新: $$ D[v] = \min(D[v], \text{cost}(s, N) + D_N[v]) $$ 其中 $D_N[v]$ 是邻居通告的到达 $v$ 的成本。

  4. 重复此过程直到没有更新发生(网络收敛)。

这本质上是 Bellman–Ford 算法的分布式形式。

示例演练

网络:

A --1-- B --2-- C
 \           /
  \--5-- D--/

初始距离:

路由器ABCD
A015
B102
C203
D530

步骤 1: A 从 B 收到向量 A 获知:$D[A,C] = 1 + 2 = 3$(优于 ∞)

步骤 2: A 从 D 收到向量 A 获知:$D[A,C] = \min(3, 5 + 3) = 3$

收敛后,A 的路由表:

目的地下一跳成本
BB1
CB3
DD5

微型代码(C)

c
#include <stdio.h>
#include <limits.h>

#define V 4
#define INF 999

int dist[V][V] = {
    {0, 1, INF, 5},
    {1, 0, 2, INF},
    {INF, 2, 0, 3},
    {5, INF, 3, 0}
};

int table[V][V];

void distanceVector() {
    for (int i = 0; i < V; i++)
        for (int j = 0; j < V; j++)
            table[i][j] = dist[i][j];

    int updated;
    do {
        updated = 0;
        for (int i = 0; i < V; i++)
            for (int j = 0; j < V; j++)
                for (int k = 0; k < V; k++)
                    if (table[i][j] > dist[i][k] + table[k][j]) {
                        table[i][j] = dist[i][k] + table[k][j];
                        updated = 1;
                    }
    } while (updated);

    for (int i = 0; i < V; i++) {
        printf("路由器 %c: ", 'A'+i);
        for (int j = 0; j < V; j++)
            printf("%c(%d) ", 'A'+j, table[i][j]);
        printf("\n");
    }
}

int main() { distanceVector(); }

为什么它很重要

  • 简单且去中心化,不需要全局地图
  • 是 RIP 的核心,RIP 是最早的互联网路由协议之一
  • 每个路由器只与其邻居通信
  • 自动适应链路故障

权衡:

  • 收敛速度慢,可能需要多次迭代才能稳定
  • 计数到无穷问题,故障期间可能形成环路
  • 可扩展性有限,RIP 的最大跳数限制为 15

一个温和的证明(为什么它有效)

每个路由器 $i$ 维护以下不变量:

$$ D_i[v] = \min_{(i,j) \in E}(c(i,j) + D_j[v]) $$

最初,只知道直接链路。 每次交换后,距离估计会传播得更远一跳。 最多经过 $V-1$ 轮后,所有最短路径(长度最多为 $V-1$)都会被获知,这与 Bellman–Ford 算法的逻辑相符。

如果在更新期间没有链路成本发生变化,则保证收敛。

亲自尝试

  1. 模拟三个路由器:A–B=1,B–C=1。 观察 C 到 A 的距离如何在两轮后稳定。
  2. 临时移除一条链路,观察"计数到无穷"问题的发生。
  3. 实现"水平分割"或"毒性逆转"来修复它。
  4. 与链路状态路由(OSPF)比较更新速度。

测试用例

网络协议收敛?备注
小型网状网络 (A–B–C–D)RIP较慢,稳定
存在链路故障RIP最终计数到无穷问题
全连接的 5 节点网络RIP几轮后稳定

复杂度

  • 时间:$O(V \times E)$(每次迭代)
  • 消息开销:与每个邻居定期交换
  • 内存:每个路由器 $O(V)$

距离向量路由是网络的八卦协议, 每个路由器分享它所知道的,倾听他人的信息,慢慢地, 整个网络就学会了到达其他所有人的最佳路径。

845 路径矢量路由(BGP)

路径矢量路由是互联网域间路由系统——边界网关协议(BGP)的基础。 它扩展了距离矢量路由,不仅包含到达每个目的地的成本,还包含整个路径,从而防止路由环路,并支持自治系统(AS)之间基于策略的路由。

我们要解决什么问题?

在全球互联网路由中,我们不仅需要最短路径,还需要在独立管理的网络(自治系统)之间获得可控、无环路且尊重策略的路径。

距离矢量算法(如 RIP)无法防止环路或尊重策略,因为它们只共享成本信息。 我们需要一个更丰富的模型,一个包含路径本身的模型。

"不要只告诉我有多远,告诉我你会走哪条路。"

它是如何工作的(通俗解释)

每个节点(自治系统)通告:

  • 它能到达的目的地前缀
  • 用于到达这些目的地的完整 AS 编号路径

当路由器收到通告时:

  1. 它检查路径列表以确保没有环路(其自身的 AS 编号不应出现)。
  2. 它可能会应用路由策略(例如,优先选择客户路由而非对等路由)。
  3. 如果新路径根据本地偏好更优,则更新其路由表。

然后,它将该路由重新通告给其邻居,并在路径前面添加自己的 AS 编号。

示例演练

四个自治系统的网络:

AS1 ---- AS2 ---- AS3
   \              /
     \          /
          AS4

目标:在 AS1 和 AS3 之间路由流量。

初始通告:

AS通告的前缀路径
AS310.0.0.0/24[AS3]

传播过程:

  • AS2 收到 [AS3],在前面添加自己 → [AS2, AS3],然后通告给 AS1。
  • AS4 也学习到 [AS3],在前面添加 [AS4, AS3],然后通告给 AS1。

现在 AS1 收到到达同一目的地的两条路径:

  • 路径1:[AS1, AS2, AS3](经由 AS2)
  • 路径2:[AS1, AS4, AS3](经由 AS4)

AS1 应用策略:

  • 优先选择最短路径 → 选择 [AS1, AS2, AS3]

没有环路,并且每个人的路径都是一致的。

微型代码(简化 Python 模拟)

python
routes = {
    'AS3': [['AS3']]
}

def advertise(from_as, to_as, network):
    new_path = [from_as] + network
    print(f"{from_as}{to_as} 通告路径 {new_path}")
    return new_path

# AS3 通告其前缀
as2_path = advertise('AS2', 'AS1', ['AS3'])
as4_path = advertise('AS4', 'AS1', ['AS3'])

# AS1 比较路由
routes['AS3'] = [as2_path, as4_path]
best = min(routes['AS3'], key=len)
print(f"AS1 选择路径: {best}")

输出:

AS2 向 AS1 通告路径 ['AS2', 'AS3']
AS4 向 AS1 通告路径 ['AS4', 'AS3']
AS1 选择路径: ['AS2', 'AS3']

为什么它很重要

  • 全球范围扩展,被所有互联网路由器使用(BGP-4)
  • 设计上无环路(检查 AS 路径)
  • 基于策略的路由:不仅仅是成本,还包括你经由谁路由
  • 支持路由聚合(CIDR)

权衡:

  • 收敛速度比链路状态协议慢
  • 策略冲突可能导致不稳定
  • 配置复杂且存在安全风险(路由劫持)

一个温和的证明(为什么它有效)

在路径矢量路由中,每条路由都是一个元组:

$$ R = (\text{destination}, \text{AS_path}) $$

节点 $i$ 从邻居 $j$ 接受路由 $R$ 当且仅当:

  1. $i \notin R.\text{AS_path}$(无环路)
  2. $R$ 满足 $i$ 的本地策略
  3. $R$ 在成本或偏好上优于当前最佳路由

这确保了无环路性: 如果形成任何环路,一个 AS 将在路径中看到自己的 ID 并拒绝它。

因为更新是单调传播的(路径只会增长),当策略一致时,收敛是有保证的。

自己动手试试

  1. 模拟具有冲突策略的 4 个 AS(AS1 偏好 AS2,AS2 偏好 AS3,等等),观察路由振荡。
  2. 添加一条虚假路由(AS5 宣告 AS1 的前缀),模拟路由劫持。
  3. 通过检查 AS 重复来实现环路检测。
  4. 与 OSPF 或 RIP 比较收敛时间。

测试用例

场景结果备注
正常传播无环路稳定
策略冲突振荡收敛缓慢
路由劫持(虚假 AS)错误路径需要安全措施
AS 路径过滤无环路正确收敛

复杂度

  • 每路由器处理:对于 $N$ 个邻居为 $O(N \log N)$
  • 消息大小:与 AS 路径长度成正比
  • 收敛时间:可变(取决于策略冲突)

路径矢量路由是互联网的外交官, 路由器不仅仅交换距离,它们交换信任策略如何到达那里的故事

846 泛洪算法

泛洪是在网络中传播信息最原始且最可靠的方式:将消息发送给所有邻居,邻居再转发给他们的所有邻居,如此反复,直到每个人都收到消息。它被用作许多系统的构建模块:链路状态路由(OSPF)、对等节点发现以及流行病协议。

我们要解决什么问题?

当一个节点拥有新信息时,例如一个新的链路状态更新或一条广播消息,它必须确保网络中的每个节点最终都能获知该信息。在没有网络拓扑全局知识的情况下,最简单的方法就是泛洪该消息。

"告诉你认识的每个人,并让他们告诉他们认识的每个人。"

只要网络是连通的,泛洪就能保证最终送达。

它是如何工作的(通俗解释)

每个节点都维护一个已见过消息的记录。当一条新消息到达时:

  1. 检查 ID:如果已经见过 → 丢弃它。
  2. 否则:将其转发给除发送者之外的所有邻居。
  3. 标记为已送达

这个过程持续进行,直到消息传播到各处。不需要中心协调。

示例演练

网络:

A --- B --- C
|     |     |
D --- E --- F

假设 A 泛洪一条 ID = 101 的消息。

步骤节点动作
1A发送 msg(101) 给 B, D
2B转发给 A, C, E (A 丢弃重复消息)
3D转发给 A, E (A 丢弃)
4E从 B, D 收到 → 转发给 C, F
5C, F收到消息,停止(没有新邻居)

每个节点恰好收到一次消息。

微型代码(Python 模拟)

python
graph = {
    'A': ['B', 'D'],
    'B': ['A', 'C', 'E'],
    'C': ['B', 'F'],
    'D': ['A', 'E'],
    'E': ['B', 'D', 'F'],
    'F': ['C', 'E']
}

seen = set()

def flood(node, msg_id, sender=None):
    if msg_id in seen:
        return
    print(f"{node} 收到 {msg_id}")
    seen.add(msg_id)
    for neighbor in graph[node]:
        if neighbor != sender:
            flood(neighbor, msg_id, node)

flood('A', 101)

输出:

A 收到 101
B 收到 101
D 收到 101
C 收到 101
E 收到 101
F 收到 101

为什么它重要

  • 保证送达(如果网络连通)
  • 简单且去中心化
  • 用于 OSPF 链路状态通告、对等节点发现、流言协议
  • 不需要路由表

权衡:

  • 冗余消息:在密集图中呈指数级增长
  • 环路:通过跟踪消息 ID 来防止
  • 高带宽使用:不适用于大型网络

优化:

  • 序列号(唯一 ID)
  • 跳数限制(TTL)
  • 选择性转发(基于生成树)

一个温和的证明(为什么它有效)

设网络是一个连通图 $G = (V, E)$。对于源节点 $s$,每个节点 $v$ 都可通过某条路径 $P(s, v)$ 到达。泛洪保证在重复消息被抑制之前,至少有一条消息会遍历 $P(s, v)$ 中的每条边。

因此,所有从 $s$ 可达的节点都将恰好收到一次消息。

形式化地:

$$ \forall v \in V, \exists P(s, v) \text{ 使得 } \text{消息沿 } P \text{ 传播} $$

并且由于节点会丢弃重复消息,因此保证终止。

亲自尝试

  1. 在 5 节点网状网络上模拟泛洪。
  2. 添加消息 ID,计算抑制前的重复消息数量。
  3. 引入 TTL(例如 3 跳),观察它如何限制传播范围。
  4. 构建一个树状覆盖网络,观察冗余的减少。
  5. 与受控泛洪(用于链路状态路由)进行比较。

测试用例

网络方法重复处理结果
4 节点链式朴素泛洪指数级消息
6 节点网状带 ID 跟踪精确送达
10 节点带 TTL=3部分可达

复杂度

  • 时间:$O(E)$(每条链路最多使用一次)
  • 消息数:使用 ID 跟踪时最多 $O(E)$,不使用则为 $O(2^V)$
  • 空间:$O(M)$ 用于跟踪已见消息 ID

泛洪是网络中的呐喊,低效、嘈杂,但确定无疑。它是更智能路由得以生长的种子。

847 生成树协议(STP)

生成树协议(STT)是一种分布式算法,它通过动态构建一个称为生成树的无环路链路子集,来防止以太网网络中出现环路。它被用于交换机和网桥中,以确保当存在冗余链路时,数据帧不会无限循环。

我们要解决什么问题?

以太网网络通常为了容错而具有冗余连接。然而,冗余路径可能导致广播风暴,即数据帧在交换机之间无限循环。

STP通过禁用某些链路来解决这个问题,使得最终的网络形成一个树状结构(无环),同时保持连通性。

"保持每个交换机可达,但到每个交换机只有一条路径。"

它是如何工作的(通俗解释)

STP选举一台交换机作为根桥,然后计算从其他每台交换机到根桥的最短路径。不在任何最短路径上的链路会被阻塞以消除环路。

步骤:

  1. 根桥选举:

    • 每台交换机都有一个唯一的桥ID(优先级 + MAC地址)。
    • 桥ID最小的交换机胜出成为根桥。
  2. 路径成本计算:

    • 每台交换机计算其到根桥的距离(路径成本)。
  3. 指定端口和阻塞:

    • 在每条链路上,只有一侧(离根桥最近的一侧)转发数据帧。
    • 所有其他端口都被置于阻塞状态。

如果拓扑发生变化(例如,链路故障),STP会重新计算生成树并重新激活备用链路。

示例演练

四台交换机的网络:

     S1
    /  \
   S2--S3
    \  /
     S4

每条链路成本 = 1。

步骤 1:S1 具有最小的桥ID → 成为根桥。 步骤 2:S2、S3 和 S4 计算到 S1 的最短路径。 步骤 3:不在最短路径上的链路(例如,S2–S3、S3–S4)被阻塞。

结果生成树:

S1
├── S2

└── S3
     \
      S4

网络保持连通但无环路。

微型代码(Python 模拟)

python
graph = {
    'S1': ['S2', 'S3'],
    'S2': ['S1', 'S3', 'S4'],
    'S3': ['S1', 'S2', 'S4'],
    'S4': ['S2', 'S3']
}

root = 'S1'
parent = {root: None}
visited = set([root])

def build_spanning_tree(node):
    for neighbor in graph[node]:
        if neighbor not in visited:
            visited.add(neighbor)
            parent[neighbor] = node
            build_spanning_tree(neighbor)

build_spanning_tree(root)
print("生成树连接:")
for n in parent:
    if parent[n]:
        print(f"{parent[n]} -> {n}")

输出:

生成树连接:
S1 -> S2
S1 -> S3
S3 -> S4

为什么它很重要

  • 防止以太网交换机中的广播环路
  • 允许冗余,链路只是暂时被禁用
  • 自动适应拓扑变化
  • 构成了二层网络稳定性的基础

权衡:

  • 链路故障后的收敛延迟
  • 所有流量遵循单一树状结构 → 某些链路利用率不足
  • 改进的变体如 RSTP(快速生成树协议)修复了收敛速度问题。

一个温和的证明(为什么它有效)

STP 确保三个关键不变量:

  1. 单一根桥:桥ID最小的交换机被全局选举出来。
  2. 无环拓扑:每台交换机选择恰好一条到根桥的最短路径。
  3. 连通性:没有交换机与根桥隔离。

该协议的消息交换(桥协议数据单元,或 BPDU)保证所有交换机最终就同一个根桥和一致的转发角色达成一致。

形式上,如果 $G=(V,E)$ 是网络图,STP 选择一个边子集 $T \subseteq E$,使得:

$$ T \text{ 是 } G \text{ 的一棵生成树} \quad \text{(连通、无环、成本最小)}. $$

自己动手试试

  1. 画一个有环路的 5 台交换机的小型网络。
  2. 分配唯一的桥ID。
  3. 确定根桥(ID最小)。
  4. 计算每台交换机到根桥的路径成本。
  5. 识别哪些端口转发,哪些端口阻塞。
  6. 暂时断开根桥的连接,观察新树是如何形成的。

测试用例

网络根桥阻塞的链路结果
4-交换机网状网络S1S2–S3, S3–S4无环路
环形拓扑S1一条链路被阻塞单一生成树
单链路故障S1重新计算恢复连通性

复杂度

  • 消息复杂度:$O(E)$(BPDU通过边泛洪)
  • 计算复杂度:$O(V \log V)$(根桥选举 + 路径成本更新)
  • 内存:每台交换机 $O(V)$

生成树协议是以太网的园丁, 它修剪冗余环路,保持网络整洁, 并在拓扑变化时重新生长路径。

848 拥塞控制 (AIMD)

加性增、乘性减(AIMD)是 TCP 拥塞控制中使用的经典算法。 它动态调整发送方的传输速率,以在不过载网络的前提下,平衡效率(最大化吞吐量)和公平性(共享带宽)。

我们要解决什么问题?

在计算机网络中,多个发送方共享有限的带宽。 如果每个人都尽可能快地发送数据,路由器会溢出并丢弃数据包,导致拥塞崩溃。 我们需要一种分布式的、自我调节的机制,根据网络反馈来调整每个发送方的速率。

“空闲时多发送,拥挤时慢下来。”

AIMD 恰恰提供了这种基于隐式拥塞信号的优雅适应能力。

它是如何工作的(通俗解释)

每个发送方维护一个拥塞窗口($cwnd$),它限制了在途的未确认数据包数量。 规则如下:

  1. 加性增:

    • 每个没有丢包的往返时间(RTT)→ 将 $cwnd$ 增加一个常数(通常为 1 个数据包)。
    • 鼓励探测可用带宽。
  2. 乘性减:

    • 当检测到拥塞(数据包丢失)时 → 将 $cwnd$ 减少一个固定比例(通常减半)。
    • 快速减轻网络压力。

这就形成了 TCP 吞吐量著名的“锯齿”模式:逐渐上升,突然下降,周而复始。

示例演练

从初始 $cwnd = 1$ 个数据包开始。

轮次事件$cwnd$ (数据包数)动作
1开始1初始慢启动
2收到 ACK2线性增加
3收到 ACK3加性增
4数据包丢失1.5乘性减
5收到 ACK2.5再次加性增
6数据包丢失1.25再次乘性减

这个过程无限期地持续下去,在最佳发送速率附近温和地振荡。

微型代码(Python 模拟)

python
import matplotlib.pyplot as plt

rounds = 30
cwnd = [1]
for r in range(1, rounds):
    if r % 8 == 0:  # 模拟每 8 轮发生一次丢包
        cwnd.append(cwnd[-1] / 2)
    else:
        cwnd.append(cwnd[-1] + 1)

plt.plot(range(rounds), cwnd)
plt.xlabel("往返时间 (RTT)")
plt.ylabel("拥塞窗口 (cwnd)")
plt.title("AIMD 拥塞控制")
plt.show()

该图显示了锯齿状的增长模式:线性爬升,乘性下降。

为什么它很重要

  • 防止拥塞崩溃(拯救了互联网)
  • 公平性:多个流收敛到相等的带宽份额
  • 稳定性:简单的反馈控制循环
  • TCP Reno、NewReno、Cubic、BBR 的基础

权衡:

  • 在高延迟网络中反应缓慢
  • 丢包可能导致利用率不足
  • 不适用于现代高速、长距离链路(Cubic、BBR 对此进行了改进)

一个温和的证明(为什么它有效)

让每个发送方的窗口大小按如下方式演变:

$$ cwnd(t+1) = \begin{cases} cwnd(t) + \alpha, & \text{若无丢包},\ \beta \cdot cwnd(t), & \text{若发生丢包}. \end{cases} $$

其中 $\alpha > 0$(加性步长)且 $0 < \beta < 1$(乘性减少因子)。

在平衡状态下,拥塞信号频繁发生,使得:

$$ \text{平均吞吐量} \approx \frac{1}{\sqrt{p}} $$

其中 $p$ = 数据包丢失概率。 因此,随着网络变得越拥塞,每个流的速率自然下降,从而保持系统稳定和公平。

自己动手试试

  1. 设置 $\alpha = 1$, $\beta = 0.5$,绘制 cwnd 增长图。
  2. 将 RTT 加倍,观察收敛速度变慢。
  3. 模拟两个发送方,观察它们收敛到相同的 cwnd 大小。
  4. 引入随机丢包,观察稳定的振荡。
  5. 与 BBR(基于速率)或 Cubic(非线性增长)进行比较。

测试用例

条件行为结果
无丢包线性增长带宽探测
周期性丢包锯齿状振荡稳定的吞吐量
随机丢包cwnd 波动受控的适应
两个流平等共享公平收敛

复杂度

  • 控制时间:每个 RTT $O(1)$(常数时间更新)
  • 计算量:极小,每个 ACK/丢包事件进行算术运算
  • 空间:$O(1)$(存储当前 cwnd 和阈值)

AIMD 是互联网的心跳, 是发送、感知和减速的节奏, 仅凭自我约束和数学, 就让数百万连接和谐共存。

849 随机早期检测 (RED)

随机早期检测 (RED) 是一种用于路由器和交换机的主动式拥塞避免算法。 它不会等到队列溢出(然后突然丢弃数据包),而是在队列开始增长时就随机地提前丢弃或标记数据包,从而在网络崩溃之前通知发送方降低发送速率。

我们要解决什么问题?

传统的路由器使用尾部丢弃策略:数据包被接收直到缓冲区满,之后所有新到达的数据包都被丢弃。 这会导致:

  • 全局同步(许多 TCP 流同时减速)
  • 突发性丢包
  • 不公平的带宽共享

RED 通过提前预测拥塞并随机通知部分流进行退避来平滑这个过程,从而在低延迟下保持高吞吐量。

"不要等到大坝决堤,提前释放几滴水。"

它是如何工作的(通俗解释)

路由器使用指数移动平均来维护平均队列大小:

$$ avg = (1 - w_q) \cdot avg + w_q \cdot q_{current} $$

其中 $w_q$ 是一个小的权重(例如 0.002)。

RED 定义了两个阈值:

  • min_th:早期检测的起始点
  • max_th:完全拥塞警告点

当一个数据包到达时:

  1. 计算 $avg$。
  2. 如果 $avg < min_{th}$ → 接受数据包。
  3. 如果 $avg > max_{th}$ → 丢弃或标记数据包(100% 概率)。
  4. 如果在两者之间 → 以概率 $p$ 丢弃,该概率在两个阈值之间线性增加。

这产生了一个柔和的拥塞信号,而不是一个突然的断崖。

示例演练

假设:

  • $min_{th} = 5$, $max_{th} = 15$ 个数据包
  • 当前平均队列 $avg = 10$
  • 最大丢弃概率 $p_{max} = 0.1$

丢弃概率:

$$ p = p_{\text{max}} \times \frac{avg - min_{\text{th}}}{max_{\text{th}} - min_{\text{th}}} = 0.1 \times \frac{10 - 5}{10} = 0.05 $$

因此,每个到达的数据包有 5% 的几率被丢弃。 这种随机早期丢弃为 TCP 流在队列溢出之前降低发送速率提供了时间。

微型代码(Python 模拟)

python
import random

min_th = 5
max_th = 15
p_max = 0.1
avg = 0
w_q = 0.002

def red_packet(arrivals):
    global avg
    for q in arrivals:
        avg = (1 - w_q) * avg + w_q * q
        if avg < min_th:
            print(f"Queue={q}, avg={avg:.2f}: ACCEPT")
        elif avg > max_th:
            print(f"Queue={q}, avg={avg:.2f}: DROP")
        else:
            p = p_max * (avg - min_th) / (max_th - min_th)
            if random.random() < p:
                print(f"Queue={q}, avg={avg:.2f}: DROP (prob={p:.3f})")
            else:
                print(f"Queue={q}, avg={avg:.2f}: ACCEPT")

arrivals = [3, 7, 10, 12, 14, 16, 20]
red_packet(arrivals)

输出(示例):

Queue=3, avg=0.01: ACCEPT
Queue=7, avg=0.02: ACCEPT
Queue=10, avg=0.04: ACCEPT
Queue=12, avg=0.07: DROP (prob=0.035)
Queue=14, avg=0.10: ACCEPT
Queue=16, avg=0.13: DROP (prob=0.080)
Queue=20, avg=0.17: DROP

为什么它很重要

  • 在拥塞发生之前预防拥塞
  • 避免 TCP 流之间的全局同步
  • 保持稳定的平均队列大小
  • 提高公平性和吞吐量

权衡:

  • 需要调参($w_q$, $min_{th}$, $max_{th}$, $p_{max}$)
  • 对于非 TCP 流量可能效果较差(没有反馈反应)
  • 过于激进 → 利用率不足;过于宽松 → 缓冲区满

一个温和的证明(为什么它有效)

RED 将队列动态建模为一个反馈系统。 平均队列长度 $avg$ 的演化如下:

$$ avg_{t+1} = (1 - w_q) \cdot avg_t + w_q \cdot q_t $$

数据包丢弃概率 $p$ 在阈值之间平滑增长:

$$ p = \begin{cases} 0, & avg < min_{th} \ p_{max} \frac{avg - min_{th}}{max_{th} - min_{th}}, & min_{th} \le avg \le max_{th} \ 1, & avg > max_{th} \end{cases} $$

这种连续控制通过随机化丢包避免了振荡,并稳定了吞吐量。

自己动手试试

  1. 设置 $min_{th}=5$, $max_{th}=15$,并模拟队列变化。
  2. 绘制平均队列随时间变化的图,观察平滑的控制效果。
  3. 增加 $w_q$,观察更快但更嘈杂的适应过程。
  4. 增加 $p_{max}$,RED 反应更激进。
  5. 与尾部丢弃比较,注意突然的缓冲区满导致的丢包。

测试用例

场景RED 行为结果
轻负载无丢包稳定队列
逐渐累积随机早期丢包平滑适应
突发流量部分丢包避免崩溃
TCP 混合流随机退避公平共享

复杂度

  • 每个数据包:$O(1)$(常数时间平均 + 概率检查)
  • 状态:每个队列 $O(1)$
  • 调参:$w_q, min_{th}, max_{th}, p_{max}$

RED 是互联网的流量耳语者, 它感知到人群正在聚集,轻拍其中几个的肩膀, 在混乱开始之前保持流量顺畅移动。

850 显式拥塞通知 (ECN)

显式拥塞通知 (ECN) 是一种现代拥塞控制增强机制,它允许路由器在不丢弃数据包的情况下发出拥塞信号。 与依赖丢包作为反馈信号的传统 TCP 不同,ECN 在数据包传输过程中对其进行标记,让端点在缓冲区溢出之前就减慢速度。

我们要解决什么问题?

传统 TCP 将数据包丢失解释为拥塞的迹象。 但丢包是一种粗糙的信号,它会浪费带宽、增加延迟,并可能导致队列不稳定。

我们希望路由器能够显式地传达拥塞信息,而不破坏数据,这样端点就可以平稳地进行调整。

"与其丢弃包裹,不如给它盖个章:‘嘿,慢一点。’"

这就是 ECN 所做的,它保留了数据包,但传递了同样的信息。

它是如何工作的(通俗解释)

ECN 通过在 IP 头中使用两个比特位以及在 TCP 头中使用反馈比特位来标记数据包。

  1. 发送方:将数据包标记为支持 ECN (ECT(0)ECT(1))。
  2. 路由器:当检测到拥塞时(例如,队列超过阈值):
    • 它不丢弃数据包,而是设置 CE(Congestion Experienced,经历拥塞)位。
  3. 接收方:看到 CE 标记,在 TCP ACK 中设置 ECE(Echo Congestion Experienced,回显拥塞经历)标志。
  4. 发送方:收到 ECE 后,减少其拥塞窗口(类似于 AIMD 的乘法减小),并发送 CWR(Congestion Window Reduced,拥塞窗口已减小)标志来确认信号。

没有发生丢包,但拥塞控制行为依然发生。

示例演练

  1. 正常流程:

    • 发送方 → 路由器 → 接收方(数据包标记为 ECT)。
    • 路由器队列增长 → 开始将数据包标记为 CE。
  2. 反馈:

    • 接收方 → 发送方(ACK 设置了 ECE 位)。
    • 发送方将其拥塞窗口减半,发送带有 CWR 的 ACK。
  3. 稳定:

    • 队列排空 → 路由器停止标记。
    • 流恢复正常加法增加。

时间线草图:

发送方: ECT(0) → ECT(0) → CE → ECT(0)
路由器: 当平均队列 > 阈值时标记 CE
接收方: ACK (ECE) → ACK (ECE) → ACK (无 ECE)
发送方: cwnd ↓ (收到 ECE 时)

微型代码(信号传递的 Python 模拟)

python
cwnd = 10
ecn_enabled = True

for rtt in range(1, 10):
    queue = 5 + rtt  # 模拟累积
    if ecn_enabled and queue > 8:
        print(f"RTT {rtt}: CE 标记 -> cwnd 从 {cwnd} 减少到 {cwnd//2}")
        cwnd //= 2
    else:
        cwnd += 1
        print(f"RTT {rtt}: 正常 -> cwnd={cwnd}")

输出:

RTT 1: 正常 -> cwnd=11
RTT 2: 正常 -> cwnd=12
RTT 3: 正常 -> cwnd=13
RTT 4: 正常 -> cwnd=14
RTT 5: CE 标记 -> cwnd 从 14 减少到 7
RTT 6: 正常 -> cwnd=8
...

为什么它很重要

  • 避免数据包丢失,对延迟敏感的流量更友好
  • 延迟更低,队列保持较短
  • 反馈平滑,减少吞吐量振荡
  • 节能且资源高效,无需重传
  • 与 RED(RFC 3168)协同工作,路由器标记而非丢弃

权衡:

  • 需要端到端的 ECN 支持(发送方、接收方和路由器)
  • 一些中间设备仍会剥离 ECN 位
  • 需要仔细配置以避免误报

一个温和的证明(为什么它有效)

实现 RED 或 AQM(主动队列管理)的路由器设置一个标记概率 $p$,而不是丢弃概率。 标记概率遵循:

$$ p = p_{max} \frac{avg - min_{th}}{max_{th} - min_{th}} $$

当一个数据包被标记为 CE 时,TCP 发送方会减少其拥塞窗口:

$$ cwnd_{new} = \beta \cdot cwnd_{old}, \quad \beta < 1 $$

这保持了与基于丢包的控制相同的 AIMD 平衡,但避免了丢包事件。 由于标记是早期且温和的,ECN 能稳定队列长度并最小化延迟。

自己动手试试

  1. 在 Linux 中启用 ECN:
    sysctl -w net.ipv4.tcp_ecn=1
  2. 在支持 ECN 的主机之间运行 iperf3 测试。
  3. 与关闭 ECN 的情况比较吞吐量和延迟。
  4. 可视化数据包捕获,查看头部的 CEECE 位。
  5. 将 ECN 与 RED 结合,形成一个完整的拥塞控制循环。

测试用例

场景行为结果
路由器队列超过 min_th标记 CE平稳减速
ECN 禁用丢弃数据包基于丢包的控制
端到端启用 ECN标记数据包低延迟,稳定的吞吐量
一个路由器不支持 ECN混合行为部分受益

复杂度

  • 每数据包:$O(1)$(标记决策)
  • 开销:可忽略不计,仅涉及比特位切换
  • 部署:增量式,与非 ECN 流向后兼容

ECN 是拥塞控制的温和之手, 它不以丢包惩罚,而是以标记警告, 即使在压力下也能保持互联网快速、公平和稳定。

第 86 节. 分布式共识

851 基础 Paxos

基础 Paxos 是分布式节点间达成一致的基石算法,即使部分节点发生故障也能工作。 它允许一组机器安全且一致地就单个值达成一致,无需完美的可靠性或同步性。

我们要解决什么问题?

在分布式系统中,多个节点可能提议值(例如“提交事务 X”或“领导者是节点 3”)。 它们可能崩溃、重启或消息延迟。我们如何确保每个人最终就同一个值达成一致,即使在部分故障的情况下?

Paxos 回答了以下基本问题:

“一个由不可靠参与者组成的系统如何能达成一致的决策?”

它保证了安全性(没有两个节点决定不同的值)和活性(假设系统稳定,最终会达成决策)。

它是如何工作的(通俗解释)

Paxos 将角色分为三类参与者:

  • 提议者:提出要达成一致的值
  • 接受者:对提案进行投票(多数票即足够)
  • 学习者:学习最终选定的值

算法分为两个阶段进行。

阶段 1:准备
  1. 一个提议者选择一个唯一的提案编号 n,并向所有接受者发送 prepare(n) 消息。

  2. 每个接受者:

    • 如果 n 大于它已见过的任何提案编号,它承诺不接受编号低于 n 的提案。
    • 它回复任何先前已接受的提案 (n', value')(如果有的话)。
阶段 2:接受
  1. 提议者在收到多数派的响应后:

    • 选择具有最高编号的先前已接受值(如果没有,则使用自己的值)。
    • 向所有接受者发送 accept(n, value)
  2. 接受者:

    • 如果它们尚未承诺更高的编号,则接受提案 (n, value)

一旦多数派接受了同一个 (n, value),该值即被选定。 随后将决策通知学习者。

示例时间线

步骤节点消息备注
1P1prepare(1) → 所有提议者开始一轮
2A1, A2承诺 n ≥ 1无先前提案
3P1accept(1, X) → 所有提议值 X
4A1, A2, A3接受 (1, X)多数派同意
5P1宣布决策 X达成共识

如果另一个提议者(例如 P2)稍后尝试使用 prepare(2),Paxos 确保它不能覆盖已选定的值。

微型代码(Python 伪代码)

python
class Acceptor:
    def __init__(self):
        self.promised_n = 0
        self.accepted = None

    def prepare(self, n):
        if n > self.promised_n:
            self.promised_n = n
            return ("promise", self.accepted)
        return ("reject", None)

    def accept(self, n, value):
        if n >= self.promised_n:
            self.accepted = (n, value)
            return "accepted"
        return "rejected"

这抓住了核心思想:接受者承诺只遵守更高编号的提案,从而保证了安全性。

为何重要

  • 容错性:最多可容忍 ⌊(N−1)/2⌋ 个故障。
  • 安全第一:即使发生崩溃,也不会出现不一致状态。
  • 基础:构成了 Raft、Multi-Paxos、Zab、EPaxos 等算法的基础。
  • 应用于:Google Chubby、Zookeeper、etcd、CockroachDB、Spanner。

权衡:

  • 消息流和编号复杂。
  • 延迟高(每个决策需要两轮通信)。
  • 并非为高变动率或分区设计,Raft 对此进行了简化。

一个温和的证明(为何有效)

令:

  • $Q_1$, $Q_2$ 为任意两个接受者的多数派。

则 $Q_1 \cap Q_2 \ne \varnothing$(任意两个多数派必有交集)。

因此,如果一个值 $v$ 被 $Q_1$ 接受, 任何后续提案在准备阶段必须联系 $Q_1$ 中的至少一个接受者, 这确保了它了解到 $v$ 并继续提议它。

因此,一旦一个值被选定,其他值就不可能再被选定。

安全不变量:

如果一个值被选定,每个更高编号的提案都会保留它。

亲自尝试

  1. 模拟三个接受者和两个提议者。
  2. 让两者同时提议,观察编号如何解决冲突。
  3. 丢弃一些消息,确保一致性仍然成立。
  4. 扩展到五个节点,并使用随机延迟进行测试。
  5. 观察学习者如何只需要一个一致的多数据响应。

测试用例

场景预期行为
一个提议者值被快速选定
两个并发提议者编号更高的获胜
节点崩溃并重启安全性得以保持
网络延迟最终一致性,无冲突

复杂度

  • 消息轮次:2(准备 + 接受)
  • 消息复杂度:对于 N 个参与者为 $O(N^2)$
  • 容错性:最多 ⌊(N−1)/2⌋ 个节点故障
  • 存储:每个接受者 $O(1)$ 状态(承诺编号 + 已接受值)

Paxos 是分布式系统的数学核心, 是一种安静而坚定的共识,即使周围的世界分崩离析,它依然屹立不倒。

852 多 Paxos

多 Paxos 是基础 Paxos 的一个优化版本,它允许分布式系统高效地达成许多连续的共识(就像一系列决策日志)。基础 Paxos 处理单个值,而多 Paxos 将其扩展为一系列值,非常适合数据库和共识集群等系统中的复制日志。

我们要解决什么问题?

在实践中,系统很少只需要对一个值达成共识。它们需要反复达成共识,例如:

  • 复制状态机中的每个日志条目
  • 每个事务提交
  • 每次配置更新

如果对每个决策都运行基础 Paxos,每次都需要两轮消息传递,效率低下。

多 Paxos 通过复用稳定的领导者来协调许多决策,从而减少了这种开销。

"既然一个领导者可以维持一段时间,为什么每次都要选举新的领导者呢?"

它是如何工作的(通俗解释)

多 Paxos 直接建立在基础 Paxos 之上,但增加了领导权和日志索引。

核心思想:领导者选举
  • 一个提议者成为领导者(使用更高的提案编号)。
  • 一旦选定,领导者对后续的提案跳过准备阶段。
过程
  1. 领导者选举

    • 一个提议者执行一次准备阶段并成为领导者。
    • 所有接受者承诺不接受更低的提案编号。
  2. 稳定状态

    • 对于每个新的日志条目(索引 $i$),领导者直接发送 accept(i, 值)。
    • 接受者回复 "accepted"。
  3. 学习和复制

    • 当多数派接受后,领导者通知学习者。
    • 该值在位置 $i$ 处被提交。

如果领导者失败,另一个提议者会以更高的提案编号开始新的准备阶段,重新获得领导权。

示例时间线

步骤动作描述
1P1 开始 prepare(1)成为领导者
2P1 提议 accept(1, "A")日志索引 1 的值
3多数派接受"A" 被选定
4P1 提议 accept(2, "B")下一个日志条目,无需准备
5领导者失败P2 运行 prepare(2),接管

因此,多 Paxos 不是每个值都需要两轮,而是:

  • 仅一次两轮(用于领导者选举)
  • 之后每个新决策只需一轮

微型代码(简化 Python 模拟)

python
class MultiPaxosLeader:
    def __init__(self):
        self.proposal_n = 0
        self.log = []

    def elect_leader(self, acceptors):
        self.proposal_n += 1
        promises = [a.prepare(self.proposal_n) for a in acceptors]
        if sum(1 for p, _ in promises if p == "promise") > len(acceptors) // 2:
            print("领导者已选举")
            return True
        return False

    def propose(self, index, value, acceptors):
        accepted = [a.accept(self.proposal_n, value) for a in acceptors]
        if accepted.count("accepted") > len(acceptors) // 2:
            self.log.append(value)
            print(f"已提交 log[{index}] = {value}")

为什么它很重要

  • 高吞吐量:将准备成本分摊到许多决策中
  • 复制日志的基础:支撑了 Raft、Zab、Chubby、etcd 等
  • 容错性:在多达 ⌊(N−1)/2⌋ 个节点故障时仍能工作
  • 一致性:所有副本以相同顺序应用操作

权衡:

  • 需要稳定的领导者以避免频繁变动
  • 领导者故障转移时有轻微延迟
  • 实际实现复杂(超时、心跳、选举)

一个温和的证明(为什么它能工作)

让每个日志索引 $i$ 代表一个独立的 Paxos 实例。

  • 所有实例共享相同的接受者。
  • 一旦领导者以提案编号 $n$ 确立,该领导者未来的每个 accept(i, 值) 消息都使用相同的 $n$。

Paxos 的安全性不变量在每个索引上仍然成立:

一旦一个值在位置 $i$ 被选定,就不能再选定其他值。

因为领导者是固定的,消除了重叠的准备阶段,确保了日志的一致前缀顺序。

形式化地,如果多数派集合 $Q_1, Q_2$ 相交,则: $$ \forall i, ; \text{chosen}(i, v) \Rightarrow \text{future proposals at } i \text{ must propose } v $$

自己动手试试

  1. 选举一个节点作为领导者;让它连续提议 5 个日志条目。
  2. 中途杀死领导者;观察另一个提议者接管。
  3. 观察已提交的日志条目保持完整。
  4. 扩展模拟以展示跨 5 个接受者的日志复制。
  5. 验证即使在重启后也没有不一致性。

测试用例

场景行为结果
稳定的领导者快速单轮提交高效达成共识
领导者崩溃新的准备阶段安全恢复
两个领导者更高提案编号者胜出安全性得以保持
延迟的消息一致的前缀日志无分歧

复杂度

  • 消息轮次:选举 2 轮,之后每个值 1 轮
  • 消息复杂度:每个决策 $O(N)$
  • 容错性:最多 ⌊(N−1)/2⌋ 个故障
  • 日志结构:K 个决策为 $O(K)$

多 Paxos 将 Paxos 的单一共识转变为一系列有序的、容错的决策,成为基于共识系统的生命线。

853 Raft

Raft 是一种共识算法,其设计目标是比 Paxos 更容易理解和实现,同时提供相同的安全性和容错性。 它使一个分布式服务器系统在复制的日志上保持一致,确保所有节点执行相同的命令序列,即使某些节点崩溃或重新连接。

我们要解决什么问题?

共识是可靠分布式系统的基础:数据库、集群管理器和复制状态机都需要它。

Paxos 保证了安全性,但众所周知,正确实现它非常困难。 Raft 的引入是为了使共识易于理解、模块化和实用,通过将其分解为三个清晰的子问题:

  1. 领导者选举 – 选择一个服务器来协调。
  2. 日志复制 – 领导者追加命令并复制它们。
  3. 安全性 – 确保即使在故障后日志也保持一致。

"Raft 并非因为它做得更少而更简单,而是因为它解释得更多而更简单。"

它是如何工作的(通俗解释)

Raft 保持了与 Paxos 相同的基本安全属性:

每个日志索引最多选择一个值。

但它通过领导权基于任期的协调来强制执行这一点。

角色
  • 领导者:处理所有客户端请求和复制。
  • 跟随者:被动节点,响应领导者的消息。
  • 候选人:超时并参与选举的跟随者。
任期

时间被划分为任期。 每个任期以一次选举开始,并且最多只能有一个领导者。

领导者选举
  1. 一个跟随者超时(没有收到心跳)并成为候选人。
  2. 它递增自己的任期,并向所有服务器发送 RequestVote(term, id, lastLogIndex, lastLogTerm)
  3. 服务器在以下条件下投票给候选人:
    • 候选人的日志至少和他们的一样新。
  4. 如果候选人获得多数票,它就成为领导者。
  5. 领导者随后开始发送 AppendEntries(心跳)。
日志复制
  • 客户端向领导者发送命令。
  • 领导者将命令追加到其日志中,并向跟随者发送 AppendEntries(term, index, entry)
  • 当大多数节点确认后,领导者提交该条目并将其应用到状态机。
安全规则

在授予投票之前,一个节点要确保候选人的日志至少和自己的日志一样完整。 这确保了所有已提交的条目在领导权变更时都能被保留。

示例时间线

步骤动作描述
1节点 A 超时开始任期 1 的选举
2A 请求投票B, C 投票给 A
3A 成为领导者开始发送心跳
4客户端发送命令 XA 追加条目 (1, X)
5A 发送 AppendEntriesB 和 C 复制
6多数确认A 提交并应用 X
7A 崩溃,B 当选B 的日志中仍有 X,保持一致性

微型代码(Python 模拟)

python
class RaftServer:
    def __init__(self, id):
        self.id = id
        self.term = 0
        self.voted_for = None
        self.log = []
        self.state = "follower"

    def request_vote(self, candidate_term, candidate_id, candidate_log_len):
        if candidate_term > self.term:
            self.term = candidate_term
            self.voted_for = None
        if (self.voted_for is None or self.voted_for == candidate_id):
            self.voted_for = candidate_id
            return True
        return False

    def append_entries(self, term, entries):
        if term >= self.term:
            self.log.extend(entries)
            self.term = term
            return True
        return False

为什么它很重要

  • 易于理解:比 Paxos 的推理更简单。
  • 高效:每个命令只需一轮通信(通过领导者)。
  • 安全:已提交的条目永远不会丢失。
  • 实用:广泛应用于生产系统(etcd, Consul, TiKV, CockroachDB)。
  • 可重新配置:支持安全的集群成员变更。

权衡:

  • 需要稳定的领导者(单点写入)。
  • 崩溃后重新选举期间有轻微延迟。
  • 更多的状态管理(任期、日志、心跳)。

一个温和的证明(为什么它有效)

Raft 的安全性依赖于领导者完整性属性:

如果一个日志条目在任期 T 中被提交,那么未来的每个领导者都必须包含该条目。

证明概要:

  1. 一个条目只有在存储在大多数服务器上时才被提交。
  2. 任何新领导者必须赢得多数票。
  3. 多数派有重叠 ⇒ 至少有一个投票者拥有已提交的条目。
  4. 该投票者的日志任期确保了该条目在新领导者的日志中被保留。

因此,一旦提交,一个条目将保留在所有未来的日志中。

数学上: $$ \forall T, i: \text{committed}(i, T) \Rightarrow \forall T' > T, \text{leader}(T') \text{ has entry } i $$

亲自尝试

  1. 模拟一个 5 节点集群(A–E)。
  2. 引入随机的选举超时。
  3. 观察领导者选举和稳定的领导权。
  4. 发送命令并观察日志复制。
  5. 终止一个领导者,并验证系统恢复后日志一致。
  6. 添加一个新节点,观察 Raft 的重新配置机制。

测试用例

场景预期行为
单一领导者稳定的心跳
领导者崩溃超时后新选举
脑裂(分区)只有多数派一侧可以提交
分区后重新加入日志安全地协调一致
集群重新配置没有丢失或重复的条目

复杂度

  • 每次操作的消息轮次:1(稳定状态)
  • 选举轮次:可变(超时 + 投票请求)
  • 容错能力:最多 ⌊(N−1)/2⌋ 个节点故障
  • 存储:每个服务器的日志 + 任期号
  • 通信:每次追加 $O(N)$

Raft 将共识变成了有节奏的心跳: 领导者起起落落,日志齐步前进, 即使混乱来袭,集群也能准确地记住已决定的内容。

854 视图戳复制 (VR)

视图戳复制 (VR) 是一种在 Raft 和 Multi-Paxos 之前开发的共识与复制算法。它的设计旨在使容错状态机复制更易于理解和实现。VR 将时间组织成由主副本领导的视图(纪元),确保一组服务器即使在部分节点发生故障时,也能维护一个一致且有序的客户端操作日志。

我们要解决什么问题?

在分布式系统中,我们需要确保:

  1. 所有副本执行相同的操作序列。
  2. 即使部分副本崩溃,系统也能继续取得进展。
  3. 不能有两个主副本(领导者)做出冲突的决策。

Paxos 解决了这个问题,但难以解释;VR 使用开发者已经理解的主-备术语重新表述了相同的问题。

"可以把它想象成一个小心翼翼地记日记的主副本,以及一个确保它永远不会忘记所写内容的法定人数。"

它是如何工作的(通俗解释)

VR 使用三个阶段,在视图中重复进行:

1. 正常操作
  • 一个副本充当主副本;其他副本为备份副本。
  • 主副本接收客户端请求,分配日志序列号,并向备份副本发送 Prepare 消息。
  • 备份副本回复 PrepareOK
  • 一旦主副本收集到来自多数派的确认,它就提交该操作并响应客户端。
2. 视图变更(领导者选举)
  • 如果备份副本在超时时间内未收到主副本的消息,它们会发起视图变更。
  • 每个副本将其日志和视图编号发送给其他副本。
  • 拥有最高视图编号的候选者成为新的主副本。
  • 新的主副本合并最新的日志并启动新视图。
3. 恢复
  • 崩溃或滞后的副本可以通过向其他副本请求当前日志来重新加入。
  • 它会同步到最近提交的操作。

示例时间线

步骤阶段操作
1正常主副本 P1 接收请求 (操作 X)
2正常发送 Prepare(view=1, op=X)
3正常多数派发送 PrepareOK
4正常P1 提交并回复客户端
5故障P1 崩溃;备份副本开始视图变更
6视图变更P2 收集选票,成为新的主副本 (view=2)
7恢复P1 重启并与 P2 的日志同步

微型代码(简化 Python 伪代码)

python
class Replica:
    def __init__(self, id):
        self.id = id
        self.view = 0
        self.log = []
        self.primary = False

    def prepare(self, op):
        if self.primary:
            msg = (self.view, len(self.log), op)
            return msg
        return None

    def prepare_ok(self, msg):
        view, index, op = msg
        if view == self.view:
            self.log.append(op)
            return True
        return False

    def start_view_change(self, new_view):
        self.view = new_view
        self.primary = False

为何重要

  • 概念清晰:采用具有明确视图的主-备模型。
  • 容错:最多可容忍 ⌊(N−1)/2⌋ 个故障。
  • 一致且安全:所有已提交的操作在所有副本上以相同顺序出现。
  • 基础:启发了 Raft 和现代复制日志协议(Zab、PBFT 等)。
  • 恢复友好:支持通过日志重放进行崩溃恢复。

权衡:

  • 比 Multi-Paxos 通信开销稍大。
  • 需要显式的视图管理。
  • 在分离安全性和活性方面不如 Raft 清晰。

一个温和的证明(为何有效)

每个视图都有一个协调提交的单一主副本。

令 $V_i$ 为视图 $i$,$Q_1$、$Q_2$ 为任意两个副本的多数派。

  1. 当 $V_i$ 中的主副本提交一个操作时,它已收到来自 $Q_1$ 的确认。
  2. 在下一个视图变更期间,新的主副本从多数派 $Q_2$ 收集日志。
  3. 因为 $Q_1 \cap Q_2 \ne \varnothing$,所以已提交的条目至少出现在 $Q_2$ 中的一个副本里。
  4. 新的主副本将其合并,确保所有未来的日志都包含它。

因此,已提交的操作永远不会丢失。

形式化地: $$ \forall v, i: \text{committed}(v, i) \Rightarrow \forall v' > v, \text{primary}(v') \text{ includes } i $$

亲自尝试

  1. 模拟一个 3 副本系统 (P1, P2, P3)。
  2. 让 P1 充当主副本;顺序发送操作。
  3. 在操作中途终止 P1,观察 P2 如何发起新视图。
  4. 重新引入 P1 并验证其同步了日志。
  5. 重复进行消息丢失和恢复的测试。

测试用例

场景行为结果
正常操作主副本通过多数派提交线性化日志
主副本崩溃视图变更选举出新主副本安全恢复
网络分区只有多数派继续运行无冲突提交
崩溃后恢复副本同步日志最终一致性

复杂度

  • 消息轮次:每个操作 2 轮(准备 + 提交)
  • 视图变更:领导者选举期间额外 1 轮
  • 容错能力:最多 ⌊(N−1)/2⌋ 个副本
  • 状态:日志条目、视图编号、主副本标志

视图戳复制是 Paxos 和 Raft 之间的桥梁, 拥有相同的数学核心,但被构造成一个关于视图和主副本的故事, 其中领导权的变更优雅从容,记忆永不褪色。

855 实用拜占庭容错 (PBFT)

实用拜占庭容错 (PBFT) 是一种共识算法,它能够容忍拜占庭故障,即某些节点可能表现出任意甚至恶意的行为,同时仍能确保正确性和活性。 它允许一个由 $3f + 1$ 个副本组成的分布式系统,即使其中最多有 $f$ 个副本行为不正确或不诚实,也能继续正确运行。

我们要解决什么问题?

像 Paxos 或 Raft 这样的经典共识算法假设的是崩溃故障:节点可能会停止工作,但它们从不说谎。 在现实世界的分布式系统中,尤其是在开放或对抗性环境(如区块链、金融系统或不可信的数据中心)中,节点的行为可能是任意的:

  • 发送相互矛盾的消息
  • 伪造响应
  • 重放或重新排序消息

PBFT 确保系统即使在某些节点是恶意的情况下,仍然能达成一致并以相同的顺序执行操作。

"这是一个共识的世界,其中一些参与者会作弊,但诚实仍然会获胜。"

它是如何工作的(通俗解释)

PBFT 在由主节点(领导者)协调的视图中运行,副本作为备份。 每个客户端请求都经过三个阶段,所有阶段都通过数字签名或消息摘要进行身份验证。

角色
  • 主节点:协调请求排序。
  • 副本:验证主节点的提议并达成一致。
  • 客户端:发送请求并等待多数派回复。
三个阶段
  1. 预准备

    • 客户端发送请求:⟨REQUEST, op, timestamp, client⟩
    • 主节点分配序列号 n,广播 ⟨PRE-PREPARE, v, n, d⟩,其中 d = 请求的摘要。
  2. 准备

    • 每个副本验证消息并向所有其他副本广播 ⟨PREPARE, v, n, d⟩
    • 当一个副本收到 $2f$ 个匹配的 PREPARE 消息时,它就进入已准备状态。
  3. 提交

    • 每个副本广播 ⟨COMMIT, v, n, d⟩
    • 当它收到 $2f + 1$ 个匹配的 COMMIT 消息时,操作被提交并执行。

客户端等待 f + 1 个匹配的回复,这保证了至少有一个回复来自正确的节点。

示例时间线(4个副本,f = 1)

阶段消息法定人数大小目的
预准备1 个主节点 → 所有N顺序分配
准备所有到所有2f = 2就顺序达成一致
提交所有到所有2f + 1 = 3安全执行

如果主节点故障或行为不当,副本会检测到不一致并触发视图变更,选举新的主节点。

微型代码(简化的 Python 模拟)

python
from hashlib import sha256

class Replica:
    def __init__(self, id, f):
        self.id = id
        self.f = f
        self.view = 0
        self.log = []

    def digest(self, msg):
        return sha256(msg.encode()).hexdigest()

    def pre_prepare(self, op, n):
        d = self.digest(op)
        return ("PRE-PREPARE", self.view, n, d)

    def prepare(self, pre_prepare_msg):
        phase, view, n, d = pre_prepare_msg
        return ("PREPARE", view, n, d)

    def commit(self, prepare_msgs):
        if len(prepare_msgs) >= 2 * self.f:
            return ("COMMIT", self.view, prepare_msgs[0][2])

为什么它很重要

  • 拜占庭容错:能在任意节点故障下存活。
  • 强一致性:所有非故障节点就相同的操作序列达成一致。
  • 实用性:避免了昂贵的密码学证明(与早期的 BFT 协议不同)。
  • 低延迟:在常见情况下只需三轮消息交换。
  • 影响力:构成了现代区块链共识协议(Tendermint、HotStuff、PBFT-SMART、LibraBFT)的基础。

权衡:

  • 通信成本高(每个阶段 $O(n^2)$ 条消息)。
  • 假设存在经过身份验证的通道。
  • 超过小型集群(通常 ≤ 20 个副本)后性能会下降。

一个温和的证明(为什么它能工作)

PBFT 通过法定人数交集和身份验证来确保安全性和活性。

  • 每个决策需要 $2f + 1$ 个节点同意。
  • 任意两个法定人数至少在一个正确节点上相交: $$ (2f + 1) + (2f + 1) > 3f + 1 $$ 因此,总是至少有一个诚实的副本连接着过去和未来的决策。
  • 因为所有消息都经过签名,故障节点无法冒充或伪造投票。

因此,即使存在最多 $f$ 个恶意节点,也不可能出现冲突的提交。

形式上: $$ \forall n, v: \text{committed}(n, v) \Rightarrow \text{no conflicting } v' $$

自己动手试试

  1. 模拟 4 个副本($f = 1$)。
  2. 让一个节点发送坏消息,系统仍然能就一个值达成一致。
  3. 观察主节点如何协调,以及故障时如何触发视图变更。
  4. 实现消息签名(例如,SHA-256 + 简单验证)。
  5. 测量处理 1 个请求时交换的总消息数与 Raft 进行比较。

测试用例

场景行为结果
无故障3阶段提交达成一致
主节点故障视图变更选举出新主节点
一个副本发送坏数据被法定人数忽略安全性得以保持
重放攻击被拒绝(时间戳/摘要)完整性得以保持

复杂度

  • 消息复杂度:每个请求 $O(n^2)$
  • 消息轮次:3(预准备、准备、提交)
  • 容错能力:使用 $3f + 1$ 个副本,最多容忍 $f$ 个拜占庭故障
  • 延迟:3 次网络 RTT(正常情况)

PBFT 是对抗性世界中的共识, 其中诚实必须通过法定人数来证明,而一致性的达成 并非源于信任,而是源于交集和完整性的数学原理。

856 Zab(Zookeeper 原子广播)

Zab,全称 Zookeeper Atomic Broadcast,是 Apache Zookeeper 使用的一种共识和复制协议,用于在所有服务器间维护一致的分布式状态。它专为基于领导者的协调服务设计,确保所有更新(状态变更)以相同的顺序传递给每个副本,即使在崩溃和重启后也是如此。

我们要解决什么问题?

Zookeeper 保证每个操作:

  1. 以全序执行(各处序列相同)。
  2. 在服务器崩溃和恢复后依然存在。
  3. 即使领导者失败,也不会丢失已提交的更新。

Zab 解决了结合以下两者的挑战:

  • 原子广播(全有或全无的交付),以及
  • 崩溃恢复(无双重提交或回滚)。

"如果一个服务器说它发生了,那么它就在所有地方都发生了,恰好一次,并且顺序相同。"

它是如何工作的(通俗解释)

Zab 建立在领导者-追随者模型之上,但对其进行了扩展,以保证全序广播和持久化恢复。它分为三个主要阶段:

1. 发现阶段
  • 选举新的领导者(使用外部机制,如快速领导者选举)。
  • 领导者确定服务器间最新的已提交事务 ID(zxid)。
  • 领导者选择最新的历史记录作为系统的官方前缀。
2. 同步阶段
  • 领导者同步追随者的日志以匹配选定的前缀。
  • 追随者截断或填补缺失的提案,以与领导者的状态对齐。
  • 一旦同步完成,追随者进入广播阶段。
3. 广播阶段
  • 领导者接收客户端事务,分配一个新的 zxid(单调递增的 ID),并向追随者发送 PROPOSAL。
  • 追随者将提案持久化到磁盘,并用 ACK 回复。
  • 当达到法定人数确认时,领导者发送 COMMIT 消息。
  • 所有追随者按顺序应用该事务。

示例时间线

阶段消息描述
发现领导者选举新领导者收集最新的 zxid
同步PROPOSAL 同步对齐追随者之间的日志
广播PROPOSAL/ACK/COMMIT稳态复制

如果领导者在提交过程中崩溃,下一个领导者将使用发现阶段来查找最先进的日志,确保没有已提交的事务丢失或重放。

微型代码(简化 Python 模型)

python
class Server:
    def __init__(self, id):
        self.id = id
        self.log = []
        self.zxid = 0
        self.is_leader = False

    def propose(self, data):
        if not self.is_leader:
            return None
        self.zxid += 1
        proposal = (self.zxid, data)
        return proposal

    def ack(self, proposal):
        zxid, data = proposal
        self.log.append((zxid, data))
        return zxid

    def commit(self, zxid):
        print(f"Committed zxid={zxid}")

这个简化模型展示了提案如何被确认,然后严格按照 zxid 顺序提交。

为什么它很重要

  • 崩溃恢复:在领导者故障后存活,且不丢失已提交的数据。
  • 全序性:所有副本以相同的序列处理更新。
  • 效率:一个领导者处理所有写入,追随者只读。
  • 应用于:Apache Zookeeper、Kafka 的控制器仲裁以及类似的元数据服务。

权衡:

  • 在繁重的写入负载下,单一领导者会成为瓶颈。
  • 需要稳定的法定人数才能取得进展。
  • 读取可能略微落后于领导者(取决于同步策略)。

一个温和的证明(为什么它有效)

Zab 通过前缀协议和提交持久性来确保原子广播。

令 $L$ 为领导者,$F_i$ 为追随者。

  • 每个事务 $T_k$ 都有一个唯一的 zxid $(epoch, counter)$。
  • 提交规则:一旦法定人数 ACK 了 $T_k$ 的提案,$T_k$ 即被提交。
  • 因为所有追随者必须在提交前 ACK,所以之后选举出的任何新领导者必须包含直到最高已提交 zxid 的所有事务。

形式化地: $$ \forall T_i, T_j: zxid(T_i) < zxid(T_j) \Rightarrow \text{deliver}(T_i) \text{ before } \text{deliver}(T_j) $$

且 $$ \text{If } T_i \text{ committed in epoch } e, \text{ then all future epochs } e' > e \text{ contain } T_i $$

因此,原子性和全序性在视图变更中得以保留。

自己动手试试

  1. 模拟三个服务器:一个领导者,两个追随者。
  2. 让领导者提议事务(T1, T2, T3)。
  3. 在提交 T2 后杀死领导者,启动一个新的领导者。
  4. 观察 T3(未提交)如何被丢弃,但 T1–T2 得以保留。
  5. 重放该过程,并验证所有节点是否收敛到相同的日志。

测试用例

场景行为结果
正常广播领导者提议,法定人数 ACK全序提交
领导者提交后崩溃恢复保留状态无回滚
领导者提交前崩溃未提交的提案被丢弃无重复
日志分歧新领导者同步最高前缀一致性恢复

复杂度

  • 消息轮次:2(提案 + 提交)
  • 消息复杂度:每事务 $O(N)$
  • 容错性:最多 ⌊(N−1)/2⌋ 个故障
  • 存储:日志条目 + zxid 序列
  • 延迟:每事务 2 次网络 RTT

Zab 是 Zookeeper 可靠性背后安静的节拍器,一个单一的领导者广播着心跳和顺序,确保每个副本,在每个地方,都以相同的节奏听到相同的故事。

857 EPaxos(平等 Paxos)

EPaxos,全称 Egalitarian Paxos(平等 Paxos),是一种快速、无领导者的共识算法,它推广了 Paxos,允许任何副本并发地提议和提交命令,而无需等待固定的领导者。它通过利用命令的可交换性(可以按任意顺序执行的独立操作)和快速仲裁提交,来优化延迟和吞吐量。

我们要解决什么问题?

在基于领导者的协议中(如 Paxos、Raft、Zab):

  • 一个节点(领导者)协调每个命令。
  • 这造成了瓶颈和额外的延迟(提交需要 2 个 RTT)。

EPaxos 消除了领导者,允许多个节点并发地提议。它仍然保证非交换操作的总序,同时为独立操作跳过协调。

"如果命令不冲突,为什么要让它们排队等待?"

它是如何工作的(通俗解释)

EPaxos 通过依赖跟踪推广了 Paxos 的仲裁逻辑。每个副本都可以提议命令,依赖关系决定排序。

关键组成部分
  1. 命令

    • 每个客户端请求:一个具有唯一 ID 和操作的命令 C
  2. 依赖关系

    • 每个命令跟踪一组必须在它之前执行的冲突命令。
    • 如果两个命令不访问重叠的键或对象,则它们是可交换的。
  3. 仲裁

    • EPaxos 使用快速仲裁,比多数仲裁稍大,以便在没有冲突时在 1 个 RTT 内提交。

协议概述

1. 提议阶段
  • 一个副本接收到客户端命令 C
  • 它向所有其他副本发送一条带有初始依赖集(空集或推测集)的 PreAccept(C, deps) 消息。
  • 每个副本添加它已知的冲突命令,返回更新后的依赖集。
2. 快速路径(无冲突)
  • 如果所有响应都同意相同的依赖集:

    • 该命令在一个往返时间内立即提交(快速路径)。
  • 否则,进入慢速路径。

3. 慢速路径(冲突)
  • 提议者收集响应并选择最大的依赖集。
  • 发送 Accept 消息以确保仲裁达成一致。
  • 一旦收到一个仲裁的 Accept,命令即被提交。
4. 执行
  • 命令按照依赖图(偏序)执行。

    • 如果 A 依赖于 B,则在 A 之前执行 B。

示例时间线(5 个副本,快速仲裁 = 3)

步骤副本操作备注
1R1PreAccept(C1, {})提议命令 C1
2R2, R3回复相同的依赖集无冲突
3R1快速提交(1 RTT)命令 C1 已提交
4R4PreAccept(C2, {C1})冲突命令
5R2, R5添加依赖关系需要走慢速路径
6R4在仲裁 Accept 后提交 C2在 C1 之后排序

简化代码(简化的 Python 草图)

python
class EPaxosReplica:
    def __init__(self, id):
        self.id = id
        self.deps = {}
        self.log = {}

    def preaccept(self, cmd, deps):
        self.deps[cmd] = deps.copy()
        # 添加本地冲突
        for c in self.log:
            if self.conflicts(cmd, c):
                self.deps[cmd].add(c)
        return self.deps[cmd]

    def conflicts(self, c1, c2):
        # 简单的基于键的冲突检测
        return c1.key == c2.key

每个副本维护依赖关系并合并它们,以形成全局偏序。

为什么它重要

  • 无领导者:没有单一的协调者或瓶颈。
  • 低延迟:在常见情况下只需 1 个 RTT 提交。
  • 并行性:多个副本并发地提议和提交。
  • 一致性:对依赖命令保证可串行化,对独立命令保证可交换性。
  • 高可用性:可容忍多达 ⌊(N−1)/2⌋ 个故障。

权衡:

  • 更复杂的依赖跟踪和消息状态。
  • 快速路径需要比多数仲裁更多的副本。
  • 日志恢复(崩溃后)更棘手。

一个温和的证明(为什么它有效)

设 $Q_f$ 为快速仲裁,$Q_s$ 为慢速仲裁,$N$ 为副本数。

  • $\vert Q_f\vert + \vert Q_s\vert > N$ 确保交集。
  • 任何两个仲裁至少在至少一个正确的副本处相交,确保达成一致。

对于每个命令 $C$:

  • 依赖集 $\text{deps}(C)$ 定义了一个偏序 $<$。
  • 如果 $C_1$ 和 $C_2$ 冲突,那么通过依赖关系强制执行 $C_1 < C_2$ 或 $C_2 < C_1$。

因此,执行顺序: $$ C_i < C_j ;\Rightarrow; \text{execute}(C_i) \text{ before } \text{execute}(C_j) $$ 为冲突命令保持了线性一致性,并为独立命令保持了高并发性。

自己动手试试

  1. 模拟 5 个副本;让两个副本在不相交的键上并发地提议命令。
  2. 观察两者都在 1 个 RTT 内提交(快速路径)。
  3. 引入一个冲突命令;观察它回退到 2 阶段的慢速路径。
  4. 绘制依赖图并验证拓扑执行顺序。
  5. 使一个副本故障,并确认仲裁交集仍然确保达成一致。

测试用例

场景行为结果
独立命令快速路径1 RTT 提交
冲突命令慢速路径2 RTT 提交
副本崩溃仲裁交集安全性得以保持
多个并发提议依赖合并确定性的总序

复杂度

  • 快速路径:1 RTT
  • 慢速路径:2 RTTs
  • 消息复杂度:$O(N^2)$(PreAccept + Accept)
  • 容错能力:最多 ⌊(N−1)/2⌋ 个故障
  • 依赖跟踪:每个命令 $O(K)$(对于 K 个冲突)

EPaxos 使共识真正平等, 没有单一领导者,没有固定节奏,只有副本 和谐地合作,共同决策, 每个都知晓依赖关系,但在可能时自由地快速行动。

858 VRR(虚拟环复制)

虚拟环复制(VRR)是一种分布式共识与复制协议,它将副本组织成一个逻辑环,以提供高吞吐量、容错的日志复制,其结构比全连接的法定人数系统更简单。 它确保所有副本以相同顺序交付更新,同时高效处理故障和恢复。

我们正在解决什么问题?

像 Paxos 或 Raft 这样的传统复制协议依赖于多数法定人数和广播通信,随着集群规模增长,这可能变得代价高昂。 VRR 则将副本安排在一个虚拟环中,消息沿一个方向流动,从而减少了协调开销。

目标是:

  1. 在所有副本之间实现一致的状态复制。
  2. 使用环拓扑实现高效通信。
  3. 通过虚拟后继和前驱映射实现容错。

"VRR 不是向所有人喊话,而是在圈子里低声传递,消息仍然能到达所有人。"

它是如何工作的(通俗解释)

VRR 在副本之间使用逻辑环覆盖层扩展了主-备模型。

组件
  • 主副本:发起客户端请求并向环上广播更新。
  • 备份副本:沿环中继并确认消息。
  • 环顺序:决定复制和确认的顺序。
  • 视图编号:标识当前配置(类似于 Paxos 的任期)。
阶段
  1. 正常操作

    • 主副本接收客户端请求。
    • 它分配一个序列号 n,并向其在环上的后继节点发送 UPDATE(n, op)。
    • 每个节点将消息转发给其后继节点,直到消息完成完整的一圈。
    • 当更新返回到主副本时,它被提交。
    • 每个节点按顺序应用该操作。
  2. 故障处理

    • 如果一个节点未能转发更新,其后继节点检测到超时并启动视图变更。
    • 环中的下一个节点成为新的主副本并继续操作。
    • 环被虚拟地重新连接以跳过故障节点。
  3. 恢复

    • 故障节点稍后可以通过从环或检查点重放错过的更新来重新加入。

示例时间线

步骤阶段操作
1正常主副本 P1 发送 UPDATE(n=1, X) 给 P2
2正常P2 → P3 → P4(更新循环)
3正常P4 → P1(完整环)
4正常P1 提交 X
5故障P3 崩溃;P4 超时
6视图变更P4 成为新的主副本,重建排除 P3 的环

微型代码(简化的 Python 模拟)

python
class Replica:
    def __init__(self, id, successor=None):
        self.id = id
        self.successor = successor
        self.log = []

    def update(self, seq, op):
        self.log.append((seq, op))
        print(f"{self.id} applied op {op}")
        if self.successor:
            self.successor.update(seq, op)

此模型演示了更新通过虚拟环的循环传播。

为何重要

  • 低通信开销:每跳仅一条消息,而非全对全。
  • 高吞吐量:适用于稳定、低故障环境。
  • 可扩展:适用于许多副本。
  • 容错:通过绕开缺失节点重新路由来处理故障。
  • 基础:启发了后来的协议,如 Corfu 和链式复制。

权衡:

  • 延迟稍高(取决于环的长度)。
  • 一次只有一个主副本,故障时重新配置有成本。
  • 要求环邻居之间有稳定的网络连接。

一个温和的证明(为何有效)

假设副本排列成一个环 $R = [r_1, r_2, \dots, r_N]$。 每个操作 $op_i$ 由主副本 $r_p$ 分配一个序列号 $n_i$。

  • 全序:更新以相同的顺序为所有副本在环中循环。
  • 持久性:在更新返回到主副本后确认提交,确保所有副本都已应用该更新。
  • 容错:当一个节点故障时,会形成一个排除该节点的新环 $R'$。

对于任意两个更新 $op_i, op_j$, $$ n_i < n_j \Rightarrow \text{deliver}(op_i) \text{ 在 } \text{deliver}(op_j) \text{ 之前} $$ 且 $$ \forall R', R'': \text{prefix}(R') = \text{prefix}(R'') \Rightarrow \text{一致状态} $$

这在故障下保持了全序和前缀一致性。

亲自尝试

  1. 创建一个包含四个副本的环(A → B → C → D → A)。
  2. 让 A 作为主副本并广播更新(1, 2, 3)。
  3. 在更新过程中终止 C,观察超时和视图变更。
  4. 将环重建为 A → B → D → A,继续复制。
  5. 重新引入 C,从 D 的日志同步。

测试用例

场景行为结果
正常操作顺序转发有序复制
单节点崩溃视图变更环重组
节点延迟恢复日志重放完全同步
网络延迟顺序一致性最终交付

复杂度

  • 消息轮次:每次更新 $O(N)$(每个副本一跳)
  • 消息复杂度:$O(N)$
  • 容错能力:通过重组最多 ⌊(N−1)/2⌋ 个故障
  • 存储:每个节点的日志 + 检查点
  • 延迟:与环长度成正比

虚拟环复制就像一场精心编排的接力赛, 每位选手都按完美的顺序传递接力棒, 即使有人跌倒,圈子也会重组,比赛继续进行。

859 基于共识的两阶段提交

基于共识的两阶段提交(2PC+C)融合了两种强大的机制——数据库中的原子提交协议和来自 Paxos/Raft 的分布式共识——以实现跨多个节点或服务的容错事务提交。 它确保一个事务要么被所有参与者提交,要么被所有参与者中止,即使在出现故障或分区的情况下也是如此。

我们要解决什么问题?

经典的两阶段提交(2PC)协议协调跨节点的事务,但它有一个致命的缺陷:

  • 如果协调者在参与者投票同意之后、但在宣布提交之前发生故障,所有参与者将无限期地阻塞。

为了解决这个问题,2PC 需要共识机制,一种让参与者即使在协调者死亡的情况下也能就结果(提交或中止)达成一致的方法。

2PC+C 集成了共识机制(如 Paxos 或 Raft),使决策持久化、可用且可恢复。

"如果一个节点陷入沉默,共识会完成这个句子。"

它是如何工作的(通俗解释)

该协议有两个主要角色:

  • 协调者:编排事务(可以使用共识机制进行复制)。
  • 参与者:准备和提交工作的本地数据库或服务。

算法在两个逻辑阶段进行,每个阶段都通过共识机制变得可靠。

1. 准备阶段(投票)
  1. 协调者提出一个事务 T
  2. 向所有参与者发送 PREPARE(T)
  3. 每个参与者:
    • 验证本地约束。
    • READY(T) 记录到持久化存储中。
    • 回复 YES(准备提交)或 NO(中止)。
  4. 协调者收集投票。
    • 如果有任何 NO → 结果 = ABORT。
    • 如果全部是 YES → 结果 = COMMIT。
2. 提交阶段(通过共识决策)
  1. 协调者通过共识机制提出最终决定(COMMITABORT)。
  2. 一旦共识组中的多数节点同意:
    • 决策被复制并持久化。
  3. 所有参与者被告知最终结果,并在本地应用它。

这样,即使协调者在决策过程中崩溃,另一个节点也可以恢复日志并安全地完成事务。

示例时间线

步骤阶段操作结果
1准备协调者发送 PREPARE(T)参与者投票
2准备所有参与者回复 YES决策:提交
3共识通过 Paxos 提出决策多数接受
4提交广播决策全部提交
5崩溃恢复协调者重启从日志中获知决策

微型代码(简化伪代码)

python
class Participant:
    def __init__(self):
        self.state = "INIT"

    def prepare(self, tx):
        if self.can_commit(tx):
            self.state = "READY"
            return "YES"
        else:
            self.state = "ABORT"
            return "NO"

    def commit(self):
        if self.state == "READY":
            self.state = "COMMIT"
            print("已提交")
        else:
            print("已中止")

协调者的决策(提交或中止)通过副本间的共识机制存储,确保了容错性。

为什么它很重要

  • 无阻塞:协调者故障不会使参与者停滞。
  • 原子性:跨分布式系统的全有或全无提交。
  • 持久性:决策在崩溃和重启后依然存在。
  • 集成数据库 + 共识系统:Spanner、CockroachDB、TiDB、Yugabyte 等的基础。
  • 通用性:适用于异构服务(微服务、键值存储、消息队列)。

权衡:

  • 消息复杂度更高(增加了共识层)。
  • 延迟略高。
  • 共识副本必须保持可用(需要法定人数)。

一个温和的证明(为什么它有效)

令 $D \in {\text{COMMIT}, \text{ABORT}}$ 为全局决策。

  • 所有参与者投票并将其决策持久地记录在日志中。
  • 协调者使用共识机制将 $D$ 记录在多数副本上。
  • 任何恢复过程都会查询共识日志: $$ \text{learn}(D) = \text{argmax}_n(\text{accepted}_n) $$ 确保所有副本收敛到相同的结果。

因为共识机制确保了一个一致同意的值,所以没有两个参与者能观察到冲突的决策。

安全性: $$ \text{如果一个节点提交了 } T, \text{ 那么每个节点最终都会提交 } T $$

活性(在部分同步模型下): $$ \text{如果有法定人数可用且全部投票 YES, } T \text{ 最终会被提交。} $$

自己动手试试

  1. 模拟三个参与者和一个共识集群(3 个节点)。
  2. 让所有参与者投票 YES。通过共识提交 → 持久化成功。
  3. 在中间阶段使协调者崩溃 → 重启 → 从日志中读取结果。
  4. 尝试让一个参与者投票 NO → 全局中止。
  5. 观察没有节点无限期阻塞。

测试用例

场景行为结果
所有参与者投票 YES通过共识提交一致提交
一个参与者投票 NO全局中止安全中止
协调者在提交中途崩溃通过共识日志恢复无阻塞
网络分区多数方决定保持一致性

复杂度

  • 消息轮次:2PC(2)+ 共识(2)= 总共 4 轮
  • 消息复杂度:对于 N 个参与者和共识中的 M 个副本,为 $O(N + M)$
  • 容错能力:最多可容忍 ⌊(M−1)/2⌋ 个协调者副本故障
  • 存储:投票和决策的日志

带有共识的 2PC 将分布式事务脆弱的 "是/否" 之舞转变为稳健的编排,即使沉默、故障或混乱也无法阻止系统共同且永久地做出决定。

860 链式复制

链式复制是一种容错复制技术,它将服务器排列成一条线性链,确保强一致性、高吞吐量和可预测的故障恢复。它广泛应用于大规模存储系统和协调服务中,这些场景要求更新必须按全序处理,且不需要全对全通信。

我们要解决什么问题?

传统的基于法定人数的复制(如 Paxos 或 Raft)需要多数节点确认,这可能会增加延迟。相比之下,链式复制保证了:

  • 线性一致性(所有副本上的操作顺序相同)
  • 通过沿链流水线化更新实现高吞吐量
  • 低延迟读取(由尾节点提供服务)

核心思想:

将副本排列成一条链:头节点 → 中间节点 → 尾节点。 写请求向前流动,读请求向后流动。

它是如何工作的(通俗解释)

设置

一个集群有 $N$ 个副本,逻辑上排序如下: $$ r_1 \rightarrow r_2 \rightarrow r_3 \rightarrow \dots \rightarrow r_N $$

  • 头节点:处理所有客户端写请求。
  • 尾节点:处理所有客户端读请求。
  • 中间节点:转发写请求和确认信息。
写路径
  1. 客户端发送 WRITE(x, v) 到头节点。
  2. 头节点在本地应用更新,并将其转发给其后继节点。
  3. 每个节点应用更新,并将其沿链向下转发。
  4. 尾节点应用更新,向上游发送 ACK
  5. 一旦头节点收到 ACK,写操作即被提交,并向客户端确认。
读路径
  • 客户端发送 READ(x) 到尾节点,尾节点拥有最新已提交的状态。

示例时间线(3节点链)

步骤节点动作备注
1头节点WRITE(x=5)本地应用
2头节点 → 中间节点转发更新
3中间节点应用,转发到尾节点
4尾节点应用,发送 ACK提交点
5中间节点 → 头节点ACK 回传
6头节点向客户端确认提交完成

故障处理

  1. 头节点故障:下一个节点成为新的头节点。
  2. 尾节点故障:前一个节点成为新的尾节点。
  3. 中间节点故障:链被重新连接,跳过故障节点。
    • 协调器或控制服务动态地重新配置链。
  4. 恢复:重启的节点可以通过获取尾节点的状态并重新加入链来恢复。

故障永远不会违反一致性,只会暂时降低可用性。

微型代码(简化的 Python 草图)

python
class Node:
    def __init__(self, id, successor=None):
        self.id = id
        self.state = {}
        self.successor = successor

    def write(self, key, value):
        self.state[key] = value
        if self.successor:
            self.successor.write(key, value)
        else:
            return "ACK"

这段代码演示了写操作沿链的传播。

为什么它很重要

  • 强一致性:所有更新的全序排序。
  • 高吞吐量:流水线化的转发和提交确认。
  • 低延迟读取:尾节点始终拥有最新已提交的数据。
  • 简单性:确定性的结构(头、中、尾)。
  • 应用场景:微软的 FAWN-KV、Ceph RADOS、Azure Cosmos DB 等。

权衡:

  • 每个数据分区使用单链,不适合高争用的全局写入。
  • 故障期间的重新配置会增加短暂的停机时间。
  • 需要外部协调(例如 Zookeeper)来管理成员资格。

一个温和的证明(为什么它有效)

令 $U = {u_1, u_2, \dots, u_n}$ 为沿链应用的一系列更新。

每个更新 $u_i$ 是:

  • 在每个节点上按顺序应用(FIFO 转发)。
  • 当被尾节点确认时提交。

线性一致性: 对于任意两个操作 $u_i$ 和 $u_j$: $$ u_i \text{ 在 } u_j \text{ 开始前完成} \Rightarrow u_i \text{ 在 } u_j \text{ 之前可见} $$

所有节点看到的写操作顺序相同,因为转发是顺序进行的: $$ \text{order}(r_1) = \text{order}(r_2) = \dots = \text{order}(r_N) $$

因此,从尾节点读取总是反映最新已提交的值。

自己动手试试

  1. 模拟一个包含三个副本的链。
  2. 向头节点发送顺序写请求。
  3. 验证每个节点是否以相同顺序应用更新。
  4. 使中间节点崩溃,重新连接链(头节点 → 尾节点)。
  5. 确认一致性得以保持。
  6. 重启故障节点并重新同步它。

测试用例

场景行为结果
顺序写入有序传播强一致性
提交后尾节点读取最新值可见线性一致性
中间节点崩溃链重新配置无不一致性
并发写入头节点 FIFO 处理确定性顺序

复杂度

  • 写延迟:与链长度成正比($O(N)$ 跳)
  • 读延迟:$O(1)$(仅尾节点)
  • 消息复杂度:每次写操作 $2N$ 条消息(转发 + 确认)
  • 容错能力:通过重新配置最多可容忍 ⌊(N−1)/2⌋ 个故障
  • 存储:每个节点存储完整状态副本

链式复制是一致性的流水线, 每个节点依次增加可靠性, 消息沿流水线流动,最终产品 是一个完美有序、容错的真理日志。

第 87 节 负载均衡与速率限制

861 轮询负载均衡

轮询负载均衡是在多个服务器之间分配请求最简单、最广泛使用的方法之一。它按顺序循环遍历服务器列表,将每个传入的请求发送给列表中的下一个服务器。一旦到达列表末尾,它会重新从列表开头开始。

该算法是确定性的、公平的且无状态的,非常适合在服务器容量相似时均匀地分散负载。

我们要解决什么问题?

在分布式系统和Web架构中,单个服务器无法处理所有传入流量。 我们需要一个负载均衡器,将请求分配到多个服务器,以便:

  • 没有单个服务器过载
  • 请求被高效处理
  • 系统保持可扩展性和响应性

轮询提供了一种简单的方法来实现公平的负载分配,而无需跟踪服务器状态或性能。

它是如何工作的(通俗解释)

假设你有三个服务器:S1, S2, S3。 请求以 R1, R2, R3, R4, R5, R6 的顺序到达。

轮询负载均衡器按如下方式路由它们:

请求服务器
R1S1
R2S2
R3S3
R4S1
R5S2
R6S3

每个服务器依次接收请求,产生平滑的工作轮换。

如果某个服务器发生故障,它将被从轮换中移除,直到恢复。

微型代码(Python 示例)

python
servers = ["S1", "S2", "S3"]
index = 0

def get_server():
    global index
    server = servers[index]
    index = (index + 1) % len(servers)
    return server

# 模拟传入请求
for r in range(1, 7):
    print(f"Request {r}{get_server()}")

输出:

Request 1 → S1
Request 2 → S2
Request 3 → S3
Request 4 → S1
Request 5 → S2
Request 6 → S3

微型代码(C 语言版本)

c
#include <stdio.h>

int main() {
    const char *servers[] = {"S1", "S2", "S3"};
    int n = 3, index = 0;

    for (int r = 1; r <= 6; r++) {
        printf("Request %d%s\n", r, servers[index]);
        index = (index + 1) % n;
    }
    return 0;
}

为什么它重要

  • 简单性:无需跟踪指标或状态。
  • 公平性:每个服务器获得大致相同数量的请求。
  • 可扩展性:易于扩展,只需将服务器添加到列表中。
  • 无状态性:负载均衡器不需要会话内存。

常见用例:

  • DNS 轮询
  • HTTP 负载均衡器(NGINX, HAProxy)
  • 任务队列和作业调度器

权衡:

  • 不考虑服务器负载差异。
  • 如果服务器容量不同,可能会使速度慢或繁忙的服务器过载。
  • 在所有服务器同构时效果最佳。

一个温和的证明(为什么它有效)

设有 $n$ 个服务器和 $m$ 个请求。 每个请求 $r_i$ 根据以下公式分配给服务器:

$$ \text{server}(r_i) = S_{(i \bmod n)} $$

每个服务器的总请求数为:

$$ \text{load}(S_j) = \left\lfloor \frac{m}{n} \right\rfloor \text{ 或 } \left\lceil \frac{m}{n} \right\rceil $$

因此,任意两个服务器之间的负载差异最多为一个请求,确保了同构服务器近乎完美的平衡。

亲自尝试

  1. 实现上面的 Python 或 C 语言版本。
  2. 添加或移除服务器,观察分配模式。
  3. 在一个服务器上引入随机延迟,注意轮询不会自适应,这就是加权轮询或最少连接算法出现的地方。
  4. 在时间线上可视化请求,注意完美的轮换。

测试用例

场景输入行为输出
3 个服务器,6 个请求S1, S2, S3完美轮换S1,S2,S3,S1,S2,S3
添加第 4 个服务器S1..S4每 4 个请求重复一次S1,S2,S3,S4,S1,S2
移除服务器 S2S1,S3在 2 个之间交替S1,S3,S1,S3
处理时间不相等S1 慢仍然平均分配S1 过载

复杂度

  • 时间:每个请求 $O(1)$(简单的模运算)
  • 空间:服务器列表 $O(n)$
  • 公平性误差:≤ 1 个请求差异
  • 容错性:取决于健康检查逻辑

轮询是负载均衡的精密时钟,简单、有节奏且可预测。 它可能不够聪明,但很可靠,而在分布式系统中,可靠性往往是最优雅的解决方案。

862 加权轮询

加权轮询(WRR)是简单轮询算法的扩展,它根据服务器的容量或性能为其分配不同的权重。 它并非平等对待所有服务器,而是按比例分发请求,使得更快或能力更强的服务器承担更多负载。

该算法广泛应用于 Web 服务器、负载均衡器和内容分发系统,以高效处理异构集群。

我们要解决什么问题?

经典的轮询算法假设每台服务器的容量相同。 但现实中:

  • 有些服务器拥有更多的 CPU 核心或内存。
  • 有些服务器距离客户端更近(延迟更低)。
  • 有些可能是处理读密集型工作负载的副本。

加权轮询确保每台服务器接收与其权重成比例的负载,从而保持利用率平衡和吞吐量最优。

它是如何工作的(通俗解释)

每台服务器都有一个权重 $w_i$,代表每个周期它应该处理的请求数量。 算法循环遍历服务器列表,但会根据每台服务器的权重重复选择它。

示例:

服务器权重分配请求数(每周期)
S111
S222
S333

对于 6 个请求: 序列 → S1, S2, S2, S3, S3, S3

这确保了负载能力更强的服务器按比例接收更多流量。

微型代码(Python 示例)

python
servers = [("S1", 1), ("S2", 2), ("S3", 3)]

def weighted_round_robin(requests):
    schedule = []
    for _ in range(requests):
        for s, w in servers:
            schedule.extend([s] * w)
    return schedule[:requests]

# 模拟 6 个请求
print(weighted_round_robin(6))

输出:

['S1', 'S2', 'S2', 'S3', 'S3', 'S3']

微型代码(C 语言版本)

c
#include <stdio.h>

typedef struct {
    const char *name;
    int weight;
} Server;

int main() {
    Server servers[] = {{"S1", 1}, {"S2", 2}, {"S3", 3}};
    int total = 6;

    int count = 0;
    while (count < total) {
        for (int i = 0; i < 3 && count < total; i++) {
            for (int w = 0; w < servers[i].weight && count < total; w++) {
                printf("Request %d%s\n", count + 1, servers[i].name);
                count++;
            }
        }
    }
    return 0;
}

为什么它很重要

加权轮询在以下情况下是理想选择:

  • 服务器容量不同。
  • 某些节点应优先处理更多请求。
  • 环境基本保持稳定(没有快速的负载变化)。

优点:

  • 简单且确定。
  • 适应异构集群。
  • 易于实现和理解。

权衡:

  • 仍然是静态的,无法响应实时负载或队列长度。
  • 需要根据性能指标定期调整权重。

一个温和的证明(为什么它有效)

假设服务器 $S_1, S_2, \dots, S_n$ 的权重为 $w_1, w_2, \dots, w_n$。 定义总权重: $$ W = \sum_{i=1}^n w_i $$

每台服务器 $S_i$ 应该处理: $$ f_i = \frac{w_i}{W} \times m $$ 其中 $m$ 是请求总数。

在一个完整的周期内,算法确保: $$ |\text{负载}(S_i) - f_i| \le 1 $$

因此,实际分布与理想分布之间的负载偏差最多为 1 个请求,保持了与容量成比例的公平性。

亲自尝试

  1. 为三台服务器分配权重:1, 2, 3。
  2. 生成 12 个请求并观察分布模式。
  3. 将 S3 的权重增加到 5,注意现在大多数请求如何流向它。
  4. 实现一个动态版本,根据延迟或 CPU 负载调整权重。

测试用例

场景权重请求数分布结果
权重相等[1,1,1]62,2,2与轮询相同
权重不等[1,2,3]61,2,3按比例分配
添加服务器[1,2,3,1]71,2,3,1公平轮转
移除慢速服务器[0,2,3]50,2,3负载转移到其他服务器

复杂度

  • 时间:每个请求 $O(1)$(可以预计算序列)
  • 空间:存储服务器和权重需要 $O(n)$
  • 公平性误差:≤ 1 个请求的差异
  • 可扩展性:对于数百台服务器表现优异

加权轮询就像一位乐队指挥将乐句分配给不同的乐器, 每件乐器根据其强度演奏, 创造出和谐而非过载。

863 最少连接

最少连接是一种动态负载均衡算法,它总是将新请求发送给活跃连接数最少的服务器。 与轮询或加权轮询不同,它响应的是实时负载,而不是假设所有服务器都同样繁忙。

这使得它成为处理请求时间可变系统最高效的算法之一,例如,当某些请求的持续时间远长于其他请求时。

我们要解决什么问题?

实际上,并非所有请求都是平等的:

  • 有些请求在几毫秒内完成,有些则需要几秒或几分钟。
  • 有些服务器可能暂时过载。
  • 轮询可能会持续向繁忙的服务器发送请求。

最少连接通过每次动态选择最不繁忙的节点来解决这个问题,它平衡的是当前负载,而不是基于静态假设。

它是如何工作的(通俗解释)

在任何给定时刻,负载均衡器都会跟踪每个服务器的活跃连接数。

当新请求到达时:

  1. 均衡器检查所有服务器的活跃连接数。
  2. 它选择正在进行连接数最少的那个。
  3. 当一个请求完成时,该服务器的计数减少。

示例:

服务器活跃连接数
S15
S22
S33

→ 新请求发送给 S2,因为它的活跃连接数最少。

微型代码(Python 示例)

python
servers = {"S1": 5, "S2": 2, "S3": 3}

def least_connections(servers):
    return min(servers, key=servers.get)

# 选择下一个服务器
next_server = least_connections(servers)
print("下一个请求 →", next_server)

输出:

下一个请求 → S2

当 S2 完成一个请求时,其计数减少,负载不断重新平衡。

微型代码(C 语言版本)

c
#include <stdio.h>

int main() {
    int connections[] = {5, 2, 3};
    const char *servers[] = {"S1", "S2", "S3"};
    int n = 3;

    int min_idx = 0;
    for (int i = 1; i < n; i++)
        if (connections[i] < connections[min_idx])
            min_idx = i;

    printf("下一个请求 → %s\n", servers[min_idx]);
    return 0;
}

为何重要

  • 动态适应负载,非常适合不均匀的工作负载。
  • 减少尾部延迟,防止过载较慢的节点。
  • 通过保持所有服务器同样繁忙来提高吞吐量。
  • 常用于 NGINX、HAProxy、Envoy 等负载均衡器。

权衡:

  • 开销稍高,需要连接跟踪。
  • 如果所有服务器的负载频繁变化,可能会产生振荡。
  • 在分布式设置中需要线程安全的计数器。

一个温和的证明(为何有效)

设有 $n$ 个服务器,其活跃连接数分别为 $c_1, c_2, \dots, c_n$。 当新连接到达时,它被分配给:

$$ S_k = \arg\min_{i}(c_i) $$

分配后:

$$ c_k \leftarrow c_k + 1 $$

随着时间的推移,假设连接持续时间遵循相似的分布,任意两台服务器负载之间的差异满足:

$$ |c_i - c_j| \le 1 $$

这确保了连接数的方差保持最小,从而实现均匀的利用率。

亲自尝试

  1. 初始服务器:S1=5, S2=3, S3=3。
  2. 使用"最少连接"规则分配 10 个随机请求。
  3. 模拟完成(减少计数)。
  4. 与轮询比较,注意更平滑的平衡。
  5. 添加权重支持(加权最少连接)并观察改进。

测试用例

场景初始连接数下一个目标结果
负载相等[2,2,2]任意平局决胜
负载不相等[5,2,3]S2选择最不繁忙的
动态变化[3,4,5]S1总是选择最低的
请求完成S2 完成计数减少平衡负载

复杂度

  • 时间:每个请求 $O(n)$(扫描服务器)
  • 空间:$O(n)$(存储活跃计数)
  • 适应性:动态,响应实时负载
  • 公平性:在变化的工作负载下表现优异

优化实现使用堆或优先队列进行 $O(\log n)$ 的选择。

最少连接是负载均衡的智能直觉, 它不只是数服务器,它倾听它们。 通过观察谁最忙、谁空闲, 它悄无声息地让系统保持完美的节奏。

864 一致性哈希

一致性哈希是一种巧妙的技术,用于将数据或请求分发到多个服务器上,同时在节点增加或移除时最小化数据移动。它是可扩展系统(如 CDN、分布式缓存(Memcached、Redis Cluster))以及像 Cassandra 或 DynamoDB 这类系统中的分布式哈希表(DHT)的基石。

一致性哈希确保当服务器加入或离开时,只有一小部分键需要移动,从而保持系统稳定高效,而不是重新平衡所有数据。

我们要解决什么问题?

传统的基于取模的哈希看似简单,但在扩展时会失效:

$$ \text{服务器} = \text{哈希}(键) \bmod N $$

当服务器数量 $N$ 发生变化时,几乎所有的键都会被重新映射,这对于缓存系统或大型数据库来说是灾难性的。

一致性哈希通过使哈希空间独立于服务器数量,并将键和服务器映射到同一个空间来解决这个问题。

它是如何工作的(通俗解释)

想象一个从 0 到 $2^{32}-1$ 排列成圆环的哈希空间。

  • 每个服务器通过哈希其名称或 ID 被分配环上的一个位置。
  • 每个键被哈希到同一个环上的一个点。
  • 键被存储在从其位置顺时针方向遇到的第一个服务器上。

如果一台服务器离开或加入,只有位于其前驱节点和它自身之间的键会移动,其余的键保持不变。

示例:

服务器哈希位置
S10.1
S20.4
S30.8

哈希到 0.35 的键分配给 S2。 哈希到 0.75 的键分配给 S3。

当 S2 离开时,只有从 0.1 到 0.4 的键转移到 S3,其他键保持不变。

微型代码(Python 示例)

python
import hashlib

def hash_key(key):
    return int(hashlib.md5(key.encode()).hexdigest(), 16) % 360

servers = [30, 150, 270]  # 环上的位置
def get_server(key):
    h = hash_key(key)
    for s in sorted(servers):
        if h < s:
            return s
    return servers[0]  # 回绕

print("键 'apple' → 服务器", get_server("apple"))
print("键 'banana' → 服务器", get_server("banana"))

这段代码将键映射到哈希值的环形空间上。

微型代码(C 语言版本,简化版)

c
#include <stdio.h>
#include <string.h>

int hash(const char *key) {
    int h = 0;
    while (*key) h = (h * 31 + *key++) % 360;
    return h;
}

int main() {
    int servers[] = {30, 150, 270};
    int n = 3;
    const char *keys[] = {"apple", "banana", "peach"};
    for (int i = 0; i < 3; i++) {
        int h = hash(keys[i]);
        int chosen = servers[0];
        for (int j = 0; j < n; j++)
            if (h < servers[j]) { chosen = servers[j]; break; }
        printf("键 %s → 服务器 %d\n", keys[i], chosen);
    }
    return 0;
}

为什么它很重要

一致性哈希对于大型分布式系统至关重要,因为:

  • 在扩展时,它将数据移动减少到 $O(1/N)$。
  • 它天然支持水平可扩展性。
  • 当与复制结合时,它消除了单点故障。
  • 它为负载均衡的请求路由、分片数据库和分布式缓存提供支持。

权衡:

  • 如果服务器分布不均匀,会导致负载不均衡,可通过"虚拟节点"解决。
  • 维护环和哈希查找有轻微开销。

一个温和的证明(为什么它有效)

设 $K$ 为键的总数,$N$ 为服务器数量。 每个键被分配给环上顺时针方向的下一个服务器。 当一个新的服务器加入时,它只负责一部分键:

$$ \frac{K}{N+1} $$

而不是全部。

预期的重新映射成本是:

$$ O\left(\frac{1}{N}\right) $$

因此,随着 $N$ 的增长,重新平衡变得可以忽略不计。

当服务器均匀分布(或通过虚拟节点)时,每个服务器的负载大致相等:

$$ \text{预期负载} \approx \frac{K}{N} $$

亲自尝试

  1. 在一个 360 度的环上创建 3 个服务器:S1, S2, S3。
  2. 哈希 10 个键并分配它们。
  3. 在位置 90 添加 S4,重新计算。
  4. 计算有多少键移动了(只有那些在 30 到 90 之间的键)。
  5. 添加"虚拟节点",对每个服务器进行多次哈希以平滑负载。

测试用例

场景服务器移动的键数结果
添加新服务器3 → 4~25%最小化重新平衡
移除服务器4 → 3~25%受控的移动
哈希环倾斜不均匀分布不平衡用虚拟节点修复
均匀环等距分布平衡理想分布

复杂度

  • 时间:每次查找 $O(\log N)$(在环上进行二分查找)
  • 空间:环结构 $O(N)$
  • 数据移动:每次服务器加入/离开 $O(1/N)$
  • 可扩展性:优秀,用于网络级系统

一致性哈希是可扩展性的几何学, 一个简单的圆环将混乱转化为平衡。 每个节点找到它的位置,每个键找到它的归宿, 系统持续平稳地、无限地运转。

865 二选一负载均衡

二选一负载均衡是一种概率性负载均衡算法,能以极小的开销显著改善负载分布。 与最少连接算法检查所有服务器不同,它仅随机采样两个服务器,并将请求发送给负载较轻的那个。

这个微小的调整——从一次随机选择变为两次——使负载不均衡呈指数级减少,使其成为分布式系统中最优雅的成果之一。

我们要解决什么问题?

纯随机负载均衡(如均匀随机选择)可能导致负载不均:

  • 有些服务器可能因偶然机会而过载。
  • 随着服务器数量增加,不均衡情况会加剧。

但是检查所有服务器(如最少连接算法)代价高昂。 二选一负载均衡算法通过仅检查两个服务器,就能达到几乎与检查每个服务器相同的均衡效果,这是一个绝妙的折中方案。

它是如何工作的(通俗解释)

当一个新的请求到达时:

  1. 随机选择两个服务器(或者更一般地,选择 d 个服务器)。
  2. 比较它们当前的负载(活动连接数、队列长度等)。
  3. 将请求分配给连接数较少的那个服务器。

就是这样,该算法几乎无需协调即可实现自我平衡。

示例:

服务器活动连接数
S110
S212
S315
S48

如果我们随机选择 S2 和 S4,S4 胜出,因为它拥有更少的活动连接数。

微型代码(Python 示例)

python
import random

servers = {"S1": 10, "S2": 12, "S3": 15, "S4": 8}

def power_of_two(servers):
    choices = random.sample(list(servers.keys()), 2)
    best = min(choices, key=lambda s: servers[s])
    return best

print("下一个请求 →", power_of_two(servers))

每次决策只比较两个服务器,但随着时间的推移,负载会非常漂亮地趋于均衡。

微型代码(C 语言版本)

c
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main() {
    srand(time(NULL));
    int connections[] = {10, 12, 15, 8};
    const char *servers[] = {"S1", "S2", "S3", "S4"};
    int a = rand() % 4, b = rand() % 4;

    int chosen = (connections[a] <= connections[b]) ? a : b;
    printf("下一个请求 → %s\n", servers[chosen]);
    return 0;
}

为什么它很重要

  • 以极少的计算量实现近乎最优的负载均衡。
  • 可以优雅地扩展到数千台服务器。
  • 应用于 Google Maglev、AWS ELB、Kubernetes 和分布式哈希表等系统。

权衡考虑:

  • 轻微的随机性可能导致短期波动。
  • 需要了解基本的每服务器指标(如连接数)。

一个温和的证明(为什么它有效)

假设有 $n$ 台服务器和 $m$ 个请求。 每个请求随机选择两台服务器,并选择负载较小的那台。

Mitzenmacher 及其同事的研究表明:

  • 对于随机分配(1次选择),最大负载 ≈ $\frac{\log n}{\log \log n}$。
  • 对于两次选择,最大负载 ≈ $\log \log n$,这是一个指数级的改进。

形式上,负载最高和最低的服务器之间的差距急剧缩小: $$ E[\text{最大负载}] = \frac{\log \log n}{\log d} + O(1) $$ 其中 $d$ 是随机选择的次数(通常为 2)。

这是随机算法中最引人注目的成果之一。

亲自尝试

  1. 模拟 10 台服务器上的 100 个请求。
  2. 比较随机负载均衡与二选一负载均衡。
  3. 绘制分配后每台服务器的连接数。
  4. 观察"二选一"算法产生的分布要紧密得多。

可选:尝试 d = 3d = 4,虽然收益递减,但平衡性更好。

测试用例

场景服务器数量策略结果
10 台服务器,100 个请求随机高度不均衡负载不均
10 台服务器,100 个请求二选一均衡偏差小
1000 台服务器,10k 个请求随机$\max - \min \approx 20$不均衡
1000 台服务器,10k 个请求二选一$\max - \min \approx 3$近乎最优

复杂度

  • 时间:$O(1)$(仅需两次随机查找)
  • 空间:$O(n)$(跟踪连接计数)
  • 均衡质量:相比随机算法有指数级改进
  • 可扩展性:在分布式环境中表现优异

二选一负载均衡是数学与优雅相遇的产物, 仅仅多看一眼,混沌便化为秩序。 它证明,有时,你只需要两个选项就能实现平衡。

866 随机负载均衡

随机负载均衡将每个传入的请求均匀地随机分配给一台服务器。 这是最简单的算法,无需跟踪、权重或指标,但对于大型同构系统却出奇地有效。

可以把它看作是分配工作的“抛硬币”方法:易于实现,长期来看统计上公平,并且速度快到足以处理每秒数百万的请求。

我们要解决什么问题?

当一个集群有许多服务器且请求快速到达时,负载均衡器必须决定将每个请求发送到哪里。

随机负载均衡以最少的计算量解决这个问题:

  • 无连接状态
  • 无服务器监控
  • O(1) 的决策时间

它非常适合所有服务器相同且请求时间短且均匀的系统,例如无状态 Web 服务器或内容分发节点。

它是如何工作的(通俗解释)

当一个请求到达时:

  1. 在 $0$ 到 $N - 1$ 之间随机选择一个服务器索引。
  2. 将请求发送到该服务器。
  3. 对每个新请求重复此过程。

以 3 台服务器(S1, S2, S3)为例:

请求随机服务器
R1S2
R2S3
R3S1
R4S3
R5S2

随着时间的推移,每台服务器接收到的请求数大致相同。

微型代码(Python 示例)

python
import random

servers = ["S1", "S2", "S3"]

def random_load_balance():
    return random.choice(servers)

for i in range(5):
    print(f"Request {i+1}{random_load_balance()}")

输出(示例):

Request 1 → S2
Request 2 → S3
Request 3 → S1
Request 4 → S3
Request 5 → S2

微型代码(C 语言版本)

c
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main() {
    srand(time(NULL));
    const char *servers[] = {"S1", "S2", "S3"};
    int n = 3;

    for (int r = 1; r <= 5; r++) {
        int idx = rand() % n;
        printf("Request %d%s\n", r, servers[idx]);
    }
    return 0;
}

为何重要

  • 快速且简单:非常适合大规模、无状态系统。
  • 无状态跟踪:无需共享内存或协调即可工作。
  • 统计上公平:随着时间的推移,每台服务器获得大致相等的负载。

应用场景:

  • DNS 级负载均衡(轮询或随机 DNS 回复)。
  • CDN 和缓存系统(例如,选择边缘节点)。
  • 服务器配置统一的云路由服务。

权衡:

  • 短期内可能不均衡。
  • 无法适应实时服务器负载。
  • 当请求持续时间或成本可变时,性能不佳。

一个温和的证明(为何有效)

假设有 $N$ 台服务器和 $M$ 个请求。 每个请求被独立且均匀地分配:

$$ P(\text{请求分配给 } S_i) = \frac{1}{N} $$

每台服务器的期望请求数:

$$ E[L_i] = \frac{M}{N} $$

根据大数定律,当 $M \to \infty$ 时:

$$ L_i \to \frac{M}{N} $$

服务器间负载的方差缩小为 $\sigma^2 = \frac{M}{N}(1 - \frac{1}{N})$,这意味着在大型系统中,不均衡变得可以忽略不计。

亲自尝试

  1. 模拟 10,000 个请求分配到 10 台服务器。
  2. 统计每台服务器获得的请求数。
  3. 计算方差,注意它有多小。
  4. 添加随机请求持续时间,看看不均衡何时开始产生影响。

测试用例

场景服务器请求数结果
3 台服务器6大致各 2 个均衡
5 台服务器1000±20 偏差可接受
10 台服务器10000±1% 偏差平滑
混合延迟不均匀不均衡在偏斜下表现差

复杂度

  • 时间:每个请求 $O(1)$(一次随机抽取)
  • 空间:$O(1)$
  • 公平性:长期统计均衡
  • 适应性:无,纯随机

随机负载均衡是分布式系统中的抛硬币, 简单、公平,当混沌成为你的盟友时,它强大得令人惊讶。 它提醒我们,有时,随机性是最纯粹的有序形式。

867 令牌桶

令牌桶是一种限流算法,它允许流量突发达到定义的容量,同时长期维持稳定的平均速率。 它广泛应用于网络、API 和操作系统中,用于控制请求速率、带宽以及客户端之间的公平性。

我们要解决什么问题?

没有速率控制,客户端可能会:

  • 向服务器发送过多请求,造成洪泛。
  • 消耗不公平的共享带宽量。
  • 导致延迟激增或拒绝服务。

我们需要一种机制,允许短时突发(以保证响应性),但强制执行长期请求速率限制。

这正是令牌桶算法所提供的。

它是如何工作的(通俗解释)

想象一个桶,它以固定速率(例如每秒 $r$ 个令牌)填充令牌。 每个请求消耗一个令牌。 如果桶是空的,请求必须等待(或被拒绝)。

关键参数:

  • 速率 $r$:令牌添加的速度。
  • 容量 $C$:桶能容纳的最大令牌数(突发大小)。

在任何时刻:

  • 如果有可用令牌 → 允许请求并移除一个令牌。
  • 如果没有令牌 → 限制或丢弃请求。

示例:

时间(秒)可用令牌操作
05请求被服务
14请求被服务
23请求被服务
30请求被阻止
41请求再次被允许

微型代码(Python 示例)

python
import time

class TokenBucket:
    def __init__(self, rate, capacity):
        self.rate = rate              # 每秒令牌数
        self.capacity = capacity
        self.tokens = capacity
        self.last_time = time.time()

    def allow(self):
        now = time.time()
        elapsed = now - self.last_time
        self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
        self.last_time = now
        if self.tokens >= 1:
            self.tokens -= 1
            return True
        return False

bucket = TokenBucket(rate=1, capacity=5)
for i in range(10):
    print(f"请求 {i+1}: {'允许' if bucket.allow() else '阻止'}")
    time.sleep(0.5)

微型代码(C 语言版本)

c
#include <stdio.h>
#include <time.h>

typedef struct {
    double rate, capacity, tokens;
    double last_time;
} TokenBucket;

double now() {
    return (double)clock() / CLOCKS_PER_SEC;
}

int allow(TokenBucket *b) {
    double t = now();
    double elapsed = t - b->last_time;
    b->tokens += elapsed * b->rate;
    if (b->tokens > b->capacity) b->tokens = b->capacity;
    b->last_time = t;
    if (b->tokens >= 1) {
        b->tokens -= 1;
        return 1;
    }
    return 0;
}

int main() {
    TokenBucket b = {1.0, 5.0, 5.0, now()};
    for (int i = 0; i < 10; i++) {
        printf("请求 %d: %s\n", i+1, allow(&b) ? "允许" : "阻止");
        struct timespec ts = {0, 500000000};
        nanosleep(&ts, NULL);
    }
}

为什么它很重要

  • 平滑突发流量:允许短期峰值,同时保持长期控制。

  • 用于:

    • 网络流量整形(路由器、Linux tc)。
    • API 速率限制(NGINX、Cloudflare、AWS)。
    • 分布式系统(公平请求调度)。

权衡:

  • 需要时间跟踪和浮点精度。
  • 在分布式节点间使用时需要同步。
  • 对于严格的控制,可与漏桶滑动窗口计数器结合使用。

一个温和的证明(为什么它有效)

令:

  • $r$ = 填充速率(每秒令牌数)
  • $C$ = 容量(最大令牌数)
  • $\Delta t$ = 自上次检查以来经过的时间

令牌计数更新规则: $$ T(t) = \min(C, T(t - \Delta t) + r \cdot \Delta t) $$

当且仅当 $T(t) \ge 1$ 时,允许请求。

因此,在任何长时间段 $\tau$ 内: $$ \text{允许的请求数} \le r \tau + C $$

这保证了平均速率永远不会超过 $r$, 同时最多可以有 $C$ 个请求立即突发,提供了控制和灵活性。

自己动手试试

  1. 设置 rate=2, capacity=5
  2. 以 0.2 秒的间隔发送 10 个请求。
  3. 观察早期的请求如何被允许(令牌可用),然后限制开始。
  4. 降低请求速率,观察令牌如何再次累积。
  5. 调整 capacity 以试验突发容忍度。

测试用例

速率容量请求间隔行为
1/s50.5s允许突发,之后稳定
2/s20.1s频繁限制
5/s100.1s平稳流动
1/s11s严格的速率限制

复杂度

  • 时间:每个请求 $O(1)$(恒定时间更新)
  • 空间:$O(1)$(跟踪速率、容量、时间戳)
  • 公平性:对于突发工作负载较高
  • 适应性:对于可变请求速率非常出色

令牌桶是速率限制的心跳, 它不会阻塞脉搏,而是调节节奏, 允许生命的爆发,同时保持系统平静而稳定。

868 漏桶算法

漏桶算法是一种经典的限流和流量整形算法,它强制执行固定的输出速率,平滑突发的传入请求或数据包。 它广泛应用于网络路由器、API 网关和分布式系统中,以在波动负载下保持可预测的性能。

我们要解决什么问题?

现实世界的流量很少是平稳的,它通常是突发性的。 如果不加以控制,突发流量可能导致:

  • 队列溢出
  • 延迟激增
  • 数据包丢弃或请求失败

我们需要一种方法将突发流量转变为平稳流, 就像水从一个流入量不可预测的桶中以恒定速率漏出一样。

这就是漏桶算法背后的思想。

它是如何工作的(通俗解释)

想象一个底部有一个小孔的桶:

  • 传入的请求(或数据包)被倒入桶中。
  • 桶以固定速率 $r$ 漏水,代表处理或发送能力。
  • 如果桶溢出(传入量超过其容量),多余的请求将被丢弃。

参数:

  • 漏水速率 $r$:令牌(或数据包)离开桶的速度。
  • 容量 $C$:桶能容纳的最大待处理请求数。

规则:

  • 对于每个传入请求:

    • 如果桶未满 → 将请求加入队列。
    • 如果已满 → 丢弃或延迟该请求。
  • 调度程序以速率 $r$ 持续漏水(处理)请求。

示例

时间传入桶大小操作
0秒33已添加
1秒23漏出1个,添加2个
2秒53溢出 → 丢弃3个
3秒02稳定漏出

即使输入波动,桶的输出流也保持恒定的速率 $r$。

微型代码(Python 示例)

python
import time
from collections import deque

class LeakyBucket:
    def __init__(self, rate, capacity):
        self.rate = rate              # 每秒漏出数量
        self.capacity = capacity
        self.queue = deque()
        self.last_check = time.time()

    def allow(self, request):
        now = time.time()
        elapsed = now - self.last_check
        leaked = int(elapsed * self.rate)
        for _ in range(leaked):
            if self.queue:
                self.queue.popleft()
        self.last_check = now

        if len(self.queue) < self.capacity:
            self.queue.append(request)
            return True
        return False

bucket = LeakyBucket(rate=1, capacity=3)
for i in range(10):
    time.sleep(0.5)
    print(f"请求 {i+1}: {'已接受' if bucket.allow(i) else '已丢弃'}")

微型代码(C 语言版本)

c
#include <stdio.h>
#include <time.h>
#include <unistd.h>

typedef struct {
    double rate;
    int capacity;
    int size;
    double last_time;
} LeakyBucket;

double now() {
    return (double)clock() / CLOCKS_PER_SEC;
}

int allow(LeakyBucket *b) {
    double t = now();
    double elapsed = t - b->last_time;
    int leaked = (int)(elapsed * b->rate);
    if (leaked > 0) {
        b->size -= leaked;
        if (b->size < 0) b->size = 0;
        b->last_time = t;
    }
    if (b->size < b->capacity) {
        b->size++;
        return 1;
    }
    return 0;
}

int main() {
    LeakyBucket b = {1.0, 3, 0, now()};
    for (int i = 0; i < 10; i++) {
        printf("请求 %d: %s\n", i+1, allow(&b) ? "已接受" : "已丢弃");
        usleep(500000); // 0.5 秒
    }
}

为什么它很重要

  • 平滑流量:将突发输入转换为均匀输出。
  • 控制简单:只需速率和容量参数。
  • 应用广泛:网络路由器、API 网关和操作系统调度程序。

权衡:

  • 严格的固定速率,不像令牌桶那样允许短期突发。
  • 如果传入速率略低于 $r$,可能导致系统利用率不足。
  • 对于弹性工作负载灵活性较差。

应用场景:

  • 流量整形:路由器和交换机。
  • QoS 实施:电信网络。
  • 速率限制:需要严格吞吐量上限的 API。

一个温和的证明(为什么它有效)

令 $I(t)$ 为每秒传入的请求数, $L(t)$ 为漏水速率, $B(t)$ 为桶的填充水平。

桶随时间的变化为:

$$ \frac{dB(t)}{dt} = I(t) - L(t) $$

受限于边界: $$ 0 \le B(t) \le C $$

且漏水速率恒定: $$ L(t) = r $$

因此,只要桶不空,输出速率始终恒定,与 $I(t)$ 的突发性无关。 如果 $B(t)$ 超过容量 $C$,溢出的请求将被丢弃,从而强制执行严格的速率限制。

亲自尝试

  1. 设置 rate=1, capacity=3
  2. 每秒发送 10 个请求。观察桶如何溢出。
  3. 将输入速率降低到每秒 1 个,观察平稳流。
  4. 与令牌桶算法比较,看看一个允许突发而另一个平滑突发。

测试用例

速率容量请求速率行为
1/秒30.5/秒无溢出
1/秒32/秒频繁丢弃
2/秒52/秒平稳流
1/秒23/秒突发流量被平滑

复杂度

  • 时间:每个请求 $O(1)$(恒定时间更新)
  • 空间:$O(1)$(跟踪桶大小、时间戳)
  • 稳定性:高,固定的漏水速率确保了可预测性
  • 公平性:确定性的

漏桶算法是流量控制的节拍器, 稳定、坚定且守纪律。 它不追逐突发;它保持节奏, 确保系统与其真实容量同步运行。

869 滑动窗口计数器

滑动窗口计数器是一种限流算法,它维护一个在滚动时间窗口内最近事件的动态计数,而不是在固定间隔重置。 它是固定窗口方法的一个更精确版本,常用于 API、认证系统和分布式网关中,以强制执行公平使用策略,同时避免在窗口边界附近出现突发性不公平。

我们解决什么问题?

固定窗口计数器在精确的间隔(例如每 60 秒)重置。 这可能导致不公平:

  • 客户端可以在一个窗口的末尾发送 100 个请求,并在下一个窗口的开始再发送 100 个 → 在几秒钟内发送了 200 个请求。

我们需要一个更平滑、有时间感知的限制器,它能统计最近 N 秒内的请求,而不仅仅是自上次时钟滴答以来的请求。

这就是滑动窗口计数器。

它是如何工作的(通俗解释)

该算法不是定期重置,而是:

  1. 跟踪每个传入请求的时间戳。
  2. 对于每个新请求,它会移除早于窗口大小(例如 60 秒)的时间戳。
  3. 如果窗口内剩余的时间戳数量低于限制,则新请求被接受;否则被拒绝。

因此,窗口随着时间“连续滑动”。

示例:60 秒窗口,限制 = 100 个请求

时间(秒)请求计数操作
0–5880允许
59+10仍然允许
61旧请求过期,计数降至 50窗口滑动
62+10 个更多再次允许

微型代码(Python 示例)

python
import time
from collections import deque

class SlidingWindowCounter:
    def __init__(self, window_size, limit):
        self.window_size = window_size
        self.limit = limit
        self.requests = deque()

    def allow(self):
        now = time.time()
        # 移除窗口外的时间戳
        while self.requests and now - self.requests[0] > self.window_size:
            self.requests.popleft()
        if len(self.requests) < self.limit:
            self.requests.append(now)
            return True
        return False

window = SlidingWindowCounter(window_size=60, limit=5)
for i in range(10):
    print(f"请求 {i+1}: {'允许' if window.allow() else '阻止'}")
    time.sleep(10)

微型代码(C 语言版本)

c
#include <stdio.h>
#include <time.h>
#include <unistd.h>

#define LIMIT 5
#define WINDOW 60

double requests[LIMIT];
int count = 0;

int allow() {
    double now = time(NULL);
    int i, new_count = 0;
    for (i = 0; i < count; i++) {
        if (now - requests[i] <= WINDOW)
            requests[new_count++] = requests[i];
    }
    count = new_count;
    if (count < LIMIT) {
        requests[count++] = now;
        return 1;
    }
    return 0;
}

int main() {
    for (int i = 0; i < 10; i++) {
        printf("请求 %d: %s\n", i + 1, allow() ? "允许" : "阻止");
        sleep(10);
    }
}

为什么它很重要

  • 平滑的速率限制:避免在窗口边界处出现重置突发。
  • 精确控制:始终连续地强制执行“每秒/分钟 N 个请求”。
  • 用于:
    • API 网关(AWS、Cloudflare、Google)。
    • 认证系统(登录尝试节流)。
    • 强制执行公平性或配额的分布式系统。

权衡:

  • 比固定计数器稍微复杂一些。
  • 需要存储最近的请求时间戳。
  • 内存使用量随请求速率增长(受限制值限制)。

一个温和的证明(为什么它有效)

令 $t_i$ 为第 $i$ 个请求的时间戳。 在时间 $t$,定义活动请求为:

$$ R(t) = { t_i \mid t - t_i \le W } $$

当满足以下条件时,请求被允许:

$$ |R(t)| < L $$

其中 $W$ 是窗口大小,$L$ 是限制。

由于 $R(t)$ 随时间连续更新,该约束适用于每个长度为 W 的可能区间,而不仅仅是固定区间,从而确保了真正的滑动窗口公平性。

这防止了固定窗口中可能出现的突发峰值,同时保持了相同的长期速率。

自己动手试试

  1. 设置 window_size = 60limit = 5
  2. 以 10 秒间隔发送请求,所有请求都应被允许。
  3. 快速连续发送 6 个请求,第 6 个应被阻止。
  4. 等待 60 秒,旧时间戳过期,新的请求再次被接受。
  5. 可视化随时间变化的活动窗口内的请求数量。

测试用例

窗口限制模式结果
60秒5每 10 秒 1 个全部允许
60秒52 秒内 6 个第 6 个被阻止
10秒3每 5 秒 1 个平滑轮换
60秒100开始时 200 个一半被阻止

复杂度

  • 时间:$O(1)$ 摊销(弹出旧时间戳)
  • 空间:$O(L)$ 用于存储最近的请求时间戳
  • 精度:窗口内精确
  • 适应性:高,连续执行

滑动窗口计数器是速率限制的计时器, 它不按时钟计数,而是按时刻计数, 确保每秒的公平性,没有时间的锯齿状边缘。

870 固定窗口计数器

固定窗口计数器是最简单的限流算法之一。它将时间划分为等长的窗口(例如 1 秒或 1 分钟),并统计当前窗口内到达的请求数量。一旦计数超过限制,新的请求就会被阻止,直到下一个窗口开始。

它易于实现且高效,但有一个缺陷:在窗口边界附近可能会出现突发流量,短暂地使允许的速率翻倍。尽管如此,对于许多应用来说,简单性是其优势。

我们要解决什么问题?

系统需要一种控制请求频率的方法,例如:

  • 限制每个用户每分钟最多 100 次 API 调用。
  • 防止暴力登录尝试。
  • 控制分布式管道中的事件发射速率。

固定计数器方法提供了一种高效且可预测的方式来强制执行这些限制。

它是如何工作的(通俗解释)

时间被分割成固定长度为 $W$ 的窗口(例如 60 秒)。 对于每个窗口,我们维护一个计数器,记录已接收的请求数量。

算法步骤:

  1. 确定当前时间窗口(例如,使用当前时间除以 $W$ 的整数除法)。
  2. 递增该窗口的计数器。
  3. 如果计数超过限制,则拒绝请求。
  4. 当时间进入下一个窗口时,将计数器重置为零。

示例:限制 = 5 个请求 / 10 秒

时间(秒)窗口请求数操作
0–9#15允许
10–19#25允许
第9秒–第10秒边界处2 秒内 10 个请求可能出现突发流量

微型代码(Python 示例)

python
import time

class FixedWindowCounter:
    def __init__(self, window_size, limit):
        self.window_size = window_size
        self.limit = limit
        self.count = 0
        self.window_start = int(time.time() / window_size)

    def allow(self):
        current_window = int(time.time() / self.window_size)
        if current_window != self.window_start:
            self.window_start = current_window
            self.count = 0
        if self.count < self.limit:
            self.count += 1
            return True
        return False

fw = FixedWindowCounter(window_size=10, limit=5)
for i in range(10):
    print(f"请求 {i+1}: {'允许' if fw.allow() else '阻止'}")
    time.sleep(1)

微型代码(C 语言版本)

c
#include <stdio.h>
#include <time.h>
#include <unistd.h>

typedef struct {
    int window_size;
    int limit;
    int count;
    int window_start;
} FixedWindowCounter;

int allow(FixedWindowCounter *f) {
    int now = time(NULL);
    int current_window = now / f->window_size;
    if (current_window != f->window_start) {
        f->window_start = current_window;
        f->count = 0;
    }
    if (f->count < f->limit) {
        f->count++;
        return 1;
    }
    return 0;
}

int main() {
    FixedWindowCounter fw = {10, 5, 0, time(NULL) / 10};
    for (int i = 0; i < 10; i++) {
        printf("请求 %d: %s\n", i+1, allow(&fw) ? "允许" : "阻止");
        sleep(1);
    }
}

为什么它重要

  • 简单性:快速、可预测,易于在集中式和分布式系统中实现。
  • 效率:每个窗口只需一个计数器,无需跟踪时间戳。
  • 应用场景:
    • API 网关和限流中间件。
    • 认证系统。
    • 网络路由器和防火墙。

权衡:

  • 允许跨边界的短时突发流量(双重消费问题)。
  • 不会平滑使用量,为此需要使用滑动窗口令牌桶算法。
  • 在窗口边界处会突然重置。

一个温和的证明(为什么它有效)

设:

  • $W$ = 窗口大小(秒)
  • $L$ = 限制(每个窗口的请求数)
  • $R(t)$ = 在时间 $t$ 接收到的请求

我们在时间 $t$ 允许一个请求的条件是: $$ C\left(\left\lfloor \frac{t}{W} \right\rfloor\right) < L $$ 其中 $C(k)$ 是窗口 $k$ 的计数器。

在下一个边界处: $$ C(k+1) = 0 $$

因此,该算法强制执行: $$ \frac{\text{请求数}}{\text{时间}} \le \frac{L}{W} $$ 对于大多数时间间隔,尽管跨边界可能会出现突发流量。

亲自尝试

  1. 设置 window_size = 10limit = 5
  2. 快速发送 5 个请求,全部被接受。
  3. 等待窗口重置,计数器清零。
  4. 立即再发送 5 个请求,再次全部被接受(突发效应)。
  5. 结合滑动窗口计数器来修复边界问题。

测试用例

窗口限制模式行为
10秒5每 2 秒 1 个平滑,全部允许
10秒55 秒内 6 个第 6 个被阻止
10秒5边界前 5 个 + 边界后 5 个突发 10 个
60秒100均匀分布完美执行

复杂度

  • 时间:每个请求 $O(1)$(简单的整数检查)
  • 空间:每个用户或键 $O(1)$
  • 精度:粗略,逐步重置
  • 性能:极高

固定窗口计数器是速率控制的节拍器, 稳定、可靠且机械。 它并不优雅,但它是可预测的, 而在分布式系统中,可预测性就是黄金。

第 88 节 搜索与索引

871 倒排索引构建

倒排索引将每个词项映射到它出现的文档(通常是位置)列表。它是网络搜索、日志搜索和代码搜索引擎背后的核心数据结构。

我们要解决什么问题?

给定一个文档语料库,我们想要回答像 cat AND dog 或像 "deep learning" 这样的短语查询。每次查询都扫描所有文档太慢了。倒排索引预先计算一个词项 → 倒排列表的映射,使得查询可以直接跳转到匹配的文档。

它是如何工作的(通俗解释)

  1. 解析并规范化每个文档

    • 将文本分词成词项
    • 转换为小写,去除标点,可选地进行词干提取或词形还原
    • 根据需要移除停用词
  2. 扫描时生成倒排项

    • 对于在文档 $d$ 中找到的每个词项 $t$,记录以下之一:
      • 文档级:$(t \to d)$
      • 位置级:$(t \to (d, p))$,其中 $p$ 是位置
  3. 分组和排序

    • 按词项对倒排项进行分组
    • 对每个词项的倒排项按文档 ID 排序,然后按位置排序
  4. 压缩和存储

    • 对文档 ID 和位置进行间隔编码
    • 应用整数压缩(例如,可变字节编码 VByte 或 Elias–Gamma 编码)
    • 持久化词典(词项词汇表)和倒排列表
  5. 回答查询

    • 布尔查询:对排序后的倒排列表进行交集或并集操作
    • 排名查询:获取倒排列表并计算分数(例如,TF、IDF、BM25)

示例演练

文档:

  • $d_1$: "to be or not to be"
  • $d_2$: "to seek truth"

分词和规范化:

  • $d_1$: [to, be, or, not, to, be]
  • $d_2$: [to, seek, truth]

倒排项(文档级):

  • be: ${d_1}$
  • not: ${d_1}$
  • or: ${d_1}$
  • seek: ${d_2}$
  • to: ${d_1, d_2}$
  • truth: ${d_2}$

to 的位置级倒排项:

  • to: ${(d_1, 1), (d_1, 5), (d_2, 1)}$ (假设位置从 1 开始计数)

微型代码(Python,位置索引)

python
import re
from collections import defaultdict

def tokenize(text):
    return re.findall(r"[a-z0-9]+", text.lower())

def build_inverted_index(docs):
    # docs: dict {doc_id: text}
    index = defaultdict(lambda: defaultdict(list))
    for d, text in docs.items():
        terms = tokenize(text)
        for pos, term in enumerate(terms, start=1):
            index[term][d].append(pos)
    # 转换为常规字典并排序倒排列表
    final = {}
    for term, posting in index.items():
        final[term] = {doc: sorted(pos_list) for doc, pos_list in sorted(posting.items())}
    return final

docs = {
    1: "to be or not to be",
    2: "to seek truth"
}
inv = build_inverted_index(docs)
for term in sorted(inv):
    print(term, "->", inv[term])

输出概览:

be -> {1: [2, 6]}
not -> {1: [4]}
or -> {1: [3]}
seek -> {2: [2]}
to -> {1: [1, 5], 2: [1]}
truth -> {2: [3]}

微型代码(C,文档级索引,最小化)

c
#include <stdio.h>
#include <string.h>
#include <ctype.h>

#define MAX_TERMS 1024
#define MAX_TERM_LEN 32
#define MAX_DOCS 128

typedef struct {
    char term[MAX_TERM_LEN];
    int docs[MAX_DOCS];
    int ndocs;
} Posting;

Posting index_[MAX_TERMS];
int nterms = 0;

void add_posting(const char *term, int doc) {
    // 查找词项
    for (int i = 0; i < nterms; i++) {
        if (strcmp(index_[i].term, term) == 0) {
            // 如果是新文档则添加
            if (index_[i].ndocs == 0 || index_[i].docs[index_[i].ndocs - 1] != doc)
                index_[i].docs[index_[i].ndocs++] = doc;
            return;
        }
    }
    // 新词项
    strncpy(index_[nterms].term, term, MAX_TERM_LEN - 1);
    index_[nterms].docs[0] = doc;
    index_[nterms].ndocs = 1;
    nterms++;
}

void tokenize_and_add(const char *text, int doc) {
    char buf[256], term[MAX_TERM_LEN];
    int j = 0, k = 0;
    for (int i = 0; ; i++) {
        char c = text[i];
        if (isalnum(c)) {
            if (k < MAX_TERM_LEN - 1) term[k++] = tolower(c);
        } else {
            if (k > 0) {
                term[k] = '\0';
                add_posting(term, doc);
                k = 0;
            }
            if (c == '\0') break;
        }
    }
}

int main() {
    tokenize_and_add("to be or not to be", 1);
    tokenize_and_add("to seek truth", 2);
    for (int i = 0; i < nterms; i++) {
        printf("%s ->", index_[i].term);
        for (int j = 0; j < index_[i].ndocs; j++)
            printf(" %d", index_[i].docs[j]);
        printf("\n");
    }
    return 0;
}

为什么它很重要

  • 速度:通过直接词项查找实现次线性查询时间
  • 可扩展性:通过压缩和分片支持数十亿文档
  • 灵活性:支持布尔搜索、短语查询、邻近查询、排名(TF–IDF、BM25)
  • 可扩展性:可以存储词频、位置、字段 ID 等有效载荷

权衡

  • 索引时的构建时间和内存消耗
  • 更新时需要维护(增量或批量)
  • 压缩和跳表需要更复杂的存储格式

一个温和的证明(为什么它有效)

设语料库为 $D = {d_1, \dots, d_N}$,词汇表为 $V = {t_1, \dots, t_M}$。 定义词项 $t$ 的倒排列表为: $$ P(t) = { (d, p_1, p_2, \dots) \mid t \text{ 在 } d \text{ 中的位置为 } p_i }. $$ 对于合取查询 $q = t_a \land t_b$ 的布尔检索,结果集为: $$ R(q) = { d \mid d \in \pi_{\text{doc}}(P(t_a)) \cap \pi_{\text{doc}}(P(t_b)) }. $$ 由于每个倒排列表内的文档 ID 是排序的,交集操作的时间与倒排列表长度之和成正比,当词项具有选择性时,这相对于整个语料库大小是次线性的。

对于带有位置的短语查询,我们额外检查位置偏移,通过要求对齐的位置间隔来保持正确性。

动手尝试

  1. 扩展 Python 代码以存储每个文档的词频,并计算 $tf$ 和 $df$。
  2. 通过对排序的文档 ID 列表求交集来实现布尔 AND
  3. 使用位置列表添加短语查询:验证 "to be" 仅返回 $d_1$。
  4. 为文档 ID 和位置添加间隔编码。
  5. 对交集成本与语料库大小进行基准测试。

测试用例

查询预期行为
to返回 $d_1, d_2$ 及其正确位置
be AND truth空集
"to be"仅返回 $d_1$
seek OR truth返回 $d_2$

复杂度

  • 构建时间:如果插入到大小为 $Z$ 的平衡字典中,则为 $O(\sum_d \vert d\vert \log Z)$;如果使用哈希加上每个词项的最终排序,则为 $O(\sum_d \vert d\vert )$
  • 空间:$O(T)$,其中 $T$ 是总词项出现次数,可通过压缩减少
  • 查询
    • 布尔 AND:使用跳跃或归并,复杂度为 $O(\vert P(t_a)\vert + \vert P(t_b)\vert )$
    • 短语查询:上述成本加上位置归并
  • I/O:通过跳表、块倒排列表和压缩(VByte、PForDelta、Elias–Gamma)来减少

倒排索引将一堆文本变成了一个快速查找结构:词项成为键,文档成为坐标,搜索变成了对排序列表求交集,而不是扫描整个世界。

872 位置索引构建

位置索引通过存储每个文档中术语的确切位置来扩展倒排索引。 这使得短语查询(如 "machine learning")和邻近查询(如 "data" 在 "model" 的 5 个词内)成为可能。

它是现代搜索引擎的基础,其相关性不仅取决于术语是否存在,还取决于它们的顺序和距离。

我们要解决什么问题?

标准的倒排索引只能回答“哪些文档包含某个术语”。 但如果用户搜索 "deep learning",我们需要的是 deep 后面紧跟着 learning 的文档。 我们还想支持像 "neural" NEAR/3 "network" 这样的查询。

为此,我们必须存储术语在文档中的位置。

它是如何工作的(通俗解释)

  1. 对文档进行分词,并为每个单词分配一个位置编号(从 1 开始)。
  2. 对于文档 $d$ 中的每个术语 $t$,记录 $t$ 出现的所有位置 $p$。
  3. 将倒排记录表存储为: $t \rightarrow [(d_1, [p_{11}, p_{12}, ...]), (d_2, [p_{21}, ...]), ...]$
  4. 使用位置对齐来评估短语查询。

示例:

文档 ID文本
1"to be or not to be"
2"to seek truth"

位置索引:

be -> {1: [2, 6]}
not -> {1: [4]}
seek -> {2: [2]}
to -> {1: [1, 5], 2: [1]}
truth -> {2: [3]}

短语查询 "to be" 检查 be.pos = to.pos + 1 的位置。

微型代码(Python 示例)

python
import re
from collections import defaultdict

def tokenize(text):
    return re.findall(r"[a-z0-9]+", text.lower())

def build_positional_index(docs):
    index = defaultdict(lambda: defaultdict(list))
    for doc_id, text in docs.items():
        terms = tokenize(text)
        for pos, term in enumerate(terms, start=1):
            index[term][doc_id].append(pos)
    return index

docs = {
    1: "to be or not to be",
    2: "to seek truth"
}
index = build_positional_index(docs)

for term in sorted(index):
    print(term, "->", index[term])

输出:

be -> {1: [2, 6]}
not -> {1: [4]}
seek -> {2: [2]}
to -> {1: [1, 5], 2: [1]}
truth -> {2: [3]}

短语查询(示例)

python
def phrase_query(index, term1, term2):
    results = []
    if term1 not in index or term2 not in index:
        return results
    for d in index[term1]:
        if d in index[term2]:
            pos1 = index[term1][d]
            pos2 = index[term2][d]
            for p1 in pos1:
                if (p1 + 1) in pos2:
                    results.append(d)
                    break
    return results

print("Phrase 'to be':", phrase_query(index, "to", "be"))

输出:

Phrase 'to be': [1]

微型代码(C 语言,简化概念)

c
#include <stdio.h>
#include <string.h>
#include <ctype.h>

#define MAX_DOCS 10
#define MAX_TERMS 100
#define MAX_POS 100
#define MAX_TERM_LEN 32

typedef struct {
    char term[MAX_TERM_LEN];
    int doc_id[MAX_DOCS];
    int positions[MAX_DOCS][MAX_POS];
    int counts[MAX_DOCS];
    int ndocs;
} Posting;

Posting index_[MAX_TERMS];
int nterms = 0;

void add_posting(const char *term, int doc, int pos) {
    for (int i = 0; i < nterms; i++) {
        if (strcmp(index_[i].term, term) == 0) {
            int d = -1;
            for (int j = 0; j < index_[i].ndocs; j++)
                if (index_[i].doc_id[j] == doc)
                    d = j;
            if (d == -1) {
                d = index_[i].ndocs++;
                index_[i].doc_id[d] = doc;
                index_[i].counts[d] = 0;
            }
            index_[i].positions[d][index_[i].counts[d]++] = pos;
            return;
        }
    }
    strcpy(index_[nterms].term, term);
    index_[nterms].ndocs = 1;
    index_[nterms].doc_id[0] = doc;
    index_[nterms].positions[0][0] = pos;
    index_[nterms].counts[0] = 1;
    nterms++;
}

为什么它很重要

  • 支持短语、邻近和精确顺序查询。
  • 对网络搜索、法律发现和代码索引至关重要。
  • 支持更高质量的排名特征(例如,术语邻近度评分)。

权衡:

  • 更大的存储空间(每个出现的位置)。
  • 更慢的构建时间。
  • 更高的压缩复杂度(位置差值)。

一个温和的证明(为什么它有效)

设术语的倒排记录表为: $$ P(t) = {(d_i, [p_{i1}, p_{i2}, ...])} $$

对于术语 $t_1, t_2, ..., t_k$ 的短语查询,如果满足以下条件,则匹配文档 $d$: $$ \exists p \in P(t_1, d): \forall j \in [2, k], (p + j - 1) \in P(t_j, d) $$

该算法按递增顺序滑动遍历位置列表,检查连续的偏移量。 因为位置是排序的,检查操作的时间复杂度为 $O(\vert P(t_1,d)\vert + ... + \vert P(t_k,d)\vert )$,在实践中是高效的。

亲自尝试

  1. 修改 Python 版本以支持 3 词短语(例如 "to be or")。
  2. 扩展到邻近查询:"data" NEAR/3 "model"
  3. 使用差值编码压缩位置。
  4. 添加文档频率统计。
  5. 测量语料库增长时的查询速度。

测试用例

查询预期结果原因
"to be"文档 1连续的术语
"to seek"文档 2有效
"be or not"文档 1三词短语
"truth be"不连续

复杂度

阶段时间空间
索引构建$O(\sum_d \vert d \vert )$$O(T)$
短语查询$O(\sum_i \vert P(t_i) \vert )$$O(T)$
存储(未压缩)与术语出现次数成正比高但可压缩

位置索引将普通搜索转变为结构化理解: 不仅仅是什么出现,还有在哪里出现。 它捕捉了语言的几何结构,文本中意义的形状。

873 TF–IDF 评分

TF–IDF(词频-逆文档频率)是信息检索领域最具影响力的评分模型之一。它量化了一个词在文档集合中对某篇文档的重要性,平衡了词在文档内的频率和在语料库中的稀有度。

我们要解决什么问题?

在搜索中,并非所有词都是平等的。像 theisdata 这样的常见词无处不在,而像 quantizationBayes 这样的稀有词则承载着更多的含义。我们需要一种方法来为词语分配权重,以反映文档与查询的匹配程度。

TF–IDF 恰好为我们提供了这种平衡。

它是如何工作的(通俗解释)

TF–IDF 结合了两个简单的思想:

  1. 词频(TF): 衡量一个词在文档中出现的频率。一个词出现的次数越多,它可能就越相关。 $$ TF(t, d) = \frac{f_{t,d}}{\max_k f_{k,d}} $$ 其中 $f_{t,d}$ 是词 $t$ 在文档 $d$ 中的原始计数。

  2. 逆文档频率(IDF): 衡量该词在所有文档中的稀有程度。稀有词获得更高的权重。 $$ IDF(t, D) = \log \frac{N}{n_t} $$ 其中 $N$ 是文档总数,$n_t$ 是包含词 $t$ 的文档数量。

  3. 组合: $$ w_{t,d} = TF(t, d) \times IDF(t, D) $$

查询 $q$ 对于文档 $d$ 的评分则为: $$ \text{score}(q, d) = \sum_{t \in q} TF(t, d) \times IDF(t, D) $$

示例

语料库:

文档 ID文本
1"deep learning for vision"
2"deep learning for language"
3"classical machine learning"

计算每个词的 IDF:

出现在文档数IDF
deep2$\log(3/2) = 0.176$
learning3$\log(3/3) = 0$
vision1$\log(3/1) = 1.099$
language1$\log(3/1) = 1.099$
classical1$\log(3/1) = 1.099$
machine1$\log(3/1) = 1.099$

然后计算词 "vision" 在文档 1 中的 TF–IDF(假设 TF = 1): $$ w_{vision,1} = 1 \times 1.099 = 1.099 $$

查询 "deep vision" 将文档 1 排名最高,因为两个词都出现且 vision 是稀有词。

微型代码(Python)

python
import math
from collections import Counter

def tfidf(corpus):
    N = len(corpus)
    tf = []
    df = Counter()

    # 计算词频和文档频率
    for text in corpus:
        words = text.lower().split()
        counts = Counter(words)
        tf.append(counts)
        for term in counts:
            df[term] += 1

    idf = {t: math.log(N / df[t]) for t in df}

    # 组合 TF 和 IDF
    weights = []
    for counts in tf:
        w = {t: counts[t] * idf[t] for t in counts}
        weights.append(w)
    return weights

docs = [
    "deep learning for vision",
    "deep learning for language",
    "classical machine learning"
]

weights = tfidf(docs)
for i, w in enumerate(weights, 1):
    print(f"文档 {i}: {w}")

微型代码(简化 C 语言)

c
#include <stdio.h>
#include <math.h>
#include <string.h>

#define MAX_TERMS 100
#define MAX_DOCS 10

char terms[MAX_TERMS][32];
int df[MAX_TERMS] = {0};
int nterms = 0;

int find_term(const char *t) {
    for (int i = 0; i < nterms; i++)
        if (strcmp(terms[i], t) == 0) return i;
    strcpy(terms[nterms], t);
    return nterms++;
}

void count_df(char docs[][256], int ndocs) {
    for (int d = 0; d < ndocs; d++) {
        int seen[MAX_TERMS] = {0};
        char *tok = strtok(docs[d], " ");
        while (tok) {
            int i = find_term(tok);
            if (!seen[i]) { df[i]++; seen[i] = 1; }
            tok = strtok(NULL, " ");
        }
    }
}

int main() {
    char docs[3][256] = {
        "deep learning for vision",
        "deep learning for language",
        "classical machine learning"
    };
    int N = 3;
    count_df(docs, N);
    for (int i = 0; i < nterms; i++)
        printf("%s -> IDF=%.3f\n", terms[i], log((double)N / df[i]));
}

为什么它很重要

  • 现代排序算法的核心。
  • 向量空间模型和余弦相似性搜索的基础。
  • 构成了 BM25、TF–IDF + 嵌入和混合搜索的基础。
  • 高效且可解释,无需训练。

权衡:

  • 不考虑词序或语义。
  • 可能过度强调长文档。
  • 简单,但对许多任务来说非常有效。

一个温和的证明(为什么它有效)

TF 随着词在文档内的相关性增加而增加。 IDF 惩罚出现在太多文档中的词。 它们的乘积放大了文档内稀有但频繁出现的词。

对于查询 $q$ 和文档 $d$:

$$ \text{score}(q,d) = \sum_{t \in q} TF(t,d) \cdot IDF(t,D) $$

这等价于将 $q$ 和 $d$ 都投影到一个加权向量空间中并计算它们的点积。 它近似地衡量了文档对查询中稀有词的特异性

自己动手试试

  1. 为你最喜欢的研究摘要计算 "machine learning" 的 TF–IDF。
  2. 比较使用原始词频计数与对数缩放 TF 时的排名。
  3. 将公式扩展为使用余弦相似度: $$ \cos(\theta) = \frac{\mathbf{v_q} \cdot \mathbf{v_d}}{||\mathbf{v_q}|| , ||\mathbf{v_d}||} $$
  4. 集成停用词过滤和词干提取以提高质量。
  5. 绘制每篇文档的顶部加权词。

测试用例

文档 1文档 2文档 3
deep0.1760.1760.000
learning0.0000.0000.000
vision1.0990.0000.000
language0.0001.0990.000
machine0.0000.0001.099
classical0.0000.0001.099

复杂度

步骤时间空间
构建 DF$O(\sum_d \vert d \vert )$$O(V)$
计算 TF$O(\sum_d \vert d \vert )$$O(V)$
查询评分$O( \vert q \vert \times \vert D \vert )$$O(V)$

TF–IDF 将纯文本转化为会说话的数字, 简单的频率揭示了意义, 它是统计词语和理解思想之间的一座桥梁。

874 BM25 排序

BM25(最佳匹配 25)是 TF-IDF 的现代演进版本。它通过引入文档长度归一化和饱和控制来改进评分,使其成为 Lucene、Elasticsearch 和 PostgreSQL 全文搜索等搜索引擎中基于关键词排序的黄金标准。

我们要解决什么问题?

TF-IDF 平等对待所有文档和词频,这导致了两个主要问题:

  1. 长文档由于自然包含更多词汇而获得不公平的高分。
  2. 过度频繁的词汇(例如,一个词出现 100 次)不应无限地增加分数。

BM25 通过抑制词频增长并根据文档相对于平均长度的调整来修复这两个问题。

它是如何工作的(通俗解释)

BM25 在 TF-IDF 的基础上进行了两项关键修正:

  1. 词频饱和——频繁出现的词带来的收益递减。
  2. 长度归一化——相对于平均长度,长文档会受到惩罚。

对于一个查询 $q$ 和文档 $d$:

$$ \text{score}(q, d) = \sum_{t \in q} IDF(t) \cdot \frac{f_{t,d} \cdot (k_1 + 1)}{f_{t,d} + k_1 \cdot (1 - b + b \cdot \frac{|d|}{avgdl})} $$

其中:

  • $f_{t,d}$ = 词项 $t$ 在文档 $d$ 中的词频
  • $\vert d\vert $ = 文档长度
  • $avgdl$ = 平均文档长度
  • $k_1$ = 饱和参数(通常为 1.2–2.0)
  • $b$ = 长度归一化因子(通常为 0.75)

IDF 定义为:

$$ IDF(t) = \log\frac{N - n_t + 0.5}{n_t + 0.5} $$

其中 $N$ = 文档总数,$n_t$ = 包含 $t$ 的文档数。

计算示例

假设:

词项$f_{t,d}$$n_t$$N$$\vert d\vert $$avgdl$
"vision"3101000120100

参数:$k_1 = 1.2$, $b = 0.75$

计算:

$$ IDF(vision) = \log\frac{1000 - 10 + 0.5}{10 + 0.5} \approx 1.96 $$

以及

$$ TF_{BM25} = \frac{3 \cdot (1.2 + 1)}{3 + 1.2 \cdot (1 - 0.75 + 0.75 \cdot \frac{120}{100})} \approx 1.87 $$

然后得分贡献:

$$ w_{vision, d} = 1.96 \times 1.87 \approx 3.67 $$

微型代码(Python 示例)

python
import math
from collections import Counter

def bm25_score(query, docs, k1=1.2, b=0.75):
    N = len(docs)
    avgdl = sum(len(d.split()) for d in docs) / N

    # 构建文档频率
    df = Counter()
    for d in docs:
        terms = set(d.split())
        for t in terms:
            df[t] += 1

    scores = []
    for doc in docs:
        words = doc.split()
        freq = Counter(words)
        score = 0.0
        for t in query.split():
            if t not in freq:
                continue
            n_t = df[t]
            idf = math.log((N - n_t + 0.5) / (n_t + 0.5))
            tf = freq[t]
            denom = tf + k1 * (1 - b + b * len(words) / avgdl)
            score += idf * (tf * (k1 + 1)) / denom
        scores.append(score)
    return scores

docs = [
    "deep learning for vision",
    "deep learning for language",
    "machine learning for vision and robotics"
]

query = "learning vision"
scores = bm25_score(query, docs)
for i, s in enumerate(scores, 1):
    print(f"Doc {i}: {s:.3f}")

输出(典型):

Doc 1: 2.74
Doc 2: 1.96
Doc 3: 2.88

微型代码(C 风格伪代码)

c
double bm25_score(double tf, double n_t, double N, double len_d, double avgdl, double k1, double b) {
    double idf = log((N - n_t + 0.5) / (n_t + 0.5));
    double denom = tf + k1 * (1 - b + b * len_d / avgdl);
    return idf * (tf * (k1 + 1)) / denom;
}

为什么它很重要

  • 现代信息检索和搜索引擎的标准基线。
  • 自然地处理文档长度。
  • 在简单性、性能和准确性之间提供了极佳的平衡。
  • 适用于稀疏索引和预计算的倒排列表。

应用于:Lucene, Elasticsearch, Solr, PostgreSQL FTS, Vespa, Whoosh, 和 OpenSearch。

一个温和的证明(为什么它有效)

TF-IDF 的线性缩放会过度加权频繁出现的词项。BM25 增加了一个饱和函数来平缓词频增长,以及一个归一化项来调整文档长度。

当 $f_{t,d} \to \infty$ 时:

$$ \frac{f_{t,d} (k_1 + 1)}{f_{t,d} + k_1(1 - b + b|d|/avgdl)} \to (k_1 + 1) $$

这确保了每个词项的贡献是有界的,防止因重复而占据主导地位。

同时,对于非常长的文档($\vert d\vert \gg avgdl$),分母会增大,从而降低有效权重,平衡了精确率和召回率。

亲自尝试

  1. 在你自己的文本数据集上实现 BM25。
  2. 实验 $b=0$(无长度归一化)和 $b=1$(完全归一化)的情况。
  3. 在包含短文档和长文档的查询上比较 BM25 和 TF-IDF。
  4. 在 1.0 到 2.0 之间调整 $k_1$ 以观察词频饱和。
  5. 可视化当 $f_{t,d}$ 增加时分数如何变化。

测试用例

词项$f_{t,d}$$\vert d\vert $$avgdl$分数影响
1短文档平均最高
3长文档中等
10长文档非常高趋于平缓
稀有词项小的 $n_t$,提升分数
频繁词项大的 $n_t$,降低分数

复杂度

步骤时间空间
构建 DF$O(\sum_d \vert d \vert )$$O(V)$
查询评分$O( \vert q \vert \times \vert D \vert )$$O(V)$
调参影响$k_1, b$ 仅影响平衡可忽略

BM25 是数学与工程学的完美结合点,一个简洁的公式驱动了数十年的搜索发展,在这里,意义与排序相遇,简单与精确相遇。

875 布尔检索

布尔检索是最简单且最古老的搜索逻辑形式,它将文档视为词的集合,将查询视为使用 AND、OR 和 NOT 的逻辑表达式。它不对结果进行排序,文档要么匹配查询,要么不匹配。然而,这种二元模型是所有现代排序模型(如 TF–IDF 和 BM25)构建的基础。

我们要解决什么问题?

早期的信息检索系统需要一种精确的方法来查找完全匹配一组术语组合的文档。例如:

  • "machine AND learning" → 包含两者的文档。
  • "neural OR probabilistic" → 包含任一的文档。
  • "data AND NOT synthetic" → 关于data但不包含synthetic的文档。

这种方法快速、简单且精确,非常适合法律检索、过滤或结构化档案。

它是如何工作的(通俗解释)

  1. 每个文档由其包含的词集合表示。
  2. 我们构建一个倒排索引,将每个词项映射到包含它的文档列表。
  3. 查询被解析成一个由 AND/OR/NOT 运算符组成的逻辑表达式树。
  4. 我们根据布尔逻辑合并倒排记录表。

示例:

词项文档
learning1, 2, 3
machine1, 3
vision2, 3

查询:

(machine AND learning) OR vision

步骤 1: machine AND learning → {1, 3} ∩ {1, 2, 3} = {1, 3} 步骤 2: {1, 3} OR vision → {1, 3} ∪ {2, 3} = {1, 2, 3}

所有文档都匹配。

微型代码(Python 示例)

python
from collections import defaultdict

# 构建倒排索引
docs = {
    1: "machine learning and vision",
    2: "deep learning for vision systems",
    3: "machine learning in robotics"
}

index = defaultdict(set)
for doc_id, text in docs.items():
    for term in text.lower().split():
        index[term].add(doc_id)

def boolean_query(query):
    tokens = query.lower().split()
    stack = []
    for token in tokens:
        if token == "and":
            b, a = stack.pop(), stack.pop()
            stack.append(a & b)
        elif token == "or":
            b, a = stack.pop(), stack.pop()
            stack.append(a | b)
        elif token == "not":
            a = stack.pop()
            all_docs = set(docs.keys())
            stack.append(all_docs - a)
        else:
            stack.append(index.get(token, set()))
    return stack.pop()

print(boolean_query("machine and learning"))
print(boolean_query("learning or vision"))
print(boolean_query("learning and not vision"))

输出:

{1, 3}
{1, 2, 3}
{3}

微型代码(C 简化概念)

c
#include <stdio.h>
#include <string.h>

int machine[] = {1, 3};
int learning[] = {1, 2, 3};
int vision[] = {2, 3};

void intersect(int *a, int na, int *b, int nb) {
    for (int i = 0; i < na; i++)
        for (int j = 0; j < nb; j++)
            if (a[i] == b[j]) printf("%d ", a[i]);
}

int main() {
    printf("machine AND learning: ");
    intersect(machine, 2, learning, 3);
    printf("\n");
}

为什么它很重要

  • 信息检索的基础:最早且最简单的模型。
  • 快速且确定:非常适合结构化查询或过滤。
  • 仍被广泛使用:数据库、Lucene 过滤器、搜索引擎和信息检索课程。
  • 透明:用户确切知道文档匹配的原因。

权衡:

  • 不进行排序,所有结果都是平等的。
  • 对精确词项敏感(没有模糊性)。
  • 如果词项限制太严格,可能返回空结果。

一个温和的证明(为什么它有效)

令:

  • $D_t$ 为包含词项 $t$ 的文档集合。
  • 布尔查询 $Q$ 是一个使用 $\cup$ (OR)、$\cap$ (AND) 和 $\setminus$ (NOT) 构建的表达式。

那么检索结果递归定义如下: $$ R(Q) = \begin{cases} D_t, & \text{如果 } Q = t \ R(Q_1) \cap R(Q_2), & \text{如果 } Q = Q_1 \text{ AND } Q_2 \ R(Q_1) \cup R(Q_2), & \text{如果 } Q = Q_1 \text{ OR } Q_2 \ D - R(Q_1), & \text{如果 } Q = \text{NOT } Q_1 \end{cases} $$

这使得检索具有组合性和精确性,每个查询都确定性地映射到一个文档集合。

亲自尝试

  1. 为 5–10 个示例文档创建你自己的倒排索引。
  2. 测试如下查询:
    • "data AND algebra"
    • "distributed OR parallel"
    • "consistency AND NOT eventual"
  3. 扩展到使用括号表示优先级:(A AND B) OR (C AND NOT D)
  4. 下一步实现排序检索(TF–IDF 或 BM25)。
  5. 在同一语料库上比较布尔检索和排序检索的结果。

测试用例

查询结果描述
machine AND learning{1, 3}两个词都存在
learning OR vision{1, 2, 3}集合的并集
learning AND NOT vision{3}排除包含 vision 的文档
machine AND robotics{3}仅文档 3
deep AND vision{2}仅文档 2

复杂度

操作时间空间
构建索引$O(\sum_d \vert d \vert )$$O(V)$
AND/OR/NOT$O( \vert D_A \vert + \vert D_B \vert )$$O(V)$
查询评估$O(\text{表达式长度})$常数

布尔检索是搜索最纯粹的逻辑,简单的集合,清晰的真实性,没有排序的灰度。它是信息的代数,是所有现代搜索理论演化的“原点”。

876 WAND 算法

WAND(Weak AND)算法是一种针对 top-$k$ 文档检索的优化算法。 它并非为查询计算每一个文档的得分(这可能涉及数百万个文档),而是巧妙地跳过那些不可能达到当前 top-$k$ 阈值的文档。

它是现代排序系统背后的效率引擎,从 Lucene 和 Elasticsearch 到网络级系统中的专用 IR 引擎,都离不开它。

我们要解决什么问题?

在像 TF–IDF 或 BM25 这样的排序模型中,一个朴素的搜索引擎必须:

  1. 为包含任何查询词的每个文档计算一个完整的得分。
  2. 对所有得分进行排序以返回顶部结果。

这很浪费,因为大多数文档的得分很低或不相关。 我们需要一种方法来避免为那些不可能进入 top-$k$ 列表的文档计算得分。

这正是 WAND 所做的:它为每个文档计算一个可能得分的上界,并尽早对搜索进行剪枝。

它是如何工作的(通俗解释)

每个查询词都有:

  • 一个文档 ID 的倒排列表,
  • 以及一个可能的最大词项得分(基于 TF–IDF 或 BM25 的上界)。

WAND 以递增的文档 ID 顺序,迭代地移动指针遍历倒排列表:

  1. 为每个词项维护一个指针(按当前文档 ID 排序)。
  2. 维护一个当前得分阈值,即迄今为止第 $k$ 好的得分。
  3. 计算所有列表中最小文档 ID 处文档的可能得分的上界。
  4. 如果该上界小于当前阈值,则向前跳过,无需计算。
  5. 否则,完整评估该文档的得分,如果它进入 top-$k$ 则更新阈值。

这能得到与穷举评分相同的 top-$k$ 结果,但跳过了成千上万的文档。

示例

假设查询 = "deep learning vision",且 top-$k = 2$。

词项倒排列表最大得分
deep[2, 5, 9]1.2
learning[1, 5, 9, 12]2.0
vision[5, 10]1.5
  • 从最小文档 ID = 1(来自 learning)开始。 上界 = 2.0 < 当前阈值 (0) → 评估。 Score(1) = 1.4 → 加入堆,阈值 = 1.4。
  • 移动到下一个候选文档 ID = 2。 上界 = 1.2 + 2.0 + 1.5 = 4.7 > 1.4 → 评估。 Score(2) = 2.5 → 更新阈值 = 1.4(因为 top-2)。
  • 之后,跳过那些上界 < 当前阈值的文档 ID。

通过持续收紧阈值,WAND 高效地跳过了不相关的文档。

微型代码(Python 示例)

python
import heapq

class Posting:
    def __init__(self, doc_id, score):
        self.doc_id = doc_id
        self.score = score

# 示例倒排列表 (词项 -> (文档ID, 得分) 列表)
index = {
    "deep": [Posting(2, 1.2), Posting(5, 1.0), Posting(9, 0.8)],
    "learning": [Posting(1, 1.4), Posting(5, 1.6), Posting(9, 1.5), Posting(12, 0.7)],
    "vision": [Posting(5, 1.5), Posting(10, 1.3)]
}

def wand(query_terms, k):
    heap = []  # top-k 最小堆
    pointers = {t: 0 for t in query_terms}
    max_score = {t: max(p.score for p in index[t]) for t in query_terms}
    threshold = 0.0

    while True:
        # 获取指针中最小的文档ID
        current_docs = [(index[t][pointers[t]].doc_id, t) for t in query_terms if pointers[t] < len(index[t])]
        if not current_docs: break
        current_docs.sort()
        pivot_doc, pivot_term = current_docs[0]

        # 计算此文档的上界
        ub = sum(max_score[t] for t in query_terms)
        if ub < threshold:
            # 向前跳过
            for t in query_terms:
                while pointers[t] < len(index[t]) and index[t][pointers[t]].doc_id <= pivot_doc:
                    pointers[t] += 1
            continue

        # 计算实际得分
        score = 0
        for t in query_terms:
            postings = index[t]
            if pointers[t] < len(postings) and postings[pointers[t]].doc_id == pivot_doc:
                score += postings[pointers[t]].score
                pointers[t] += 1

        if score > 0:
            heapq.heappush(heap, score)
            if len(heap) > k:
                heapq.heappop(heap)
                threshold = min(heap)
        else:
            for t in query_terms:
                while pointers[t] < len(index[t]) and index[t][pointers[t]].doc_id <= pivot_doc:
                    pointers[t] += 1

    return sorted(heap, reverse=True)

print(wand(["deep", "learning", "vision"], 2))

为什么它很重要

  • 即使对于海量文档集,也能实现实时排序检索。
  • 用于 Lucene、Elasticsearch、Vespa 和其他生产搜索引擎。
  • 通过跳过低潜力文档来减少计算量。
  • 保持精确的正确性,与穷举评估得到相同的 top-$k$。

权衡:

  • 需要每个词项的得分上界。
  • 实现略有复杂性。
  • 当查询包含少数高权重词项时表现最佳。

一个温和的证明(为什么它有效)

设 $S(d)$ 为文档 $d$ 的真实得分,$U(d)$ 为其计算出的上界。 设 $\theta$ 为当前的 top-$k$ 阈值(即迄今为止 top-$k$ 结果中的最小得分)。

对于任何文档 $d$: $$ U(d) < \theta \implies S(d) < \theta $$ 因此,$d$ 不可能进入 top-$k$,所以跳过它是安全的。 这个不变量确保了 top-$k$ 的精确正确性。

通过单调收紧 $\theta$,WAND 高效地剪枝了越来越多的文档。

亲自尝试

  1. 在一个小的 BM25 索引上实现 WAND。
  2. 可视化当 $k$ 减小时,有多少文档被跳过。
  3. 与暴力评分的运行时间进行比较。
  4. 扩展到 Block-Max WAND (BMW),使用块级得分上界。
  5. 当阈值稳定时添加提前终止。

测试用例

查询k文档数跳过比例匹配结果
deep learning210,00080%与暴力方法相同
data systems55,00060%相同
ai101,000很少相同
long query (10 词项)350,00090%相同

复杂度

阶段时间空间
构建索引$O!\left(\sum_d d\right)$$O(V)$
查询评分$O(k \log k + \text{#evaluated docs})$$O(V)$
剪枝效果减少 60–95% 的评估量可忽略的开销

WAND 算法是一门知道计算什么的艺术。 它是大规模搜索中效率的低语, 只为少数重要的文档评分,而跳过其余部分,却不会遗漏任何一个答案。

877 块最大 WAND(BMW)

块最大 WAND(BMW)是 WAND 算法的一种高级改进,它使用块级别的分数上界来一次性跳过倒排列表中的大块数据。它是高性能搜索引擎(如 Lucene 的 "BlockMax" 评分器和 Bing 的 "Fat-WAND" 系统)背后的关键优化之一。

我们要解决什么问题?

尽管 WAND 会跳过那些上界太低的文档,但它仍然需要访问每一个倒排列表项来检查单个文档 ID。当倒排列表包含数百万个条目时,这太慢了。

BMW 将倒排列表项分组为固定大小的块(例如每块 64 或 128 个文档 ID),并存储每个块可能的最大分数。这使得算法在知道某个块中没有任何文档能达到当前阈值时,可以跳过整个块,而不是逐个跳过文档。

它是如何工作的(通俗解释)

  1. 每个词项的倒排列表被划分为块。
  2. 对于每个块,存储:
    • 该块中词项的最大分数,以及
    • 最大文档 ID(该块中的最后一个文档)。
  3. 当评估查询时:
    • 使用块最大分数来计算下一个可能的块组合的上界。
    • 如果这个上界低于当前阈值 → 跳过整个块。
    • 否则,深入该块并使用标准评分(如 BM25)评估单个文档。

这大大减少了文档访问的次数,同时保持了精确的前 $k$ 个结果正确性。

示例

假设我们有词项 "vision",其倒排列表如下:

文档 ID分数块最大值
1[1, 3, 5, 7][0.4, 0.6, 0.5, 0.3]0.6
2[10, 11, 13, 15][0.9, 0.8, 0.7, 0.5]0.9

如果当前阈值 = 1.5,且其他词项的最大块分数总和为 1.2, 那么块 1(0.6 + 1.2 < 1.5)可以被完全跳过,我们直接跳转到块 2。

微型代码(Python 示例)

python
import heapq

# 简化的倒排列表:词项 -> [(文档_id, 分数)],按块分组
index = {
    "deep": [[(1, 0.3), (2, 0.7), (3, 0.4)], [(10, 1.0), (11, 0.8)]],
    "vision": [[(2, 0.4), (3, 0.6), (4, 0.5)], [(10, 1.2), (11, 1.1)]]
}

block_max = {
    "deep": [0.7, 1.0],
    "vision": [0.6, 1.2]
}

def bm25_bmw(query_terms, k):
    heap, threshold = [], 0.0
    pointers = {t: [0, 0] for t in query_terms}  # [块索引, 倒排列表索引]

    while True:
        current_docs = []
        for t in query_terms:
            b, p = pointers[t]
            if b >= len(index[t]): continue
            if p >= len(index[t][b]):
                pointers[t][0] += 1
                pointers[t][1] = 0
                continue
            current_docs.append((index[t][b][p][0], t))
        if not current_docs: break
        current_docs.sort()
        doc, pivot_term = current_docs[0]

        # 计算块级别上界
        ub = sum(block_max[t][pointers[t][0]] for t in query_terms if pointers[t][0] < len(block_max[t]))
        if ub < threshold:
            # 跳过整个块
            for t in query_terms:
                b, _ = pointers[t]
                while b < len(index[t]) and block_max[t][b] + ub < threshold:
                    pointers[t][0] += 1
                    pointers[t][1] = 0
                    b += 1
            continue

        # 计算实际分数
        score = 0.0
        for t in query_terms:
            b, p = pointers[t]
            if b < len(index[t]) and p < len(index[t][b]) and index[t][b][p][0] == doc:
                score += index[t][b][p][1]
                pointers[t][1] += 1
        if score > 0:
            heapq.heappush(heap, score)
            if len(heap) > k:
                heapq.heappop(heap)
                threshold = min(heap)
        else:
            for t in query_terms:
                b, p = pointers[t]
                while b < len(index[t]) and p < len(index[t][b]) and index[t][b][p][0] <= doc:
                    pointers[t][1] += 1
    return sorted(heap, reverse=True)

print(bm25_bmw(["deep", "vision"], 2))

为什么它很重要

  • 块跳过极大地减少了随机内存访问。
  • 使得在数十亿文档集合中进行近实时搜索成为可能。
  • 与压缩倒排列表自然集成。
  • 用于生产系统,如 Lucene 的 BlockMaxScore、Anserini 和 Elasticsearch。

权衡:

  • 需要存储每个块的最大值(略微增加索引大小)。
  • 性能取决于块大小和词项分布。
  • 正确实现起来更复杂。

一个温和的证明(为什么它有效)

令 $B_t(i)$ 为词项 $t$ 的第 $i$ 个块,令 $U_t(i)$ 为其最大分数。块组合的上界为:

$$ U_B = \sum_{t \in Q} U_t(b_t) $$

如果 $U_B < \theta$(当前前 $k$ 个结果的阈值),那么这些块中的任何文档都不可能超过 $\theta$,因此完全跳过它们是安全的。

因为 $U_t(i)$ ≥ $B_t(i)$ 内任何文档的实际分数,所以这个界限在最大化跳过效率的同时保持了精确性。

亲自尝试

  1. 在你的 WAND 实现之上实现 BMW。
  2. 尝试不同的块大小(例如,32、64、128),测量跳过的文档数。
  3. 在大型数据集上比较 WAND 和 BMW,统计总的文档评估次数。
  4. 可视化分数阈值随时间增长的情况。
  5. 扩展到 "MaxScore" 或 "Exhaustive BMW" 混合方法以实现提前终止。

测试用例

数据集文档数查询算法评估文档数相同 Top-k
10k"deep learning"WAND3,000
10k"deep learning"BMW900
1M"neural vision"WAND70,000
1M"neural vision"BMW12,000

复杂度

阶段时间空间
构建索引$O!\left(\sum_d d\right)$$O(V)$
查询评分$O(k \log k + \text{#visited blocks})$$O(V)$
跳过收益访问的倒排列表项减少 3 倍–10 倍少量开销

块最大 WAND 是大规模效率的体现, 在这里索引与几何相遇, 数百万的倒排列表项融汇成几个有意义的块。

878 影响度排序索引

影响度排序索引是一种检索优化技术,它根据影响度分数(重要性)而非文档ID来对倒排列表项进行排序。 这使得系统能够快速找到最相关的文档,而无需扫描所有倒排列表项。

它是高性能排序检索的基石,被用于量化 BM25 系统、学习排序引擎以及神经混合搜索中。

我们要解决什么问题?

传统的倒排索引按文档ID排序,非常适合布尔查询,但对于排序检索来说效率低下。

在对文档进行排序时,我们只需要前 $k$ 个得分最高的结果。 按文档ID顺序扫描每个倒排列表项,会浪费时间评估低影响度的文档。

影响度排序通过为每个倒排列表项预先计算一个得分估计值,并按该得分对倒排列表项进行排序来解决此问题,从而使引擎能够首先关注最有希望的文档。

它是如何工作的(通俗解释)

  1. 为每个词项-文档对计算一个影响度分数,例如: $$ I(t, d) = w_{t,d} = TF(t,d) \times IDF(t) $$ (或其 BM25 等价形式)。

  2. 在每个词项的倒排列表中,存储元组 (影响度, 文档ID),并按影响度降序排序。

  3. 在查询评估期间:

    • 按影响度降序遍历每个词项的倒排列表项。
    • 在一个堆中为前 $k$ 个结果累积部分得分。
    • 当剩余最大影响度之和无法超过当前阈值时,提前停止。

示例

词项倒排列表(影响度, 文档ID)
deep(2.3, 9), (1.5, 3), (0.8, 10)
learning(2.8, 3), (1.9, 5), (0.7, 12)
vision(3.1, 5), (2.5, 9), (1.2, 11)

评估查询 "deep learning vision" 时, 系统首先处理影响度最高的倒排列表项, 例如 (3.1,5), (2.8,3), (2.5,9),并且一旦剩余的可能贡献低于前 $k$ 个结果的阈值,就可以停止。

微型代码(Python 示例)

python
import heapq

# 示例影响度排序索引
index = {
    "deep": [(2.3, 9), (1.5, 3), (0.8, 10)],
    "learning": [(2.8, 3), (1.9, 5), (0.7, 12)],
    "vision": [(3.1, 5), (2.5, 9), (1.2, 11)]
}

def impact_ordered_search(query_terms, k):
    heap = []  # 前 k 个结果
    scores = {}
    pointers = {t: 0 for t in query_terms}
    threshold = 0.0

    while True:
        # 选择下一个最佳影响度的倒排列表项
        next_term, best_doc, best_impact = None, None, 0
        for t in query_terms:
            p = pointers[t]
            if p < len(index[t]):
                impact, doc = index[t][p]
                if impact > best_impact:
                    best_impact = impact
                    best_doc = doc
                    next_term = t
        if not next_term:
            break

        # 累积得分
        scores[best_doc] = scores.get(best_doc, 0) + best_impact
        pointers[next_term] += 1

        # 如果新文档进入前 k 名
        heapq.heappush(heap, scores[best_doc])
        if len(heap) > k:
            heapq.heappop(heap)
            threshold = min(heap)

        # 提前停止条件
        remaining_max = sum(index[t][p][0] for t, p in pointers.items() if p < len(index[t]))
        if remaining_max < threshold:
            break

    return sorted(scores.items(), key=lambda x: x[1], reverse=True)[:k]

print(impact_ordered_search(["deep", "learning", "vision"], 3))

输出:

[(5, 5.0), (9, 4.8), (3, 4.3)]

为什么它很重要

  • 首先处理高价值文档。
  • 一旦剩余潜力无法改变前 $k$ 个结果时,即可提前终止。
  • 与压缩索引和量化 BM25(得分预先分桶)完美配合。
  • 是 MaxScore、BMW 和学习排序重排器的基础。

权衡:

  • 失去了高效的布尔过滤能力(没有文档ID顺序)。
  • 需要预先计算影响度(量化步骤)。
  • 按影响度存储和排序倒排列表项会产生内存开销。

一个温和的证明(为什么它有效)

令 $I(t,d)$ 为词项 $t$ 在文档 $d$ 中的预计算影响度。 令 $U_t$ 为词项 $t$ 尚未处理的最大剩余影响度。

在任何时刻,如果: $$ \sum_{t \in Q} U_t < \theta $$ (其中 $\theta$ 是当前前 $k$ 个结果的阈值), 那么任何未见的文档都无法超过当前最低的前 $k$ 个得分, 因此评估可以安全停止,而不会遗漏任何顶级文档。

这保证了在单调评分函数(如 BM25 或 TF–IDF)下的正确性。

亲自尝试

  1. 使用 TF–IDF 权重构建一个包含 10–20 个文档的小型索引。
  2. 按影响度降序对倒排列表项进行排序,并使用提前终止运行查询。
  3. 比较运行时与按文档ID排序的 BM25 评分。
  4. 尝试使用量化(整数分桶)的影响度进行压缩。
  5. 与 WAND 或 BMW 结合使用以实现混合跳过。

测试用例

查询算法扫描文档数相同前 k 名加速比
deep learning文档ID顺序1000
deep learning影响度顺序150~6× 更快
neural vision影响度顺序 + 提前停止80~10× 更快

复杂度

阶段时间空间
索引构建$O!\left(\sum_d d \log d\right)$$O(V)$
查询评分$O(k + \text{#高影响度倒排列表项})$$O(V)$
提前停止80–95% 的倒排列表项被跳过少量元数据开销

影响度排序索引就像在比赛开始前就按重要性排序, 你先到达获胜者那里, 一旦你已经知道他们是谁,就停止奔跑。

879 分层索引

分层索引是一种多层组织的搜索索引结构,它优先考虑高质量高分文档以便早期访问。 它不是平等地扫描所有倒排列表,而是将数据组织成多个层级,以便首先搜索最有希望的文档,从而实现更快的 top-$k$ 检索。

这种方法在网页搜索引擎、大规模检索系统以及向量+关键词混合引擎中至关重要,这些系统对响应时间要求极高,并且必须快速返回部分结果。

我们要解决什么问题?

现代搜索引擎索引着数十亿的文档。 在延迟限制下,为每个查询扫描所有内容是不可能的。

我们需要一种渐进式搜索的方法, 从最有价值(高优先级)的子集开始, 并且仅在需要时才进行扩展。

分层索引正是通过基于文档质量、排名潜力或访问频率将索引划分为多个层级来实现这一点的。

它是如何工作的(通俗解释)

  1. 在索引构建期间,文档通过一个质量指标(例如,PageRank、权威性、点击率、流行度)进行排序或评分。

  2. 索引被分割成多个层级:

    • 第 0 层 → 高质量、高影响力的文档。
    • 第 1 层、第 2 层…… → 优先级逐渐降低的文档。
  3. 在查询时:

    • 从第 0 层开始,使用常规的排序检索进行搜索。
    • 如果 top-$k$ 结果尚未稳定(分数差距小),则转到第 1 层,依此类推。
    • 一旦 top-$k$ 结果可以确定(剩余层级无法改进它们)就停止。

这通常能为大多数查询产生快速且通常是精确的近似答案。

示例

假设您有 100 万个按质量排序的文档。 将它们划分为每层 10 万个文档。

层级质量示例
0前 10%维基百科、官方页面
1接下来的 30%可信博客、学术页面
2接下来的 60%普通网页文档

当处理查询 "深度学习" 时, 第 0 层可能已经包含了大部分排名靠前的结果。 除非为了召回率需要,系统可以跳过第 1-2 层。

微型代码(Python 示例)

python
def tiered_search(query_terms, tiers, k):
    results = []
    threshold = 0.0

    for tier_id, tier_index in enumerate(tiers):
        tier_results = retrieve_from_index(query_terms, tier_index)
        results.extend(tier_results)
        results.sort(key=lambda x: x[1], reverse=True)  # 按分数排序

        if len(results) > k:
            results = results[:k]
            threshold = results[-1][1]

        # 计算剩余层级的分数上限
        if tier_id < len(tiers) - 1:
            remaining_max = max(tier_index.get_max_possible_score(query_terms))
            if remaining_max < threshold:
                break  # 提前终止

    return results[:k]

这里的 retrieve_from_index 是应用于该层级内的任何检索方法(BM25、影响排序、WAND 等)。 每个层级都是一个独立的倒排索引。

为什么它很重要

  • 更快的响应:通常 top 结果来自第 0 层或第 1 层。
  • 更好的缓存:较高层级可以放入内存或 SSD;较低层级可以保留在磁盘上。
  • 增量刷新:新的或高流量文档可以动态插入到较高层级。
  • 可扩展的混合搜索:向量索引可以镜像相同的结构以进行近似最近邻检索。

权衡:

  • 需要维护多个索引。
  • 如果阈值设置过于严格,可能会错过相关但位于低层级的文档。
  • 跨层级合并结果会增加协调开销。

一个温和的证明(为什么它有效)

令 $S_i(d)$ 表示文档 $d$ 在第 $i$ 层中的分数, 并令 $U_i$ 为该层级中任何文档可能获得的最高分数。

如果 $\theta$ 是在搜索了第 $0 \dots i$ 层之后当前的 top-$k$ 阈值, 并且如果: $$ \max_{j>i} U_j < \theta $$ 那么较低层级中任何尚未被看到的文档都无法进入 top-$k$。 这在单调评分下保证了正确性。

因为高质量文档集中在较早的层级中, 所以对于大多数查询,这个条件会较早达到。

自己动手试试

  1. 将您的数据集按文档权威性或点击率分成 3 个层级。
  2. 使用 BM25 分别索引每个层级。
  3. 按顺序运行查询:第 0 层 → 第 1 层 → 第 2 层。
  4. 测量第 0 层已经能提供完整 top-$k$ 结果的频率。
  5. 结合 WAND 或影响排序索引以获得更深的效率。
  6. (可选)将 ANN 向量层级(用于嵌入)与关键词层级一起使用。

测试用例

数据集查询层级数在第 0 层解决的查询百分比与完整搜索的准确率对比
Web 1M"AI"394%99.8%
Web 10M"data systems"390%99.5%
News 1M"latest election"288%99.9%

复杂度

阶段时间空间
索引构建$O(N)$ 每层$O(N)$
查询评估$O(k + \text{#访问的层级数})$$O(V)$
提前终止通常 1–2 层少量元数据开销

分层索引是大规模系统保持响应性的方式, 先搜索顶峰, 只在必要时才深入探索。

880 DAAT 与 SAAT 评估

DAAT(按文档处理)和 SAAT(按分数处理)是评估排序检索查询的两种对比策略。 它们定义了在计算文档分数时如何遍历和合并倒排列表,这是每个搜索引擎架构中的核心决策。

两者最终得到相同的结果,但它们在 CPU 效率、内存访问和提前终止能力之间做出了不同的权衡。

我们要解决什么问题?

当多个查询词出现在不同的倒排列表中时,引擎必须决定以何种顺序访问它们以及如何高效地合并它们的分数。

  • 我们是否应该在处理下一个文档之前,先收集一个文档的所有贡献分数?(DAAT)
  • 或者我们应该一次处理所有词项的一个倒排项,逐步完善分数?(SAAT)

这种区别会影响查询延迟、缓存、并行性,甚至影响压缩和跳转的工作方式。

工作原理

  1. DAAT(按文档处理)
  • 按文档 ID 顺序合并倒排列表。
  • 对于出现在任何倒排列表中的每个文档,计算其所有匹配词项的总分。
  • 一旦知道文档的完整分数,就将其插入到 top-$k$ 堆中。

这是 WAND、BMW 和 Lucene 中使用的方法。

  1. SAAT(按分数处理)
  • 一次处理一个倒排项(词项-文档对),按影响力或分数排序。
  • 随着高影响力倒排项的到达,逐步更新文档的部分分数。
  • 当可以证明任何未见的倒排项都不可能影响 top-$k$ 时停止。

这是影响力排序索引、MaxScore 和量化 BM25 系统中使用的模型。

示例

假设我们有查询词 "deep" 和 "learning"。

词项倒排项 (文档ID, 分数)
deep(1, 0.6), (2, 0.9), (4, 0.5)
learning(2, 0.8), (3, 0.7), (4, 0.6)

DAAT 遍历:

步骤文档IDdeeplearning总分
110.600.6
220.90.81.7
3300.70.7
440.50.61.1

SAAT 遍历(按分数排序): 按词项-文档分数降序处理倒排项: (2,0.9), (2,0.8), (4,0.6), (1,0.6), (3,0.7), (4,0.5)...

随着部分分数的累积,一旦剩余的倒排项无法影响 top-$k$,我们就可以停止。

微型代码(Python 示例)

python
import heapq

def daat(query_lists, k):
    # 按排序顺序合并所有文档ID
    all_docs = sorted(set().union(*[dict(pl).keys() for pl in query_lists]))
    scores = {}
    for doc in all_docs:
        total = sum(dict(pl).get(doc, 0) for pl in query_lists)
        scores[doc] = total
    return sorted(scores.items(), key=lambda x: x[1], reverse=True)[:k]

def saat(query_lists, k):
    # 按分数降序处理倒排项
    postings = [(score, doc) for pl in query_lists for doc, score in pl]
    postings.sort(reverse=True)
    scores, heap, threshold = {}, [], 0.0

    for score, doc in postings:
        scores[doc] = scores.get(doc, 0) + score
        heapq.heappush(heap, (scores[doc], doc))
        if len(heap) > k:
            heapq.heappop(heap)
            threshold = heap[0][0]
        remaining_max = score  # 简化的上界
        if remaining_max < threshold:
            break
    return sorted(scores.items(), key=lambda x: x[1], reverse=True)[:k]

deep = [(1, 0.6), (2, 0.9), (4, 0.5)]
learning = [(2, 0.8), (3, 0.7), (4, 0.6)]

print("DAAT:", daat([deep, learning], 2))
print("SAAT:", saat([deep, learning], 2))

为什么重要

特性DAATSAAT
遍历顺序文档ID 顺序分数顺序
提前终止通过 WAND/BMW通过 MaxScore
跳转能力强(文档ID 已排序)较弱
缓存局部性随机
并行性有限高(倒排项级别)
压缩友好性较低
典型使用场景Lucene, ElasticsearchTerrier, Anserini, 学习式检索

两者都得到相同的最终排序, 但 DAAT 在 CPU 缓存效率方面更好, 而 SAAT 在 GPU 或向量化环境中表现出色。

一个温和的证明(为什么两者都是正确的)

令 $S(d)$ 为文档 $d$ 对于查询 $Q$ 的总分。 每种算法计算:

$$ S(d) = \sum_{t \in Q} w_{t,d} $$

DAAT 和 SAAT 都完全枚举了所有 $(t, d)$ 对,只是顺序不同。 因为加法满足交换律和结合律,所以最终的分数集合 ${S(d)}$ 是相同的。

在单调评分且遵守上界的情况下,提前终止可以保持正确性: $$ \sum_{t \in Q} U_t < \theta \implies \text{可以安全停止} $$

因此,两者在语义上是等价的,仅在遍历顺序上有所不同。

亲自尝试

  1. 为同一个索引实现 TF–IDF 的 DAAT 和 SAAT。
  2. 测量每个查询的时间和访问的倒排项数量。
  3. 为两者添加提前停止阈值。
  4. 尝试混合评估:短查询用 DAAT,长查询用 SAAT。
  5. 可视化分数累积曲线,看看提前终止有何不同。

测试用例

查询算法扫描文档数相同的 Top-k加速比
"deep learning"DAAT1,200
"deep learning"SAAT250~5×
"neural vision"DAAT3,000
"neural vision"SAAT800~4×

复杂度

阶段DAATSAAT
合并成本$O!\left(\sum P_t\right)$$O!\left(\sum P_t \log P_t\right)$
提前停止通过边界检查通过分数边界
空间$O(V)$$O(V)$

DAAT 与 SAAT 体现了检索工程的哲学, DAAT 按文档合并, SAAT 按重要性合并, 两者从不同的方向汇聚到相同的真理。

第 89 节 系统中的压缩与编码

881 游程编码 (RLE)

游程编码 (RLE) 是一种简单的无损压缩方法,它将连续重复的相同符号替换为一个符号和一个计数值。 对于具有长游程的数据,例如二值图像、简单图形以及具有重复值的传感器流,这种方法极其有效。

我们要解决什么问题?

未压缩的数据通常包含重复的符号:

  • 具有相同像素值长水平游程的图像
  • 具有重复标志的日志或遥测数据
  • 具有大片零区域的位图和掩码

当重复占主导地位时,RLE 可以紧凑地编码这些游程,从而降低存储和传输成本。

它是如何工作的(通俗解释)

从左到右扫描序列,并将相同的相邻符号分组为游程。 每个符号 $x$ 长度为 $k$ 的游程被写成一个对 $(x, k)$。

示例:

  • 输入:AAAABBCCDAA
  • 游程:A×4, B×2, C×2, D×1, A×2
  • RLE:(A,4)(B,2)(C,2)(D,1)(A,2)

两种常见布局:

  1. 符号-计数对:同时存储符号和长度。
  2. 计数-符号对:对于字节对齐的格式,先存储长度。

解码时,通过将符号重复 $k$ 次来展开每个对。

微型代码 (Python)

python
def rle_encode(data: str):
    if not data:
        return []
    out = []
    curr, count = data[0], 1
    for ch in data[1:]:
        if ch == curr and count < 255:  # 为演示目的,限制在一个字节内
            count += 1
        else:
            out.append((curr, count))
            curr, count = ch, 1
    out.append((curr, count))
    return out

def rle_decode(pairs):
    return "".join(ch * cnt for ch, cnt in pairs)

s = "AAAABBCCDAA"
pairs = rle_encode(s)
print(pairs)            # [('A', 4), ('B', 2), ('C', 2), ('D', 1), ('A', 2)]
print(rle_decode(pairs))  # AAAABBCCDAA

微型代码 (C)

c
#include <stdio.h>
#include <string.h>

void rle_encode(const char *s) {
    int n = strlen(s);
    for (int i = 0; i < n; ) {
        char c = s[i];
        int j = i + 1;
        while (j < n && s[j] == c && j - i < 255) j++;
        printf("(%c,%d)", c, j - i);
        i = j;
    }
    printf("\n");
}

int main() {
    const char *s = "AAAABBCCDAA";
    rle_encode(s);
    return 0;
}

为什么它很重要

  • 简单性:编码器和解码器极小,易于在受限系统中实现
  • 速度:线性时间复杂度,CPU 和内存占用极低
  • 互操作性:可作为更大格式内部的构建块
    • 传真 Group 3/4 位图
    • TIFF PackBits
    • 某些精灵图和瓦片地图格式
    • 硬件帧缓冲器和 FPGA 流

权衡:

  • 对高熵或高度交替的数据压缩效果差
  • 最坏情况下可能扩大数据大小(例如 ABABAB...
  • 需要游程上限和转义规则来处理边界情况

一个温和的证明(为什么它有效)

令输入为序列 $S = s_1 s_2 \dots s_n$。 将 $S$ 划分为最大游程 $R_1, R_2, \dots, R_m$,其中每个游程 $R_i$ 由符号 $x_i$ 重复 $k_i$ 次组成,且 $k_i \ge 1$。 这些游程是唯一的,因为每个边界恰好出现在 $s_j \ne s_{j+1}$ 的地方。

编码将 $S$ 映射为对序列: $$ E(S) = \big((x_1, k_1), (x_2, k_2), \dots, (x_m, k_m)\big) $$

解码是逆映射: $$ D(E(S)) = \underbrace{x_1 \dots x_1}{k_1} \underbrace{x_2 \dots x_2}{k_2} \dots \underbrace{x_m \dots x_m}_{k_m} = S $$

因此 $D \circ E$ 是序列集合上的恒等映射,这证明了无损的正确性。

压缩比取决于游程的数量和长度。 如果平均游程长度为 $\bar{k}$ 且每对的开销是常数,那么压缩后的长度按 $O(n / \bar{k})$ 的比例缩放。

亲自尝试

  1. 对黑白图像的水平扫描线进行编码,并与原始大小进行比较。
  2. 为计数值添加一个字节的上限,并引入转义序列来表示不可压缩的原文字段。
  3. 测量随机数据上的最坏情况扩展。
  4. 在 RLE 之前结合像素差异的增量编码。
  5. 在简单的结构化数据上,比较排序游程后与排序前的 RLE 效果。

测试用例

输入编码后的对备注
AAAAA(A,5)单个长游程
ABABAB(A,1)(B,1)(A,1)(B,1)(A,1)(B,1)扩展风险
0000011100(0,5)(1,3)(0,2)典型的二进制掩码
`` (空)[]边界情况
A(A,1)最小情况

变体和扩展

  • 位的游程编码:为位图打包 0 和 1 的游程
  • PackBits:使用带符号的计数字节区分原文字段和游程字段
  • RLE + 熵编码:先进行 RLE,然后对计数和符号进行霍夫曼编码
  • 对差值进行 RLE:压缩重复的差值而非原始值

复杂度

  • 时间:编码和解码均为 $O(n)$
  • 空间:流式处理为 $O(1)$,输出缓冲区除外
  • 压缩:当平均游程长度 $\bar{k} \gg 1$ 时效果最佳
  • 最坏情况大小:由于对的开销,可能超过输入一个常数因子

何时使用 RLE:具有长同质段、可预测符号或结构化掩码的数据。 何时避免:噪声文本、打乱的字节或游程短的随机化流。

882 霍夫曼编码

霍夫曼编码是无损数据压缩的基石。 它为高频符号分配较短的比特模式,为低频符号分配较长的比特模式,在已知符号频率的情况下实现近乎最优的压缩。 它是 ZIP、PNG、JPEG 以及无数编解码器的核心。

我们要解决什么问题?

定长编码(如 ASCII)浪费比特。 如果 'E' 的出现频率为 13%,而 'Z' 仅为 0.1%,那么为两者使用相同的 8 比特是没有意义的。

我们希望最小化总比特长度,同时保持编码可唯一解码。

它是如何工作的(通俗解释)

霍夫曼编码构建一棵根据频率加权的符号二叉树:

  1. 统计所有符号的频率。
  2. 将每个符号放入一个以频率为键的优先队列(最小堆)中。
  3. 重复移除两个频率最低的节点,并将它们合并成一个新的父节点。
  4. 持续此过程,直到只剩下一个节点,即树的根节点。
  5. 为一个分支分配 0,为另一个分支分配 1。 每个符号的比特码就是其从根节点到叶节点的路径。

高频符号最终更靠近根节点 → 编码更短。

示例

符号频率编码
A510
B2110
C1111
D100
E101

每个符号的平均比特数 ≈ 按频率加权,远少于固定的 3 比特或 8 比特编码。

微型代码(Python)

python
import heapq

def huffman_code(freqs):
    heap = [[w, [sym, ""]] for sym, w in freqs.items()]
    heapq.heapify(heap)

    while len(heap) > 1:
        lo = heapq.heappop(heap)
        hi = heapq.heappop(heap)
        for pair in lo[1:]:
            pair[1] = "0" + pair[1]
        for pair in hi[1:]:
            pair[1] = "1" + pair[1]
        heapq.heappush(heap, [lo[0] + hi[0]] + lo[1:] + hi[1:])
    return sorted(heapq.heappop(heap)[1:], key=lambda p: (len(p[-1]), p))

freqs = {"A": 5, "B": 2, "C": 1, "D": 1, "E": 1}
for sym, code in huffman_code(freqs):
    print(sym, code)

微型代码(C)

c
#include <stdio.h>
#include <stdlib.h>

typedef struct Node {
    char c;
    int f;
    struct Node *left, *right;
} Node;

Node* newNode(char c, int f, Node* l, Node* r) {
    Node* n = malloc(sizeof(Node));
    n->c = c; n->f = f; n->left = l; n->right = r;
    return n;
}

void printCodes(Node* root, char* code, int depth) {
    if (!root) return;
    if (!root->left && !root->right) {
        code[depth] = '\0';
        printf("%c: %s\n", root->c, code);
        return;
    }
    code[depth] = '0';
    printCodes(root->left, code, depth + 1);
    code[depth] = '1';
    printCodes(root->right, code, depth + 1);
}

为何重要

  • 对于独立符号,实现近乎最优的熵压缩
  • 快速:使用查找表进行单次编码和解码
  • 通用:是 DEFLATE、GZIP、PNG、JPEG、MP3、PDF 文本流的基础

权衡:

  • 需要预先知道符号频率
  • 对于小字母表或变化分布效率低下
  • 生成前缀码,解码必须遵循树路径

一个温和的证明(为何有效)

设每个符号 $i$ 的频率为 $p_i$,码长为 $l_i$。 期望码长为:

$$ L = \sum_i p_i l_i $$

霍夫曼编码证明,对于任何前缀无关码,

$$ L_{\text{Huffman}} \le L_{\text{任何其他}} $$

对于任何符号概率固定且独立的给定分布,它是最优的。

利用克拉夫特不等式,

$$ \sum_i 2^{-l_i} \le 1 $$

霍夫曼编码找到满足此约束的最短整数长度。

亲自尝试

  1. 使用霍夫曼编码对字符串 "MISSISSIPPI" 进行编码。
  2. 比较总比特数与固定 8 比特 ASCII。
  3. 添加自适应霍夫曼编码(动态重建树)。
  4. 应用于灰度图像数据或传感器读数。
  5. 探索规范霍夫曼码(用于 DEFLATE)。

测试用例

输入原始比特数(8 比特)霍夫曼比特数压缩率
AAAAABBC642068.8%
HELLO402342.5%
MISSISSIPPI884746.6%

复杂度

阶段时间空间
树构建$O(n \log n)$$O(n)$
编码$O(N)$$O(n)$
解码$O(N)$$O(n)$

霍夫曼编码揭示了一个简单的真理: 给予常见的更多,给予稀有的更少, 正因如此,它构建了现代压缩的支柱。

883 算术编码

算术编码是一种强大的无损压缩算法,它将整个消息表示为 0 到 1 之间的一个单一分数。 与为每个符号分配离散比特模式的霍夫曼编码不同,算术编码将整个序列编码到一个逐步缩小的区间中,从而实现接近熵的效率。

我们要解决什么问题?

当符号概率不是 ½ 的幂次时,霍夫曼编码无法完美匹配熵极限。 算术编码通过为每个符号分配分数比特来解决这个问题,使其对于偏斜或自适应分布更加高效。

它是如何工作的(通俗解释)

该算法将整个消息视为区间 $[0,1)$ 内的一个实数,并根据符号概率进行细分。 在每一步,它将区间缩小到对应于下一个符号的子范围。

假设符号 A, B, C 的概率分别为 0.5, 0.3, 0.2。

符号范围
A[0.0, 0.5)
B[0.5, 0.8)
C[0.8, 1.0)

编码 "BA":

  1. 从 $[0, 1)$ 开始
  2. 符号 B → $[0.5, 0.8)$
  3. 符号 A → 使用相同的概率细分 $[0.5, 0.8)$:
    • 新的 A 范围 = $[0.5, 0.65)$
    • 新的 B 范围 = $[0.65, 0.74)$
    • 新的 C 范围 = $[0.74, 0.8)$
  4. 最终区间 = $[0.5, 0.65)$

该区间内的任何数字都代表 "BA",例如 0.6。

解码过程通过反复识别数字落在哪个子区间来逆转此过程。

微型代码(Python)

python
def arithmetic_encode(symbols, probs):
    low, high = 0.0, 1.0
    for s in symbols:
        span = high - low
        for sym, (p_low, p_high) in probs.items():
            if sym == s:
                high = low + span * p_high
                low = low + span * p_low
                break
    return (low + high) / 2

def arithmetic_decode(code, n, probs):
    result = []
    for _ in range(n):
        for sym, (p_low, p_high) in probs.items():
            if p_low <= code < p_high:
                result.append(sym)
                code = (code - p_low) / (p_high - p_low)
                break
    return "".join(result)

probs = {"A": (0.0, 0.5), "B": (0.5, 0.8), "C": (0.8, 1.0)}
encoded = arithmetic_encode("BA", probs)
print("编码后的值:", encoded)
print("解码后:", arithmetic_decode(encoded, 2, probs))

为什么它很重要

  • 更接近熵极限:可以实现分数比特压缩。
  • 适应性:适用于动态更新的概率。
  • 应用领域:JPEG2000、H.264、AV1、Bzip2 以及现代 AI 模型量化。

权衡:

  • 必须控制算术精度(浮点数漂移)。
  • 实现复杂度高于霍夫曼编码。
  • 实际使用时需要重归一化和比特流编码。

一个温和的证明(为什么它有效)

如果一个消息 $S = s_1 s_2 \dots s_n$ 被编码到区间 $[L, H)$,那么

$$ H - L = \prod_{i=1}^{n} P(s_i) $$

因此,所需的总比特数大约为

$$ -\log_2(H - L) = -\sum_{i=1}^{n} \log_2 P(s_i) $$

这等于香农信息量,在理想的算术精度下实现了最优熵编码。

动手试试

  1. 使用概率 A:0.6, B:0.4 编码 "ABBA"。
  2. 可视化每个符号对应的收缩区间。
  3. 添加重归一化以逐步输出比特(范围编码器)。
  4. 在偏斜文本上比较压缩率与霍夫曼编码。
  5. 实现自适应概率(每个符号后更新)。

测试用例

输入概率比特数压缩效果
"AAAA"A:1.0~0 比特完美
"ABAB"A:0.9, B:0.1~1.37 比特/符号优于霍夫曼
"ABC"A:0.5, B:0.3, C:0.2~1.49 比特/符号接近熵

复杂度

阶段时间空间
编码$O(n)$$O(1)$
解码$O(n)$$O(1)$
精度要求取决于比特宽度

算术编码捕捉了信息的连续性, 它不是从符号的角度思考,而是从概率空间的角度思考。 每条消息都成为单位区间内一个独特的切片。

884 增量编码

增量编码通过存储连续值之间的差值而非值本身来压缩数据。 它简单、快速,对于具有强局部相关性的数据集(如时间戳、计数器、音频样本或传感器读数)极其有效。

我们要解决什么问题?

许多现实世界中的序列变化缓慢。 例如: [1000, 1001, 1002, 1005, 1006]

我们可以不存储每个完整的数字,而只存储差值: [1000, +1, +1, +3, +1]

这减少了数值的大小、方差和熵,使得序列更容易进行二次压缩(如霍夫曼编码或算术编码)。

它是如何工作的(通俗解释)

  1. 取一个数值序列: $x_1, x_2, x_3, \dots, x_n$
  2. 选择一个参考值(通常是第一个值)。
  3. 存储增量序列: 对于 $i > 1$,$d_i = x_i - x_{i-1}$
  4. 为了重建,执行累积求和: $x_i = x_{i-1} + d_i$

如果序列逐渐变化,大多数 $d_i$ 将很小,可以用更少的比特进行压缩。

示例

原始值增量重建值
1000,1000
1001+11001
1002+11002
1005+31005
1006+11006

增量比完整的整数使用更少的比特,并且之后可以用变长编码或熵编码进行编码。

微型代码(Python)

python
def delta_encode(values):
    if not values:
        return []
    deltas = [values[0]]
    for i in range(1, len(values)):
        deltas.append(values[i] - values[i - 1])
    return deltas

def delta_decode(deltas):
    if not deltas:
        return []
    values = [deltas[0]]
    for i in range(1, len(deltas)):
        values.append(values[-1] + deltas[i])
    return values

nums = [1000, 1001, 1002, 1005, 1006]
encoded = delta_encode(nums)
print("增量编码:", encoded)
print("解码:", delta_decode(encoded))

微型代码(C)

c
#include <stdio.h>

void delta_encode(int *data, int *out, int n) {
    if (n == 0) return;
    out[0] = data[0];
    for (int i = 1; i < n; i++)
        out[i] = data[i] - data[i - 1];
}

void delta_decode(int *deltas, int *out, int n) {
    if (n == 0) return;
    out[0] = deltas[0];
    for (int i = 1; i < n; i++)
        out[i] = out[i - 1] + deltas[i];
}

为什么它很重要

  • 压缩协同效应:更小的增量 → 更好的霍夫曼或算术压缩。
  • 硬件效率:用于视频(帧增量)、遥测、时间序列数据库、音频编码和二进制差异。
  • 流友好:支持以最小状态进行增量更新。

权衡:

  • 对噪声敏感(大的随机跳跃会破坏效率)。
  • 需要基础帧或检查点以支持随机访问。
  • 必须安全地处理有符号增量。

一个温和的证明(为什么它有效)

设输入序列为 $x_1, x_2, \dots, x_n$,输出增量 $d_1, \dots, d_n$ 定义为

$$ d_1 = x_1, \quad d_i = x_i - x_{i-1} \text{ for } i > 1 $$

解码是逆映射:

$$ x_i = d_1 + \sum_{j=2}^{i} d_j $$

由于在整数上加减法是可逆的,所以该变换是无损的。 当 $x_i$ 具有局部相关性时,${d_i}$ 的熵更低,这使得二次编码器可以利用减少的方差。

亲自尝试

  1. 对温度读数或股票价格进行编码,绘制数值与增量分布图。
  2. 结合可变字节编码处理整数流。
  3. 为支持随机访问添加周期性的"重置"值。
  4. 对更平滑的信号使用二阶增量($d_i = d_i - d_{i-1}$)。
  5. 使用 gzip 或 zstd 比较使用和不使用增量预处理的压缩效果。

测试用例

输入增量编码重建值
[5, 7, 9, 12][5, 2, 2, 3][5, 7, 9, 12]
[10, 10, 10, 10][10, 0, 0, 0][10, 10, 10, 10]
[1, 5, 2][1, 4, -3][1, 5, 2]

复杂度

操作时间复杂度空间复杂度
编码$O(n)$$O(1)$
解码$O(n)$$O(1)$

增量编码是通过变化感知进行压缩的本质, 它忽略保持不变的部分,只关注事物如何变化。

885 可变字节编码

可变字节(VB)编码是一种简单且广泛使用的整数压缩技术,它使用较少的字节存储较小的数字,使用较多的字节存储较大的数字。它在搜索引擎和倒排索引中特别受欢迎,因为这些场景下的倒排列表包含大量的文档ID或间隔值。

我们要解决什么问题?

如果每个整数都使用固定的4字节存储,那么小数字会浪费空间。但大多数数据(如文档ID间隔)都是小整数。VB编码使用尽可能少的字节来存储每个数字。

它速度快、字节对齐且易于解码,非常适合 Lucene、Zettair 或 SQLite 等系统。

工作原理(通俗解释)

每个整数被分割成7位的块(基数为128的表示形式)。每个字节的最高位是一个延续标志位:

  • 1 表示后面还有更多字节
  • 0 表示这是该数字的最后一个字节

这使得解码变得非常简单:持续读取字节,直到遇到最高位为 0 的字节。

示例:编码 300

  • 二进制:100101100
  • 分割为7位块:[0010110] [00101100]
  • 编码:
    • 低7位:00101100 (0x2C)
    • 高几位:0000010 (0x02)
    • 为第一个字节设置延续位:100101100 → 字节 [0x82, 0x2C]

示例表格

整数二进制编码后的字节(二进制)
10000000100000001
1281000000010000001 00000000
30010010110010000010 00101100
1638401000000000000010000001 10000000 00000000

微型代码(Python)

python
def vb_encode_number(n):
    bytes_out = []
    while True:
        bytes_out.insert(0, n % 128)
        if n < 128:
            break
        n //= 128
    bytes_out[-1] += 128  # 设置停止位(最高有效位=1)
    return bytes_out

def vb_encode_list(numbers):
    result = []
    for n in numbers:
        result.extend(vb_encode_number(n))
    return bytes(result)

def vb_decode(data):
    n, out = 0, []
    for b in data:
        if b < 128:
            n = 128 * n + b
        else:
            n = 128 * n + (b - 128)
            out.append(n)
            n = 0
    return out

nums = [824, 5, 300]
encoded = vb_encode_list(nums)
print(list(encoded))
print(vb_decode(encoded))

微型代码(C)

c
#include <stdio.h>
#include <stdint.h>

int vb_encode(uint32_t n, uint8_t *out) {
    uint8_t buf[5];
    int i = 0;
    do {
        buf[i++] = n % 128;
        n /= 128;
    } while (n > 0);
    buf[0] += 128;  // 设置延续位
    for (int j = i - 1; j >= 0; j--) out[i - 1 - j] = buf[j];
    return i;
}

uint32_t vb_decode(const uint8_t *in, int *pos) {
    uint32_t n = 0;
    uint8_t b;
    do {
        b = in[(*pos)++];
        if (b >= 128) n = 128 * n + (b - 128);
        else n = 128 * n + b;
    } while (b < 128);
    return n;
}

为什么它很重要

  • 用于信息检索系统:压缩倒排列表(文档ID间隔、词频)。
  • 紧凑 + 快速:字节对齐,简单的位操作,利于CPU缓存。
  • 非常适合增量编码的整数(遵循算法884)。

权衡:

  • 压缩密度低于位打包或帧内参照压缩。
  • 不便于SIMD优化(长度不规则)。
  • 对于长数字,每7位需要一个额外的字节。

一个温和的证明(为什么它有效)

设每个数字 $x$ 可以按基数 $128$ 分解:

$$ x = a_0 + 128 a_1 + 128^2 a_2 + \dots + 128^{k-1} a_{k-1} $$

每个块 $a_i$ 都适合7位 $(0 \le a_i < 128)$。编码过程以逆序附加字节,并将最后一个字节的最高位设为1。解码过程通过将部分和乘以128,直到遇到终止字节,来逆转这个过程。

$\mathbb{N}$ 与有效字节序列之间的双射关系保证了正确性和无前缀性。

亲自尝试

  1. 与文档ID的增量编码结合使用:存储 gap[i] = doc[i] - doc[i-1]
  2. 对比纯整数,对编码和解码吞吐量进行基准测试。
  3. 可视化不同大小的数字如何消耗可变字节。
  4. 为固定批次大小添加类似SIMD的展开解码器。
  5. 应用于时间序列或日志中的整数流。

测试用例

输入编码后的字节解码后
[1][129][1]
[128][1, 128][128]
[300][2, 172][300]
[824, 5, 300][6, 136, 5, 2, 172][824, 5, 300]

复杂度

操作时间空间
编码$O(n)$$O(n)$
解码$O(n)$$O(1)$
压缩比取决于数据量级

可变字节编码是信息检索领域的压缩主力军,它小巧、快速、字节对齐,并且完美适配那些倾向于变小的整数。

886 埃利亚斯 Gamma 编码

埃利亚斯 Gamma 编码是一种用于正整数的通用编码,它使用可变数量的比特对数字进行编码,而不需要已知的上限。它优雅、无前缀,并且构成了其他通用编码(如 Delta 和 Rice 编码)的基础。

我们要解决什么问题?

当压缩范围未知的整数序列时,定长编码是浪费的。我们想要一种自定界的、比特级的编码,能够高效地表示小数字和大数字,且无需预定义限制。

埃利亚斯 Gamma 编码通过对数比特长度编码实现了这一点,使其成为在压缩索引中存储间隔、计数或排名的理想选择。

它是如何工作的(通俗解释)

要编码一个正整数 $x$:

  1. 将 $x$ 写成二进制形式。 令 $b = \lfloor \log_2 x \rfloor$。
  2. 写入 $b$ 个零(一元前缀)。
  3. 写入 $x$ 的二进制表示。

例如,编码数字 10:

  • 二进制(10) = 1010,长度 = 4 比特 → 前缀 = 000(3 个零)。
  • 输出:0001010
数字二进制前缀Gamma 编码
11(无)1
2100010
3110011
41000000100
51010000101
1010100000001010

示例(解码)

要解码,首先计算前导零的数量 ($b$),然后读取接下来的 $b+1$ 个比特作为二进制值。

示例: 0001010 → 前导零 = 3 → 读取 4 个比特 → 1010 = 10。

微型代码(Python)

python
def elias_gamma_encode(n):
    if n <= 0:
        raise ValueError("Elias gamma only encodes positive integers")
    binary = bin(n)[2:]
    prefix = '0' * (len(binary) - 1)
    return prefix + binary

def elias_gamma_decode(code):
    i = 0
    while i < len(code) and code[i] == '0':
        i += 1
    length = i + 1
    value = int(code[i:i+length], 2)
    return value

nums = [1, 2, 3, 4, 5, 10]
codes = [elias_gamma_encode(n) for n in nums]
print(list(zip(nums, codes)))

微型代码(C)

c
#include <stdio.h>
#include <math.h>

void elias_gamma_encode(unsigned int x) {
    int b = (int)floor(log2(x));
    for (int i = 0; i < b; i++) printf("0");
    for (int i = b; i >= 0; i--)
        printf("%d", (x >> i) & 1);
}

int main() {
    for (int i = 1; i <= 10; i++) {
        printf("%2d: ", i);
        elias_gamma_encode(i);
        printf("\n");
    }
    return 0;
}

为什么它重要

  • 通用性:适用于任何正整数,无需固定范围。
  • 无前缀:每个码字都可以无歧义地解析。
  • 对小数字紧凑:开销为 $2 \lfloor \log_2 n \rfloor + 1$ 比特。
  • 应用于
    • 倒排索引中的文档 ID 间隔
    • 图邻接表
    • 紧凑字典和秩/选择结构

权衡:

  • 比特级(非字节对齐)→ 解码速度比可变字节编码慢。
  • 对大整数不理想(编码长度呈对数增长)。

一个温和的证明(为什么它有效)

每个数字 $n$ 的编码长度为: $$ \text{长度} = 2\lfloor \log_2 n \rfloor + 1 $$

由于每个二进制编码都有唯一的一元前缀长度,该编码满足克拉夫特不等式: $$ \sum_{n=1}^\infty 2^{-\text{长度}(n)} \le 1 $$

因此,该编码是无前缀且可解码的。冗余度(相对于 $-\log_2 n$ 的额外比特)每个符号不超过 1 比特。

自己动手试试

  1. 编码序列 [1, 3, 7, 15] 并计算总比特数。
  2. 比较小间隔情况下与可变字节编码的压缩率。
  3. 实现埃利亚斯 Delta 编码(在长度上添加 Gamma 编码)。
  4. 可视化前缀长度增长与数字大小的关系。
  5. 测量随机数据比特流解码的速度。

测试用例

数字Gamma 编码比特数
111
20103
30113
4001005
5001015
1000010107

复杂度

操作时间空间
编码$O(\log n)$$O(1)$
解码$O(\log n)$$O(1)$

埃利亚斯 Gamma 编码是优雅简约的典范,它是一种自定界的比特低语,其扩展程度恰好足以容纳数字的大小,不多不少。

887 莱斯编码

莱斯编码(或 Golomb–Rice 编码)是一种实用且高效的非负整数压缩方法,尤其适用于较小值出现频率远大于较大值的情况。它是 Golomb 编码的一种简化形式,使用 2 的幂作为除数,从而实现极快的比特级编码和解码。

我们要解决什么问题?

在对计数、游程长度或残差(如差值)进行编码时,我们常常会遇到几何分布的数据:小数字很常见,大数字很罕见。莱斯编码利用这种偏斜分布,使用一个参数 $k$ 来控制分配给余数的比特数,从而高效地进行编码。

它简单、无损,广泛应用于 FLAC、H.264 和 LZMA 等格式的整数压缩中。

它是如何工作的(通俗解释)

莱斯编码将一个整数 $x$ 分成两部分:

  • 商:$q = \lfloor x / 2^k \rfloor$
  • 余数:$r = x \bmod 2^k$

然后:

  1. 用一元编码表示 $q$(一连串 q 个 1 后面跟一个 0)。
  2. 用二进制编码表示 $r$,恰好使用 $k$ 比特。

因此,Rice$(x, k)$ = 111...10 + (r 的 k 比特表示)

示例

设 $k = 2$(除数 $= 4$):

$x$$q = \lfloor x/4 \rfloor$$r = x \bmod 4$编码
0000 00000
1010 01001
2020 10010
3030 11011
41010 001000
51110 011001
71310 111011
820110 0011000

一元编码表示商;二进制编码表示余数。

微型代码(Python)

python
def rice_encode(x, k):
    q = x >> k
    r = x & ((1 << k) - 1)
    unary = "1" * q + "0"
    binary = format(r, f"0{k}b")
    return unary + binary

def rice_decode(code, k):
    q = code.find("0")
    r = int(code[q + 1:q + 1 + k], 2)
    return (q << k) + r

for x in range(0, 9):
    code = rice_encode(x, 2)
    print(x, code)

微型代码(C)

c
#include <stdio.h>

void rice_encode(unsigned int x, int k) {
    unsigned int q = x >> k;
    unsigned int r = x & ((1 << k) - 1);
    for (unsigned int i = 0; i < q; i++) putchar('1');
    putchar('0');
    for (int i = k - 1; i >= 0; i--)
        putchar((r >> i) & 1 ? '1' : '0');
}

int main() {
    for (int x = 0; x < 9; x++) {
        printf("%2d: ", x);
        rice_encode(x, 2);
        printf("\n");
    }
    return 0;
}

为什么它很重要

  • 对小整数压缩效果好:类似几何分布的数据能得到很好的压缩。
  • 速度快:仅涉及移位、掩码和简单循环。
  • 参数可调:$k$ 控制商和余数之间的平衡。
  • 应用场景
    • FLAC(音频残差编码)
    • FFV1 / H.264 残差
    • LZMA、Bzip2 变体中的熵编码器

权衡点

  • 需要调整 $k$ 以获得最佳效率。
  • 不适合均匀分布或包含大量离群值的数据。
  • 当 $E[x] \approx 2^k$ 时效果最佳。

一个温和的证明(为什么它有效)

给定整数 $x \ge 0$,令 $$ x = q \cdot 2^k + r, \quad 0 \le r < 2^k $$

码字由 $q + 1 + k$ 比特组成:

  • $q + 1$ 比特来自一元编码
  • $k$ 比特来自二进制余数编码

对于几何分布 $P(x) = (1 - p)^x p$,当 $$ 2^k \approx \frac{-1}{\log_2(1 - p)} $$ 时,期望码长最小。

因此,调整 $k$ 以匹配数据的偏斜度,可以实现接近最优的熵编码。

自己动手试试

  1. [0, 1, 2, 3, 4, 5, 6, 7] 分别取 $k = 1, 2, 3$ 进行编码,比较码长。
  2. 根据数据的均值自适应地调整 $k$。
  3. 在莱斯编码之前结合差值编码。
  4. 可视化一元编码和余数编码之间的权衡。
  5. 比较小整数情况下与 Elias Gamma 编码的压缩比。

测试用例

输入 $x$$k$码字比特数
020003
320113
4210004
7210114
82110005

复杂度

阶段时间复杂度空间复杂度
编码$O(1)$ 每值$O(1)$
解码$O(1)$ 每值$O(1)$

莱斯编码是数学精度与机器效率之间的完美桥梁,几次移位,几个比特,数据便压缩成节奏紧凑的形式。

888 Snappy 压缩

Snappy 是一种快速的、基于块的压缩算法,由 Google 设计,适用于那些速度比最大压缩率更重要的实时系统。与 zlib 或 LZMA 等重型压缩器不同,Snappy 优先考虑吞吐量而非压缩比,在压缩和解压缩时都能达到数百 MB/s 的速度。

我们要解决什么问题?

许多现代系统、数据库、流处理器和日志管道会产生大量需要快速存储或传输的数据。传统的压缩器(如 DEFLATE 或 bzip2)提供了良好的压缩效果,但对于这些管道来说太慢了。

Snappy 牺牲了一些压缩比以换取较低的 CPU 开销,非常适合:

  • 列式数据库(Parquet, ORC)
  • 消息队列(Kafka)
  • 数据交换(Avro, Arrow)
  • 内存缓存(RocksDB, LevelDB)

它是如何工作的?(通俗解释)

Snappy 基于 LZ77 风格的压缩,但针对速度进行了优化:

  1. 将数据分成块(通常为 32 KB)。
  2. 维护一个先前字节序列的滑动哈希表。
  3. 对于每个新序列:
    • 如果在历史记录中找到匹配项,则发出一个复制命令(偏移量 + 长度)。
    • 否则,发出一个字面量(原始字节)。
  4. 重复此过程直到块结束。

每个输出都由交替的字面量和复制片段组成。

编码格式

每个片段以一个标签字节开始:

  • 低 2 位:类型(00 字面量,01 复制-1字节偏移量,10 复制-2字节偏移量,等等)
  • 剩余位:长度或额外信息。

示例布局(简化):

类型描述
字面量00后跟长度和原始字节
复制-1013字节复制(1字节偏移量)
复制-2104字节复制(2字节偏移量)
复制-4115字节复制(4字节偏移量)

微型代码(Python 原型)

python
def snappy_compress(data: bytes):
    i = 0
    out = []
    while i < len(data):
        # 字面量部分(为简化起见,无匹配检测)
        literal_len = min(60, len(data) - i)
        out.append((literal_len << 2) | 0x00)  # 标签
        out.append(data[i:i+literal_len])
        i += literal_len
    return b"".join(out if isinstance(x, bytes) else bytes([x]) for x in out)

def snappy_decompress(encoded: bytes):
    i = 0
    out = bytearray()
    while i < len(encoded):
        tag = encoded[i]
        i += 1
        ttype = tag & 0x03
        if ttype == 0:
            length = tag >> 2
            out += encoded[i:i+length]
            i += length
    return bytes(out)

(简化版,真实的 Snappy 增加了匹配、偏移量和可变长度头部。)

微型代码(C 语言草图)

c
#include <stdio.h>
#include <string.h>

void snappy_literal(const char *input, int len) {
    unsigned char tag = (len << 2) | 0x00;
    fwrite(&tag, 1, 1, stdout);
    fwrite(input, 1, len, stdout);
}

int main() {
    const char *text = "hello hello hello";
    snappy_literal(text, strlen(text));
    return 0;
}

为什么它很重要

  • 极快:通常 300–500 MB/s。
  • CPU 高效:几乎没有熵编码开销。
  • 广泛采用:用于 RocksDB、Parquet、BigQuery、TensorFlow 检查点。
  • 确定且简单:块本地化,可在任何位置重新启动。

权衡:

  • 压缩比约 1.5–2.0 倍,低于 DEFLATE 或 LZMA。
  • 无熵编码(无哈夫曼阶段)。
  • 不适用于高度重复或结构化的文本。

一个温和的证明(为什么它有效)

Snappy 通过信息论的局部性实现其平衡:如果大部分冗余出现在 32 KB 窗口内,我们无需完整的熵建模就能快速找到重复序列。

对于一个字节流 $S = s_1, s_2, \dots, s_n$,Snappy 发出令牌 $(L_i, O_i)$ 使得: $$ S = \bigoplus_i \text{(literal)}(L_i) \oplus \text{(copy)}(O_i, L_i) $$

由于字面量和复制令牌不相交地覆盖了流并编码了完整的偏移量,压缩和解压缩函数形成了一个可逆映射,确保了无损性。

亲自尝试

  1. 压缩文本文件并测量压缩比与 gzip 的对比。
  2. 检查 Parquet 文件元数据,查看 compression=SNAPPY
  3. 为匹配检测实现滚动哈希。
  4. 为重复输入可视化字面量/复制片段。
  5. 用随机数据与重复数据对你的实现进行基准测试。

测试用例

输入编码(十六进制)注释
hello140x68656c6c6f仅字面量
aaaaaa比原始数据小由于重复模式
大型重复日志缩小 2–3 倍可预测的结构

复杂度

操作时间空间
压缩$O(n)$$O(1)$
解压缩$O(n)$$O(1)$
压缩比通常 1.5–2.0 倍

Snappy 是压缩领域的速度精灵,只牺牲少量比特,以完美契合你 CPU 的节奏。

889 Zstandard (Zstd)

Zstandard(简称 Zstd)是由 Facebook 开发的一种现代通用压缩算法,在速度和压缩比之间取得了出色的平衡。它提供可调的压缩级别、自适应字典系统以及极快的解压速度——这使其成为数据存储、流式传输和传输系统的理想选择。

我们要解决什么问题?

像 zlib(DEFLATE)这样的传统压缩器提供了不错的压缩比,但在速度上存在瓶颈;像 LZ4 这样的新压缩器速度很快,但压缩深度往往不够。Zstd 填补了这一空白:它的压缩速度比 zlib 快 3-5 倍,同时实现了更好的压缩比。

Zstd 支持:

  • 可调压缩级别(1-22)
  • 针对小数据(日志、JSON、RPC 负载)的预训练字典
  • 针对大文件的流式和基于帧的编码

它是如何工作的(通俗解释)

Zstd 建立在三个概念层之上:

  1. LZ77 反向引用 它查找重复的字节序列,并用(偏移量,长度)对替换它们。
  2. FSE(有限状态熵)编码 它使用熵模型压缩字面字节、偏移量和长度。FSE 是非对称数字系统(ANS) 的一种高效实现,是霍夫曼编码的一种替代方案。
  3. 自适应字典和块模式 它可以从先前的样本中学习模式,从而高效地压缩小负载。

每个压缩帧包含:

  • 头部(魔数、选项)
  • 一个或多个压缩块
  • 校验和(可选)

示例(概念流程)

原始数据:  "abcabcabcabc"
步骤 1:  LZ77 → [字面量 "abc", 复制(3,9)]
步骤 2:  对字面量和偏移量进行熵编码
步骤 3:  帧头 + 块输出
结果:  紧凑的流,大小小于原始大小的 50%

微型代码(使用 zstandard 库的 Python)

python
import zstandard as zstd

data = b"The quick brown fox jumps over the lazy dog." * 10
cctx = zstd.ZstdCompressor(level=5)
compressed = cctx.compress(data)

dctx = zstd.ZstdDecompressor()
decompressed = dctx.decompress(compressed)

print("压缩比:", len(data)/len(compressed))

微型代码(C 语言示例)

c
#include <zstd.h>
#include <stdio.h>
#include <string.h>

int main() {
    const char* input = "hello hello hello hello";
    size_t input_size = strlen(input);
    size_t bound = ZSTD_compressBound(input_size);
    char compressed[bound];
    char decompressed[100];

    size_t csize = ZSTD_compress(compressed, bound, input, input_size, 3);
    size_t dsize = ZSTD_decompress(decompressed, sizeof(decompressed), compressed, csize);

    printf("原始大小: %zu 字节, 压缩后: %zu 字节\n", input_size, csize);
    return 0;
}

为什么它很重要

  • 性能
    • 在级别 3-5 下,压缩速度比 zlib 快
    • 解压速度接近 LZ4
  • 灵活性
    • 广泛的压缩级别范围
    • 适用于小对象(通过字典)或太字节级数据(流模式)
  • 采用情况
    • 用于 zstdtar --zstd、Facebook RocksDB、TensorFlow 检查点、Linux 内核(initramfs)、Kafka 和 Git

权衡

  • 更高的压缩级别需要更多内存(压缩器和解压器都需要)。
  • 实现复杂度略高于简单的基于 LZ 的方案。

一个温和的证明(为什么它有效)

Zstd 的核心熵引擎——有限状态熵,维护一个单一的状态变量 $x$,该变量同时代表多个概率。对于符号流 $s_1, s_2, \dots, s_n$ 及其概率 $P(s_i)$,状态更新规则如下:

$$ x_{i+1} = \lfloor x_i / P(s_i) \rfloor + f(s_i) $$

这保持了与算术编码相当的信息平衡,但开销更小。由于熵编码直接与反向引用集成,Zstd 实现了接近 DEFLATE + Huffman 的压缩比,但通过使用预归一化表运行得更快。

亲自尝试

  1. 使用 zstd -1zstd -9 压缩日志,比较大小和速度。
  2. 使用字典训练:
    zstd --train *.json -o dict
  3. 尝试流式 API(ZSTD_CStream)。
  4. 比较解压时间与 gzip 和 LZ4。
  5. 使用 zstd --list --verbose file.zst 检查帧头。

测试用例

输入类型级别压缩比速度 (MB/s)
文本日志32.8×420
JSON 负载94.5×250
二进制列数据53.0×350
视频帧11.6×500

复杂度

阶段时间复杂度空间复杂度
压缩$O(n)$$O(2^k)$(级别为 $k$ 时)
解压$O(n)$$O(1)$
熵编码$O(n)$$O(1)$

Zstandard 是现代压缩技术的典范——快速、灵活且数学上优雅,是熵编码与工程实用主义的完美结合。

890 LZ4 压缩

LZ4 是一种轻量级、无损的压缩算法,专注于速度和简洁性。它通过使用极简版的 LZ77 方案,实现了极快的压缩和解压缩速度,非常适合实时系统、内存存储和网络序列化。

我们要解决什么问题?

当应用程序处理海量数据流、日志、指标、缓存或列式数据块时,每一个 CPU 周期都至关重要。像 gzip 或 zstd 这样的传统压缩器提供了不错的压缩率,但会引入延迟。LZ4 则提供即时压缩,速度快到足以跟上高吞吐量的流水线,即使在单个 CPU 核心上也是如此。

应用场景:

  • 数据库:RocksDB、Cassandra、SQLite
  • 文件系统:Btrfs、ZFS
  • 序列化框架:Kafka、Arrow、Protobuf
  • 实时系统:遥测、日志摄取

它是如何工作的(通俗解释)

LZ4 围绕类似于 LZ77 的匹配-复制模型构建,但为简洁性进行了优化。

  1. 扫描输入并维护一个 64 KB 的滑动窗口。
  2. 在最近的历史数据中查找重复的序列。
  3. 将数据编码为字面量(未匹配的字节)或匹配项(偏移量 + 长度)。
  4. 每个块都以紧凑的二进制格式编码多个片段。

一个块的编码格式如下:

[token][literals][offset][match_length]

其中:

  • token 的高 4 位 = 字面量长度
  • token 的低 4 位 = 匹配长度(减去 4)
  • 偏移量 = 2 字节的后向距离

示例(概念流程)

原始数据:

ABABABABABAB
  • 第一个 "AB" 存储为字面量。
  • 后续的重复在偏移量 2 处找到 → 编码为 (offset=2, length=10)。
  • 结果:紧凑且几乎可以即时解压缩。

微型代码(Python 原型)

python
def lz4_compress(data):
    out = []
    i = 0
    while i < len(data):
        # 发射最多 4 个字节的字面量块
        lit_len = min(4, len(data) - i)
        token = lit_len << 4
        out.append(token)
        out.extend(data[i:i+lit_len])
        i += lit_len
    return bytes(out)

def lz4_decompress(data):
    out = []
    i = 0
    while i < len(data):
        token = data[i]; i += 1
        lit_len = token >> 4
        out.extend(data[i:i+lit_len])
        i += lit_len
    return bytes(out)

(简化版,真实的 LZ4 包含匹配编码和偏移量。)

微型代码(C 语言示例)

c
#include <lz4.h>
#include <stdio.h>
#include <string.h>

int main() {
    const char *src = "Hello Hello Hello Hello!";
    char compressed[100];
    char decompressed[100];

    int csize = LZ4_compress_default(src, compressed, strlen(src), sizeof(compressed));
    int dsize = LZ4_decompress_safe(compressed, decompressed, csize, sizeof(decompressed));

    printf("Original: %zu, Compressed: %d, Decompressed: %d\n", strlen(src), csize, dsize);
    return 0;
}

为什么它很重要

  • 速度:世界上最快的压缩器之一。

    • 压缩:约 400–700 MB/s
    • 解压缩:约 1500–2500 MB/s
  • 确定性:没有熵编码,分支极少。

  • 跨平台:有 C、Rust、Go、Python、Java 的实现。

  • 流式友好:内置帧和块 API。

权衡:

  • 压缩率较低(典型为 1.5–2 倍)。
  • 没有熵编码,因此会错过局部范围之外的冗余。

一个温和的证明(为什么它有效)

令 $S = s_1, s_2, \dots, s_n$ 为输入字符串。LZ4 找到表示匹配的对 $(o_i, \ell_i)$,其中匹配长度为 $\ell_i$ 字节,起始位置向后 $o_i$ 字节。

编码流在字面量和匹配项之间交替: $$ S = L_1 + (o_1, \ell_1) + L_2 + (o_2, \ell_2) + \dots $$

由于每个 $(o_i, \ell_i)$ 都引用先前已发射的数据,解码只是一个简单的线性遍历。LZ4 确保每个字节只被处理一次,使得压缩和解压缩的时间复杂度均为 $O(n)$。

自己动手试试

  1. 比较 LZ4、gzip 和 zstd 的压缩率。

  2. 在大日志文件上使用 lz4 -1lz4 -9

  3. 启用块流模式:

    lz4 --block-size=256KB input.txt output.lz4
  4. 测量随机数据与结构化文本的解压缩速度。

  5. 将 LZ4 嵌入数据库或缓存中以减少 I/O。

测试用例

输入大小LZ4 压缩比zstd 压缩比备注
随机文本100 KB1.4×2.6×快速
JSON 日志1 MB1.8×3.5×中等
重复性数据500 KB2.5×3.0×两者都很好
二进制数据2 MB1.2×2.0×增益很小

复杂度

阶段时间空间
压缩$O(n)$$O(1)$
解压缩$O(n)$$O(1)$

LZ4 是压缩领域的 F1 赛车,极简、快速,并且经过精心设计,以领先于传输中的每一个字节。

第 90 节 容错与复制

891 主从复制

主从复制(也称为主备复制)是分布式系统中实现容错和高可用性最简单且应用最广泛的方法之一。它维护一个处理所有写入操作的主节点,以及一个或多个保持数据同步副本的备份节点。

我们要解决什么问题?

在分布式系统或数据库中,节点可能因崩溃、网络分区或维护而意外故障。如果数据只有一个副本,单个故障就意味着服务中断或数据丢失。

主从复制确保了:

  • 始终有一个备用副本准备接管。
  • 更新能够可靠地从主节点复制到备份节点。

它是如何工作的(通俗解释)

  1. 客户端向主节点发送请求(通常是写入或事务)。
  2. 主节点执行操作并记录更新。
  3. 它将更新(或日志条目)发送给备份节点。
  4. 一旦所有备份节点都确认,主节点就提交更改。
  5. 如果主节点发生故障,一个备份节点会被提升为新的主节点。

消息流示例

客户端 → 主节点 → 备份节点
     (写入请求)
主节点 → 备份节点: 复制(数据)
备份节点 → 主节点: 确认
主节点 → 客户端: 成功

如果主节点崩溃,系统执行故障转移:

备份节点 → 成为主节点
客户端 → 重新连接到新的主节点

微型代码(Python 模拟)

python
class Node:
    def __init__(self, name):
        self.name = name
        self.log = []

    def apply_update(self, data):
        self.log.append(data)

class PrimaryBackup:
    def __init__(self):
        self.primary = Node("主节点")
        self.backup = Node("备份节点")

    def write(self, data):
        print(f"[主节点] 写入: {data}")
        self.primary.apply_update(data)
        print(f"[备份节点] 正在复制...")
        self.backup.apply_update(data)
        print("[确认] 写入完成并已复制")

system = PrimaryBackup()
system.write("x = 10")
system.write("y = 20")

微型代码(C 语言草图)

c
#include <stdio.h>
#include <string.h>

void replicate(const char *data) {
    printf("正在复制到备份节点: %s\n", data);
}

int main() {
    const char *updates[] = {"x=1", "y=2", "z=3"};
    for (int i = 0; i < 3; i++) {
        printf("主节点提交: %s\n", updates[i]);
        replicate(updates[i]);
    }
    printf("所有更新已成功复制。\n");
    return 0;
}

为什么它很重要

  • 简单性:易于实现和理解。
  • 可用性:如果一个节点故障,另一个可以接管。
  • 持久性:备份确保在故障期间数据持久保存。
  • 应用场景:MySQL 复制、ZooKeeper 观察者、Redis 复制、PostgreSQL 流复制。

权衡考虑:

  • 写入操作必须经过单一主节点(瓶颈)。
  • 故障转移可能导致短暂的服务不可用。
  • 复制延迟可能导致读取到过时数据。
  • 如果多个节点都认为自己是主节点,存在脑裂风险。

一个温和的证明(为什么它有效)

设 $S_p$ 为主节点的状态,$S_b$ 为备份节点的状态。

对于每个写入操作 $w_i$: $$ S_p = S_p \cup {w_i}, \quad S_b = S_b \cup {w_i} $$

如果复制是同步的,那么: $$ S_p = S_b \quad \forall i $$

如果是异步的,则存在一个延迟 $\Delta$,使得: $$ |S_p| - |S_b| \leq \Delta $$

在主节点发生故障的情况下,如果 $\Delta = 0$,备份节点可以安全地恢复;否则,可以恢复到最后一个已复制的状态。

动手尝试

  1. 用两个进程模拟主从复制。
  2. 在备份节点收到更新前引入故障。
  3. 测量异步模式下的数据丢失情况。
  4. 添加心跳机制用于故障检测。
  5. 实现同步复制(等待确认)。

测试用例

模式复制方式故障损失延迟
同步立即较高
异步延迟可能较低
半同步有界延迟最小中等

复杂度

操作时间空间
写入(同步)$O(n)$,n 为副本数$O(n)$
读取$O(1)$$O(1)$
故障转移$O(1)$ 检测$O(1)$ 恢复

主从复制是构建可靠系统的第一块基石,它简单、强大,并且随时准备在一个节点失效时接过火炬。

892 法定人数复制

法定人数复制是一种分布式一致性协议,它通过要求操作成功前只需副本的一个子集(即法定人数)达成一致,来平衡可用性、容错性和一致性。它是现代分布式数据库(如 Cassandra、DynamoDB 和 MongoDB)的基石。

我们要解决什么问题?

在全复制系统中,每次写入都必须到达所有节点,当某些节点发生故障或无法访问时,这会变得缓慢甚至不可能。法定人数复制确保即使系统部分宕机,只要足够多的节点达成一致,也能保证正确性。

核心思想:

  • 并非每个副本都必须响应,只需足够形成法定人数即可。
  • 法定人数的交集保证了数据一致性。

它是如何工作的(通俗解释)

假设有 N 个副本。每个操作都需要联系其中的一个子集:

  • R:一次读取操作所需的副本数量
  • W:一次写入操作所需的副本数量

当满足以下条件时,一致性得到保证: $$ R + W > N $$

这确保了每次读取操作至少会与一个副本上的最新写入操作重叠。

示例:

  • $N = 3$ 个副本
  • 选择 $W = 2$, $R = 2$ 那么:
  • 如果有 2 个副本确认,则写入成功。
  • 如果收到 2 个副本的响应,则读取成功。
  • 它们的集合存在交集 → 总能读取到最新的数据。

示例流程

  1. 客户端写入值 x = 10 → 发送给所有 N 个节点。
  2. 等待 W 个确认 → 提交。
  3. 客户端读取 → 查询所有 N 个节点,等待 R 个响应。
  4. 使用最新时间戳或版本向量解决冲突(如果存在)。

微型代码(Python 模拟)

python
import random

N, R, W = 3, 2, 2
replicas = [{"v": None, "ts": 0} for _ in range(N)]

def write(value, ts):
    acks = 0
    for r in replicas:
        if random.random() < 0.9:  # 模拟成功
            r["v"], r["ts"] = value, ts
            acks += 1
    return acks >= W

def read():
    responses = sorted(
        [r for r in replicas if random.random() < 0.9],
        key=lambda r: r["ts"],
        reverse=True
    )
    return responses[0]["v"] if len(responses) >= R else None

write("alpha", 1)
print("Read:", read())

微型代码(C 语言草图)

c
#include <stdio.h>

#define N 3
#define R 2
#define W 2

typedef struct { int value; int ts; } Replica;
Replica replicas[N];

int write_quorum(int value, int ts) {
    int acks = 0;
    for (int i = 0; i < N; i++) {
        replicas[i].value = value;
        replicas[i].ts = ts;
        acks++;
    }
    return acks >= W;
}

int read_quorum() {
    int latest = -1, value = 0;
    for (int i = 0; i < N; i++) {
        if (replicas[i].ts > latest) {
            latest = replicas[i].ts;
            value = replicas[i].value;
        }
    }
    return value;
}

int main() {
    write_quorum(42, 1);
    printf("Read quorum value: %d\n", read_quorum());
}

为何重要

  • 容错性:即使有 $(N - W)$ 个节点宕机,系统仍能工作。
  • 可扩展性:可以在延迟和一致性之间进行权衡。
  • 一致性保证:R 和 W 集合的交集确保了不会读到陈旧数据。
  • 应用场景:Amazon Dynamo、Cassandra、Riak、MongoDB 副本集。

权衡:

  • 法定人数过大 → 延迟更高。
  • 法定人数过小 → 存在读到陈旧数据的风险。
  • 需要解决并发写入的冲突。

一个温和的证明(为何有效)

令 $W$ 为一次写入所需的副本数量,$R$ 为一次读取所需的副本数量。

为了保证每次读取都能看到最新的写入: $$ R + W > N $$

这确保了任意两个法定人数集合(一个用于写入,一个用于读取)至少在一个节点上相交: $$ |Q_r \cap Q_w| \ge 1 $$

该相交节点总是携带最新的值,从而传播一致性。

如果 $R + W \le N$,则可能存在两个不相交的法定人数集合,导致读到陈旧数据。

亲自尝试

  1. 模拟一个 $N = 5$ 的集群。
  2. 设置不同的法定人数对:
  • $R=3$, $W=3$ → 强一致性
  • $R=1$, $W=3$ → 快速读取
  • $R=3$, $W=1$ → 快速写入
  1. 注入随机故障或慢节点。
  2. 验证哪些读取操作仍然保持一致。

测试用例

NRW条件行为
322R + W > N一致
311R + W ≤ N可能读到陈旧数据
533强一致性高延迟
514快速读取,较慢写入可用

复杂度

操作时间空间
写入$O(W)$$O(N)$
读取$O(R)$$O(N)$
恢复$O(N)$$O(N)$

法定人数复制通过选择需要多少一致性,优雅地平衡了分布式系统中不可能三角(一致性、可用性、分区容错性)。

893 链式复制

链式复制是一种容错复制技术,专为分布式存储系统中的强一致性和高吞吐量而设计。它将副本组织成一条线性链,其中写操作从头部流向尾部,而读操作则由尾部提供服务。

我们要解决什么问题?

传统的复制模型要么牺牲一致性(例如异步复制),要么牺牲吞吐量(例如向所有副本进行同步广播)。链式复制通过将副本构建成流水线,同时提供了线性一致性和高吞吐量。

它是如何工作的(通俗解释)

副本按顺序排列:

[头部] → [中间] → [尾部]
  • 写操作从头部开始,并沿着链向下转发。
  • 每个副本应用更新并转发它。
  • 当尾部应用更新后,它会向客户端发送成功确认。
  • 读操作发送到尾部,确保客户端始终看到最新提交的状态。

示例流程

写入 x = 10 的序列:

客户端 → 头部: 写入(x=10)
头部 → 中间: 转发(x=10)
中间 → 尾部: 转发(x=10)
尾部 → 客户端: 确认

读取序列:

客户端 → 尾部: 读取(x)
尾部 → 客户端: 返回最新值

微型代码(Python 模拟)

python
class Node:
    def __init__(self, name):
        self.name = name
        self.value = None
        self.next = None

    def write(self, value):
        self.value = value
        print(f"{self.name}: 写入 {value}")
        if self.next:
            self.next.write(value)

class ChainReplication:
    def __init__(self):
        self.head = Node("头部")
        mid = Node("中间")
        self.tail = Node("尾部")
        self.head.next = mid
        mid.next = self.tail

    def write(self, value):
        print("客户端写入:", value)
        self.head.write(value)
        print("尾部确认。\n")

    def read(self):
        print("从尾部读取:", self.tail.value)
        return self.tail.value

chain = ChainReplication()
chain.write(42)
chain.read()

微型代码(C 语言草图)

c
#include <stdio.h>

void replicate_chain(const char *data) {
    printf("头部收到写入: %s\n", data);
    printf("转发到中间: %s\n", data);
    printf("转发到尾部: %s\n", data);
    printf("尾部确认写入。\n");
}

int main() {
    replicate_chain("x=10");
    return 0;
}

为什么它很重要

  • 强一致性:所有读取都反映最新提交的写入。
  • 高吞吐量:每个副本只与一个邻居通信。
  • 可预测的流程:更新确定性地沿着链移动。
  • 应用于:FAWN-KV、Microsoft 的 PacificA 以及分布式日志系统。

权衡:

  • 单条链 → 头部或尾部可能存在瓶颈。
  • 故障转移需要重新配置。
  • 写入延迟随链长度增加而增加。

一个温和的证明(为什么它有效)

设副本为 $R_1, R_2, \dots, R_n$。

对于任何写入 $w_i$:

$$ R_1 \xrightarrow[]{w_i} R_2 \xrightarrow[]{w_i} \dots \xrightarrow[]{w_i} R_n $$

所有写入都遵循相同的顺序,并且读取只发生在 $R_n$。因此,所有读取都观察到写入序列的一个前缀,满足线性一致性。

形式上,如果 $w_i$ 在 $w_j$ 之前完成,那么:

$$ w_i \text{ 在所有副本中可见先于 } w_j $$

因此,客户端永远不会看到过时或乱序的数据。

自己动手试试

  1. 模拟一个包含 3 个节点的链,并在中间节点注入一个写入故障。
  2. 重新配置链以绕过故障节点。
  3. 测量吞吐量,并与同步复制到所有节点进行比较。
  4. 扩展到 5 个节点,观察写入延迟的增长。

测试用例

节点数写入/秒延迟一致性
3中等
5中等较高
3(头部故障)需要重新配置暂停可恢复

复杂度

操作时间空间
写入$O(n)$$O(n)$
读取$O(1)$$O(n)$
重新配置$O(1)$$O(1)$

链式复制将复制变成了一个有序的流水线,每个节点都是可靠性链条中的一个环节,确保数据从头到尾平稳且一致地流动。

894 流言协议

流言协议,也称为流行病传播通信,是一种在分布式系统中传播信息的去中心化机制。 它没有中央协调器,而是每个节点定期与随机选择的节点“闲聊”以交换更新,就像人群中传播谣言一样。

我们正在解决什么问题?

在大型、不可靠的网络中,我们需要一种方式让所有节点最终都能了解到新数据、故障或配置变更。 向每个节点广播成本太高,而集中式协调无法扩展。 流言协议以概率保证和最小的协调实现了最终一致性。

它是如何工作的(通俗解释)

每个节点维护一个本地状态(例如成员信息、键值对或版本向量)。 在固定的时间间隔内:

  1. 一个节点随机选择另一个节点。
  2. 它们交换更新(推送、拉取或两者兼具)。
  3. 每个节点合并它学到的信息。
  4. 重复此过程,直到所有节点收敛到相同的状态。

经过几轮之后,系统中的几乎所有节点都将拥有一致的信息,类似于病毒的传播。

流言风格

类型描述
推送向随机节点发送更新。
拉取向节点询问缺失的更新。
推送-拉取双向交换,收敛最快。

示例:成员流言

节点维护一个带有时间戳或心跳的成员列表。

每一轮流言传播:

节点 A → 节点 B:
{ 节点C: 存活, 节点D: 疑似故障 }

节点 B 将此信息合并到自己的列表中,并进一步传播。

经过几轮之后,整个集群就哪些节点存活或故障达成一致。

微型代码(Python 模拟)

python
import random

nodes = {
    "A": {"x": 0},
    "B": {"x": 0},
    "C": {"x": 0}
}

def gossip_round():
    for node in list(nodes.keys()):
        peer = random.choice(list(nodes.keys()))
        if peer != node:
            # 合并信息
            nodes[peer]["x"] = nodes[node]["x"]

# 节点 A 更新数据
nodes["A"]["x"] = 42

for _ in range(5):
    gossip_round()

print(nodes)

这个简单的模拟显示,经过几轮之后,所有节点都收敛到相同的值。

微型代码(C 语言概览)

c
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

#define N 3

int state[N] = {0, 0, 0};

void gossip_round() {
    for (int i = 0; i < N; i++) {
        int peer = rand() % N;
        if (peer != i)
            state[peer] = state[i];
    }
}

int main() {
    srand(time(NULL));
    state[0] = 42;
    for (int i = 0; i < 5; i++) gossip_round();

    for (int i = 0; i < N; i++)
        printf("Node %d state: %d\n", i, state[i]);
}

为何重要

  • 可扩展性:适用于数千个节点。
  • 容错性:没有单点故障。
  • 概率效率:以较低的网络成本实现快速收敛。
  • 广泛应用:Cassandra、Dynamo、Redis Cluster、Akka、Serf、Consul。

权衡:

  • 收敛是概率性的,而非确定性的。
  • 可能存在暂时的不一致。
  • 如果更新频率过高,流言通信量可能会增长。

一个温和的证明(为何有效)

设 $n$ 为节点数量,$t$ 为流言轮数。 每轮中每个节点随机联系另一个节点。

获知信息的节点数量呈指数增长: $$ I_t = n \left(1 - e^{-t/n}\right) $$

经过 $O(\log n)$ 轮后,几乎所有节点都获知了信息,类似于流行病在人群中传播的方式。 这以高概率实现了最终一致性。

亲自尝试

  1. 从 10 个节点开始,让其中 1 个节点拥有新数据。
  2. 运行几轮流言传播;跟踪收敛时间。
  3. 尝试仅推送与推送-拉取模式。
  4. 添加随机故障以测试弹性。
  5. 调整流言间隔以平衡速度和带宽。

测试用例

节点数流言风格收敛轮数可靠性
10推送~795%
10推送-拉取~4100%
100推送-拉取~999%
1000推送-拉取~1499.9%

复杂度

操作时间消息数量
每轮流言传播$O(1)$$O(n)$
收敛$O(\log n)$$O(n \log n)$

流言协议将混沌转化为和谐, 每个节点分享着低语,直到整个系统哼唱着相同的真相。

895 反熵修复

反熵修复是一种后台进程,用于在分布式系统中保持节点间复制数据的一致性。它能检测并协调副本之间的差异,确保即使在更新或故障导致数据分叉时,最终也能达成一致性。

我们正在解决什么问题?

在真实的分布式系统中,节点可能由于以下原因错过更新:

  • 网络分区
  • 临时中断
  • 消息丢失

随着时间的推移,副本会逐渐漂移,它们的状态会产生差异。反熵修复持续比较副本并同步差异,无需中央协调即可恢复一致性。

它是如何工作的(通俗解释)

每个节点定期选择另一个节点,执行一次协调交换。

主要有两个步骤:

  1. 检测差异 比较数据摘要(哈希、版本向量、默克尔树)。
  2. 修复差异 发送并合并缺失的或更新的数据项。

这个过程在后台持续进行,缓慢而持续地修复不一致性。

示例流程

节点 A ↔ 节点 B
比较:摘要或版本向量
如果不匹配:
   A → B:发送缺失的键
   B → A:发送更新的值
合并 → 两者收敛

经过多轮之后,所有副本都会达到相同的版本化状态。

使用的技术

技术描述
默克尔树用于大数据集的层次化哈希比较
向量时钟跟踪更新的因果顺序
时间戳在发生冲突时选择最新版本
版本合并如果可能,合并冲突的写入

微型代码(Python 模拟)

python
from hashlib import md5

def digest(data):
    return md5("".join(sorted(data)).encode()).hexdigest()

A = {"x": "1", "y": "2"}
B = {"x": "1", "y": "3"}

def anti_entropy_repair(a, b):
    if digest(a.values()) != digest(b.values()):
        for k in a:
            if a[k] != b.get(k):
                if a[k] > b.get(k, ""):
                    b[k] = a[k]
                else:
                    a[k] = b[k]

anti_entropy_repair(A, B)
print("修复后:", A, B)

微型代码(C 语言草图)

c
#include <stdio.h>
#include <string.h>

struct KV { char key[10]; char val[10]; };

void repair(struct KV *a, struct KV *b, int n) {
    for (int i = 0; i < n; i++) {
        if (strcmp(a[i].val, b[i].val) != 0) {
            strcpy(a[i].val, b[i].val); // 简单的合并规则
        }
    }
}

int main() {
    struct KV A[2] = {{"x", "1"}, {"y", "2"}};
    struct KV B[2] = {{"x", "1"}, {"y", "3"}};
    repair(A, B, 2);
    printf("修复后: y=%s\n", A[1].val);
}

为什么它很重要

  • 修复最终一致性:在故障后保持数据同步。
  • 自治性:每个节点独立修复,无需全局协调。
  • 带宽高效:使用哈希(默克尔树)来最小化数据传输。
  • 应用场景:Amazon Dynamo、Cassandra、Riak 以及其他 AP 系统。

权衡:

  • 后台修复会消耗带宽。
  • 冲突需要解决逻辑。
  • 修复频率影响新鲜度与开销之间的平衡。

一个温和的证明(为什么它有效)

令 $S_i$ 和 $S_j$ 为节点 $i$ 和 $j$ 的数据集。在时间 $t$,它们可能不同: $$ \Delta_{ij}(t) = S_i(t) \setminus S_j(t) $$

每次反熵会话都会减少差异: $$ |\Delta_{ij}(t+1)| < |\Delta_{ij}(t)| $$

经过多轮重复,只要网络最终连通且修复持续进行: $$ \lim_{t \to \infty} \Delta_{ij}(t) = \emptyset $$

因此,所有副本都会收敛,确保最终一致性。

亲自尝试

  1. 模拟具有不一致状态的三个副本。
  2. 使用简单的摘要比较实现一轮修复。
  3. 在轮次之间添加随机故障。
  4. 观察多次迭代后的收敛情况。
  5. 扩展到使用默克尔树比较大数据集。

测试用例

节点数修复方法收敛轮次一致性
2直接差异比较1 轮强一致性
3成对八卦~log(n) 轮最终一致性
100默克尔树少数几轮最终一致性

复杂度

操作时间带宽
摘要比较$O(n)$$O(1)$
完全修复$O(n)$$O(n)$
默克尔树修复$O(\log n)$$O(\log n)$

反熵修复如同分布式系统安静的守护者,稳步地在网络中穿行,比对记录,确保每个副本最终都能讲述同一个故事。

896 纠删码

纠删码是一种容错技术,它通过将数据分割成片段并添加冗余的奇偶校验块来保护数据免遭丢失。与简单的复制不同,它能以更低的存储开销实现相同的可靠性,这使其成为现代分布式存储系统的基石。

我们要解决什么问题?

复制(为每个块保留3个或更多副本)保证了持久性,但浪费了空间。纠删码提供了一种数学上高效的替代方案,它在使用更少额外字节的同时保持了冗余性。

目标: 如果部分数据丢失,系统可以从片段的一个子集重建原始数据。

它是如何工作的(通俗解释)

数据被分成 k 个数据块,并使用代数编码生成 r 个奇偶校验块。这些块共同构成了 n = k + r 个总片段。

n 个片段中的任意 k 个都可以重建原始数据。即使最多丢失 r 个片段,数据仍然可以恢复。

示例: 对于一个 (6, 4) 编码:

  • 4 个数据块
  • 2 个奇偶校验块
  • 可以容忍 2 个故障
  • 存储开销 = 6/4 = 1.5 倍(对比 3 倍复制)

可视化示例

原始数据: [D1, D2, D3, D4]

编码后:
  D1, D2, D3, D4 → P1, P2

存储的片段:
  节点1: D1
  节点2: D2
  节点3: D3
  节点4: D4
  节点5: P1
  节点6: P2

如果节点2和节点4故障,仍然可以从其他节点重建数据。

微型代码(Python 示例)

python
import numpy as np

def encode(data_blocks, r):
    k = len(data_blocks)
    data = np.array(data_blocks)
    parity = [sum(data) % 256 for _ in range(r)]  # 简单的 XOR 奇偶校验
    return data_blocks + parity

def decode(blocks, k):
    if len(blocks) >= k:
        return sum(blocks[:k]) % 256
    return None

blocks = [10, 20, 30, 40]
encoded = encode(blocks, 2)
print("编码后:", encoded)

这是一个玩具示例;真实系统使用有限域上的线性代数(例如,Reed–Solomon 码)。

微型代码(C 语言草图)

c
#include <stdio.h>

int parity(int *data, int n) {
    int p = 0;
    for (int i = 0; i < n; i++) p ^= data[i];
    return p;
}

int main() {
    int data[4] = {1, 2, 3, 4};
    int p = parity(data, 4);
    printf("奇偶校验块: %d\n", p);
    return 0;
}

为什么它很重要

  • 存储高效的持久性:比复制减少 50–70% 的开销。
  • 容错性:可以从多个故障中恢复。
  • 应用于:Hadoop HDFS、Ceph、MinIO、Google Colossus、Azure Storage。

权衡:

  • 编码/解码的 CPU 成本更高。
  • 重建丢失数据需要多个片段。
  • 修复期间延迟会增加。

一个温和的证明(为什么它有效)

纠删码依赖于编码片段的线性无关性。

设原始数据为一个向量: $$ \mathbf{d} = [d_1, d_2, \dots, d_k] $$

编码矩阵 $G$(大小为 $k \times n$)产生片段: $$ \mathbf{c} = \mathbf{d} G $$

只要 $G$ 的任意 $k$ 列是线性无关的, 我们就可以通过求解以下方程来恢复 $\mathbf{d}$: $$ \mathbf{d} = \mathbf{c} G^{-1} $$

因此,即使有 $r = n - k$ 个片段丢失,恢复也是有保证的。

自己动手试试

  1. 创建 4 个数据块,2 个奇偶校验块。
  2. 随机删除任意 2 个,使用剩余的 4 个进行重建。
  3. 测量系统扩展时的恢复时间。
  4. 与 3 倍复制比较存储效率。
  5. 在 Python 中试验 Reed–Solomon 库(pyreedsolomonzfec)。

测试用例

方案数据 (k)奇偶校验 (r)容忍能力开销示例用途
(3, 2)322 个故障1.67×Ceph
(6, 3)633 个故障1.5×MinIO
(10, 4)1044 个故障1.4×Azure Storage
3× 复制122 个故障简单系统

复杂度

操作时间空间
编码$O(k \times r)$$O(n)$
解码$O(k^3)$(矩阵求逆)$O(n)$
修复$O(k)$$O(k)$

纠删码将数学转化为韧性, 它从数据中编织出奇偶校验,允许系统丢失部分而不丢失整体。

897 校验和验证

校验和验证是一种轻量级的完整性算法,用于检测数据在存储或传输过程中是否损坏。它通过计算数据的紧凑数字指纹(即校验和)并在读取或接收数据时进行验证来工作。

我们要解决什么问题?

当数据在磁盘、内存或网络间移动时,可能会因以下原因而悄无声息地改变:

  • 位翻转
  • 传输噪声
  • 硬件故障
  • 软件错误

即使是一个错误的比特位也可能导致整个文件无效。校验和验证确保我们能够快速检测到损坏,通常在它造成危害之前。

它是如何工作的(通俗解释)

  1. 在发送或保存数据之前计算其校验和。
  2. 存储或传输数据和校验和。
  3. 在读取或接收时重新计算并比较校验和。
  4. 如果两个值不同 → 数据已损坏。

校验和使用简单的算术运算、哈希函数或循环冗余校验(CRC)。

常见算法

类型描述应用场景
求和 / 异或将所有字节相加或进行异或运算快速、简单、精度低
CRC(循环冗余校验)基于比特的多项式除法网络、文件系统
MD5 / SHA加密哈希函数安全验证
Fletcher / Adler加权模和嵌入式系统

示例

数据:"HELLO"

计算简单校验和: $$ \text{sum} = H + E + L + L + O = 72 + 69 + 76 + 76 + 79 = 372 $$

存储 (data, checksum=372)

读取时重新计算:

  • 如果 sum == 372 → 有效
  • 否则 → 已损坏

微型代码(Python 示例)

python
import zlib

data = b"HELLO WORLD"
checksum = zlib.crc32(data)
print("Checksum:", checksum)

# 模拟传输错误
received = b"HELLO WORLE"
print("Valid?" , zlib.crc32(received) == checksum)

微型代码(C 语言草图)

c
#include <stdio.h>
#include <stdint.h>

uint32_t simple_checksum(const char *s) {
    uint32_t sum = 0;
    while (*s) sum += (unsigned char)(*s++);
    return sum;
}

int main() {
    const char *data = "HELLO";
    uint32_t c1 = simple_checksum(data);
    printf("Checksum: %u\n", c1);

    const char *bad = "HELLP"; // 已损坏
    uint32_t c2 = simple_checksum(bad);
    printf("Corrupted? %s\n", c1 == c2 ? "no" : "yes");
}

为什么它很重要

  • 检测磁盘、内存或网络上的静默损坏。
  • 保护存储系统(HDFS、ZFS、Ceph、S3)。
  • 防止复制管道中出现未被检测到的数据漂移。
  • 计算简单,验证容易。

权衡:

  • 无法修复数据,只能检测错误。
  • 弱校验和(如异或)可能会漏掉某些错误模式。
  • 加密哈希函数消耗更多 CPU 时间。

一个温和的证明(为什么它有效)

校验和函数 $f(x)$ 将数据 $x$ 映射为一个紧凑的签名。

如果数据 $x$ 被损坏为 $x'$,当满足以下条件时我们检测到错误: $$ f(x) \ne f(x') $$

如果 $f$ 在 $k$ 个比特上均匀分布其值,则未被检测到的损坏概率大约为: $$ P_{\text{miss}} = 2^{-k} $$

例如:

  • 16 位校验和 → $1/65536$ 的漏检概率
  • 32 位 CRC → $1/4,294,967,296$
  • SHA-256 → 实际上为零

动手尝试

  1. 为任意文件计算 CRC32。
  2. 翻转单个字节并重新计算,观察校验和的变化。
  3. 尝试不同的算法(MD5、SHA-1、Adler-32)。
  4. 比较速度与可靠性。
  5. 将校验和集成到复制或传输管道中。

测试用例

算法比特位检测率典型用途
求和8遗留系统
CRC3232优秀网络数据包
MD5128非常高文件完整性
SHA-256256近乎完美安全验证

复杂度

操作时间复杂度空间复杂度
计算$O(n)$$O(1)$
验证$O(n)$$O(1)$

校验和验证是数据信任的最简单形式,一个默默守护着无形损坏的小数字,确保你存储或发送的正是你取回的东西。

898 心跳监控

心跳监控是一种简单但至关重要的分布式故障检测算法,它帮助系统了解哪些节点存活,哪些节点已静默失效。 通过在节点间定期发送“心跳”信号,系统可以快速检测到何时某个节点停止响应,并触发恢复或故障转移操作。

我们解决什么问题?

在分布式系统中,节点可能因多种原因失效:

  • 断电或网络中断
  • 进程崩溃
  • 网络分区或拥塞

如果没有明确的检测机制,系统可能会继续向已失效的节点发送请求,或者导致数据不可用。 心跳监控提供了一种轻量级、持续性的存活检查,以自动检测故障。

它是如何工作的(通俗解释)

每个节点(或一个中央监控器)维护一个对等节点列表及其最近一次心跳的时间戳。 在固定的时间间隔内:

  1. 节点向对等节点(或协调器)发送心跳消息。
  2. 接收方更新发送方的时间戳。
  3. 如果在超时时间内未收到心跳,则该节点被标记为疑似失效。
  4. 开始恢复或重新配置(例如,选举新领导者、重新分配负载)。

示例流程

节点 A → 心跳 → 节点 B
节点 B 记录:last_seen[A] = 当前时间
如果 当前时间 - last_seen[A] > 超时时间:
    节点 A 被认为失效

微型代码(Python 示例)

python
import time, threading

peers = {"A": time.time(), "B": time.time(), "C": time.time()}

def send_heartbeat(name):
    while True:
        peers[name] = time.time()
        time.sleep(1)

def check_failures(timeout=3):
    while True:
        now = time.time()
        for node, last in list(peers.items()):
            if now - last > timeout:
                print(f"[警报] 节点 {node} 超时!")
        time.sleep(1)

threading.Thread(target=send_heartbeat, args=("A",), daemon=True).start()
threading.Thread(target=check_failures, daemon=True).start()
time.sleep(10)

微型代码(C 语言示例)

c
#include <stdio.h>
#include <time.h>
#include <unistd.h>

int main() {
    time_t last_heartbeat = time(NULL);
    int timeout = 3;

    while (1) {
        sleep(1);
        time_t now = time(NULL);
        if (difftime(now, last_heartbeat) > timeout)
            printf("节点超时!\n");
        else
            printf("心跳正常。\n");
    }
}

为何重要

  • 快速故障检测:实现自动恢复。
  • 对领导者选举、复制和负载均衡至关重要
  • 简单而健壮:适用于所有分布式架构。
  • 应用场景:Kubernetes 存活探针、Raft、ZooKeeper、Redis Sentinel、Cassandra。

权衡考虑

  • 网络抖动可能导致误报。
  • 选择合适的超时时间很棘手(太短 → 频繁抖动,太长 → 延迟)。
  • 没有更高层逻辑时,无法区分节点崩溃和网络分区。

一个温和的证明(为何有效)

假设每个节点每 $\Delta$ 秒发送一次心跳,故障检测器的超时时间为 $T$。 如果一个节点在 $t_f$ 时刻停止发送,则在以下时刻被检测为失效: $$ t_d = t_f + T $$

检测时间满足: $$ \Delta \le T < \text{网络延迟上限} + \text{心跳抖动} $$

在此范围内选择 $T$ 可以确保完备性(不遗漏故障)和准确性(误报少)。 因此,心跳算法在理论上被归类为 $\phi$-累积检测器或最终故障检测器。

动手尝试

  1. 实现一个包含 3 个节点的集群,每 2 秒发送一次心跳。
  2. 引入随机延迟或丢包来模拟网络抖动。
  3. 调整超时阈值以平衡敏感性和稳定性。
  4. 记录节点被标记为存活或失效的时刻。
  5. 扩展功能,在检测到故障时选举新领导者。

测试用例

心跳间隔 (秒)超时时间 (秒)检测延迟误报率
13~2秒
11.5~0.5秒
0.51~0.5秒较高
26~4秒非常低

复杂度

操作时间空间
发送心跳$O(1)$$O(1)$
检查故障$O(n)$$O(n)$
网络开销$O(n)$

心跳监控是分布式系统的脉搏, 一种稳定的节奏,告诉我们谁还活着,谁已沉默,以及何时是系统自我修复的时刻。

899 领导者选举(霸道算法)

霸道算法是一种经典的分布式领导者选举算法,用于在集群节点中选择一个协调者。 它假设所有节点都有唯一的 ID 并且可以直接通信,当发生故障时,具有最高 ID 的节点将成为新的领导者。

我们要解决什么问题?

分布式系统通常需要一个节点充当领导者或协调者,来管理任务、分配工作或确保一致性。 当该领导者发生故障时,系统必须自动选举出一个新的领导者。

霸道算法提供了一种确定性的、容错的方法,用于在节点能够检测到崩溃并比较身份时进行领导者选举。

它是如何工作的(通俗解释)

每个节点都有一个唯一的 ID(通常是数字)。 当一个节点注意到领导者宕机时,它会发起一次选举:

  1. 该节点向所有 ID 更高的节点发送 ELECTION 消息。
  2. 如果没有更高 ID 的节点回复 → 它成为新的领导者。
  3. 如果有更高 ID 的节点回复 → 它等待该更高 ID 的节点完成其自身的选举。
  4. 胜出者通过 COORDINATOR 消息宣布自己。

示例流程

节点动作
节点 3 检测到领导者 5 宕机向节点 {4,5} 发送 ELECTION
节点 4 回复 "OK"节点 3 停止其选举
节点 4 现在发起自己的选举向 {5} 发送
节点 5 已宕机 → 无回复节点 4 成为领导者
节点 4 广播 "COORDINATOR(4)"所有节点更新领导者 = 4

微型代码(Python 示例)

python
nodes = [1, 2, 3, 4, 5]
alive = {1, 2, 3, 4}  # 节点 5 故障

def bully_election(start):
    higher = [n for n in nodes if n > start and n in alive]
    if not higher:
        print(f"节点 {start} 成为领导者")
        return start
    for h in higher:
        print(f"节点 {start} → ELECTION → 节点 {h}")
    print(f"节点 {start} 等待...")
    return max(alive)

leader = bully_election(3)
print("选举出的领导者:", leader)

微型代码(C 语言草图)

c
#include <stdio.h>

int bully(int ids[], int n, int start, int alive[]) {
    int leader = -1;
    for (int i = 0; i < n; i++) {
        if (ids[i] > start && alive[i])
            leader = ids[i];
    }
    if (leader == -1) leader = start;
    return leader;
}

int main() {
    int ids[] = {1, 2, 3, 4, 5};
    int alive[] = {1, 1, 1, 1, 0}; // 节点 5 宕机
    int leader = bully(ids, 5, 3, alive);
    printf("选举出的领导者: %d\n", leader);
}

为什么它重要

  • 确定性:最高 ID 的节点总是获胜。
  • 简单:只需要消息交换和 ID 比较。
  • 快速恢复:快速替换故障的领导者。
  • 应用场景:遗留分布式系统、Raft 或 ZooKeeper 的选举阶段,以及容错控制器。

权衡:

  • 需要可靠的故障检测。
  • 对于大型集群,消息开销高。
  • 假设完全连通性和同步性。

一个温和的证明(为什么它有效)

设 $N$ 为具有唯一 ID 的节点集合,$L = \max(N_{\text{alive}})$ 为存活的最高 ID。

  1. 节点 $i$ 检测到领导者故障。
  2. 它向所有 $j > i$ 发送 ELECTION。
  3. 如果没有 $j$ 回复,那么 $i = L$。
  4. 否则,$j$ 会发起自己的选举,并且由于 $L$ 是最大的,它最终会宣布自己为领导者。

因此,恰好选举出一个领导者(最高 ID 的节点),满足: $$ \text{安全性:同一时间只有一个领导者}
\text{活性:最终会选出一个领导者} $$

自己动手试试

  1. 模拟一个包含 5 个节点并带有随机故障的集群。
  2. 触发选举并记录消息流。
  3. 测量收敛时间。
  4. 修改为使用异步超时。
  5. 与 Raft 的随机化选举进行比较。

测试用例

节点故障的领导者选举出的领导者发送的消息数
{1,2,3,4,5}546
{1,2,3,5}533
{1,2,3,4,6}657

复杂度

操作时间消息数
选举$O(n)$$O(n^2)$
宣布$O(n)$$O(n)$

霸道算法确保了分布式世界中的秩序, 当寂静降临,最高的声音将升起引领,直到系统再次恢复健康。

900 领导者选举(环算法)

环算法是分布式系统中领导者选举的另一种方法,尤其适用于节点被组织成逻辑环的情况。 与 Bully 算法(通过直接消息倾向于最高 ID 节点)不同,环算法在环上循环传递选举消息,直到通过合作产生唯一的领导者。

我们要解决什么问题?

在一个没有中央控制器的分布式网络中,当当前领导者失效时,节点必须选举出一个新的领导者。 环算法专为以下场景设计:

  • 具有环状或圆形拓扑结构的系统
  • 对称通信(每个节点只知道其后继节点)
  • 完全广播或直接寻址成本高昂的情况

它确保所有节点平等参与,并保证最终由最高 ID 的节点成为领导者。

它是如何工作的(通俗解释)

  1. 拓扑结构:每个节点在逻辑环中仅知道其直接邻居。
  2. 选举开始:一个检测到领导者失效的节点发起选举。
  3. 它发送一个包含其 ID 的 ELECTION 消息给下一个节点。
  4. 每个节点将接收到的 ID 与自己的 ID 进行比较:
    • 如果接收到的 ID 更高 → 原样转发。
    • 如果更低 → 用自己的 ID 替换后转发。
    • 如果等于自己的 ID → 该节点获胜,并广播 COORDINATOR 消息。
  5. 所有节点将它们的领导者更新为宣布的获胜者。

示例流程

假设节点 {1, 3, 4, 5, 7} 排列成一个环。

节点 3 检测到领导者失效。
ELECTION(3) → 4 → 5 → 7 → 1 → 3
每个节点比较 ID。
节点 7 拥有最高 ID → 发送 COORDINATOR(7)
所有节点接受 7 作为领导者。

微型代码(Python 示例)

python
nodes = [1, 3, 4, 5, 7]

def ring_election(start):
    n = len(nodes)
    current = start
    candidate = nodes[start]

    while True:
        current = (current + 1) % n
        if nodes[current] > candidate:
            candidate = nodes[current]
        if current == start:
            break

    print(f"选举出的领导者: {candidate}")
    return candidate

ring_election(1)  # 从节点 3 开始

微型代码(C 语言草图)

c
#include <stdio.h>

int ring_election(int ids[], int n, int start) {
    int leader = ids[start];
    int i = (start + 1) % n;

    while (i != start) {
        if (ids[i] > leader)
            leader = ids[i];
        i = (i + 1) % n;
    }
    return leader;
}

int main() {
    int ids[] = {1, 3, 4, 5, 7};
    int leader = ring_election(ids, 5, 1);
    printf("选举出的领导者: %d\n", leader);
}

为什么它很重要

  • 自然地适用于环状结构或覆盖网络。
  • 与完全广播相比,减少了消息复杂度。
  • 确保公平性:所有节点都可以平等地发起选举。
  • 常用于基于令牌的系统(如令牌环协议)。

权衡:

  • 在大环中速度较慢(必须经过所有节点)。
  • 假设环链路可靠。
  • 如果拓扑结构发生变化(节点加入/离开),需要重新形成环。

一个温和的证明(为什么它有效)

设每个节点 $n_i$ 具有唯一的 ID $id_i$。 在选举期间,ID 在环上循环传递。 只有最大 ID 能在每次比较中保留下来:

$$ \max(id_1, id_2, \ldots, id_n) $$

当发起选举的节点收到自己的 ID 时,它知道自己是最大值,并宣布自己为领导者。

安全性:只有一个领导者(唯一的最大 ID) 活性:选举最多在 $n$ 次消息跳转后终止

形式上,消息数量 ≤ $2n - 1$(一次用于选举,一次用于协调者宣布)。

自己动手试试

  1. 模拟一个有 5 个节点和随机 ID 的环。
  2. 从不同节点开始选举,观察相同的结果。
  3. 引入消息丢失,看看选举如何重新开始。
  4. 测量消息数量与环大小的关系。
  5. 在时间和成本上与 Bully 算法进行比较。

测试用例

节点起始节点选举出的领导者发送的消息数
{1, 3, 4, 5, 7}378
{10, 20, 15, 5}0208
{2, 5, 8}186

复杂度

操作时间消息数
选举$O(n)$≤ $2n$
宣布$O(n)$$O(n)$

环算法捕捉了分布式系统的协作节奏, 每个节点依次传递消息,通过集体协议,系统找到了其最强的领导者。

基于 CC BY-NC-SA 4.0 许可协议发布