Lecture 8: Peer-to-Peer Systems — Gnutella, DHT and Chord(对等网络:Gnutella、DHT 与 Chord)
Lecture 8: Peer-to-Peer Systems — Gnutella, DHT and Chord(对等网络:Gnutella、DHT 与 Chord)
讲义对应:CS 425 FA2026 第 7 讲与第 8 讲(Peer-to-peer Systems I & II,原始讲义
L7-8.FA25.pdf,共 84 页)。本章把这两讲合并为一章:第 7 讲讲”工业界实际部署的 P2P 系统”(Napster、Gnutella、FastTrack、BitTorrent),第 8 讲讲”有可证明性质的 P2P 系统”(Chord、Pastry、Kelips)。合并的原因是二者回答的是同一个问题——如何在没有任何中心索引的前提下定位一份数据——只是一个用泛洪、一个用结构化路由。 教材对应:Coulouris 5th Ed. Ch. 10(Peer-to-Peer Systems)、Ch. 2(System Models)、Ch. 4(Interprocess Communication);补充:I. Stoica et al., Chord: A Scalable Peer-to-peer Lookup Protocol for Internet Applications, SIGCOMM 2001;A. Rowstron & P. Druschel, Pastry, 2001。 阅读材料:gnutella_protocol_0.4.pdf(Clip2, The Gnutella Protocol Specification v0.4,de facto 标准)、chord-ton.pdf(Chord 论文,重点 Sections 1–4 与 6–7)。
8.1 概述
对等网络(Peer-to-Peer, P2P)是分布式系统史上第一次严肃地把”可扩展性”当作第一设计目标的系统族:它要回答的核心问题是”当节点数 $N$ 从 $10^3$ 涨到 $10^7$,并且每小时有 25%–100% 的节点加入、离开或失效时,我还能不能找到我要的那份数据”。围绕这个问题,本章给出了一条清晰的技术演化线:Napster 用中心索引换效率 → Gnutella 用泛洪换零状态 → FastTrack/BitTorrent 用超节点与块交换换可用带宽 → Chord 用 $O(\log N)$ 的路由表换 $O(\log N)$ 的查找。这条线的本质是分布式系统中最经典的权衡——状态(state)与通信(communication)的互换:结构化 P2P 在每台机器上放 $O(\log N)$ 个指针,把查找的消息数压到 $O(\log N)$;非结构化 P2P 一个指针都不放,代价是每一条查询在网络里炸开成 $O(d^{\text{TTL}})$ 条消息。
本章还是理解后续”云存储”部分的钥匙:Cassandra、Riak、Voldemort、DynamoDB 的 key-value 存储骨架就是 Chord 的一致性哈希环(consistent hashing ring)加虚拟节点(virtual node)。当你在 Lecture 13 之后看到”数据按 token 范围分布到节点上”,那就是本章的 Chord 环换了一身衣服。
8.2 核心概念与分布式机制图解
8.2.1 P2P 的动机(Why Peer-to-Peer?)
- 定义与目的:P2P 系统是一种没有中心控制、也没有层次组织的分布式系统,每个节点运行功能等价的软件,既消费资源也提供资源。
- 直观解释(”它是什么?”):传统客户/服务器像”图书馆”——所有书都要经过一个柜台登记、借出;P2P 像”宿舍楼里的私下换书”——每个人手里有几本,问一圈谁有,直接去那个人房间拿。图书馆的柜台一关门,整个系统就死了;宿舍楼里走了几个人,换书照样进行。
- 四条根本动机:
- 可扩展性(Scalability):中心服务器的出口带宽、CPU、索引内存都是 $O(1)$ 的硬上限,而用户数在增长。Napster 在 2000 年有 6000 万用户,所有查询都要挤过 napster.com 的几台服务器。
- 去中心化(Decentralization):中心是单点故障(single point of failure),也是单点控制。Napster 的结局证明了后者更致命:2001 年 2 月美国联邦上诉法院裁定用户侵权、Napster “教唆”(abetting)侵权,构成”间接侵权”(indirect infringement),Napster 被迫在 2001 年 9 月转为付费服务。
- 资源聚合(Aggregation):把成千上万边缘节点的空闲磁盘与上行带宽聚合成一个巨大的存储/分发系统。BitTorrent 的核心洞见正是”下载者同时也是上传者”,一份文件越热门,能提供它的节点越多,服务能力反而越强——这是中心服务器永远做不到的反直觉的正反馈。
- 抗审查与匿名:没有可以被传票、被封锁、被关停的中心实体;但这与”可追责、可验证”天然冲突,也为 Lecture 27 的安全话题埋下伏笔。
- 历史脉络与它在整门课中的位置:P2P 不是凭空出现的,它是分布式系统”资源组织方式”演化链上的一代。课程用”一部多云的历史(A Cloudy History of Time)”这条线索串起三代系统(补充说明:这条时间线在讲义的不同版本中作为开篇背景出现,本讲的
L7-8.FA25.pdf只保留了 Napster 的具体时间点,故此处按课程整体叙事补齐,数值以讲义为准):- P2P(1999–2004):把服务能力分散到用户的机器上,解决的是”节点数与带宽的可扩展性“。代表:Napster(1999/6 发布,2000 年 6000 万用户;2001/2 被判”间接侵权”,2001/9 转为付费)、Gnutella(2000/3 AOL 发布后立即撤回,2003/3 仍有 8.8 万用户)、FastTrack/KaZaA、BitTorrent。
- Grids 网格(1990s–2000s):把科研机构拥有的计算资源跨机构联合起来,解决的是”异构资源与跨域信任“(Globus、Condor)。
- Clouds 云(2006–):把同一家公司数据中心的机器组织成弹性资源池,解决的是”多租户隔离、弹性与运维简化“。MapReduce、GFS、Hadoop、EC2 都属于这一代。
- 三代的共同问题是同一个:在不可靠的机器上、用不可靠的网络,提供可靠的资源定位与访问。区别在于”机器归谁所有、规模多大、信任边界在哪”。P2P 这一代的价值不只是历史——它的技术直接活进了云里:Cassandra、Riak、Voldemort、DynamoDB 的 key-value 骨架就是本章要讲的 Chord 一致性哈希环 + 虚拟节点。可以说,P2P 是”广域网上的可扩展性”这门学问的第一次严肃实践,而云把它收编成了内部实现。
- 机制图解:三代 P2P 的结构差异一目了然。
(1) 集中式 Centralized —— Napster
┌──────────────────────────────┐
│ napster.com 中央索引服务器 │ 只存 <filename, ip, port>
│ (ternary tree 搜索索引) │ 不存任何文件内容
└───┬───────┬───────┬──────────┘
│1 查询 │3 候选 │2 上传共享列表
┌───┴───┬───┴───┬───┴───┐
│ P1 │ P2 │ P3 │ ... 4. ping 候选测速
└───────┴───────┴───────┘ 5. 从最快的主机直接下载
每个 Peer 存自己的文件(文件传输不经过服务器)
(2) 完全分布式 Decentralized —— Gnutella
P1 ── P2 ── P5 每个 servent 既是服务器又是客户端
│ ╲ │ ╱ │ 邻居关系构成无结构 mesh overlay
P3 ── P4 P6 查询靠 flooding + TTL,没有任何索引
(3) 混合式 Hybrid —— FastTrack / KaZaA / eDonkey / BitTorrent
┌── S1 ──── S2 ──┐ S = supernode(超节点)
│ │ ╲ │ │ 超节点之间组成高速骨干网
P1 P2 P3 P4 P5 普通 peer 只连一个超节点
超节点存"附近一部分 peer 的 <filename, peer pointer> 目录"
- 关键假设与系统模型:所有 P2P 系统都假设互联网是”平坦可达”的——任意两个节点之间可以建立 TCP 连接。这个假设在 NAT 与防火墙面前会碎掉,Gnutella 给出的补丁是 Push 消息(见 8.2.6),BitTorrent 的补丁是只要求”能主动外连”(NAT 后的节点也能下载)。
8.2.2 体系结构分类(Taxonomy of P2P Architectures)
- 定义与目的:按”是否有中心、中心承担多少职责”把 P2P 分成三类,这是本章最重要的对比框架,也是后面所有性能数字的来源。
- 集中式(Centralized)——Napster:
- 客户端连上服务器后上传自己的音乐文件列表;服务器维护
<filename, ip_address, portnum>三元组的列表,服务器本身不存文件。 - 搜索流程:客户端把关键词发给服务器 → 服务器在自己的列表里搜(讲义注明用的是 ternary tree 算法)→ 返回一组
<ip, port>→ 客户端 ping 每个候选主机测传输速率 → 从最快的主机下载。 - 全部通信用 TCP(可靠、有序)。
- 优点:查找 $O(1)$ 延迟、$O(1)$ 消息;索引质量高(可以精确匹配、可以做排行、可以计费)。
- 缺点(讲义原文):中心服务器是拥塞源与单点故障;明文消息与明文口令、没有安全性;法律上被认定为对用户的侵权行为负责。
- 思考延伸:同样是”中心服务器”,为什么 YARN 的 ResourceManager、HDFS 的 NameNode 用中心式却能工作?因为那是受控集群:节点数已知且有界(几千台)、网络是数据中心内的高带宽低延迟、故障率远低于公网、元数据规模可控。中心化不是原罪,”中心与规模的匹配”才是。
- 客户端连上服务器后上传自己的音乐文件列表;服务器维护
- 完全分布式(Decentralized / Pure)——Gnutella:取消服务器,客户端之间彼此搜索与传输;客户端同时也是服务器,称为 servent(server + client)。见 8.2.4–8.2.8。
- 混合式(Hybrid)——FastTrack / KaZaA / eDonkey / BitTorrent:
- FastTrack(KaZaA、KaZaA Lite、Grokster 的底层技术)是 Gnutella 与 Napster 的混合:像 Gnutella,但把一部分”更健康”的节点指定为超节点(supernode)。超节点存一份附近一部分 peer 的
<filename, peer pointer>目录——这就是 Napster 服务器被切碎、复制了很多份的结果。超节点成员随时间变化:任何 peer 只要攒够了声誉(reputation)就能成为并保持超节点。KaZaA Lite 的”参与度”(participation level)取值 0–1000,初值 10,随后由在线时长与总上传量决定。搜索时 peer 只联系一个就近的超节点。 - BitTorrent:每个文件一个 tracker,tracker 只跟踪一部分 peer 的心跳与加入/离开;peer 通过网页拿到
.torrent文件后向 tracker 要 peer 列表,再从多个 peer 处并行下载文件的块(block,32 KB–256 KB)。三条例外重要策略:Local Rarest First(优先下载邻居中副本最少的块,保证块在整个 swarm 中的均匀扩散)、Tit-for-tat(只给”给我上传速率最高”的邻居供块,形成上传激励)、Choking(同时上传的邻居数上限约 5 个,每 ~10 秒重估一次;每 ~30 秒做一次 optimistic unchoke,随机解锁一个邻居,以防被现有邻居”锁死”)。新加入的节点允许随机选一个邻居,用于引导(bootstrapping)。 - BitTorrent 的 tracker 也是中心,但每个文件一个 tracker、故障域极小,且后续版本用 DHT(Kademlia) 完全替掉了 tracker(trackerless torrent)。
- FastTrack(KaZaA、KaZaA Lite、Grokster 的底层技术)是 Gnutella 与 Napster 的混合:像 Gnutella,但把一部分”更健康”的节点指定为超节点(supernode)。超节点存一份附近一部分 peer 的
| 维度 | Napster(集中式) | Gnutella(完全分布式) | FastTrack/KaZaA(混合式) | BitTorrent |
|---|---|---|---|---|
| 索引位置 | 中心服务器全量索引 | 无索引,靠泛洪 | 超节点存局部目录 | tracker 只存 peer 列表(无文件索引) |
| 查找方式 | 中心精确查找 | flooding + TTL | 就近超节点 + 超节点间泛洪 | 不查找,直接按 infohash 找 swarm |
| 自举(bootstrap) | 连 napster.com | host cache / introducer | 连一个超节点 | 网页 + tracker 或 DHT |
| 抗单点故障 | 差(中心挂了全挂) | 好 | 较好(超节点多副本) | 好(tracker 可选、DHT 化) |
| 激励/防搭便车 | 无 | 无(70% 是 freeloader) | 有(participation level) | 强(tit-for-tat + choking) |
| 典型瓶颈 | 服务器带宽/法律 | 查询泛洪流量 | 超节点上行带宽 | 做种者(seed)带宽 |
8.2.3 覆盖网络(Overlay Network)
- 定义与目的:覆盖网络是建立在物理网络之上的逻辑拓扑:节点是进程,边是逻辑连接(通常是一条 TCP 连接),每条 overlay 边对应一条隐式的 Internet 路径。P2P 系统设计的全部自由都集中在”选一个什么样的 overlay 拓扑”。
- 直观解释:物理 Internet 像全国公路网,overlay 像你手机里的”常用联系人”列表。你不想认识全国每个人(也不可能),你只维护几十个联系人,通过他们转达消息——overlay 就是被压缩过的、可路由的社交图。
- 机制图解:常见拓扑及其在 P2P 里的代表。
ring(环) mesh(无结构网状) tree(树)
A A ── B A
/ \ │ ╲╱ │ / \
F B C ── D B C
│ │ │ ╳ │ /|\ |
E───C E ── F D E F G
Chord/Kelips Gnutella/FastTrack I3/组播树
hypercube(超立方体,Pastry 的前缀路由等价物)
000 ── 001 每条边翻转一个二进制位
│ │ 路由 = 逐位纠正前缀
010 ── 011 N 个节点 → 每节点 O(log N) 邻居
│ │ 任意两点间 O(log N) 跳
110 ── 111
- 关键假设与系统模型:
- overlay 的跳数(hop count)与物理延迟(RTT)不是一回事。一条 overlay 边可能是同城 1 ms,也可能是跨国 150 ms,因此 $O(\log N)$ 跳不等于 $O(\log N)$ 毫秒(见 8.5 的延迟现实)。
- 拓扑决定了路由需要多少状态:环 + finger table 是 $O(\log N)$,mesh 是 $O(1)$(只有邻居表),超立方体是 $O(\log N)$ 每行。这条对应关系就是本章的主线。
8.2.4 Gnutella 的 servent 与连接建立(Joining a P2P System)
- 定义与目的:Gnutella 取消服务器,客户端之间彼此搜索与传输。同时扮演服务器与客户端角色的进程称为 servent。
- 直观解释:Napster 像”打电话给总机查号”,Gnutella 像”在宿舍楼里挨个敲门问”。敲门的人自己不负责记住整栋楼谁有什么,他只记住”我认识哪几户”。
- 机制图解:加入过程与最终形成的无结构 mesh。
1. 已知地址来源:host cache(缓存了一批最近活跃 servent 的 ip:port)
或用户手工配置的已知节点
2. 建立 TCP 连接,发送 ASCII 握手串:
GNUTELLA CONNECT/0.4\n\n
对方同意则回:
GNUTELLA OK\n\n
任何其它回应都表示拒绝(可能原因:入连接槽位已满、协议版本不符)
3. 之后所有通信都是 Gnutella descriptor 的收发
servent 之间的 overlay(每条边 = 一条 TCP 连接 = 一条隐式 Internet 路径)
+--------+ +--------+
| P1 |--------------------| P7 |
+--------+ +--------+
| \ | \
| \ | \
+--------+ +--------+ +--------+ +--------+
| P2 |---| P4 |------| P5 |--| P9 |
+--------+ +--------+ +--------+ +--------+
\ | /
\ | /
+--------+ +--------+
| P3 |---| P6 |
+--------+ +--------+
* 这个图是"无结构"的:边的两端与节点 ID 之间没有任何数学关系
* 邻居表大小由用户指定,异构性导致有些 peer 的邻居远多于其它 peer
- 自举问题(bootstrap)与 introducer:任何 P2P 系统都要解决”第一个邻居从哪来”。通用做法是向一个知名 URL 发 HTTP 请求(如
http://www.myp2pservice.com),经 DNS 解析后到达 introducer——一个记录着最近加入节点列表的知名服务器,由它初始化新节点的邻居表。关键洞见:introducer 只参与”入场”,不参与”查询”,因此它挂了只影响新节点加入,不影响已有节点继续工作;这与 Napster 的中央服务器(查询必经之路)有本质区别。 - 关键假设与系统模型:连接是可靠有序的 TCP 流;邻居集合是软状态——节点随时可能消失,靠周期性 Ping/Pong 刷新(见 8.2.6)。
8.2.5 Gnutella 的消息格式(Descriptor Header & 5 Descriptors)
- 定义与目的:Gnutella 协议 0.4 定义了 5 种描述符(descriptor):Ping、Pong、Query、QueryHit、Push,以及一套”谁可以转发什么”的路由规则。
- 直观解释:这套头部设计像快递面单:Descriptor ID 是运单号(用来配对去程与回程)、TTL 是”最多还能转几手”、Hops 是”已经转了几手”、Payload Length 是”箱子多大”。
- 机制图解:23 字节的描述符头部与 5 种负载。
Descriptor Header(所有消息共用,共 23 字节;除 IP 地址外全部 little-endian)
byte offset 0 15 16 17 18 22
+-----------------+--+----+----+---------------+
| Descriptor ID |P |TTL |Hops| Payload Length|
| 16 bytes |1B| 1B | 1B | 4 bytes |
+-----------------+--+----+----+---------------+
|<--- 头部 23 字节 --->|<---- Payload(长度见上)---->
Payload Descriptor(1 字节的消息类型)
0x00 = Ping 0x01 = Pong 0x40 = Push
0x80 = Query 0x81 = QueryHit
Descriptor ID : 16 字节,唯一标识网络上这一个描述符;
同一个搜索事务的去程(Query)与回程(QueryHit)共用它
TTL : 还要被转发几次;每个 servent 转发前先减 1,减到 0 就不再转发
Hops : 已经被转发了几次
不变量 : TTL(0) = TTL(i) + Hops(i) (TTL 初值通常 7~10)
Payload Length: 紧跟其后的负载字节数,是分隔下一个描述符的唯一依据
(协议没有"眼标"eye-catcher,长度字段错了整个流就错位)
--- 各负载结构 ---------------------------------------------------------
Ping (0x00) : 无负载,长度为 0
Pong (0x01) : Port(2B) | IP(4B, big-endian) | NumFiles(4B) | NumKB(4B)
—— 一个 Ping 可以引出多个 Pong(host cache 借此批量返回地址)
Query (0x80) : MinimumSpeed(2B) | 以 NUL(0x00) 结尾的关键词串
—— 只有速率 >= MinimumSpeed 的 servent 才应答
QueryHit(0x81) : NumHits(1B) | Port(2B) | IP(4B) | Speed(4B)
| ResultSet | ServentIdentifier(16B)
ResultSet 每项 = FileIndex(4B) | FileSize(4B) | 以双 NUL 结尾的文件名
Push (0x40) : ServentIdentifier(16B) | FileIndex(4B) | IP(4B) | Port(2B)
- 易错点澄清:有些二手资料把 Query 记作
0x02、QueryHit 记作0x03,但课程讲义与 0.4 规范一致地使用0x80与0x81(0x00Ping、0x01Pong、0x40Push)。原因很实用:最高位用来区分”请求类”(Query 0x80)与”应答类”(QueryHit 0x81),中间位留给扩展。本笔记一律采用讲义与规范的值。 - 关键假设与系统模型:
- TTL 是网络上唯一的过期机制。规范明确要求 servent 严格审视收到的 TTL,并且”滥用 TTL 会导致不必要的网络流量与糟糕的网络性能”。
- Payload Length 是唯一可靠的同步依据。若一个 servent 发现自己的输入流错位(长度字段非法),正确做法是直接断开这条连接——因为上游要么在生成、要么在转发非法描述符。
- Pong 与 QueryHit 里的 IP 字段是 big-endian,其余字段 little-endian。
8.2.6 泛洪查询与反向路径(Flooding & Reverse-Path Routing)
- 定义与目的:在没有任何索引与路由表的前提下,找到”谁有 PennyLane.mp3”。
- 直观解释:像在陌生城市问路:你不知道谁知道,只能问遍所有你能问的人,并要求他们把问题继续传下去;但为了防止消息永远传下去,你在纸条上写”最多再传 7 个人”。
- 机制图解:TTL 的分层扩散与 QueryHit 的沿路返回。
发起者 P1,设置 TTL=3(每条边最多传一次;来源邻居不再回传)
hop0 TTL=3 [P1] "Who has PennyLane.mp3?" ← 用户发起搜索
/ | \
hop1 TTL=2 [P2] [P3] [P4] <-- Query 被转发给除来源外的所有邻居
/ \ | \
hop2 TTL=1 [P5][P6][P7] [P8] <-- 继续转发,TTL 递减为 1
| | |
hop3 TTL=0 (X) (X) [P9] <-- TTL 减到 0:收到但不再转发
|
| P9 本地有该文件 ⇒ 回 QueryHit(沿 Query 的反向路径)
v
hop0 <──────────────── [P1] 收到 QueryHit,开始 HTTP 直连下载
每一条箭头都是一次消息传输;TTL=3 时消息数已被放大到十几条
—— 这就是"泛洪"的代价,也是 8.2.7 要定量分析的对象。
QueryHit 反向返回的两条机制(缺一不可):
(1) Descriptor ID 匹配:QueryHit 的 Descriptor ID 必须等于对应 Query 的 ID
(2) 软状态路由表 route[ID] = 收到该 Query 的那个邻居
—— 每个中间节点转发 QueryHit 时只需查 route[ID]
- 协议规范里的 6 条路由规则(这是 Gnutella 0.4 的”法律条文”,必须逐条记住):
- Pong 只能沿承载该 Ping 的同一路径返回。收到 Descriptor ID = n 的 Pong 却没收到过 ID = n 的 Ping 的 servent,必须把它从网络中丢弃。
- QueryHit 只能沿承载该 Query 的同一路径返回;同理,没见过对应 Query 的 QueryHit 要丢弃。
- Push 只能沿承载该 QueryHit 的同一路径返回,并且 Push 是按 Servent Identifier 路由、而不是按 Descriptor ID 路由;收到 Push 的 servent 只有在
ServentIdentifier等于自己的 servent id 时才应处理。 - Ping 与 Query 要转发给所有直接相连的邻居,但发起该消息的那个邻居除外。
- 转发前 TTL 减 1、Hops 加 1;若减完变成 0,则该描述符不再向任何连接转发。
- 收到相同的 (Payload Descriptor, Descriptor ID) 组合时,不向任何已连接的 servent 转发——目的节点早就收到了,再发只是浪费带宽。
- 为什么用 TTL 而不是路由表? 这是本章最重要的”设计动机”之一:无结构 overlay 里没有任何”下一步更接近目标”的信息。节点不知道 key 在环上的位置,也不知道邻居各自覆盖了哪片名字空间——它唯一拥有的信息是”我认识这几个人”。在这种信息量为零的前提下,唯一完备的策略就是问遍所有能问的人,而 TTL 是把这种”问遍”限制在有限半径内的唯一手段。对比 Chord:finger table 提供了”每一步距离至少减半”的方向信息,于是 O(1) 状态换成 O(log N) 跳,泛洪就不需要了。
- 关键假设与系统模型:消息可能重复到达(一个节点可以经由多条路径收到同一 Query),因此必须靠 Descriptor ID + “最近收到的消息表”去重;这张表本身是有状态的,需要按 TTL 时间窗清理,否则会无限增长。
8.2.7 Gnutella 的可扩展性问题(定量分析)
- 定义与目的:证明”泛洪”在节点数增长时必然崩溃,并给出讲义提到的三种补救。
- 直观解释:泛洪像在广场上喊话:人少时有效,人一多,每个人都要处理所有人喊的话,最后没有人在听,大家只是在喊。Gnutella 的历史正是如此——不是被用户放弃的,是被自己的流量压垮的。
- 定量分析(核心):
- 设平均邻居数(度数)为 $d$,TTL 初值为 $t$。一条 Query 产生的消息总数是 $O(d^{t})$:第 1 跳转发给 $d$ 个邻居,第 2 跳每个邻居再转发给 $d-1$ 个(不回传),依此类推。$d=4,\ t=7$ 时约 $4^7 = 16384$ 条消息只为了回答一次搜索。
- 更糟的是,这个代价与 $N$ 无关:只要 $d$ 和 $t$ 不变,$N$ 涨十倍,单次查询的流量不变;但全网的查询总数随用户数线性增长,于是总流量 $\propto N \cdot d^{t}$。当每台机器的上行带宽是 $O(1)$(拨号、ADSL 时代的上行只有几十 KB/s)时,连接带宽被查询流量吃干——这就是”Gnutella 的崩溃”。
- 讲义给出的实测问题清单:Ping/Pong 占据了 50% 的流量(因为它周期性泛洪,且 Pong 会带来新的邻居候选 → 更多 Ping,正反馈);重复搜索(同样的关键词被反复泛洪);拨号主机带宽不足(解决方法是”用一个中心服务器做这些 peer 的代理”——注意这又退回了中心化,是 Gnutella 设计上的自相矛盾);70% 的用户是搭便车者(freeloader),只下载不上传。
- 幂律度数分布:讲义指出 Gnutella 的度数服从 幂律(power law),即 $P(\#links = L) \sim k \cdot L^{-\gamma}$($k$、$\gamma$ 为常数)。这意味着少数节点拥有大量连接。这个事实直接催生了 FastTrack 的 supernode 设计:既然总有一些节点天生”更健康、连接更多”,那就把它们显式地提升为索引节点。
- 讲义给出的三种补救:
- 超节点(super-peer)/ 分层 Gnutella:把网络分成两层,普通 peer 只连一个超节点,超节点之间组成高速骨干。查询先到超节点,超节点查本地目录、必要时才在超节点骨干上泛洪。代价从 $O(d^{t})$ 变成 $O((d_{sp})^{t_{sp}} + \text{叶子数})$,其中 $t_{sp}$ 可以远小于 $t$(因为超节点数量少、目录质量高)。实测(见 8.4.5 代码):同样覆盖 2 万个节点,扁平泛洪 120470 条消息,两级超节点只要 2813 条,降低约 50 倍。
- 动态查询(dynamic querying):先发一个小 TTL(例如 2)的查询试探;若结果足够就停止,若结果太少再逐步加大 TTL。用”流行文件”的低成本换取”稀有文件”的完备性。
- 缓存(caching):把 Query/QueryHit 缓存下来,同样的关键词再来时直接本地应答;同时 多路复用(multiplex)并降低 Ping/Pong 的频率以压制那 50% 的探测流量。
- 关键假设与系统模型:上述所有补救都承认了一个事实——泛洪的完备性是用带宽买的,任何降低带宽的手段都在某种程度上牺牲完备性或新鲜度。这正是”非结构化 P2P 的宿命”。
8.2.8 Gnutella 的容错性与完备性缺失
- 定义与目的:说清楚”无中心”到底给了什么、又拿走了什么。
- 容错性(优点):无中心 ⇒ 没有单点故障。节点可以任意加入与离开,只要 overlay 大部连通,系统就继续工作;新节点从 host cache 或 introducer 拿到几个地址就能挂上去。讲义的评价是:”由于它的分布式本性,实现了 Gnutella 协议的网络是高度容错的——一部分 servent 下线不会中断网络的运行。”
- 完备性(缺点):Gnutella 不保证查询命中(no completeness guarantee)。若目标文件只存在于 TTL 半径之外的节点上,查询就找不到它——而且发起者无法区分”网络里没有这份文件”与”我喊得不够远”。这是与 Chord 最本质的差别:Chord 的
find_successor要么返回正确的节点,要么明确失败;Gnutella 的泛洪返回空结果时语义是模糊的。 - 机制图解(TTL 耗尽导致漏查的具体例子):
链式 overlay(极端但合法):
P1 ── P2 ── P3 ── P4 ── P5 ── P6 ── P7 ── P8 ── P9 ── P10
只有 P10 拥有目标文件
P1 发起 Query,TTL=3:
hop0 → P2 (TTL=3)
hop1 → P3 (TTL=2)
hop2 → P4 (TTL=1)
hop3 → P5 (TTL=0,P5 收到但不转发)
结果:P1 收到 0 个 QueryHit,结论"没找到"
事实:文件就在 10 跳之外 —— 这是漏查(false negative),不是不存在
- 关键假设与系统模型:Gnutella 的容错是“不可靠的可用性”——它保证系统活着,不保证答案正确。这与 Bloom 过滤器(Bloom filter)的”可能有 / 一定没有”正好相反:Gnutella 是”没找到不等于没有“。
8.2.9 分布式哈希表(Distributed Hash Table, DHT)
- 定义与目的:DHT 的目标只有一句话——给定一个 key,找出哪个节点负责存储这个 key,而且不需要任何中心索引。
- 直观解释:普通哈希表像一本字典,
hash(key)直接算出数组下标;DHT 像把字典撕成很多页分给一群人,并且每个人都知道”要去哪几页找”——没有人拿着整本字典,但任何一页都能在两三次问路之内找到。 - 核心机制:
- 一个哈希函数同时用于两件事:
hash(key) → key id与hash(node_ip:port) → node id,二者落在同一个 $m$ 位标识空间里(共 $2^m$ 个标识符)。这样”key 与节点的距离”才有意义。 - 讲义列出的 5 个性能关注点:负载均衡(load balancing)、容错(fault-tolerance)、查找/插入效率(efficiency of lookups and inserts)、局部性(locality)。
- 讲义的一句”暴论”值得记住:Napster、Gnutella、FastTrack 都算是一种 DHT(sort of)——它们都在做”key → 节点”的映射,只是 Napster 用中心表、Gnutella 用泛洪。Chord 的区别在于这个映射是结构化的、可证明的。
- 一个哈希函数同时用于两件事:
- 与普通哈希表、一致性哈希(consistent hashing)的关系:
- 普通哈希表:
bucket = hash(key) mod N。问题:$N$ 一旦从 $N$ 变成 $N+1$,几乎所有 key 的归属都变了(约 $K \cdot N/(N+1)$ 个 key 要搬家)。 - 一致性哈希:把 key 与节点都映射到同一个环上,key 归”环上顺时针第一个 ≥ 它的节点”。性质:第 $N+1$ 个节点加入或离开时,只有 $O(K/N)$ 个 key 需要换手(而且只会与加入/离开的那个节点交换)。这是”最小必要迁移量”。
- Chord = 一致性哈希 + 分布式查找。原始一致性哈希论文假设”每个节点知道几乎所有其它节点”,这在大规模下不可扩展;Chord 用 finger table 把每个节点需要知道的节点数压到 $O(\log N)$。
- 普通哈希表:
- 一致性哈希的定量结论(论文 Theorem IV.1):对任意 $N$ 个节点与 $K$ 个 key,以高概率:
- 每个节点负责不超过 $(1+\epsilon) K/N$ 个 key;
- 第 $N+1$ 个节点加入/离开时,只有 $O(K/N)$ 个 key 的归属变化(且只涉及加入/离开的那个节点)。 直接实现时 $\epsilon = O(\log N)$;若每个节点运行 $\Omega(\log N)$ 个虚拟节点(virtual node),$\epsilon$ 可降到任意小的常数。
- 关键假设与系统模型:DHT 的正确性建立在一个概率假设上——节点 ID 与 key 的哈希值都近似均匀随机分布。论文明确讨论了这一点:”给定期望的良好分布性质”下,随机选出的 key 集合是均衡的;但一旦对手可以挑选 key(比如反复插入只映射到某个节点的 key),或者对手可以挑选节点 ID(Sybil 攻击),整个均匀性假设崩塌。这个隐患在 8.2.16 与 Lecture 27 展开。
8.2.10 Chord 的标识空间与环(The Chord Ring)
- 定义与目的:Chord 只提供一个操作——给定 key,把它映射到一个节点。整个系统都建立在这个原语上。
- 直观解释:把 $2^m$ 个标识符想象成一个只有 $2^m$ 个座位的圆形餐桌,每个人(节点)按自己 ID 的大小落座;一份文件(key)放在”从它的号码开始顺时针走,遇到的第一位客人”手里。没有名单、没有总台,每个人只需要认识顺时针前方的少数几个人。
- 机制图解:$m=6$($2^6=64$)、10 个节点的教科书例子(论文 Figure 2 的还原)。
N1 (1)
N56 ──────── N8
/ \
N51 ── ── N14
| |
N48 (Chord ring) N21
| |
N42 ── ── N32
\ /
──── N38 ────────
顺时针顺序:N1 → N8 → N14 → N21 → N32 → N38 → N42 → N48 → N51 → N56 → N1
(注意:图上底边从左到右读是 N42,N38,N32,因为顺时针方向在底部是"从右向左")
每个节点负责的 key 区间(左开右闭,(predecessor, self]):
N1 : keys (56, 1] = {57,...,63, 0, 1} 共 9 个
N8 : keys (1, 8] = {2,...,8} 共 7 个
N14 : keys (8, 14] = {9,...,14} 共 6 个
N21 : keys (14, 21] = {15,...,21} 共 7 个
N32 : keys (21, 32] = {22,...,32} 共 11 个
N38 : keys (32, 38] = {33,...,38} 共 6 个
N42 : keys (38, 42] = {39,...,42} 共 4 个
N48 : keys (42, 48] = {43,...,48} 共 6 个
N51 : keys (48, 51] = {49,...,51} 共 3 个
N56 : keys (51, 56] = {52,...,56} 共 5 个
论文 Figure 2 的 5 个 key:
K10 → N14 (10 ∈ (8,14])
K24 → N32 (24 ∈ (21,32],24 与 30 都落在 N32 的区间)
K30 → N32
K38 → N38 (38 恰好等于节点 ID:successor 取"大于或等于")
K54 → N56 (54 ∈ (51,56])
- 形式化定义:
- 标识符按模 $2^m$ 排列成一个环(ring);标识符就是 0 到 $2^m-1$ 的整数。
- 后继(successor):
successor(k)是环上第一个标识符 $\ge k$ 的节点(顺时针方向”第一个在 $k$ 处或 $k$ 之后的节点”)。 - 前驱(predecessor):环上逆时针第一个节点,即
n的前驱是满足”从该节点顺时针走到n途中没有其它节点”的节点。 - key $k$ 由
successor(k)负责存储。 - 讲义中的例子用 $m=7$($2^7=128$)与 6 个节点 N16、N32、N45、N80、N96、N112 说明同一件事:K42 会存在 N45 上——因为从 42 顺时针第一个节点是 45。本章统一用论文的 $m=6$ 例子做数值推演。
- 关键假设与系统模型:论文假设底层网络通信对称(若 A 能到 B,则 B 能到 A)且传递(若 A 能到 B、B 能到 C,则 A 能到 C)。$m$ 必须足够大,使两个节点(或两个 key)哈希到同一标识符的概率可以忽略;讲义用
SHA-1(ip_address, port)得到 160 位串,再截断到 $m$ 位作为 peer id。ID 不唯一,但冲突概率极低(这也意味着实践中仍需要处理 ID 冲突)。
8.2.11 简单查找与它的 $O(N)$ 代价(Simple Key Location)
- 定义与目的:先看最朴素的方案:每个节点只维护一个后继指针,查询沿着环一站一站地走。
- 直观解释:像”击鼓传花”——你不知道花在谁手里,就沿着圆圈一个接一个地问过去,直到某个人说”它在我这里”。
- 机制图解:
N8 发起 lookup(K54),每个节点只有 successor 指针:
N8 ──► N14 ──► N21 ──► N32 ──► N38 ──► N42 ──► N48 ──► N51 ──► N56
(8) (14) (21) (32) (38) (42) (48) (51) (56)
^
N56 发现 54 ∈ (51, 56] ⇒ 自己就是答案 ⇒ 沿反向路径把结果送回 N8
总共经过 8 个节点、8 次 RPC —— 这就是 O(N) 跳
判定条件(每一步):id ∈ (n, successor] ? 满足则答案是 successor(n)
- 为什么不够:$N = 10^6$ 时平均要走 $O(N)$ 跳,每跳一次广域网 RTT,延迟无法接受。但注意:这个朴素方案的状态开销是 $O(1)$——每个节点只需知道一个后继,而且只要这一个指针正确,查找就永远正确。这个”只需要一个正确指针”的性质是 Chord 全部优雅性的来源,即使加了 finger table 也依然成立(见 8.3.3)。
- 讲义中的三系统对比表(本章的核心对比,建议背下来):
| 系统 | 内存(每节点状态) | 查找延迟 | 一次查找的消息数 |
|---|---|---|---|
| Napster | $O(1)$(服务器 $O(N)$) | $O(1)$ | $O(1)$ |
| Gnutella | $O(N)$ | $O(N)$ | $O(N)$ |
| Chord | $O(\log N)$ | $O(\log N)$ | $O(\log N)$ |
- 关键假设与系统模型:表中 Gnutella 的 $O(N)$ 是最坏情况(TTL 足够大到覆盖全网);实践中 TTL 限制了半径,因此它换来的是“查不全”而不是 $O(N)$ 的延迟——用正确性换延迟,这比表里写的更糟。
8.2.12 手指表(Finger Table)—— Chord 的核心
- 定义与目的:让查找的距离每一步至少减半,从而把 $O(N)$ 跳到 $O(\log N)$ 跳。
- 直观解释:finger table 就是为环上的距离做”二进制表示”。你要去 46 米外的地方,先跨 32 米,再跨 8 米,再跨 4 米,再跨 2 米——每次跨的步长是当前位置到目标距离的最高有效位。这就是跳表(skip list)在环上的版本:每个节点维护”跨 $2^0, 2^1, \dots, 2^{m-1}$ 步之后是谁”。
- 形式化定义:节点 $n$ 维护最多 $m$ 项,第 $i$ 项 \(\text{finger}[i] = \text{successor}\big((n + 2^{i-1}) \bmod 2^m\big), \quad 1 \le i \le m\) 所有算术模 $2^m$。第 1 项就是 $n$ 的直接后继(
finger[1] == successor)。每个 finger 项同时保存对方的 Chord 标识符与 IP:port。 - 机制图解:$m=6$ 时 N8 的完整 finger table 计算过程(与论文 Figure 4(a)、以及 8.4.1 代码实际打印的结果完全一致——代码会对每一项与”全局排序算出的标准答案”做断言)。
节点 N8 的 finger table(m=6,环上节点 1,8,14,21,32,38,42,48,51,56)
i +2^(i-1) 目标标识符 8+2^(i-1) 落在哪个区间 finger[i]
--- --------- -------------------- ------------------ ----------
1 +1 8 + 1 = 9 9 ∈ (8, 14] N14
2 +2 8 + 2 = 10 10 ∈ (8, 14] N14
3 +4 8 + 4 = 12 12 ∈ (8, 14] N14
4 +8 8 + 8 = 16 16 ∈ (14, 21] N21
5 +16 8 + 16 = 24 24 ∈ (21, 32] N32
6 +32 8 + 32 = 40 40 ∈ (38, 42] N42
读法:finger[i] 是"从 8 出发顺时针跨 2^(i-1) 步落点处、顺时针方向的第一个节点"
区间形状:finger[i] 覆盖的标识符区间是 [n+2^(i-1), n+2^i) —— 长度正好是 2^(i-1)
两个重要性质:
(1) 节点知道"离自己近的"信息更密:i 小的时间隔小、指向近邻;
i 大的时间隔大、指向远方。总状态只有 m = O(log 2^m) 项。
(2) finger table 通常不足以直接判定任意 key 的 owner:
N8 无法仅仅靠自己的表回答 key 34 归谁(N38 不在它的表里)
—— 所以必须"转发给更接近的节点",让那个节点用它的表继续
- 关键假设与系统模型:
- 只有 $O(\log N)$ 项是不同的。论文指出:对任意 $i \le m - 2\log N$,第 $i$ 个 finger 以高概率就等于本节点的直接后继(因为 $2^{i}$ 太小,那段区间里根本没有别的节点),因此不必单独存储。实际实现里只需要 $O(\log N)$ 项有效。
- finger table 不是正确性的必要条件。论文原话:额外的路由信息”对正确性不是必需的,只要每个节点知道它正确的后继就能保证正确”。
8.2.13 可扩展查找(Scalable Key Location)
- 定义与目的:用 finger table 把查询一次”弹射”很远,实现距离的指数收缩。
- 直观解释:问路时不要说”往前 500 米有个路口”,而要说”你去那边那个最大的地标,到了再问”——每一步都跳到你已知范围内最接近目标的那一站。
- 算法规则(一句话版):在 finger table 中找”不超过目标 id 的最大 finger”(
closest_preceding_node),把查询转发给它;如果找不到(所有 finger 都在目标之后),就转发给自己的后继。 精确伪代码见 8.3.2。 - 机制图解:N8 查找 key 54 的三跳路径与每步的距离收缩(数值由 8.4.2 的代码实测打印,与论文 Figure 4(b) 一致)。
lookup(54) issued at N8
step at finger chosen why distance to 54
---- ----- ------------- --------------------------- --------------
0 N8 N42 N8 表中 <=54 的最大 finger 46 -> 12
是 finger[6]=N42
1 N42 N51 N42 表中 <=54 的最大 finger 12 -> 3
是 N51(N42 的 finger[4])
2 N51 N56 54 ∈ (51, 56] ⇒ N51 直接 3 -> 0
知道答案就是自己的后继
---------------------------------------------------------------------
结果 N56,共 2 次转发、3 个节点参与;距离序列 46 → 12 → 3 → 0
N42 的 finger table(用于验证第 1 步):
i=1: 42+1 =43 → N48 i=4: 42+8 =50 → N51
i=2: 42+2 =44 → N48 i=5: 42+16=58 → N1 (58>51,绕回环首)
i=3: 42+4 =46 → N48 i=6: 42+32=74 mod 64 = 10 → N14
<=54 的最大者是 N51,正确。
- 关键假设与系统模型:转发可以是递归式(recursive)或迭代式(iterative)(论文 §V-A 明确区分)。
- 递归式:每个中间节点把请求转发给下一个节点,最终由目标节点沿原路把结果回送。中间节点需要维护请求状态;一跳的 RTT 更少(每次转发只有一个单程)。
- 迭代式:发起者自己依次联系 N42、N51、N56,每次都只问”你的 finger 表里哪个更接近 54”,然后自己发下一个请求。发起者掌握完整的超时与重试逻辑,中间节点无状态,对节点失效更健壮;代价是 RTT 更多(每次一个来回)。论文的仿真器用的就是迭代式,本章的代码也是。
- 论文 Theorem IV.4:若一个稳定的 $N$ 节点网络里又加入至多 $N$ 个节点,且所有后继指针正确但 finger 可能陈旧,查找仍以高概率在 $O(\log N)$ 时间内完成——因为距离减半的论证只依赖标识符空间的距离,不依赖 finger 具体指向谁。更一般地,只要”调整 finger 的速度”快于”网络规模翻倍的速度”(例如任意 $N$ 次加入之间至少发生 $\Omega(\log^2 N)$ 轮稳定化),查找就保持 $O(\log N)$。
8.2.14 一致性与动态维护(Joins, Stabilization, Failures)
- 定义与目的:P2P 系统的成员集合在持续变化(churn)。讲义给出的实测数字触目惊心:Overnet(eDonkey)每小时节点周转率 25%,Gnutella 每小时 100%——也就是说,一小时后 Gnutella 里几乎所有节点都换了一批。Chord 必须在这个前提下保证”任何 key 都还能被找到”。
- 直观解释:像一家 24 小时营业、员工每几分钟换一批的便利店。你不可能让所有人随时知道所有同事的名字;你只需要保证”每个人知道下一个接班的是谁”,再让每个人每隔一会儿回头确认一下自己知道的信息还对不对。这就是 stabilization(稳定化)。
- 机制图解:加入过程的四步(论文 Figure 7 的例子:ID 为 26 的节点加入 N21 与 N32 之间)。
(a) 初始:N21 ──────────► N32 环上 N21 的后继是 N32
K24,K30 存在 N32
(b) N26 通过某个已知节点 n' 执行 join:
N26.succ = n'.find_successor(26) = N32
N21 ──────────► N32
▲
└── N26 ──► N32 (N26 指向 N32;N21 还不知道 N26)
注意:此时 N26 已可达,但环上还没有人指向它
(c) N26 从 N32 复制本属于它的 key:
N21 ──────────► N32 (只剩 K30)
│
N26 ◄───────────┘ (拿到 K24:因为 24 ∈ (21, 26])
N26 ──► N32
(d) N21 运行 stabilize():问 N32 的前驱是谁 → 得到 N26;
26 ∈ (21, 32) ⇒ N21.succ = N26;随后 N21.notify(N26),
N26 把 N21 记为前驱 ⇒ 后继链完全正确
N21 ──► N26 ──► N32
- 稳定化协议的四件事(论文 Figure 6 的四个过程,伪代码见 8.3.3):
stabilize():$n$ 问自己的后继 $s$:”你的前驱是谁?”得到 $x$;若 $x$ 落在 $(n, s)$ 区间内(说明有新节点插进来了),则把后继改为 $x$;然后调用s.notify(n)告诉 $s$ 自己的存在。notify(n'):若自己还没有前驱,或 $n^{\prime}$ 落在 $(\text{predecessor}, n)$ 区间内,则把前驱设为 $n^{\prime}$。两个判定都只接受”更近的”候选,这是单调收敛的关键。fix_fingers():周期性地重算 finger table,每轮只修一项(用next游标循环推进)。为什么不一次全修?因为一次全修意味着 $m$ 次查找、$O(m \log N)$ 条消息集中在同一瞬间——把周期性开销摊平(amortize)是分布式维护算法的通用手法。check_predecessor():若前驱已失效,把前驱清空(置nil),这样notify才有机会接受新的前驱。这是”故障检测器”在 Chord 里的落点(呼应 MP2 与 Lecture 6)。
- 为什么用后台周期运行而不是一次性完成? 因为并发加入存在竞态。
join()只做了三件事:设前驱为nil、找到后继、指向它。它不通知任何人——新节点是被别人的stabilize()发现的。假设两个节点 $x, y$ 同时加入同一区间 $(p, s)$:如果采用”加入时立刻通知所有人并切换指针”的做法,$p$ 可能先指向 $x$ 再指向 $y$,而 $x$、$y$ 之间的相对顺序可能被弄反,产生指针绕圈(loopiness)甚至查询死循环。stabilize()的做法是:任何人只在”发现了一个更近的前驱候选”时才修改自己的后继,这保证了指针在环序上单调地向真实后继靠拢,因此并发加入不会破坏可达性——代价是暂时的不一致(最终一致)。 - 节点离开/故障:
- 有序离开(voluntary departure):离开前把 key 移交给后继,并通知前驱 $p$ 与后继 $s$;$p$ 从后继列表中删掉它、补上 $n$ 后继列表的最后一个节点;$s$ 把前驱替换为 $n$ 的前驱。
- 故障离开(failure):节点静默消失。靠 successor list(后继列表)——每个节点维护自己的前 $r$ 个后继;若直接后继不回话,就用列表里的下一个。后继列表的维护方式:$n$ 与后继 $s$ 协调——复制 $s$ 的后继列表、去掉最后一项、把 $s$ 放到最前面。
- 查找中遇到节点失效:超时后改用 finger table 与后继列表里”次优的前驱”继续推进。
- 副本(replication):一个典型应用会把 key 的副本放在后继链上连续的 $r$ 个节点(
successor、successor.successor、…)。$r$ 取多大?- 论文的定量结论:$r = \Omega(\log N)$ 就足够。若每个节点独立地以概率 $p$ 失效,则”某一个节点的 $r$ 个后继全部失效”的概率是 $p^r$。讲义用 $p = 1/2$ 与 $r = 2\log N$ 推了一遍: \(\Pr(\text{某节点至少有一个存活后继}) = 1 - \left(\tfrac{1}{2}\right)^{2\log N} = 1 - \tfrac{1}{N^2}\) \(\Pr(\text{所有存活节点都满足}) \approx \left(1 - \tfrac{1}{N^2}\right)^{N/2} \approx e^{-\frac{1}{2N}} \to 1\)
- 论文 Theorem IV.5:在初始稳定的网络中,即使每个节点以 $1/2$ 的概率失效,只要 $r = \Omega(\log N)$,
find_successor仍以高概率返回”最近的存活后继”。仿真($N=1000$、$r=20$)显示:平均路径长度从 3.84 涨到 5.09($p=0.5$),所有查找都成功。
- 关键假设与系统模型:Chord 提供的是查找的正确性(最终收敛到一个正确的环),不是成员视图的强一致性。任何时刻的 finger table 都可能是陈旧的;新加入的节点可能还没被某些 finger 指向。这就是”弱一致性”的代价,换来的是不需要任何共识协议(不涉及 FLP、不需要 quorum、不怕网络分区时的多数派问题)。
8.2.15 虚拟节点(Virtual Nodes)与负载均衡
- 定义与目的:把哈希带来的负载不均衡压下去。
- 直观解释:随机撒 10 个点在一个圆上,点与点之间的弧长非常不均匀——有的弧很长(那个节点要存很多 key),有的很短(几乎不存)。解决办法不是换一个”更好的哈希”,而是让每个真实节点用多个身份(虚拟节点)各占一个随机位置,这样每个真实节点总共有多个区间,总长度趋于平均。这就像买彩票:买 1 张的收益方差极大,买 100 张的收益接近期望值。
- 定量分析(论文 §V-B 的实测):
- $10^4$ 个节点、$5\times 10^5$ 个 key 时,有些节点一个 key 都分不到(0 个);最多的节点存了 457 个 key,是均值(50)的 9.1 倍;99 分位是均值的 4.6 倍。
- 原因:从单个节点的视角看,它”拥有”的环长度是它到前驱的距离;这个距离近似服从均值为 $2^m/N$ 的指数分布,超过两倍均值的概率是 $e^{-2} \approx 13.5\%$。
- 虚拟节点的效果:每个真实节点分配 $v$ 个虚拟节点后,99 分位从 4.8× 均值降到 1.6×,1 分位从 0 升到 0.5×($v = 20$)。
- 代价:路由状态变成 $O(\log^2 N)$(每个虚拟节点各有一张 finger table);控制消息数量增加 $O(\log N)$ 倍。$N = 10^6$ 时 $\log^2 N = 400$,仍然完全可接受。渐近查找跳数不变(变成 $O(\log(N\log N)) = O(\log N)$)。
- 在真实系统里:Cassandra 用按 token 范围(token range)分布的虚拟节点来落地这个概念(每个物理节点持有若干个 token 区间,
num_tokens默认 256)——这正是 8.2.15 的工程化版本。
8.2.16 DHT 家族:Chord / Pastry / Kademlia / CAN / Kelips
- 定义与目的:所有结构化 P2P 都在做同一件事——用一张小的路由表,在标识空间里做”地理定位”。差别只在”标识空间长什么样”和”每一步怎么逼近”。
- 机制图解与要点:
Chord: 1 维环 + 距离减半 Pastry: 环 + 前缀路由(超立方体)
N8 id = 01110100101
├─ finger[1..6] 维护每个前缀的邻居:
│ 覆盖 2^0..2^5 的距离 * 0* 01* 011* ... 0111010010*
└─ 每跳距离至少减半 路由时转发给"匹配前缀最长"的邻居
状态 O(log N),跳数 O(log N) 状态 O(log N),跳数 O(log N)
Kademlia: XOR 距离 CAN: d 维笛卡尔空间
d(x,y) = x XOR y 把空间切成 d 维小立方体,
路由表 = k-bucket(按与自己 每个节点持有自己的区域,
的公共前缀长度分桶) 邻居是相邻区域的持有者
每跳把 XOR 距离的首个 1 位纠正 状态 O(d),跳数 O(d·N^(1/d))
(等价于二叉树逐层下降)
状态 O(log N),跳数 O(log N)
- 各家族细节:
- Chord(Berkeley/MIT:Stoica、Karger、Kaashoek、Balakrishnan、Morris):环 + finger table,$O(\log N)$ 状态与跳数,无网络局部性优化。
- Pastry(Microsoft Research 的 Rowstron 与 Rice 的 Druschel,用于 Microsoft 的系统):同样用虚拟环分配 ID,但路由表是基于前缀匹配(prefix matching)的——讲义说”把它想成超立方体(think of a hypercube)”。每个节点维护一个 leaf set(最近的若干个后继与前驱)与一张按前缀分层组织的路由表;路由时转发给匹配前缀最长的邻居,因此跳数 $\log N$。它最大的特点是 locality:对每个前缀,在所有候选邻居中选 RTT 最短的那个;由于短前缀的候选多(遍布全网)、长前缀的候选少,于是早期的跳是短跳、后期的跳是长跳,整体相对直连 Internet 路径的”拉伸(stretch)”很小。代价是更复杂的 join 协议(要沿 join 消息路径收集信息来初始化路由表)。
- Kademlia(补充说明,讲义未展开):用 XOR 距离 $d(x,y) = x \oplus y$ 作为度量。它的精妙之处在于 XOR 距离满足”单边“性质:$d(x,y)$ 的最高有效位只由 x 与 y 的公共前缀长度决定,因此每个节点对同一个目标看到的是同一棵二叉树,路由表可以组织成 k-bucket(按与自己的公共前缀长度分桶,每桶存 k 个节点),查找等价于”在二叉树里逐层下降”,每跳至少纠正距离的一个比特。它支持并行查询(α 路并行),因而对节点失效非常健壮;BitTorrent 的 Mainline DHT 与 eMule 都用它。
- CAN(Content Addressable Network)(补充说明):用 $d$ 维笛卡尔坐标空间,每个节点维护 $O(d)$ 个邻居(相邻区域的持有者),查找成本 $O(d \cdot N^{1/d})$。注意它与 Chord 的权衡方向不同:CAN 的状态与 $N$ 无关(只依赖 $d$),但查找成本比 $\log N$ 涨得快。若取 $d = \log N$,CAN 的查找时间与存储需求正好与 Chord 相当——但 CAN 的设计不允许 $d$ 随 $N$ 变化,所以这种”匹配”只在某个特定的 $N$ 上成立。CAN 还需要额外的维护协议周期性地把标识空间重新映射到节点上。
- Kelips(讲义第 8 讲最后介绍,1 跳查找的 DHT):把节点哈希到 $k$ 个亲和组(affinity group),$k \approx \sqrt{N}$;每个节点的邻居是”自己亲和组里(几乎)所有其它节点 + 每个外部亲和组一个联系节点(contact node)”;成员管理用 gossip 风格的心跳,$O(\log N)$ 时间完成传播。每个文件名哈希到一个亲和组,该组所有节点都复制这份
<filename, file-location>元信息(注意:亲和组不存文件本身,只存指针——”把文件复制/定位与文件查询解耦”)。查找只需 1 跳(或少数几跳):算出文件所属亲和组 → 找自己的 contact → 若失败就在邻居里再找一个 contact。内存代价 $O(\sqrt N)$:10 万个节点、1000 万个文件只需 1.93 MB,完全可以放进普通工作站/笔记本的内存里。文件元信息是软状态,需要定期从源节点刷新,过期即失效(这正好缓解了 churn 下的元数据一致性问题)。
- 对比表:
| 系统 | 标识空间 | 路由表大小 | 查找跳数 | 容错机制 | 代表系统/部署 |
|---|---|---|---|---|---|
| Chord | 1 维环($2^m$) | $O(\log N)$ | $O(\log N)$ | successor list + 副本 | Cassandra、Riak、Voldemort、DynamoDB、CFS/Ivy |
| Pastry | 环 + 前缀(超立方体) | $O(\log N)$ | $O(\log N)$ | leaf set + 路由表行冗余 | Microsoft 相关系统、Pastry/FreePastry |
| Kademlia | 128 位 ID + XOR 距离 | $O(\log N)$(k-bucket) | $O(\log N)$(可 α 并行) | k-bucket 每桶多副本 | BitTorrent Mainline DHT、eMule、以太坊 |
| CAN | $d$ 维笛卡尔空间 | $O(d)$ | $O(d \cdot N^{1/d})$ | 邻域冗余 | 早期的学术系统 |
| Kelips | $\sqrt N$ 个亲和组 | $O(\sqrt N)$ | $O(1)$ | gossip 成员 + 元数据软状态 | 学术系统(元数据复制路线) |
| Viceroy(补充) | 环 + 多级”蝴蝶”结构 | $O(1)$(近似) | $O(\log N)$ | 有限 | 学术系统 |
| Tapestry/Plaxton(补充) | 前缀路由 + 拓扑感知 | $O(\log N)$ | $O(\log N)$ | 冗余指针 | OceanStore |
- DHT 的根本假设与安全崩塌:所有上述结论都建立在”节点 ID 与 key 的哈希值均匀随机“这一概率假设之上。论文自己就点破了:一旦对手可以选择 key,它可以只插入那些映射到同一个节点的 key,从而制造出极端不均匀的分布;一旦对手可以批量制造节点 ID(Sybil 攻击),它可以让自己在某个目标 key 附近”占满”标识空间,从而接管对该 key 的所有查询(eclipse 攻击:把受害者的邻居全部替换成攻击者控制的节点,它就看到并篡改一切)。结构化 P2P 把”可扩展性”建立在一个非对抗假设上;在对抗环境下,必须叠加身份认证(Crypto Puzzles、S/Kademlia)或声誉机制。 详细讨论见 Lecture 27。
8.3 算法伪代码与正确性分析
算法 8.3.1:Gnutella 泛洪查询协议(Flooding Query Protocol)
假设与系统模型
- 同步性:异步消息传递。链路级(TCP 连接)可靠有序,但 overlay 级不可靠——邻居随时断开。
- 故障模型:crash-stop(节点静默断开 TCP 连接),不存在拜占庭故障。因此本算法不提供任何针对恶意节点的保证。
- 通道假设:邻居之间的 TCP 连接 FIFO 且不丢包;但消息可能经由多条路径重复到达同一节点(这是泛洪的必然结果,必须显式去重)。
- 规模:$N$ 个 servent,平均度数 $d$,TTL 初值 $t$;没有全局视图,没有成员协议,没有任何索引。
- 节点状态:邻居集合
neighbors;去重表seen(键为(payload_descriptor, descriptor_id),带过期时间);反向路由表route(descriptor_id → 上游邻居,软状态);本地共享文件表localfiles。
伪代码
state at servent S:
neighbors : set of (ip, port) # 软状态,靠 Ping/Pong 刷新
seen : set of (type, descriptor_id) # 去重;条目按 TTL 时间窗过期
route : map descriptor_id -> neighbor # 反向路径(Query/Ping 的来路)
localfiles : set of filename
upon user issues search(keywords) at S:
id ← fresh_descriptor_id() # 16 字节随机数
route[id] ← nil # nil 表示"我就是发起者"
for each nb in neighbors:
send QUERY(id, ttl = TTL_INIT, hops = 0, min_speed, keywords) to nb
upon receive DESCRIPTOR d from neighbor N: # 所有 5 种消息的统一入口
if d.ttl + d.hops ≠ TTL_INIT: drop; return # 头部自洽性检查
if (d.type, d.id) ∈ seen: drop; return # 规范第 6 条:去重,不再转发
seen ← seen ∪ {(d.type, d.id)}
case d.type of
PING: # 探测网络,发现新邻居
if d.ttl = 0: return # 规范第 5 条:TTL 用尽即止
forward(d with ttl-1, hops+1) to neighbors \ {N}
if random() < PONG_PROB: # 概率性应答,避免 Pong 风暴
send PONG(id = d.id, port, ip, nfiles, nkb) to N
PONG: # 收到邻居地址信息
neighbors ← neighbors ∪ {(d.ip, d.port)}
return # 不转发(规范第 1 条)
QUERY:
route[d.id] ← N # 记住来路,供 QueryHit 反向
hits ← match(d.keywords, localfiles)
if hits ≠ ∅ and my_speed ≥ d.min_speed:
send QUERYHIT(id = d.id, nhits = |hits|, port, ip, speed,
resultset = hits, servent_id = my_id) to N
if d.ttl = 0: return
forward(d with ttl-1, hops+1) to neighbors \ {N}
QUERYHIT:
if d.id ∉ route: drop; return # 规范第 2 条:没见过 Query 就丢
if route[d.id] ≠ nil:
send d to route[d.id] # 中间节点:沿反向路径继续回送
else:
results ← results ∪ {d} # 发起者:收集候选结果
chosen ← argmax over results of speed(d)
# 之后用 HTTP 直连下载(不在 overlay 上传输文件)
send HTTP GET /get/<fileindex>/<filename>/ HTTP/1.0 to chosen
if chosen is firewalled:
send PUSH(id = fresh_id(), servent_id = chosen.sid,
fileindex, ip = my_ip, port = my_port) along
the same reverse path that carried the QueryHit
PUSH: # 按 servent_id 路由,不按 descriptor_id
if d.servent_id ≠ my_id:
send d to route[d.id] # 或按 servent_id 查表继续转发
else:
open TCP to (d.ip, d.port)
send "GIV <fileindex>:<my_servent_id>/<filename>\n\n"
算法逻辑解说 一次完整的搜索事务由两组消息构成:去程 Query(泛洪)+ 回程 QueryHit(反向单播)。
- 发起者 $P_1$ 生成一个 16 字节
descriptor_id,把route[id]设为nil以标记”我就是源头”,然后把QUERY发给所有邻居。 - 每个收到
QUERY的节点做三件事:匹配本地文件(命中就回QueryHit,其descriptor_id与来程Query相同)、记录反向路径route[id] = 来路邻居、TTL 减 1 后转发给除来路外的所有邻居。TTL 减到 0 时只做前两件事,不再转发。 - 若 $P_9$ 命中,它沿
route[id]把QueryHit送回给 $P_1$;中间节点只查自己的route[id]表转发,根本不需要知道 $P_1$ 是谁。这就是”反向路径路由(reverse-path routing)”:用软状态代替地址信息。 - $P_1$ 收到若干
QueryHit后,选速率最高的响应者,脱离 overlay 用 HTTP 直连下载(Range头支持断点续传)。如果对方在防火墙后面无法接受入连接,$P_1$ 沿 QueryHit 的来路反向发一条PUSH,让对方主动连回来。 - 防火墙对防火墙(双方都无法接受入连接):Gnutella 放弃(讲义原话)。
一个具体数值例子:$d = 4$、TTL $= 3$ 时,从 $P_1$ 出发的消息数为 $1 + 4 + 4\times3 + 4\times 3 \times 3 = 53$ 条(8.4.5 的模拟器在 2 万节点的随机 mesh 上实测 TTL=3 时平均 280 条,TTL=4 时 2124 条,TTL=6 时 61217 条——每一步放大 5–8 倍)。
正确性论证
- 安全性(Safety):
- S1(不泄漏):
QueryHit只在”见过对应Query的节点”之间传递(规范第 2 条 +route表判定),因此结果不会流向未参与该查询的节点。若一个节点收到descriptor_id ∉ route的QueryHit,它必须丢弃。 - S2(有限终止):每次转发都令
TTL ← TTL-1,且seen集合保证同一(type, id)最多被处理一次。因此每个描述符的转发次数上界为 $d^{t}$(有限),网络不会无止境地泛洪。 - S3(头部自洽):
TTL + Hops = TTL_INIT恒成立,是识别伪造/损坏头部的第一道防线。
- S1(不泄漏):
- 活性(Liveness / 完备性):
- L1(部分活性):若持有目标文件的节点与发起者的最短 overlay 路径长度 $\le \text{TTL\_INIT}$,且路径上所有节点在线,则必定收到
QueryHit。 - L2(无完备性保证):若最短路径长度 $> \text{TTL\_INIT}$,查询必定漏掉该文件。例子(8.2.8 的图):链式拓扑 P1–…–P10、文件只在 P10、TTL=3,则 P1 收到 0 个 QueryHit——发起者无法区分”没有”与”没找到”。这是 Gnutella 与 Chord 最本质的差别。
- L3(反向路径的脆弱性):
route是软状态。若QueryHit返回途中某个中间节点掉线,或发起者已经不再是某个上游节点的邻居,则QueryHit被丢弃(规范第 2 条要求丢弃”没有对应 Query 的 QueryHit”)——查询会成功,但结果拿不到。
- L1(部分活性):若持有目标文件的节点与发起者的最短 overlay 路径长度 $\le \text{TTL\_INIT}$,且路径上所有节点在线,则必定收到
- 复杂度:消息数 $O(d^{t})$($t$ 为 TTL 初值);每节点空间 $O(d + \vert seen\vert )$,其中 $\vert seen\vert $ 近似为”$t$ 个时间窗内经过本节点的描述符数”,在繁忙节点上增长很快,必须靠过期清理控制。
算法 8.3.2:Chord 的 find_successor(id) 查找算法
假设与系统模型
- 同步性:异步。每个 RPC 有超时(论文仿真中 500 ms 视为对端失效,包延时指数分布、均值 50 ms)。
- 故障模型:crash-stop,节点可能在任何时刻失效;节点可能随时加入。不排除 finger table 与 successor 指针陈旧。
- 通道假设:底层网络对称且传递(论文 §IV 的显式假设);RPC 可靠或可超时重试。
- 规模:$N$ 个节点,$m$ 位标识符;每个节点维护
successor、predecessor、$m$ 项finger[]、长度为 $r$ 的successor_list。 - 不变量(正确性的唯一依赖):每个节点知道它至少一个存活的正确后继。
伪代码
# ---- 递归式(论文 Figure 5 的原型)----
n.find_successor(id):
if id ∈ (n, successor]: # 区间左开右闭,顺时针
return successor # 我直接知道答案
n' ← n.closest_preceding_node(id) # 表里 <= id 的最大 finger
return n'.find_successor(id) # RPC 转发,继续逼近
n.closest_preceding_node(id):
for i ← m downto 1: # 从最大的 finger 开始找
if finger[i] ∈ (n, id):
return finger[i]
return n # 没有更近的:转发给自己(
# 递归版实际会落到 successor)
# ---- 迭代式(本笔记代码与论文仿真器采用)----
n.find_successor_iterative(id): # 返回 (owner, hops)
if id = n.id: return (n, 0)
cur ← n; hops ← 0
loop:
s ← cur.first_alive_successor() # 后继列表里第一个存活者
if id ∈ (cur, s]: return (s, hops) # 找到:s 就是 successor(id)
nxt ← cur.closest_preceding_node_skip_dead(id)
if nxt = cur or nxt dead:
return (s, hops) # 信息不完整:沿后继链爬行
cur ← nxt; hops ← hops + 1
if hops > MAX_HOPS: return (s, hops) # 兜底,防止环被破坏时死循环
# ---- RPC 失效处理(论文 §IV-E.3)----
n.rpc_find_successor(id):
try: return n'.find_successor(id) with timeout 500ms
on timeout:
mark n' as failed; remove from finger[] and successor_list
pick the next-best preceding node from finger[] ∪ successor_list
retry rpc_find_successor(id) at that node
算法逻辑解说 以 $m=6$ 环上 N8 查 key 54 为例(与 8.2.13 的图一致,代码实测):
- N8 检查 $54 \in (8, 14]$?否。
- N8 在 finger table 中从 $i=6$ 向下找第一个落在 $(8, 54)$ 的项:
finger[6] = N42(40 在区间内)→ 转发给 N42。此时距离从 46 缩到 12。 - N42 检查 $54 \in (42, 48]$?否。它的 finger 中 $\le 54$ 的最大者是
finger[4] = N51→ 转发给 N51。距离从 12 缩到 3。 - N51 检查 $54 \in (51, 56]$?是 → 返回
successor = N56。距离归零。 - 迭代式实现中,最终答案沿 RPC 的返回值一路送回 N8;递归式实现中,目标节点沿调用栈把结果传回。
正确性论证
- 安全性(Safety):
- S1(返回的必是 owner):算法只在
id ∈ (cur, s]时返回 $s$,此时 $s$ 是环上第一个 $\ge id$ 的节点,即successor(id)(前提是 $s$ 确实是cur的真实后继,即不变量成立)。中间节点的选择(closest_preceding_node)只影响走多远,不影响最终判定条件——因此路由信息错了只会让查询变慢,不会让答案变错(论文强调的 “performance degrades gracefully”)。 - S2(不会返回已失效节点):候选节点经过
alive检查与超时剔除,返回的 $s$ 来自存活的后继列表。 - S3(不变量保持):
closest_preceding_node只返回落在 $(n, id)$ 内的节点,因此指针永远不会指向环上”越过”目标的节点,避免了指针环(loopiness)导致的错误答案。
- S1(返回的必是 owner):算法只在
- 活性(Liveness):
- L1(距离减半,确定性):设 $p$ =
predecessor(id),当前持有查询的节点是 $n \ne p$。令 $i$ 满足 $p \in [n + 2^{i-1}, n + 2^i)$(由 $p$ 与 $n$ 的距离的二进制表示唯一确定)。由于该区间非空,$n$ 会联系它的第 $i$ 个 finger,即区间内的第一个节点 $f$。则 $\text{dist}(n,f) \ge 2^{i-1}$,而 $f \le p$,故 \(\text{dist}(f,p) = \text{dist}(n,p) - \text{dist}(n,f) \le \text{dist}(n,p) - 2^{i-1} < \text{dist}(n,p) - \tfrac{\text{dist}(n,p)}{2} = \tfrac{\text{dist}(n,p)}{2}\) 最后一步用到 $\text{dist}(n,p) < 2^{i}$($p$ 落在区间内)。即每转发一次,与目标的距离至少减半。 - L2(终止):距离至多 $2^m$,经过 $m$ 次减半必降到 1,此时当前节点就是 $p$,下一步必然命中区间 $(p, \text{successor}(p)]$ 而返回。因此在信息正确的前提下算法在 $\le m+1$ 步内终止。
- L3(信息陈旧时仍终止):若 finger 全部陈旧,
closest_preceding_node返回 $n$ 自身,算法退化为”沿 successor 链爬行”——只要环没有被切成不连通的碎片,就一定能爬到答案;MAX_HOPS兜底防止环被破坏时的死循环。这正是”finger 错只影响速度”的形式化体现。
- L1(距离减半,确定性):设 $p$ =
- 复杂度:
- 跳数 $O(\log N)$(高概率):减半论证给出上界 $m$,但真正的紧界来自随机性。经过 $2\log N$ 次转发后,距离已缩到 $2^m/N^2$;区间长度为 $2^m/N^2$ 时,其中存在其它节点的概率至多 $N \cdot (1/N^2) = 1/N$(”球与箱”论证),因此下一次转发就能命中目标。故跳数 $O(\log N)$ w.h.p.;平均为 $\tfrac{1}{2}\log_2 N$(原因见 8.8 的思考题)。
- 消息数:迭代式每跳 1 个请求 + 1 个响应 = $O(\log N)$ 条消息;递归式相同但 RTT 更少。
- 空间:每节点 $O(\log N)$ 状态(实际互不相同的 finger 只有 $O(\log N)$ 项)。
算法 8.3.3:Chord 的加入与一致性维护(join / stabilize / notify / fix_fingers / check_predecessor)
假设与系统模型
- 同步性:异步,且成员集合持续变化(churn):讲义给出的实测数字是 Gnutella 每小时 100% 节点周转、Overnet 每小时 25%。
- 故障模型:crash-stop;故障检测器不完备(可能误判,但论文假设基于超时的失效判定)。
- 通道假设:RPC 可能丢失、延迟、乱序(论文 Theorem IV.3 明确要求算法在”并发加入 + 消息丢失/重排”下仍然收敛)。
- 并发假设:多个节点可以同时加入同一区间;节点可能在
stabilize执行到一半时失效。 - 目标(不是强一致):最终一致(eventual consistency)——在最后一次 join 之后的某个有限时间(”稳定期”)内,所有 successor 指针形成一个覆盖全体节点的环,此后所有 key 都能被正确查到。
伪代码(论文 Figure 6 的完整还原,加上后继列表协调与失效处理)
# ============ 节点初始化 ============
n.create(): # 创建一个全新的环
predecessor ← nil
successor ← n
successor_list ← [n]
n.join(n'): # n' 是任意一个已在环中的已知节点
predecessor ← nil # 显式置空:等别人 notify 我
successor ← n'.find_successor(n) # 关键:用查找确定自己的位置
successor_list ← [successor]
for i ← 1 to m: finger[i] ← successor # 先粗指向 successor,之后由
# fix_fingers 逐项精化
# 注意:join() 自己不做任何"通知"——让网络通过 stabilize 发现我
# ============ 后台维护(每个节点周期性执行)============
n.stabilize(): # 周期 T_stab
s ← first_alive(successor_list) # 容错:跳过已失效者
if s = n: return # 环中只有我一个
x ← s.predecessor # RPC:问后继的前驱是谁
if x ≠ nil and x.alive and x ∈ (n, s): # 有节点插到我和后继之间
successor ← x # 只接受"更近的"候选
s ← x
s.notify(n) # 告诉后继我的存在
successor_list ← [s] ⊕ (s.successor_list \ {s}) # 后继列表协调:
# 复制 s 的列表、去掉末尾
successor ← s
n.notify(n'): # 被别人的 stabilize 调用
if predecessor = nil or predecessor not alive
or n' ∈ (predecessor, n): # 只接受"更近的"候选
predecessor ← n'
n.fix_fingers(): # 周期 T_fix
next ← (next mod m) + 1 # 每轮只修一项,摊平开销
finger[next] ← find_successor((n + 2^(next-1)) mod 2^m)
n.check_predecessor(): # 周期 T_check
if predecessor ≠ nil and predecessor not alive:
predecessor ← nil # 清空,以便接受新的 notify
# ============ key 迁移(应用层,被 Chord 的责任变化通知触发)============
n.transfer_keys_to_successor():
for each (k, v) in local_store where k ∉ (predecessor, n]:
successor.store[k] ← v; delete local_store[k]
算法逻辑解说($m=6$ 环,ID 26 的节点加入 N21 与 N32 之间)
N26.join(N21):N21.find_successor(26)返回 N32(因为 $26 \in (21, 32]$),于是N26.successor = N32、N26.predecessor = nil、所有 finger 先指向 N32。此时 N26 已经可以服务查询(它会沿 successor 走到 N32),但环上还没有任何人指向它——它是一段”挂在环外但指向环”的支路。关键:这段支路不会破坏任何已有节点的可达性。N26从N32复制本属于它的 key:区间 $(21, 26]$ 内的 K24 迁移到 N26,N32 只剩 K30。N21.stabilize():$s = N32$,问得 $x = \text{N32.predecessor} = \text{N26}$(第 2 步中 N26 若已 notify 过),$26 \in (21, 32)$ 成立 →N21.successor = N26;随后N26.notify(N21)让 N26 把 N21 记为前驱。- 至此后继链完全正确:N21 → N26 → N32。论文特别指出:这个过程的每一步里,$n_s$(N32)都可以从 $n_p$(N21)沿 successor 指针到达,因此与加入并发进行的查找不会被打断。
在讲义的 $m=7$ 例子里,同样的事发生在 N40 加入 N32 与 N45 之间:N32 把 successor 更新为 N40,N40 初始化 successor 为 N45 并从 N45 复制 K34、K38(即区间 $(32, 40]$ 内的 key)。
正确性论证
- 安全性(Safety):
- S1(单调性 / 不产生错误指针):
stabilize只在 $x \in (n, s)$ 时更新后继,notify只在 $n^{\prime} \in (\text{pred}, n)$ 时更新前驱。两个条件都要求”新候选严格更近“,因此指针在环序上单调地朝真实邻居靠拢,永远不会反向跳到一个更远的节点。这排除了”指针来回振荡”这一最危险的失效模式。 - S2(保持可达性):
join只让新节点指向一个已有节点,不让任何已有节点指向新节点;已有节点只在stabilize中把 successor 从 $s$ 改成 $x$($x$ 是 $s$ 的前驱,因此 $n \to x \to \dots \to s$ 仍然连通)。归纳可得:任何时刻,未被吸收的新节点不会把环切断。 - S3(
fix_fingers不破坏正确性):finger[i] ← find_successor(...)的结果可能陈旧,但 finger 只用于加速,不参与”答案是否正确”的判定(判定条件是id ∈ (n, successor])。因此 finger 的错误永远不会导致错误答案。 - S4(
check_predecessor的保守性):它只在判定前驱失效时清空前驱,从不清除一个存活的前驱;误判只会让节点暂时拒绝通知,而下一轮stabilize会重新建立前驱关系——误判的代价是延迟,不是错误。
- S1(单调性 / 不产生错误指针):
- 活性(Liveness / 最终一致性):
- L1(Theorem IV.3 的复述):若任意序列的 join 操作与 stabilize 交错执行,则在最后一次 join 之后的某个时刻,successor 指针会形成一个覆盖网络中所有节点的环。
- L1 的论证思路(不变量式):固定最终的节点集合 $S$(假设此后不再有 join)。设 $s$ 是当前”successor 指针不正确”的节点中环序最小者,$s^* = \text{successor}(s)$ 是它应有的真实后继。由于 $s$ 的指针不正确,它的后继 $t$ 必然在环序上”越过”了 $s^$(即 $t$ 在 $s^$ 之后),这说明 $s^$ 已经加入但还没被 $s$ 发现——$s^$ 是通过某个节点的
stabilize被发现的唯一途径是”它成为某个节点的 successor 的前驱”。$s^$ 加入时调用了join,它指向了当时正确的后继,并且此后每一轮stabilize都会调用notify通知自己的后继。因此在有限轮stabilize内(fairness 假设:每个节点无限次执行stabilize),$s^$ 会通知到它的前驱链;最终 $s$ 的stabilize会读到 $x = s^*$ 并接受它。由于每一轮修正都使”不正确节点集合”在环序上向前推进,且集合大小单调不增(S1 单调性保证指针不会退回错误状态),系统在有限时间内收敛。 - L2(收敛后的正确性):当所有 successor 指针都正确时,
find_successor的判定条件id ∈ (n, successor]恰好刻画successor(id),因此所有 key 都能被正确定位(算法 8.3.2 的 S1)。 - L3(为什么必须周期执行):
join自身不通知任何人,失效检测也不可靠。若stabilize只执行一次就停止,那么在任何一次”执行之后发生的 join”都将永远不可见。周期执行 + fairness 是最终一致的充分条件。 - L4(已知的病理情形):论文明确说明,稳定化协议不能修复已经分裂成多个不相交环、或绕标识空间多圈的环。这类状态不可能由普通的 join 序列产生;若真的出现(例如被人为构造或长时间网络分区),需要用周期性采样环拓扑来检测与修复。
- 复杂度:
- 单次 join:1 次
find_successor= $O(\log N)$ 条消息;随后新节点每轮fix_fingers修一项 = $O(\log N)$ 条消息,修完整张表 $O(\log^2 N)$。 - 系统整体:一个新节点的加入平均影响 $O(\log N)$ 个其它节点的 finger 表项 → 每条 join 引起 $O(\log N \cdot \log N) = O(\log^2 N)$ 条消息(讲义原话:Number of messages per peer join = $O(\log(N)\times\log(N))$)。
- 稳定化轮数:讲义指出达到”强稳定(strong stability)”需要 $O(\log^2 N)$ 轮稳定化;论文 Theorem IV.4 的等价说法是:只要任意 $N$ 次 join 之间至少发生 $\Omega(\log^2 N)$ 轮稳定化,查找就保持 $O(\log N)$。
- 每轮
stabilize的消息数:常数条(1 个 RPC + 1 个 notify),这是”摊销”设计的要点。
- 单次 join:1 次
算法 8.3.4:Chord 的副本放置(Replica Placement on the Successor Chain)
假设与系统模型
- crash-stop 故障;节点可能在被写入副本之前就失效;成员视图是软状态(任何时刻的 successor 链都可能暂时不完整)。
- 副本数 $r$;副本存放在 key 的 owner 及其后续 $r-1$ 个存活后继上。
- 不需要共识:多个副本之间的冲突由”最后写入者胜”或版本号解决(这一层属于应用,不属于 Chord)。
伪代码
# 后继链上第 i 个存活后继(i = 0 表示 owner 自己)
succ_chain(n, i):
cur ← n
for j ← 1 to i:
nxt ← first_alive(cur.successor_list) # 跳过失效节点
if nxt = cur or nxt = n: return nil # 环断了 / 绕回来了
cur ← nxt
return cur
# 写入:主副本 + (r-1) 个冗余副本
put(key, value):
n ← find_successor(hash(key)) # 唯一的"归属判定"
n.store[key] ← value
for i ← 1 to r-1:
m ← succ_chain(n, i)
if m ≠ nil: m.replica[key] ← value
return n
# 读取:如果 owner 已失效,就用副本
get(key):
n ← find_successor(hash(key))
if key ∈ n.store: return n.store[key]
if key ∈ n.replica: return n.replica[key] # owner 崩溃时,新 owner
# 往往已持有这份副本
for i ← 1 to r-1:
m ← succ_chain(n, i)
if m ≠ nil and key ∈ m.replica: return m.replica[key]
return NOT_FOUND
# 副本修复(周期性,与 stabilize 一同运行)
n.repair_replicas():
for each (k, v) in n.store: # 把主副本推给后继链
for i ← 1 to r-1:
m ← succ_chain(n, i); if m ≠ nil: m.replica[k] ← v
for each k in n.replica: # 清掉自己不该持有的副本
owner ← find_successor(hash(k))
if n ∉ {succ_chain(owner, i) : i = 1..r-1}: delete n.replica[k]
算法逻辑解说:以 $m=6$ 环、$r=3$、key K24(owner 是 N32)为例,副本分别落在 N32、N38、N42 上。若 N32 与 N38 同时崩溃,find_successor(24) 会返回”第一个存活且 $\ge 24$ 的节点”= N42。N42 手里恰好握着 K24 的冗余副本,于是 get 在第三步命中——查找路径的自然漂移与副本链的覆盖范围恰好吻合,这是把副本放在”后继链”而不是随便 $r$ 个节点的精妙之处(8.4.3 的代码实测了这一场景:崩溃前 N21.succ_list = [N32, N38, N42],崩溃并稳定化后变成 [N42, N48, N51],四个原本属于 N32 的 key 全部从 N42 读出)。
正确性论证
- 安全性:每个 key 的 $r$ 份副本内容相同(写入时按同一顺序推给后继链;
repair_replicas幂等地重建不一致的副本)。若使用版本号,读取返回最高版本,满足线性一致的单 key 语义;但 Chord 本身不保证跨 key 的原子性或副本间的强一致——那是上层(Dynamo 的 quorum、Cassandra 的 quorum/CL)的责任。 - 活性:论文 Theorem IV.5 —— 若网络初始稳定,每个节点以概率 $1/2$ 独立失效,$r = \Omega(\log N)$,则
find_successor以高概率返回最近的存活后继。证明:失效前每个节点知道自己的 $r$ 个后继;$r$ 个后继全部失效的概率是 $(1/2)^r$,取 $r = 2\log N$ 得 $1/N^2$,对全网络 $N/2$ 个存活节点做并集界即得 \(\Pr(\text{所有存活节点都知道一个存活后继}) \ge 1 - \tfrac{N}{2}\cdot\tfrac{1}{N^2} = 1 - \tfrac{1}{2N}\) 再由算法 8.3.2 的 S1,所有查询被正确路由。讲义给出了完全相同的推导。 - 复杂度:写放大 $r = O(\log N)$(写入消息 $r$ 条);读取只需 1 个响应(最坏 $r$ 次尝试);副本修复的稳态带宽 $O(r)$ 每 key 每次修复。代价的根源:churn 高时”key 反复搬家”会带来大量无用的复制流量——讲义指出”主要问题是文件本身被复制,而其实只需要复制文件的元信息”,这正是 Kelips 路线(只复制
<filename, location>)的动机。
算法 8.3.5:Chord 查找复杂度的不变量式证明($O(\log N)$ Hops)
假设与系统模型
- 环稳定:所有 successor 指针正确;$N$ 个节点、$m$ 位标识符;节点 ID 与 key 由 SHA-1 哈希得到,近似均匀随机且相互独立(非对抗模型)。
- 查询从任意节点 $n$ 出发,目标为任意标识符 $k$;$p = \text{predecessor}(k)$,
successor(k)是 $p$ 的后继。
伪代码(分析性,不变量式论证)
THEOREM: 在稳定的 N 节点 Chord 环上,find_successor 平均需要 0.5·log2(N) 跳,
最坏以高概率为 O(log N) 跳。
INVARIANT HALVING(n, k):
若 n ≠ p,则每一步转发后的持有者 f 满足
dist(f, p) < dist(n, p) / 2 # 距离至少减半
PROOF:
设 i 是唯一满足 p ∈ [n + 2^(i-1), n + 2^i) 的整数 # 由距离的二进制唯一确定
f ← finger[i](n) = successor(n + 2^(i-1)) # 算法转发规则
(a) f ≤ p 因为 p ≥ n + 2^(i-1) 且 f 是其中第一个节点
(b) dist(n, f) ≥ 2^(i-1) 因为 f ≥ n + 2^(i-1)
(c) dist(n, p) < 2^i 因为 p < n + 2^i(i 的定义)
于是
dist(f, p) = dist(n, p) − dist(n, f)
≤ dist(n, p) − 2^(i-1)
< dist(n, p) − dist(n, p)/2 # 由 (c): 2^(i-1) > dist(n,p)/2
= dist(n, p) / 2
∎
INVARIANT TERMINATION:
距离最多 2^m,经 ≤ m 次减半降到 1;此时当前节点必为 p,
下一步命中区间 (p, successor(p)] 并返回。 ⇒ 确定性上界 m+1 跳
REFINEMENT RANDOM-FINISH: # 为什么是 O(log N) 而不是 O(m)
经过 2·log2(N) 次转发后,dist(cur, k) ≤ 2^m / N^2
区间长度 2^m / N^2 内"存在其它节点"的概率
Pr ≤ N · (1 / N^2) = 1 / N # 并集界(balls and bins)
故下一次转发直接命中目标,概率 1 − 1/N。
⇒ 跳数 = 2·log2(N) + O(1) = O(log N) w.h.p.
REFINEMENT AVERAGE: # 为什么常数是 1/2
把 dist(n, k) 写成二进制。第 i 个最高有效位由第 i 个 finger 纠正;
若该位是 1,就必须走一步;若是 0,则跳过对应的 finger。
⇒ 步数 = 距离的二进制表示中 1 的个数
随机 ID ⇒ 每一位为 1 的概率 1/2 ⇒ 期望 log2(N)/2 位是 1
⇒ 平均跳数 ≈ 0.5·log2(N)
算法逻辑解说:这个证明有一个非常实用的推论——“减半”论证只依赖标识符空间的距离,而不依赖 finger 具体指向哪个节点。因此即使 finger table 因为并发加入而陈旧,查找的渐近跳数不变(论文 Theorem IV.4)。这解释了为什么 Chord 可以在持续 churn 下继续工作:正确性靠 successor 保证,性能靠 finger 保证,而性能的证明对 finger 的错误是鲁棒的。
正确性论证
- 安全性:证明过程只用到”$f$ 是区间 $[n+2^{i-1}, n+2^i)$ 中第一个节点”这一事实,因此结论对任何满足该性质的转发规则成立,不存在反例。
- 活性:减半是严格不等式,距离是正整数,因此必然在有限步内归零(终止性);结合 8.3.2 的 L3,即使信息陈旧也不会死循环。
- 测量的验证:8.4.3 的代码在 $m=16$、$N \in \{4,8,\dots,128\}$ 上实测平均跳数为 $0.99, 1.46, 1.86, 2.24, 2.75, 3.36$,而 $\tfrac12\log_2 N = 1, 1.5, 2, 2.5, 3, 3.5$——误差全部在 0.15 跳以内,理论与实现完全吻合。
- 复杂度:跳数 $O(\log N)$ w.h.p.、平均 $\tfrac12\log_2 N$;消息数 $O(\log N)$;每节点空间 $O(\log N)$。
8.4 代码示例与分布式实现
本节给出 5 段可直接 python3 运行的代码,共 3 个文件(放在同一目录下即可):chord_core.py(Chord 核心,分两段展示)、chord_ring_demo.py(教科书环演示)、chord_scale.py(规模与故障实验)、gnutella_flood.py(泛洪模拟器)。全部只用标准库,随机种子固定,输出可复现。
代码 8.4.1:Chord 核心(一)——环、标识空间与查找
# -*- coding: utf-8 -*-
"""Chord DHT core: m-bit identifier ring + consistent hashing + finger table
+ stabilization protocol (join / stabilize / fix_fingers / check_predecessor)
+ successor-list replication.
Single-process simulation: a Node object is a "process", Ring.call() is one RPC
(returns None when the peer has crashed, i.e. timeout / unreachable).
"""
import bisect
import hashlib
import random
class Ring:
"""A Chord ring of m-bit identifiers (0 .. 2^m - 1)."""
def __init__(self, m=6, repl=3):
self.m = m
self.size = 1 << m
self.repl = repl # r: number of replicas / successor-list length
self.nodes = {} # id -> Node
self.rpc_count = 0
# ---- hashing: node ids and key ids share one identifier space ----
def hash_id(self, s):
return int(hashlib.sha1(str(s).encode()).hexdigest(), 16) % self.size
def call(self, node, method, *args):
"""One RPC. A crashed peer => None (modelled timeout)."""
self.rpc_count += 1
if node is None or not node.alive:
return None
return getattr(node, method)(*args)
# ---- topology helpers ----
def any_alive(self):
for n in self.nodes.values():
if n.alive:
return n
return None
def alive_ids(self):
return sorted(i for i, n in self.nodes.items() if n.alive)
def true_owner_id(self, kid):
"""Off-line ground truth: first alive id >= kid (clockwise)."""
ids = self.alive_ids()
if not ids:
return None
return ids[bisect.bisect_left(ids, kid) % len(ids)]
def new_node(self, nid=None):
"""Create a node with the given id (or a random one) and join it."""
if nid is None:
nid = random.randrange(self.size)
if nid in self.nodes and self.nodes[nid].alive:
return None
n = Node(nid, self)
self.nodes[nid] = n
boot = self.any_alive()
if boot is None:
n.succ, n.pred, n.succ_list = n, None, [n]
n.fix_all_fingers()
else:
n.join(boot)
return n
def maintain(self, rounds=1):
"""Every alive node runs its background routines once per round."""
for _ in range(rounds):
alive = [n for n in self.nodes.values() if n.alive]
random.shuffle(alive)
for n in alive:
n.stabilize()
n.fix_fingers()
n.check_predecessor()
class Node:
def __init__(self, nid, ring):
self.id = nid
self.ring = ring
self.alive = True
self.succ = self
self.pred = None
self.fingers = [self] * ring.m # fingers[i-1] = finger[i]
self.succ_list = [self] # r successors, for failure tolerance
self.store = {} # primary key -> value
self.replica = {} # replica copies held for predecessors
self.next_finger = 1
def __repr__(self):
return "N%d" % self.id
# ---------- ring interval predicates (a != b) ----------
def in_open(self, x, a, b):
"""x in (a, b) clockwise (mod 2^m)? a == b means the whole ring."""
if a == b:
return True
if a < b:
return a < x < b
return x > a or x < b
def in_closed_right(self, x, a, b):
"""x in (a, b] ?"""
return x == b or self.in_open(x, a, b)
# ---------- successors ----------
def live_succ(self):
for s in self.succ_list:
if s.alive:
return s
return self.succ
def get_pred(self):
return self.pred
def get_succ_list(self):
return list(self.succ_list)
# ---------- lookup ----------
def closest_preceding(self, tid):
"""Largest finger that precedes tid; self if none."""
for i in range(self.ring.m, 0, -1):
f = self.fingers[i - 1]
if f.alive and self.in_open(f.id, self.id, tid):
return f
return self
def find_successor(self, tid):
"""Iterative lookup: return (owner_of_tid, hops)."""
if tid == self.id:
return self, 0
n, hops = self, 0
while True:
s = n.live_succ()
if n.in_closed_right(tid, n.id, s.id):
return s, hops
nxt = n.closest_preceding(tid)
if nxt is n or not nxt.alive:
return s, hops # incomplete info: crawl along successors
n, hops = nxt, hops + 1
if hops > 4 * self.ring.m + 16:
return s, hops
【代码做什么?】
Ring类代表一个 Chord 环(也就是”整个分布式系统”):它保存 $m$(标识位数)、$2^m$(标识空间大小)、副本数 $r$ 与所有节点。hash_id()用 SHA-1 把任意字符串映射到 $[0, 2^m)$,节点 ID 与 key ID 共用同一个空间。Ring.call(node, method, ...)就是一次 RPC:若对端alive == False(已崩溃),返回None模拟超时/不可达。整个模拟里所有节点间通信都必须经过它。Ring.true_owner_id(kid)是离线计算的标准答案:把存活节点 ID 排序后用二分找到第一个 $\ge kid$ 的 ID(模 $N$ 回绕)。它不参与任何算法,只用于在实验末尾断言”算法的答案 == 标准答案”。Node保存分布式状态:succ、pred、fingers($m$ 项)、succ_list(长度 $r$ 的后继列表)、store(主副本)、replica(冗余副本)。in_open/in_closed_right实现环上区间判定。注意a == b被定义成”整个环”——这不是偷懒,而是论文对区间 $(a,b]$ 的定义在 $a=b$ 时的自然推论(单节点环里,任何 id 都落在 $(n, n]$ 内)。这个细节是最容易写错、也最容易导致”环永远收敛不了”的地方。closest_preceding()从 $i=m$ 递减查找第一个落在 $(n, id)$ 内的 finger,找不到就返回自己——这就是论文的closest_preceding_node。find_successor()是迭代式查找:先判断id ∈ (cur, live_succ],满足就返回当前后继;否则跳到closest_preceding。它返回(owner, hops),hops用于后面的复杂度实验。若真后继已死,live_succ()会从后继列表里取第一个存活者——这就是”容错的迭代式查找”。
【分布式机制透视】
- 消息传递:所有跨节点操作都是
Ring.call(...)(stabilize里的两次 RPC 尤其典型)。真实的 Chord 用 TCP + protobuf,这里用方法调用,但调用语义完全一致:可能超时、可能对端已死、返回值可能过期。 - 进程状态:每个
Node对象就是一台机器上的一个进程。它的fingers/succ/pred是本地状态,别的节点看不到,只能通过 RPC 询问(get_pred()、get_succ_list()就是两个只读 RPC)。这一点很重要:任何人都不能”直接读别人的表”,这正是分布式与共享内存的区别。 - 并发与时序:
Ring.maintain()每轮把所有存活节点打乱顺序依次执行一遍stabilize / fix_fingers / check_predecessor,模拟”每个节点各自按自己的时钟周期执行”。打乱顺序就是在模拟并发交错——如果把顺序固定,很多竞态就不会出现,收敛性验证也就不充分了。 - 对应物:
alive=False对应进程 crash-stop;succ_list对应论文的 successor list;store/replica对应”应用层把数据放在 owner 与其后继上”。
【与理论的对应】
- 本段代码逐行实现了算法 8.3.2 的迭代式伪代码:
find_successor对应主循环,closest_preceding对应closest_preceding_node,live_succ对应论文 §IV-E.3 中”后继不回话就用后继列表里的下一个”。 true_owner_id与find_successor的对比,验证的正是安全性 S1:”返回的必是 owner”。- 8.4.3 会断言 N8 的 6 个 finger 与逐项手算结果完全一致,验证 finger[i] = successor(n+2^(i-1)) 的定义;并断言 64 个 key 全部落到正确 owner,验证 8.2.10 的区间划分。
代码 8.4.2:Chord 核心(二)——加入、稳定化与数据操作
# ---------- join ----------
def fix_all_fingers(self):
for i in range(1, self.ring.m + 1):
self.fingers[i - 1] = self.find_successor(
(self.id + (1 << (i - 1))) % self.ring.size)[0]
def join(self, boot):
"""boot: any node already in the ring."""
self.pred = None
self.succ = boot.find_successor(self.id)[0]
self.succ_list = [self.succ]
for i in range(self.ring.m): # coarse: everything points at successor
self.fingers[i] = self.succ # fix_fingers() refines it later
# ---------- stabilization ----------
def stabilize(self):
s = self.live_succ()
if not s.alive:
return
x = self.ring.call(s, "get_pred")
if x is not None and x.alive and self.in_open(x.id, self.id, s.id):
self.succ = x
s = x
self.ring.call(s, "notify", self)
tail = [t for t in (self.ring.call(s, "get_succ_list") or [s]) if t is not s]
merged = [s]
for t in tail + self.succ_list: # reconcile successor list
if t is not s and t.alive and t not in merged:
merged.append(t)
if len(merged) >= self.ring.repl:
break
self.succ_list = merged
self.succ = s
def notify(self, other):
if other is self or other is None or not other.alive:
return
if self.pred is None or self.pred is self or (not self.pred.alive) or \
self.in_open(other.id, self.pred.id, self.id):
self.pred = other
def fix_fingers(self):
i = self.next_finger
self.next_finger = 1 if i >= self.ring.m else i + 1
self.fingers[i - 1] = self.find_successor(
(self.id + (1 << (i - 1))) % self.ring.size)[0]
def check_predecessor(self):
if self.pred is not None and not self.pred.alive:
self.pred = None
# ---------- data operations ----------
def replica_at(self, i):
cur = self
for _ in range(i):
nxt = cur.live_succ()
if nxt is cur or nxt is self:
return None
cur = nxt
return cur
def put(self, key, value):
owner, hops = self.find_successor(self.ring.hash_id(key))
owner.store[key] = value
for i in range(1, self.ring.repl):
h = owner.replica_at(i)
if h is not None and h is not owner:
h.replica[key] = value
return owner, hops
def get(self, key):
owner, hops = self.find_successor(self.ring.hash_id(key))
if key in owner.store:
return owner.store[key], owner, hops
if key in owner.replica: # owner crashed: the new
return owner.replica[key], owner, hops # owner already held a copy
for i in range(1, self.ring.repl): # walk the successor list
h = owner.replica_at(i)
if h is not None and key in h.replica:
return h.replica[key], h, hops
return None, owner, hops
def refresh_replicas(self):
for k, v in self.store.items():
for i in range(1, self.ring.repl):
h = self.replica_at(i)
if h is not None and h is not self:
h.replica[k] = v
for k in list(self.replica): # drop stale copies
owner, _ = self.find_successor(self.ring.hash_id(k))
if not any(owner.replica_at(i) is self for i in range(1, self.ring.repl)):
del self.replica[k]
def rebalance_keys(self):
"""Application-layer hand-off: keys I no longer own move to my successor."""
moved = 0
for k in list(self.store):
owner, _ = self.find_successor(self.ring.hash_id(k))
if owner is not self and owner.alive:
owner.store[k] = self.store.pop(k)
moved += 1
return moved
【代码做什么?】
join(boot)严格按论文 Figure 6:前驱置nil、successor ← boot.find_successor(self.id)、后继列表只含后继、所有 finger 先粗指向后继。它不通知任何节点——新节点是被别人的stabilize发现的。这正对应 8.2.14 讲的”后台周期运行 vs 一次性完成”。stabilize()是核心:取第一个存活后继 $s$ → RPC 问 $s$ 的前驱 $x$ → 只有当 $x \in (n, s)$ 时才把后继改成 $x$ → 调用s.notify(n)→ 协调后继列表。这个”只接受更近候选”的判断是收敛性(安全性 S1:单调性)的实现。notify(other)同样只在”我没有前驱 / 我的前驱失效 /other ∈ (pred, n)“时更新前驱。前两条正是check_predecessor清空失效前驱的意义所在。fix_fingers()用next_finger游标每轮只修一项,把 $O(\log N)$ 次查找的开销摊平到 $m$ 轮里。check_predecessor():前驱失效就清空(这是故障检测器在 Chord 中的落点,与 Lecture 6 的 failure detector 呼应)。- 数据操作:
put写主副本 + 后继链上 $r-1$ 个冗余副本;get依次尝试 owner 的主副本、owner 自己持有的冗余副本(这是崩溃后立刻可读的关键)、再沿后继链找;refresh_replicas幂等地重建/清理副本;rebalance_keys是应用层在 Chord 通知”责任发生变化”后把不该自己管的 key 交给新主人的动作。
【分布式机制透视】
- RPC 与失效检测:
stabilize里的self.ring.call(s, "get_pred")返回None就等价于”对端超时”;代码里对None与x.alive都做了检查,对应论文”若某个节点在查找过程中失效,超时后改用次优前驱”。 - 软状态与最终一致:
succ_list的合并逻辑(tail + self.succ_list去重截断到 $r$ 项)是”向后继学习 + 保留自己已知的信息”的折中——真实系统里就是 gossip 式的成员信息收敛。代码允许列表里暂时含有失效节点(用之前才过滤),这正是软状态的做法:不做一致性协议,只做周期性修复。 - 并发交错:
Ring.maintain(rounds)打乱节点顺序反复执行,模拟 churn 下的并发 stabilize。8.4.4 的实验 A 在每一次 join 之后都跑若干轮维护再校验,证明”维护轮数足够时最终一致成立”;反过来,如果你把rounds设为 0,断言立刻失败——这就是最终一致与强一致的实验化区别。 - 哪些是真实系统的对应物:
Ring.call↔ RPC 框架;alive↔ failure detector;succ_list↔ Cassandra 的 “preference list” 雏形;refresh_replicas↔ Cassandra 的 hinted handoff/anti-entropy 的简化版;rebalance_keys↔ Cassandra 的 bootstrap/streaming。
【与理论的对应】
join/stabilize/notify/fix_fingers/check_predecessor与算法 8.3.3 的伪代码一一对应,包括”每轮只修一项”的摊销细节。- 8.4.4 实验 A 的断言(”所有 key 都落在正确的后继节点上”)就是对 Theorem IV.3(最终一致) 的实验验证;实验 C 的断言(”崩溃两个节点后所有 key 仍可读”)就是对 Theorem IV.5($r = \Omega(\log N)$ 的容错性) 的验证。
- 代码里的
replica字典对应 算法 8.3.4 的副本放置:主副本在store,冗余副本在replica,二者分离正是为了让rebalance_keys只迁移主副本、而副本由refresh_replicas单独维护。
代码 8.4.3:教科书环演示($m=6$)——finger table、key 归属与查找路径
# -*- coding: utf-8 -*-
"""Chord on an m=6 ring: the textbook 10-node example, finger table of N8,
key->node mapping, and a step-by-step lookup for key 54."""
import random
from chord_core import Ring
RING_IDS = [1, 8, 14, 21, 32, 38, 42, 48, 51, 56] # paper's Figure 2 ring
DEMO_NODES = [1, 8, 14, 21, 32, 38, 42, 48, 51, 56]
def build(m=6, ids=None, repl=3, seed=425):
random.seed(seed)
ring = Ring(m=m, repl=repl)
order = list(ids or [])
random.shuffle(order) # join order is arbitrary
for i in order:
ring.new_node(i)
ring.maintain(rounds=2)
ring.maintain(rounds=8)
return ring
def trace_lookup(ring, start_id, tid):
"""Walk the lookup hop by hop and print the distance shrink."""
n = ring.nodes[start_id]
print(" lookup(%d) issued at N%d" % (tid, start_id))
print(" step at chosen next distance to %d" % tid)
hops = 0
while hops < 20:
d = (tid - n.id) % ring.size
s = n.live_succ()
if n.in_closed_right(tid, n.id, s.id):
print(" %-5d N%-5d %-13s %d -> 0 (answer: N%d)"
% (hops, n.id, repr(s), d, s.id))
return s.id, hops
nxt = n.closest_preceding(tid)
nd = (tid - nxt.id) % ring.size
print(" %-5d N%-5d %-13s %d -> %d" % (hops, n.id, repr(nxt), d, nd))
n, hops = nxt, hops + 1
return None, hops
def main():
ring = build(6, RING_IDS)
ids = ring.alive_ids()
print("=== 1. Chord ring (m=6, 2^6=64 identifiers, N=%d) ===" % len(ids))
for i, nid in enumerate(ids):
nxt = ids[(i + 1) % len(ids)]
lo = (nid + 1) % ring.size
owned = [k for k in range(ring.size)
if ring.true_owner_id(k) == nid]
print(" N%-3d successor=N%-3d owns keys (%d, %d] -> %2d keys %s"
% (nid, nxt, nid, nxt, len(owned),
owned if len(owned) <= 8 else str(owned[:8]) + "..."))
print("\n ring unrolled (left end joins the right end, one cell per node):")
owner = {}
for k in range(ring.size):
owner.setdefault(ring.true_owner_id(k), []).append(k)
print(" " + "".join("[N%-3d|%-2d]" % (nid, len(owner[nid])) for nid in ids))
print(" " + "".join(" key " for _ in ids) + "<- 每格 = 该节点负责的 key 数")
print("\n=== 2. successor pointers are consistent with the ring ===")
ok = True
for i, nid in enumerate(ids):
s = ring.nodes[nid].live_succ()
if s.id != ids[(i + 1) % len(ids)]:
ok = False
print(" MISMATCH at N%d: successor=N%d" % (nid, s.id))
print(" all %d successor pointers correct: %s" % (len(ids), ok))
assert ok
print("\n=== 3. finger table of N8 (finger[i] = successor(8 + 2^(i-1))) ===")
n8 = ring.nodes[8]
for i in range(1, ring.m + 1):
target = (8 + (1 << (i - 1))) % ring.size
print(" i=%d +%-3d 8+%d=%2d -> finger[%d] = N%-3d (should be N%d)"
% (i, 1 << (i - 1), 1 << (i - 1), target, i,
n8.fingers[i - 1].id, ring.true_owner_id(target)))
assert n8.fingers[i - 1].id == ring.true_owner_id(target)
print("\n=== 4. key -> node mapping (consistent hashing) ===")
for k in [10, 24, 30, 38, 54, 3, 61]:
owner, hops = ring.nodes[8].find_successor(k)
print(" key %-3d -> N%-3d (ground truth N%d) %d hop(s)"
% (k, owner.id, ring.true_owner_id(k), hops))
assert owner.id == ring.true_owner_id(k)
print("\n=== 5. lookup path for key 54 starting at N8 ===")
final, hops = trace_lookup(ring, 8, 54)
assert final == 56, final
print("\n=== 6. every key 0..63 resolves to its true owner ===")
bad = 0
for k in range(ring.size):
owner, _ = ring.nodes[random.choice(ids)].find_successor(k)
if owner.id != ring.true_owner_id(k):
bad += 1
print(" keys checked=64, wrong answers=%d" % bad)
assert bad == 0
print("\n=== 7. insert/lookup/delete over the DHT ===")
for name in ["PennyLane.mp3", "cnn.com/index.html", "a.bin"]:
o, _ = ring.nodes[32].put(name, "v:" + name)
v, holder, hops = ring.nodes[56].get(name)
print(" put(%s) -> N%-3d get() -> N%-3d = %s (%d hop)"
% (name, o.id, holder.id, v, hops))
assert v == "v:" + name
if __name__ == "__main__":
main()
【代码做什么?】
build()用论文 Figure 2 的 10 个节点 ID(1, 8, 14, 21, 32, 38, 42, 48, 51, 56)建环:打乱顺序逐个加入,每次加入后跑 2 轮维护,最后再跑 8 轮——刻意让加入顺序随机,以检验收敛性不依赖顺序。- 第 1 节打印每个节点的后继与它负责的 key 区间 $(pred, self]$(用
true_owner_id离线算出),逐个对照 8.2.10 的表格;并把环”展开”成一行 ASCII 图([N1\|9][N8\|7]...[N56\|5],每格标注该节点负责的 key 数),把两端接起来就是环——这既是拓扑可视化,也顺带展示了负载均衡的不均匀(9、7、6、7、11、6、4、6、3、5,最重的 N32 是最轻的 N51 的 3.7 倍)。 - 第 2 节断言每个节点的后继指针都与”排序后的下一个节点”一致(这是环正确的充要条件)。
- 第 3 节打印 N8 的完整 finger table 计算过程,并对每一项断言
finger[i] == true_owner_id(8 + 2^(i-1))。 - 第 4 节对 key 10/24/30/38/54 等做查找,断言答案等于标准答案。
- 第 5 节
trace_lookup()逐跳打印查找路径与距离收缩(46 → 12 → 3 → 0),直接展示算法 8.3.5 的减半不变量。 - 第 6 节把 0..63 全部 64 个 key 都查一遍(从随机节点发起),断言零错误。第 7 节演示
put/get的实际数据读写。
【分布式机制透视】
- 这是行为验证而非单元测试:断言的目标不是”函数返回了什么”,而是”分布式不变量是否成立”——后继链是否成环、finger 是否等于定义、任意节点发起的查找是否收敛到同一个答案。
trace_lookup揭示了一个真实分布式系统的性质:查询是由多个节点协作完成的,每个节点只贡献自己那一小段视角(”我认识的最接近的节点是谁”),没有任何一个节点知道完整路径。- 距离收缩序列(46→12→3→0)就是”每跳至少减半”的实测证据:$46/2=23 \ge 12$、$12/2=6 \ge 3$、$3/2=1.5 \ge 0$。
【与理论的对应】
- 输出与论文 Figure 2(环与 5 个 key 的归属)、Figure 4(a)(N8 的 finger table)、Figure 4(b)(N8→N42→N51→N56 的查找路径)逐项吻合,可作为”讲义图示的数值还原”。
- 第 6 节的全量验证对应算法 8.3.2 的安全性 S1;第 2 节的环正确性断言对应算法 8.3.3 的活性 L2(收敛后所有 successor 正确 ⇒ 所有 key 可定位)。
代码 8.4.4:规模实验——增量加入、跳数随 $N$ 增长、崩溃注入
# -*- coding: utf-8 -*-
"""Chord at scale: incremental joins + key verification, hop count vs N,
and a crash-failure experiment with successor-list replicas."""
import math
import random
from chord_core import Ring
TEXTBOOK_IDS = [1, 8, 14, 21, 32, 38, 42, 48, 51, 56]
def build_ring(m, n, repl=3, seed=425, maintain=4, ids=None):
random.seed(seed)
ring = Ring(m=m, repl=repl)
ids = ids if ids is not None else random.sample(range(ring.size), n)
ring.new_node(ids[0])
ring.maintain(rounds=3)
for nid in ids[1:]:
ring.new_node(nid)
ring.maintain(rounds=maintain)
ring.maintain(rounds=12)
return ring
def check_all_keys(ring, hub, keys):
"""Assert every key sits on the node that the ring says owns it."""
wrong = 0
for k in keys:
kid = ring.hash_id(k)
owner, _ = hub.find_successor(kid)
truth = ring.true_owner_id(kid)
if owner.id != truth or k not in ring.nodes[truth].store:
wrong += 1
return wrong
def experiment_incremental_join():
print("=== A. start from 1 node, then add 12 random nodes ===")
random.seed(425)
ring = Ring(m=6, repl=3)
hub = ring.new_node(1)
ring.maintain(rounds=3)
keys = ["file-%02d" % i for i in range(20)]
for k in keys:
hub.put(k, "v:" + k)
print(" N=1, keys stored on N1: %d/%d" % (len(ring.nodes[1].store), len(keys)))
print(" step joined N keys on wrong node")
for step in range(1, 13):
nid = random.randrange(ring.size)
node = ring.new_node(nid)
if node is None: # id collision with a live node
print(" %-5d N%-6d (id already taken, join skipped)" % (step, nid))
continue
ring.maintain(rounds=4)
for n in ring.nodes.values(): # app layer reacts to ownership change
if n.alive:
n.rebalance_keys()
ring.maintain(rounds=3)
for n in ring.nodes.values():
if n.alive:
n.refresh_replicas()
print(" %-5d N%-6d %-3d %d" % (step, nid, len(ring.alive_ids()),
check_all_keys(ring, hub, keys)))
wrong = check_all_keys(ring, hub, keys)
print(" final: N=%d, wrong placement=%d, hub N1 store=%d keys"
% (len(ring.alive_ids()), wrong, len(ring.nodes[1].store)))
assert wrong == 0
return ring, keys
def experiment_hop_count():
print("\n=== B. average lookup hops vs N (m=16, r=3, 500 lookups each) ===")
print(" N log2(N) avg hops max hops wrong answers")
for n in [4, 8, 16, 32, 64, 128]:
ring = build_ring(16, n, seed=1000 + n)
ids = ring.alive_ids()
total = worst = wrong = 0
for _ in range(500):
kid = ring.hash_id("key-%d" % random.randrange(10 ** 9))
owner, hops = ring.nodes[random.choice(ids)].find_successor(kid)
total += hops
worst = max(worst, hops)
if owner.id != ring.true_owner_id(kid):
wrong += 1
print(" %5d %6.1f %8.2f %8d %13d"
% (n, math.log2(n), total / 500.0, worst, wrong))
assert wrong == 0
def experiment_failure():
print("\n=== C. crash two nodes (N32 and N38) and keep serving ===")
ring = build_ring(6, 10, ids=TEXTBOOK_IDS)
keys = ["doc-%d" % i for i in range(12)]
for k in keys:
ring.nodes[random.choice(ring.alive_ids())].put(k, "v:" + k)
victim_keys = [k for k in keys if ring.true_owner_id(ring.hash_id(k)) == 32]
print(" before crash: N21.succ=%s N21.succ_list=%s"
% (ring.nodes[21].succ, ring.nodes[21].succ_list))
print(" keys owned by N32: %s" % victim_keys)
print(" replica holders of %s: N%d, N%d"
% (victim_keys[0],
ring.nodes[32].replica_at(1).id, ring.nodes[32].replica_at(2).id))
for dead in (32, 38):
ring.nodes[dead].alive = False # crash-stop
print(" >>> N32 and N38 crash simultaneously")
ring.maintain(rounds=10)
print(" after stabilize: N21.succ=%s N21.succ_list=%s N21.pred=%s"
% (ring.nodes[21].succ, ring.nodes[21].succ_list, ring.nodes[21].pred))
ok = True
for i, nid in enumerate(ring.alive_ids()):
ids = ring.alive_ids()
if ring.nodes[nid].live_succ().id != ids[(i + 1) % len(ids)]:
ok = False
print(" ring healed (successors correct for all alive nodes): %s" % ok)
assert ok
wrong = 0
for k in keys:
v, holder, hops = ring.nodes[1].get(k)
truth = ring.true_owner_id(ring.hash_id(k))
if v is None or holder.id != truth:
wrong += 1
if k in victim_keys:
print(" get(%-7s) -> N%-3d (was N32, now the live successor) %s"
% (k, holder.id, v))
print(" keys unreadable after the crash: %d/%d" % (wrong, len(keys)))
assert wrong == 0
random.seed(7)
tot = 0
for _ in range(200):
kid = ring.hash_id("x-%d" % random.randrange(10 ** 9))
_, h = ring.nodes[random.choice(ring.alive_ids())].find_successor(kid)
tot += h
print(" lookups still work after failures: avg %.2f hops (8 alive nodes)" % (tot / 200.0))
if __name__ == "__main__":
experiment_incremental_join()
experiment_hop_count()
experiment_failure()
【代码做什么?】
- 实验 A(增量加入 + 正确性校验):从 1 个节点开始,先写入 20 个 key,再依次加入 12 个随机 ID 的节点;每加入一个就跑 4 轮维护 + 应用层
rebalance_keys+ 3 轮维护 +refresh_replicas,然后逐个校验”20 个 key 是否都在正确的后继节点上”。输出会显示每一步的”错位 key 数 = 0”。 - 实验 B(跳数 vs $N$):在 $m=16$ 的空间里分别构造 $N = 4, 8, 16, 32, 64, 128$ 个节点的环,各做 500 次随机查找,统计平均跳数、最大跳数、错误答案数,并与 $\log_2 N$ 并列打印。
- 实验 C(故障注入):建好教科书环、写入 12 个 key,打印
N21.succ_list与 N32 的副本持有者;然后让 N32 与 N38 同时崩溃(alive = False),跑 10 轮维护,再打印 N21 的新后继列表,断言所有存活节点的后继链正确,并逐个get全部 key,验证没有一个 key 变得不可读。 - 固定种子,输出完全可复现;所有关键结论都用
assert兜底,任何一个不变量被破坏都会立刻抛异常。
【分布式机制透视】
- 故障注入:
alive = False模拟 crash-stop;此后所有Ring.call到该节点都返回None,等价于 RPC 超时。关键观察是:崩溃节点不会被”删除”,它的 ID 仍在nodes字典里——这是真实系统的样子(你不知道它是慢还是死),只能靠后继列表绕过它。 - 修复的方向性:N32/N38 崩溃后,N21 的
succ_list从[N32, N38, N42]变成[N42, N48, N51]。注意这是两个独立机制共同作用的结果:live_succ()直接跳过失效节点(快速路径),stabilize的后继列表协调把正确的后继链重新填满(慢速路径)。 - 副本生效的时机:崩溃后
find_successor(24)返回的是第一个存活的 $\ge 24$ 的节点 N42,而 N42 手里正好有 K24 的副本——查找路径的自然漂移与副本链的覆盖范围精确重合,这是把副本放在后继链上的设计红利。如果副本是随机放的 $r$ 个节点,就会经常出现”查到了 owner 但 owner 没有数据”的尴尬。 - churn 与最终一致:实验 A 每次 join 都制造一次”环短暂不正确”的窗口;读者可以自行把
maintain(rounds=4)改成rounds=0观察断言失败——这是理解”最终一致不是免费午餐”的最好实验。
【与理论的对应】
- 实验 A ↔ 算法 8.3.3 的活性 L1(Theorem IV.3:交错 join + stabilize 最终收敛)。
- 实验 B ↔ 算法 8.3.5:实测 $0.99/1.46/1.86/2.24/2.75/3.36$ 对应 $\tfrac12\log_2 N = 1/1.5/2/2.5/3/3.5$,验证”平均 $\tfrac12\log_2 N$ 跳”。
- 实验 C ↔ 算法 8.3.4 的活性(Theorem IV.5):$r=3$ 时两个后继同时死掉仍能服务,直观展示”$r$ 越大越安全”与”$r = \Omega(\log N)$ 足够”的关系。
代码 8.4.5:Gnutella 泛洪模拟器——消息量的指数爆炸与超节点分层
# -*- coding: utf-8 -*-
"""Gnutella flooding on a random mesh overlay, vs. a FastTrack-style
super-peer hierarchy. Counts Query transmissions per search."""
import math
import random
from collections import deque
def make_mesh(n, degree, rng):
"""Random mesh: ring backbone (keeps it connected) + random chords."""
adj = {i: set() for i in range(n)}
for i in range(n):
j = (i + 1) % n
adj[i].add(j)
adj[j].add(i)
for i in range(n):
tries = 0
while len(adj[i]) < degree + 2 and tries < 8 * degree:
j = rng.randrange(n)
tries += 1
if j != i:
adj[i].add(j)
adj[j].add(i)
return adj
def flood(adj, src, ttl):
"""Flood a Query: returns (Query transmissions, distinct nodes reached)."""
msgs = 0
reached = {src}
q = deque([(src, ttl, -1)])
while q:
node, t, frm = q.popleft()
if t == 0:
continue
for nb in adj[node]:
if nb == frm:
continue
msgs += 1 # one Query transmission on this link
if nb in reached:
continue # same Descriptor ID => dropped
reached.add(nb)
q.append((nb, t - 1, node))
return msgs, len(reached)
def make_superpeers(n, nsp, sp_degree, rng):
supers = sorted(rng.sample(range(n), nsp))
sset = set(supers)
leaves = {s: [] for s in supers}
for p in range(n):
if p not in sset:
leaves[rng.choice(supers)].append(p)
sp_adj = {s: set() for s in supers}
for i, s in enumerate(supers): # super-peer mesh keeps a ring backbone
t = supers[(i + 1) % nsp]
sp_adj[s].add(t)
sp_adj[t].add(s)
for s in supers:
tries = 0
while len(sp_adj[s]) < sp_degree and tries < 20 * sp_degree:
t = rng.choice(supers)
tries += 1
if t != s:
sp_adj[s].add(t)
sp_adj[t].add(s)
return supers, leaves, sp_adj
def superpeer_query(n, nsp, sp_degree, sp_ttl, rng):
supers, leaves, sp_adj = make_superpeers(n, nsp, sp_degree, rng)
origin = rng.choice([p for s in supers for p in leaves[s]])
home = None
for s in supers:
if origin in leaves[s]:
home = s
break
msgs = 1 # leaf -> its super-peer
reached_sp = {home}
q = deque([(home, sp_ttl, -1)])
while q:
s, t, frm = q.popleft()
if t == 0:
continue
for nb in sp_adj[s]:
if nb == frm:
continue
msgs += 1 # super-peer <-> super-peer
if nb in reached_sp:
continue
reached_sp.add(nb)
q.append((nb, t - 1, s))
for s in reached_sp: # each super-peer queries its leaves
msgs += max(len(leaves[s]) - (1 if s == home else 0), 0)
return msgs, len(reached_sp), nsp
def bar_chart(title, labels, values, width=44, logscale=True):
print(title)
scale = max(math.log2(v + 1) for v in values) if logscale else max(values)
for lab, v in zip(labels, values):
size = math.log2(v + 1) if logscale else v
bar = "#" * max(int(round(size / scale * width)), 1)
print(" %-8s |%s %d" % (lab, bar, v))
def main():
rng = random.Random(425)
n = 20000
mesh = make_mesh(n, 4, rng) # average degree 6, TTL default 7
degs = [len(v) for v in mesh.values()]
print("=== mesh overlay: N=%d, average degree=%.2f ===" % (n, sum(degs) / n))
ttl_list = [1, 2, 3, 4, 5, 6, 7]
flat = []
for t in ttl_list:
tot_m = tot_r = 0
for _ in range(5):
m, r = flood(mesh, rng.randrange(n), t)
tot_m += m
tot_r += r
flat.append((tot_m // 5, tot_r // 5))
print("\n TTL Query msgs nodes reached msgs/growth")
prev = None
for t, (m, r) in zip(ttl_list, flat):
g = "-" if prev is None else "x%.2f" % (m / prev)
print(" %-4d %-12d %-15d %s" % (t, m, r, g))
prev = m
bar_chart("\n=== messages grow exponentially with TTL (log scale) ===",
["TTL=%d" % t for t in ttl_list], [m for m, _ in flat])
print("\n=== same query, flat Gnutella vs super-peer hierarchy (N=%d) ===" % n)
print(" flat flood TTL=7 : %d messages" % flat[-1][0])
nsp = int(math.sqrt(n)) # ~sqrt(N) super-peers
for sp_ttl in (1, 2, 3):
m, rsp, k = superpeer_query(n, nsp, 4, sp_ttl, rng)
print(" super-peer TTL=%d : %d messages (%d super-peers reached, "
"%.1f leaves each)" % (sp_ttl, m, rsp, n / float(nsp)))
print(" reduction factor: x%.1f" % (flat[-1][0] / float(
superpeer_query(n, nsp, 4, 2, rng)[0])))
print("\n=== messages vs average degree (TTL=4, N=%d) ===" % n)
dlab, dval = [], []
for d in (1, 2, 3, 4, 5):
m2 = make_mesh(n, d, random.Random(7))
avg = sum(len(v) for v in m2.values()) / float(n)
tot = sum(flood(m2, rng.randrange(n), 4)[0] for _ in range(3)) // 3
dlab.append("deg=%.1f" % avg)
dval.append(tot)
bar_chart("", dlab, dval)
print(" => per-query cost grows like degree^TTL, independent of N: the wall")
if __name__ == "__main__":
main()
【代码做什么?】
make_mesh(n, degree, rng)生成无结构随机 mesh:先连一条环做骨架保证连通(否则随机图会出现孤立分量,实验结论会被污染),再给每个节点加若干随机长边,让平均度数落在目标附近。flood(adj, src, ttl)是算法 8.3.1 的核心:BFS + TTL 递减,每个节点对同一消息只转发一次(reached集合模拟 Descriptor ID 去重),统计 Query 消息总条数与实际到达的节点数。- 第一组实验:固定 $N=20000$、平均度数约 7,把 TTL 从 1 扫到 7,观察消息数增长(6 → 53 → 280 → 2124 → 13206 → 61217 → 120470)。输出里同时打印逐层放大倍数(×8.83、×5.28、×7.59、×6.22、×4.64),并画 ASCII 折线/条形图(对数刻度)。
- 第二组实验:同一网络、同一条查询,对比扁平泛洪(TTL=7)与 FastTrack 风格的两级超节点($N$ 个 peer 分成 $\sqrt N$ 组、每组一个超节点、超节点之间再泛洪)的消息数,直接给出降低倍数。
- 第三组实验:固定 TTL=4、扫描平均度数,展示”每次度数加 1,消息量乘数级增长”(76 → 408 → 1006 → 2391 → 4234),即 $O(d^{\text{TTL}})$ 的经验证据。
【分布式机制透视】
- overlay 与物理网络分离:
adj只描述”谁连着谁”,与节点编号无关——这正是”无结构”的准确含义。你可以把adj的边理解成一条条 TCP 连接,每条边背后的 Internet 路径长度完全不在模拟范围内(这也是 Gnutella 的真实缺陷之一:它无法做拓扑感知)。 - TTL 与 Hops 的双重作用:
ttl限制深度,reached集合限制重复。两者缺一不可:只有 TTL 会导致同一条消息从多条路径反复到达同一节点(消息量进一步膨胀数倍);只有去重则消息会在环里永远转圈。 - 超节点的本质:第二组实验里,消息量下降的关键不是”少转发”,而是把”每个 peer 都参与泛洪”换成了”只有 $\sqrt N$ 个超节点参与泛洪,其余 peer 只接收自己超节点的查询”。这就是用层次换广播域大小——与 DNS 的层次化、与讲解 MapReduce 时说的”聚合树”是同一个思想。但代价也立刻显现:超节点成为新的热点与潜在单点,且需要声誉机制来决定谁能当超节点(KaZaA 的 participation level)。
- 敏感性:把
ttl从 6 调到 7,消息数从 61217 涨到 120470(接近翻倍)——但此时已经有 20000 个节点全部收到查询,增长被网络规模”截断”了。这解释了一个重要的现实:当 TTL 足够大时,泛洪的代价从 $O(d^{\text{TTL}})$ 变成 $O(N \cdot d)$,即全网广播。无论哪种,都与 $N$ 同阶或更差。
【与理论的对应】
- 代码直接测量算法 8.3.1 的复杂度 $O(d^{\text{TTL}})$,并展示了它的两个极端:TTL 小时”漏查”(第 1 组实验中 TTL=2 只到达 54 个节点,占 2 万的 0.27%),TTL 大时”全网广播”。
- 第三组实验对应讲义”Gnutella 的崩溃”这一论断:在 $N$ 不变的情况下,只要度数增加 1,全网查询流量就成倍增长;而 $N$ 本身还在增长。
- 与 8.4.4 的对照是本节的收尾论点:同一个 2 万节点的网络,Chord 的一次查找是 $O(\log N) \approx 14$ 条消息,Gnutella 的一次查找是 120470 条消息——相差四个数量级。这就是”DHT 用 $O(\log N)$ 状态换 $O(\log N)$ 查找”的真实含义。
8.5 性能与可扩展性分析
8.5.1 四代 P2P 系统的横向对比
| 系统 | 每节点状态 | 查找跳数 | 一次查找消息数 | 容错能力 | 可扩展性瓶颈 | 是否保证查全 |
|---|---|---|---|---|---|---|
| Napster | $O(1)$(服务器 $O(N)$) | $O(1)$ | $O(1)$ | 差:中心宕机 = 全系统宕机 | 服务器带宽、索引内存、法律 | 保证(只要索引完整) |
| Gnutella(扁平) | $O(d)$(邻居表) | $O(N)$(TTL 内) | $O(d^{\text{TTL}})$ | 好:无单点,任意节点可退出 | 查询泛洪流量(Ping/Pong 占 50%) | 不保证(TTL 耗尽即漏查) |
| FastTrack/KaZaA | peer $O(1)$;超节点 $O(\text{本地目录})$ | 2–3 跳(超节点骨干) | $O(d_{sp}^{t_{sp}} + \text{叶子数})$ | 较好:超节点多副本,但超节点是热点 | 超节点上行带宽与声誉机制 | 不保证(取决于超节点覆盖) |
| BitTorrent | $O(\text{swarm 大小})$ 由 tracker 承担;peer 只需几个邻居 | 不查找(按 infohash 直连) | $O(1)$ 找 tracker | 好:tracker 可选、DHT 化 | 做种者带宽、tit-for-tat 冷启动 | 保证(只要 swarm 里有完整副本) |
| Chord | $O(\log N)$ | $O(\log N)$,平均 $\tfrac12\log_2 N$ | $O(\log N)$(迭代式 2 条/跳) | 好:$r=\Omega(\log N)$ 时容忍 50% 同时失效 | 稳定化带宽(churn 高时) | 保证(找到 owner,或明确失败) |
| Kelips | $O(\sqrt N)$ | $O(1)$ | $O(1)$ | 较好:元数据多副本 + gossip | 内存($O(\sqrt N)$)与元数据刷新流量 | 保证(元数据新鲜时) |
8.5.2 Chord 的两个成本曲线
(1)跳数与状态随 $N$ 的增长(下表左半为 8.4.4 代码实测,$m=16$、$r=3$、每档 500 次随机查找):
| $N$ | $\log_2 N$ | 平均跳数(实测) | 最大跳数 | 错误答案 | 有效 finger 项(≈ $\log_2 N$) |
|---|---|---|---|---|---|
| 4 | 2.0 | 0.99 | 2 | 0 | 2 |
| 8 | 3.0 | 1.46 | 3 | 0 | 3 |
| 16 | 4.0 | 1.86 | 4 | 0 | 4 |
| 32 | 5.0 | 2.24 | 5 | 0 | 5 |
| 64 | 6.0 | 2.75 | 6 | 0 | 6 |
| 128 | 7.0 | 3.36 | 6 | 0 | 7 |
实测平均跳数几乎精确等于 $\tfrac12\log_2 N$(最大偏差 0.15 跳)。注意这是 $m=16$ 的小规模实验:真实 Chord 部署里 $N=10^6$ 时平均只有约 10 跳,而论文的仿真($k$ 从 3 到 14,即 $N$ 从 8 到 16384)给出的曲线与 $\tfrac12\log_2 N$ 完全吻合。
(2)与 Gnutella 的对比(同一量级网络,8.4.5 实测):
| 指标 | Gnutella 扁平泛洪($N=20000$) | Gnutella 两级超节点 | Chord($N=20000$,理论) |
|---|---|---|---|
| 一次查询的消息数 | 120470 | 2813 | $\approx 2 \times 7 = 14$ |
| 查找半径 | TTL 限制,可能漏查 | 覆盖全部超节点 | 全环可达 |
| 每节点状态 | 邻居表 | peer $O(1)$ / 超节点目录 | $\approx 14$ 个 finger + $r$ 个后继 |
| 是否保证查全 | 否 | 否 | 是 |
结论:在同样的规模下,结构化 P2P 的查找代价比非结构化低约 4 个数量级——这是”用 $O(\log N)$ 状态换 $O(\log N)$ 查找”的量化含义。
8.5.3 Chord 在真实系统中的变体与实测表现
- 论文的仿真数据($N=1000$、$r=20=2\log_2 N$):
- 无故障时平均路径长度 3.84 跳(理论 $\tfrac12\log_2 1000 - \tfrac12\log_2 20 + 1 = 3.82$);
- 50% 节点同时崩溃后,平均路径长度涨到 5.09 跳,平均超时 5.10 次,但10000 次查找全部成功;
- 加入/离开速率从 0.05/秒升到 0.40/秒(即每次稳定化周期内 1.5 到 12 次加入+离开)时,路径长度几乎不变(3.81–4.06),查找失败率从 0 升到每 10000 次 16 次。
- 负载均衡:$10^4$ 节点、$5\times 10^5$ key 时最重的节点承担均值 9.1 倍的 key,且存在 0 key 的节点;引入 $v = 20$ 个虚拟节点后,99 分位从 4.8× 降到 1.6×,1 分位从 0 升到 0.5×。代价是路由状态 $O(\log^2 N)$($N=10^6$ 时约 400 项,仍然可接受)。
- 真实系统的变体:
- Cassandra:一致性哈希环 + 虚拟节点(token range) + 可调一致性级别(quorum)+ hinted handoff + Merkle 树 anti-entropy。Cassandra 的”一个物理节点持有多个 token 区间”直接对应 8.2.15 的虚拟节点。
- Dynamo(Amazon):一致性哈希环 + preference list(等价于后继列表)+ sloppy quorum + hinted handoff + 向量时钟(vector clock)。Dynamo 用”$N$ 个副本、$R$ 个读 quorum、$W$ 个写 quorum,$R+W>N$”把 Chord 的副本链变成了可调的可用性/一致性旋钮。
- Riak / Voldemort / DynamoDB:同一族系,都基于虚拟环 + 一致性哈希。
- BitTorrent Mainline DHT:用 Kademlia 而非 Chord,用于无 tracker 的 torrent;节点数长期在千万量级,是规模最大的 DHT 部署。
- CFS / Ivy(文件系统)、Chord-based DNS、I3(Internet Indirection Infrastructure) 是论文提到的学术应用。
8.5.4 DHT 在广域网上的延迟现实
这是本章最容易被忽略、也最影响工程决策的一点:$O(\log N)$ 跳 ≠ $O(\log N)$ 毫秒。
- 每一跳都是一次跨网络的 RPC:请求要经过用户的接入链路、若干 ISP 骨干、目标节点的链路。广域网单跳 RTT 的典型值是 20–150 ms,跨洲可以到 200–300 ms。
- 论文的仿真用的是均值 50 ms 的指数分布包时延、500 ms 超时判定失效——这已经相当乐观(同城/同国的数据中心之间)。
- 因此 $N = 10^6$(平均约 10 跳)时,递归式查找的最坏延迟约 0.5–1.5 秒;迭代式因为每跳两个单程,延迟还要再乘上约 1.5–2 倍。这已经超出交互式应用的容忍范围。
- 工程上的五种缓解手段:
- 拓扑感知(proximity-aware routing):Pastry 的做法——每个前缀在所有候选邻居中选 RTT 最小的,使”早期跳短、后期跳长”,整体 stretch 接近 1。Chord 本身没有这个优化(论文也承认这一点),工程实现通常叠加。
- 迭代式查找 + 客户端并行/预测:客户端自己控制请求,可以在等待一个响应时并行尝试次优候选,把 RTT 并发起来;也可以缓存”上一次查找经过的节点”作为下次的起点。
- 缓存与副本:把热点 key 复制到物理上更近的节点(Cassandra 的 snitch、Dynamo 的 preference list + 数据中心感知)。
- 虚拟节点与请求负载均衡:让查找起点分散,避免热点节点成为延迟瓶颈。
- 减少跳数:Kelips 路线($O(1)$ 跳、$O(\sqrt N)$ 状态)就是”用内存换延迟”的极端版本——1.93 MB 就能支撑 10 万节点、1000 万文件。
- 一句话总结:DHT 把”节点数”这个维度的扩展性问题解决了,但没有解决”物理距离”这个维度的问题。 前者靠 $O(\log N)$,后者只能靠拓扑感知与就近副本,而这两者都会牺牲一部分负载均衡或一致性简洁性。
8.6 关键要点
- 本章的核心洞见是”状态 vs 通信”的换算率:结构化 P2P(DHT)用每节点 $O(\log N)$ 的路由状态,把一次查找的通信量从 $O(d^{\text{TTL}})$ 压到 $O(\log N)$;非结构化 P2P(Gnutella)一个指针都不存,代价是每条查询在全网炸开成指数多条消息。两者没有绝对优劣,只有”你愿意在每台机器上放多少状态”的选择——这与后续课程里”缓存 vs 一致性”、”复制 vs 延迟”的权衡是同一枚硬币。
- Chord 的优雅在于”只需一个指针必须正确”:finger table 全部陈旧时,查找退化为沿 successor 链爬行——变慢,但不会错。所有正确性都锚定在 successor 上,所有性能都锚定在 finger 上,两者的失效模式完全解耦。这是”性能可以退化,正确性不能妥协”的教科书范例。
- 一致性维护必须是后台、增量、幂等、单调的:
stabilize周期运行(而非 join 时一次性完成)是为了让并发加入的竞态在多次重试中自然收敛;fix_fingers每轮只修一项是为了摊平开销;所有更新都要求”候选更近”以保证单调收敛。任何”加入时立刻广播全网”的设计都会在 churn 下产生指针环与查询死循环。 - 副本放在后继链上是”零额外信息”的容错设计:因为查找时 owner 崩溃后查询天然漂移到”第一个存活后继”,而那个节点恰好就是副本持有者,二者精确重合。$r = \Omega(\log N)$ 可以把”某个 key 的副本全部失效”的概率压到 $O(1/N^2)$,从而在 50% 节点同时崩溃时仍保持查找成功(实测 10000 次查找全部成功)。
- DHT 的一切建立在”哈希均匀 + 非对抗”的假设上:负载均衡、$O(\log N)$ 跳数、$O(1/N)$ 的迁移量都是概率性结论(”with high probability”),一旦对手能挑选 key 或批量制造节点 ID(Sybil/eclipse 攻击),这些结论全部失效。可扩展性与安全性在这里第一次正面冲突(详见 Lecture 27)。
- 泛洪买的是简单与容错,付出的是完备性与带宽:Gnutella 的无中心设计让系统在节点任意进出时依然存活,但它无法回答”是真的没有,还是我没喊到”——“没有完备性保证”是它走向 super-peer 与 DHT 的根本原因。
8.7 常见陷阱与注意事项
- 误以为 finger table 必须正确。很多同学在实现时会花大力气保证 finger 精确,却忽略了 successor 的正确性。正确做法:把全部一致性努力放在
successor与successor_list上(它决定答案对不对),finger 只当作加速提示(它只决定快慢);fix_fingers用陈旧结果也不会产生错误答案。 - 把区间开闭写反。
successor(k)是”大于或等于 $k$”的第一个节点,判定条件是 $id \in (n, \text{successor}]$——左开右闭。写成 $[n, s)$ 会让 key 恰好等于节点 ID 时归属到前一个节点(本笔记的例子中 K38 就会错分给 N32 而不是 N38)。另一个致命细节:$a = b$ 时区间 $(a,b]$ 表示整个环(这是”大于等于”语义的自然推论);若实现成”空集”,单节点环永远无法接受新节点,实验会表现为”所有节点都指向同一个节点”。 - 在
join里立刻通知全网。join只做三件事(前驱置空、找后继、指向它),不通知任何人。为什么错:并发加入时,两个新节点可能互相把对方设成前驱/后继,形成小环或指针绕圈,甚至让查询陷入死循环。正确做法:让stabilize逐步发现新节点,每次只接受”更近的候选”。 - 一次重算整张 finger table。为什么错:$m$ 次查找、每次 $O(\log N)$ 条消息,全部集中在一个瞬间,会在 churn 高峰期造成带宽尖峰。正确做法:每轮只修一项(
next游标循环推进),$m$ 轮摊平。 - 用普通哈希
hash(key) mod N做分片。为什么错:节点数从 $N$ 变到 $N+1$ 时几乎所有 key 的归属都变了,系统要全量搬迁数据。正确做法:一致性哈希(环上”第一个 ≥ key 的节点”),加入/离开只迁移 $O(K/N)$ 个 key。 - 认为 Gnutella 一定找得到文件。为什么错:TTL 是唯一的过期机制,半径之外的文件查不到;发起者拿到空结果时无法区分”文件不存在”与”喊得不够远”。正确做法:需要完备性时用 DHT(Chord 的查找要么返回正确 owner,要么明确失败),或使用动态查询(逐步加大 TTL)并接受其代价。
- 混淆 TTL 与 Descriptor ID 去重的作用。TTL 限制深度(能走多远),Descriptor ID 去重限制重复(同一消息不从多路径反复处理)。两者缺一不可:只用 TTL,一个节点会从多条路径反复收到同一 Query 并反复转发,消息量再翻数倍;只用去重,消息会在环状拓扑里无限循环。另外:去重表本身是有状态的,必须按时间窗清理,否则内存无限增长。
- 在 churn 下用”一次稳定化”验证正确性。为什么错:
Ring.maintain(rounds=0)时断言立刻失败——最终一致需要足够多轮的后台维护才能收敛。正确做法:明确”稳定期”的长度(论文的结论是 $N$ 次 join 之间需要 $\Omega(\log^2 N)$ 轮稳定化),并在测试中显式运行这些轮次;生产系统里则要通过监控”环不一致率”来验证稳定化的速度跟得上 churn 速度。 - 以为虚拟节点能降低跳数。虚拟节点只改善负载均衡(99 分位从 4.8× 降到 1.6×),渐近跳数仍是 $O(\log(N \log N)) = O(\log N)$,而且每节点状态从 $O(\log N)$ 涨到 $O(\log^2 N)$。它是用内存换均衡,不是用内存换延迟。
- 忘记”非对抗假设”。所有 $O(\log N)$ 与负载均衡结论都是”以高概率”成立的概率性结论,前提是 ID 与 key 的哈希在非对抗环境下均匀分布。生产系统必须叠加身份认证(Crypto Puzzles、S/Kademlia)或声誉机制,否则一次 Sybil 攻击就能让攻击者接管目标 key 的全部查询。
8.8 思考题(带答案)
题 1(计算/推演题):$m = 6$ 的 Chord 环上有节点 $\{1, 8, 14, 21, 32, 38, 42, 48, 51, 56\}$。(a) key 30 归谁?(b) N8 的 finger[4] 是谁?(c) 从 N8 查找 key 54 的完整路径与每步距离是多少?(d) 若 N14、N21、N32 三个节点同时崩溃,N8 查找 key 30 会发生什么?
答:
- (a) $30 \in (21, 32]$,所以
successor(30) = N32(注意 N32 同时负责 K24 与 K30,这正是 8.2.10 图里的情形)。 - (b)
finger[4] = successor(8 + 2^3) = successor(16);$16 \in (14, 21]$,所以是 N21。 - (c) N8 → N42 → N51 → N56,共 2 次转发。距离序列:$\text{dist}(8,54) = 46$;N8 表中 $\le 54$ 的最大 finger 是 N42(
finger[6]),$\text{dist}(42,54) = 12$;N42 表中 $\le 54$ 的最大 finger 是 N51,$\text{dist}(51,54) = 3$;N51 发现 $54 \in (51, 56]$,返回 N56,距离归零。46 → 12 → 3 → 0,每步都至少减半(46/2=23≥12,12/2=6≥3)。 - (d) 分三种实现讨论(这正是论文 §IV-E.3 与讲义 “Search under peer failures” 的原始场景):
- 只有 successor 指针(没有 finger、没有后继列表):N8 的后继仍是已死的 N14,它会把 key 30 的查询错误地交给 N42(它 finger/记忆中第一个能联系上的节点),而正确 owner 是”第一个存活且 $\ge 30$ 的节点” N38。返回错误答案比返回失败更危险,因为调用者无法察觉。
- 有 finger table 但 finger 陈旧:纸面上 N8 的 finger 里恰好有 N42,而 30 不在 $(8, 42)$ 区间内,所以它会继续逼近;但由于 N32(原本的 owner)已死,查询必须沿 successor 链一个个爬过存活节点直到 N38,速度退化为 $O(N)$,但答案不会错——这就是”finger 错只影响速度”。
- 有长度为 $r$ 的后继列表:这里的故障是连续 3 个节点同时失效,因此需要 $r \ge 4$ 才能让 N8 在
succ_list = [N14, N21, N32, N38, ...]里直接找到第一个存活者 N38;若 $r = 3$,后继列表恰好全是死者,N8 会卡死。这不是 Chord 的缺陷,而是 $r$ 小于设计容限:论文的结论是 $r = \Omega(\log N)$(本例 $N=10$ 时约需 $r \ge 7$)足以把”某节点的 $r$ 个后继全部失效”的概率压到 $O(1/N^2)$。 另外,即使 owner 变成了 N38,K30 依然可读——因为 N38 原本就在 N32 的后继链上持有 K30 的副本(8.4.4 实验 C 实测的正是这个机制)。“查找路径的自然漂移”与”副本链的覆盖范围”精确重合,是 Chord 把副本放在后继链上的设计红利。
题 2(计算题):$N = 1000$ 个节点,每个节点维护 $r = 2\log_2 N$ 个后继。若每个节点以 $1/2$ 的概率独立失效,求”某个节点的 $r$ 个后继全部失效”的概率,以及”整个网络所有存活节点都至少知道一个存活后继”的概率下界。
答:$r = 2\log_2 1000 \approx 20$。
- 单个节点的 $r$ 个后继全部失效:$(1/2)^{20} = 1/1048576 \approx 9.5\times 10^{-7}$。
- 由于失效事件在节点间不独立(同一个节点可能是多个节点的后继),严格做法是用并集界:至多有 $N$ 个节点可能出问题,故 \(\Pr(\text{存在一个存活节点其 } r \text{ 个后继全死}) \le N \cdot (1/2)^{r} = 1000/1048576 \approx 9.5\times 10^{-4}\) 于是”所有存活节点都至少知道一个存活后继”的概率 $\ge 1 - 9.5\times 10^{-4} \approx 99.9\%$。
- 因为后继链上最前面的那一个存活节点就是每个查询的落点(算法 8.3.2 的判定),这个条件成立就意味着所有查找都能被正确路由(Theorem IV.5)。论文的仿真实测也印证了这一点:$p=0.5$ 时 10000 次查找全部成功,平均路径长度只从 3.84 涨到 5.09。
题 3(反驳题):”既然 Gnutella 的泛洪不需要任何路由状态,节点可以任意加入离开、不存在单点故障,还能自动发现新邻居,那它比 Chord 更健壮也更简单,应该全面优于 Chord。——这个想法错在哪?”
答:这句话的每一部分单独看都对,但结论错在把”健壮”等同于”可用”。三处具体错误:
- 把”零状态”当成纯粹的优点:零状态意味着零方向信息。Chord 的 finger table 提供了”每一步距离至少减半”的信息,所以 $O(\log N)$ 跳就能到达;Gnutella 没有这个信息,只能问遍所有人——代价是 $O(d^{\text{TTL}})$ 条消息。本笔记 8.4.5 的实测:同样 2 万节点,Gnutella 一次查询 120470 条消息,Chord 约 14 条,相差四个数量级。当每条查询要消耗 12 万条消息时,”健壮”的系统会因为带宽耗尽而在用户层面不可用——Gnutella 的 Ping/Pong 一度占 50% 流量就是实证。
- 混淆了”容错”与”完备性”:Gnutella 能容忍节点任意进出,但它不保证查得到。TTL 耗尽后返回空结果,发起者无法区分”没有这份文件”与”没喊到那么远”;链式拓扑下 TTL=3 而文件在 10 跳之外就会漏查(8.2.8 的例子)。Chord 的
find_successor要么返回正确的 owner,要么明确失败,语义是可组合的;Gnutella 的”可能没有”是不可组合的。 - 忽略了”简单”是相对的:Gnutella 的协议确实简单,但它把复杂度推给了运维与用户——需要 host cache、需要不断调 TTL、需要 super-peer 来救场(而 super-peer 又引入了声誉机制与新的热点),最终演化出 FastTrack 这种”半中心化”的混合体。而 Chord 的复杂度集中在一个可证明收敛的后台协议里,协议的每一部分都有明确的正确性论证与复杂度上界。把复杂度放在可证明的地方,比把它推给用户要好。
题 4(设计/推演题):Chord 的平均查找跳数是 $\tfrac12\log_2 N$ 而不是 $\log_2 N$,为什么?
答:把当前节点与目标 key 之间的标识符距离写成二进制。距离的最高有效位(设为第 $i$ 位,权重 $2^{i-1}$)可以由第 $i$ 个 finger 一次性纠正为 0——因为 finger[i] 恰好覆盖了距离 $[2^{i-1}, 2^i)$ 的范围。接下来看次高有效位:
- 若该位是 1,则需要再走一步(用对应的 finger 纠正它);
- 若该位是 0,则这一步被”跳过”了——当前节点已经落在正确的那半边,不需要额外转发。 因此总步数等于距离的二进制表示中 1 的个数(这正是论文 §V-C 给出的解释)。由于节点与 key 的 ID 由 SHA-1 均匀随机生成,距离的每一位独立地以 $1/2$ 的概率为 1,所以期望的 1 的个数是 $\log_2 N / 2$。再考虑最后一段:经过 $\log_2 N$ 个最高有效位之后,期望只剩一个节点在当前与目标之间(8.3.5 的 balls-and-bins 论证),所以平均跳数就是 $\tfrac12\log_2 N$。 这也解释了为什么”最大跳数”仍然可以接近 $\log_2 N$:当距离的二进制是 $111\ldots1$ 时(概率 $2^{-\log N} = 1/N$,仍常有发生),每一位都要走一步。本笔记的实测($m=16$、$N=128$)平均 3.36 跳、最大 6 跳,正好落在 $\tfrac12\log_2 N = 3.5$ 与 $\log_2 N = 7$ 之间。
