Lecture 8: Peer-to-Peer Systems — Gnutella, DHT and Chord(对等网络:Gnutella、DHT 与 Chord)

目录 · ← l7 · l9 →

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 像”宿舍楼里的私下换书”——每个人手里有几本,问一圈谁有,直接去那个人房间拿。图书馆的柜台一关门,整个系统就死了;宿舍楼里走了几个人,换书照样进行。
  • 四条根本动机
    1. 可扩展性(Scalability):中心服务器的出口带宽、CPU、索引内存都是 $O(1)$ 的硬上限,而用户数在增长。Napster 在 2000 年有 6000 万用户,所有查询都要挤过 napster.com 的几台服务器。
    2. 去中心化(Decentralization):中心是单点故障(single point of failure),也是单点控制。Napster 的结局证明了后者更致命:2001 年 2 月美国联邦上诉法院裁定用户侵权、Napster “教唆”(abetting)侵权,构成”间接侵权”(indirect infringement),Napster 被迫在 2001 年 9 月转为付费服务。
    3. 资源聚合(Aggregation):把成千上万边缘节点的空闲磁盘与上行带宽聚合成一个巨大的存储/分发系统。BitTorrent 的核心洞见正是”下载者同时也是上传者”,一份文件越热门,能提供它的节点越多,服务能力反而越强——这是中心服务器永远做不到的反直觉的正反馈
    4. 抗审查与匿名:没有可以被传票、被封锁、被关停的中心实体;但这与”可追责、可验证”天然冲突,也为 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)。
维度Napster(集中式)Gnutella(完全分布式)FastTrack/KaZaA(混合式)BitTorrent
索引位置中心服务器全量索引无索引,靠泛洪超节点存局部目录tracker 只存 peer 列表(无文件索引)
查找方式中心精确查找flooding + TTL就近超节点 + 超节点间泛洪不查找,直接按 infohash 找 swarm
自举(bootstrap)连 napster.comhost 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 规范一致地使用 0x800x810x00 Ping、0x01 Pong、0x40 Push)。原因很实用:最高位用来区分”请求类”(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 的”法律条文”,必须逐条记住):
    1. Pong 只能沿承载该 Ping 的同一路径返回。收到 Descriptor ID = n 的 Pong 却没收到过 ID = n 的 Ping 的 servent,必须把它从网络中丢弃。
    2. QueryHit 只能沿承载该 Query 的同一路径返回;同理,没见过对应 Query 的 QueryHit 要丢弃。
    3. Push 只能沿承载该 QueryHit 的同一路径返回,并且 Push 是按 Servent Identifier 路由、而不是按 Descriptor ID 路由;收到 Push 的 servent 只有在 ServentIdentifier 等于自己的 servent id 时才应处理。
    4. Ping 与 Query 要转发给所有直接相连的邻居,但发起该消息的那个邻居除外
    5. 转发前 TTL 减 1、Hops 加 1;若减完变成 0,则该描述符不再向任何连接转发
    6. 收到相同的 (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 设计:既然总有一些节点天生”更健康、连接更多”,那就把它们显式地提升为索引节点。
  • 讲义给出的三种补救
    1. 超节点(super-peer)/ 分层 Gnutella:把网络分成两层,普通 peer 只连一个超节点,超节点之间组成高速骨干。查询先到超节点,超节点查本地目录、必要时才在超节点骨干上泛洪。代价从 $O(d^{t})$ 变成 $O((d_{sp})^{t_{sp}} + \text{叶子数})$,其中 $t_{sp}$ 可以远小于 $t$(因为超节点数量少、目录质量高)。实测(见 8.4.5 代码):同样覆盖 2 万个节点,扁平泛洪 120470 条消息,两级超节点只要 2813 条,降低约 50 倍
    2. 动态查询(dynamic querying):先发一个小 TTL(例如 2)的查询试探;若结果足够就停止,若结果太少再逐步加大 TTL。用”流行文件”的低成本换取”稀有文件”的完备性
    3. 缓存(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 idhash(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. 每个节点负责不超过 $(1+\epsilon) K/N$ 个 key;
    2. 第 $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):
    1. stabilize():$n$ 问自己的后继 $s$:”你的前驱是谁?”得到 $x$;若 $x$ 落在 $(n, s)$ 区间内(说明有新节点插进来了),则把后继改为 $x$;然后调用 s.notify(n) 告诉 $s$ 自己的存在。
    2. notify(n'):若自己还没有前驱,或 $n^{\prime}$ 落在 $(\text{predecessor}, n)$ 区间内,则把前驱设为 $n^{\prime}$。两个判定都只接受”更近的”候选,这是单调收敛的关键。
    3. fix_fingers():周期性地重算 finger table,每轮只修一项(用 next 游标循环推进)。为什么不一次全修?因为一次全修意味着 $m$ 次查找、$O(m \log N)$ 条消息集中在同一瞬间——把周期性开销摊平(amortize)是分布式维护算法的通用手法。
    4. 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$ 个节点successorsuccessor.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 下的元数据一致性问题)。
  • 对比表
系统标识空间路由表大小查找跳数容错机制代表系统/部署
Chord1 维环($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
Kademlia128 位 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),带过期时间);反向路由表 routedescriptor_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(反向单播)

  1. 发起者 $P_1$ 生成一个 16 字节 descriptor_id,把 route[id] 设为 nil 以标记”我就是源头”,然后把 QUERY 发给所有邻居。
  2. 每个收到 QUERY 的节点做三件事:匹配本地文件(命中就回 QueryHit,其 descriptor_id 与来程 Query 相同)、记录反向路径 route[id] = 来路邻居TTL 减 1 后转发给除来路外的所有邻居。TTL 减到 0 时只做前两件事,不再转发。
  3. 若 $P_9$ 命中,它沿 route[id]QueryHit 送回给 $P_1$;中间节点只查自己的 route[id] 表转发,根本不需要知道 $P_1$ 是谁。这就是”反向路径路由(reverse-path routing)”:用软状态代替地址信息
  4. $P_1$ 收到若干 QueryHit 后,选速率最高的响应者,脱离 overlay 用 HTTP 直连下载Range 头支持断点续传)。如果对方在防火墙后面无法接受入连接,$P_1$ 沿 QueryHit 的来路反向发一条 PUSH,让对方主动连回来
  5. 防火墙对防火墙(双方都无法接受入连接):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 ∉ routeQueryHit,它必须丢弃。
    • S2(有限终止):每次转发都令 TTL ← TTL-1,且 seen 集合保证同一 (type, id) 最多被处理一次。因此每个描述符的转发次数上界为 $d^{t}$(有限),网络不会无止境地泛洪
    • S3(头部自洽)TTL + Hops = TTL_INIT 恒成立,是识别伪造/损坏头部的第一道防线。
  • 活性(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”)——查询会成功,但结果拿不到
  • 复杂度:消息数 $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$ 位标识符;每个节点维护 successorpredecessor、$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 的图一致,代码实测):

  1. N8 检查 $54 \in (8, 14]$?否。
  2. N8 在 finger table 中从 $i=6$ 向下找第一个落在 $(8, 54)$ 的项:finger[6] = N42(40 在区间内)→ 转发给 N42。此时距离从 46 缩到 12。
  3. N42 检查 $54 \in (42, 48]$?否。它的 finger 中 $\le 54$ 的最大者是 finger[4] = N51 → 转发给 N51。距离从 12 缩到 3。
  4. N51 检查 $54 \in (51, 56]$? → 返回 successor = N56。距离归零。
  5. 迭代式实现中,最终答案沿 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)导致的错误答案。
  • 活性(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 错只影响速度”的形式化体现。
  • 复杂度
    • 跳数 $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 之间)

  1. N26.join(N21)N21.find_successor(26) 返回 N32(因为 $26 \in (21, 32]$),于是 N26.successor = N32N26.predecessor = nil、所有 finger 先指向 N32。此时 N26 已经可以服务查询(它会沿 successor 走到 N32),但环上还没有任何人指向它——它是一段”挂在环外但指向环”的支路。关键:这段支路不会破坏任何已有节点的可达性。
  2. N26N32 复制本属于它的 key:区间 $(21, 26]$ 内的 K24 迁移到 N26,N32 只剩 K30。
  3. 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 记为前驱。
  4. 至此后继链完全正确: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 会重新建立前驱关系——误判的代价是延迟,不是错误
  • 活性(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),这是”摊销”设计的要点。

算法 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

【代码做什么?】

  1. Ring 类代表一个 Chord 环(也就是”整个分布式系统”):它保存 $m$(标识位数)、$2^m$(标识空间大小)、副本数 $r$ 与所有节点。hash_id() 用 SHA-1 把任意字符串映射到 $[0, 2^m)$,节点 ID 与 key ID 共用同一个空间
  2. Ring.call(node, method, ...) 就是一次 RPC:若对端 alive == False(已崩溃),返回 None 模拟超时/不可达。整个模拟里所有节点间通信都必须经过它。
  3. Ring.true_owner_id(kid)离线计算的标准答案:把存活节点 ID 排序后用二分找到第一个 $\ge kid$ 的 ID(模 $N$ 回绕)。它不参与任何算法,只用于在实验末尾断言”算法的答案 == 标准答案”。
  4. Node 保存分布式状态:succpredfingers($m$ 项)、succ_list(长度 $r$ 的后继列表)、store(主副本)、replica(冗余副本)。
  5. in_open / in_closed_right 实现环上区间判定。注意 a == b 被定义成”整个环”——这不是偷懒,而是论文对区间 $(a,b]$ 的定义在 $a=b$ 时的自然推论(单节点环里,任何 id 都落在 $(n, n]$ 内)。这个细节是最容易写错、也最容易导致”环永远收敛不了”的地方。
  6. closest_preceding() 从 $i=m$ 递减查找第一个落在 $(n, id)$ 内的 finger,找不到就返回自己——这就是论文的 closest_preceding_node
  7. 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_nodelive_succ 对应论文 §IV-E.3 中”后继不回话就用后继列表里的下一个”。
  • true_owner_idfind_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

【代码做什么?】

  1. join(boot) 严格按论文 Figure 6:前驱置 nilsuccessor ← boot.find_successor(self.id)、后继列表只含后继、所有 finger 先粗指向后继。它不通知任何节点——新节点是被别人的 stabilize 发现的。这正对应 8.2.14 讲的”后台周期运行 vs 一次性完成”。
  2. stabilize() 是核心:取第一个存活后继 $s$ → RPC 问 $s$ 的前驱 $x$ → 只有当 $x \in (n, s)$ 时才把后继改成 $x$ → 调用 s.notify(n) → 协调后继列表。这个”只接受更近候选”的判断是收敛性(安全性 S1:单调性)的实现。
  3. notify(other) 同样只在”我没有前驱 / 我的前驱失效 / other ∈ (pred, n)“时更新前驱。前两条正是 check_predecessor 清空失效前驱的意义所在。
  4. fix_fingers()next_finger 游标每轮只修一项,把 $O(\log N)$ 次查找的开销摊平到 $m$ 轮里。
  5. check_predecessor():前驱失效就清空(这是故障检测器在 Chord 中的落点,与 Lecture 6 的 failure detector 呼应)。
  6. 数据操作:put 写主副本 + 后继链上 $r-1$ 个冗余副本;get 依次尝试 owner 的主副本、owner 自己持有的冗余副本(这是崩溃后立刻可读的关键)、再沿后继链找;refresh_replicas 幂等地重建/清理副本;rebalance_keys应用层在 Chord 通知”责任发生变化”后把不该自己管的 key 交给新主人的动作。

【分布式机制透视】

  • RPC 与失效检测stabilize 里的 self.ring.call(s, "get_pred") 返回 None 就等价于”对端超时”;代码里对 Nonex.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()

【代码做什么?】

  1. build() 用论文 Figure 2 的 10 个节点 ID(1, 8, 14, 21, 32, 38, 42, 48, 51, 56)建环:打乱顺序逐个加入,每次加入后跑 2 轮维护,最后再跑 8 轮——刻意让加入顺序随机,以检验收敛性不依赖顺序。
  2. 第 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 倍)。
  3. 第 2 节断言每个节点的后继指针都与”排序后的下一个节点”一致(这是环正确的充要条件)。
  4. 第 3 节打印 N8 的完整 finger table 计算过程,并对每一项断言 finger[i] == true_owner_id(8 + 2^(i-1))
  5. 第 4 节对 key 10/24/30/38/54 等做查找,断言答案等于标准答案。
  6. 第 5 节 trace_lookup() 逐跳打印查找路径与距离收缩(46 → 12 → 3 → 0),直接展示算法 8.3.5 的减半不变量。
  7. 第 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()

【代码做什么?】

  1. 实验 A(增量加入 + 正确性校验):从 1 个节点开始,先写入 20 个 key,再依次加入 12 个随机 ID 的节点;每加入一个就跑 4 轮维护 + 应用层 rebalance_keys + 3 轮维护 + refresh_replicas,然后逐个校验”20 个 key 是否都在正确的后继节点上”。输出会显示每一步的”错位 key 数 = 0”。
  2. 实验 B(跳数 vs $N$):在 $m=16$ 的空间里分别构造 $N = 4, 8, 16, 32, 64, 128$ 个节点的环,各做 500 次随机查找,统计平均跳数、最大跳数、错误答案数,并与 $\log_2 N$ 并列打印。
  3. 实验 C(故障注入):建好教科书环、写入 12 个 key,打印 N21.succ_list 与 N32 的副本持有者;然后让 N32 与 N38 同时崩溃alive = False),跑 10 轮维护,再打印 N21 的新后继列表,断言所有存活节点的后继链正确,并逐个 get 全部 key,验证没有一个 key 变得不可读
  4. 固定种子,输出完全可复现;所有关键结论都用 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()

【代码做什么?】

  1. make_mesh(n, degree, rng) 生成无结构随机 mesh:先连一条环做骨架保证连通(否则随机图会出现孤立分量,实验结论会被污染),再给每个节点加若干随机长边,让平均度数落在目标附近。
  2. flood(adj, src, ttl) 是算法 8.3.1 的核心:BFS + TTL 递减,每个节点对同一消息只转发一次reached 集合模拟 Descriptor ID 去重),统计 Query 消息总条数实际到达的节点数
  3. 第一组实验:固定 $N=20000$、平均度数约 7,把 TTL 从 1 扫到 7,观察消息数增长(6 → 53 → 280 → 2124 → 13206 → 61217 → 120470)。输出里同时打印逐层放大倍数(×8.83、×5.28、×7.59、×6.22、×4.64),并画 ASCII 折线/条形图(对数刻度)。
  4. 第二组实验:同一网络、同一条查询,对比扁平泛洪(TTL=7)FastTrack 风格的两级超节点($N$ 个 peer 分成 $\sqrt N$ 组、每组一个超节点、超节点之间再泛洪)的消息数,直接给出降低倍数
  5. 第三组实验:固定 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/KaZaApeer $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$)
42.00.99202
83.01.46303
164.01.86404
325.02.24505
646.02.75606
1287.03.36607

实测平均跳数几乎精确等于 $\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$,理论)
一次查询的消息数1204702813$\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 DNSI3(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 倍。这已经超出交互式应用的容忍范围。
  • 工程上的五种缓解手段:
    1. 拓扑感知(proximity-aware routing):Pastry 的做法——每个前缀在所有候选邻居中选 RTT 最小的,使”早期跳短、后期跳长”,整体 stretch 接近 1。Chord 本身没有这个优化(论文也承认这一点),工程实现通常叠加。
    2. 迭代式查找 + 客户端并行/预测:客户端自己控制请求,可以在等待一个响应时并行尝试次优候选,把 RTT 并发起来;也可以缓存”上一次查找经过的节点”作为下次的起点。
    3. 缓存与副本:把热点 key 复制到物理上更近的节点(Cassandra 的 snitch、Dynamo 的 preference list + 数据中心感知)。
    4. 虚拟节点与请求负载均衡:让查找起点分散,避免热点节点成为延迟瓶颈。
    5. 减少跳数:Kelips 路线($O(1)$ 跳、$O(\sqrt N)$ 状态)就是”用内存换延迟”的极端版本——1.93 MB 就能支撑 10 万节点、1000 万文件。
  • 一句话总结DHT 把”节点数”这个维度的扩展性问题解决了,但没有解决”物理距离”这个维度的问题。 前者靠 $O(\log N)$,后者只能靠拓扑感知与就近副本,而这两者都会牺牲一部分负载均衡或一致性简洁性。

8.6 关键要点

  1. 本章的核心洞见是”状态 vs 通信”的换算率:结构化 P2P(DHT)用每节点 $O(\log N)$ 的路由状态,把一次查找的通信量从 $O(d^{\text{TTL}})$ 压到 $O(\log N)$;非结构化 P2P(Gnutella)一个指针都不存,代价是每条查询在全网炸开成指数多条消息。两者没有绝对优劣,只有”你愿意在每台机器上放多少状态”的选择——这与后续课程里”缓存 vs 一致性”、”复制 vs 延迟”的权衡是同一枚硬币。
  2. Chord 的优雅在于”只需一个指针必须正确”:finger table 全部陈旧时,查找退化为沿 successor 链爬行——变慢,但不会错。所有正确性都锚定在 successor 上,所有性能都锚定在 finger 上,两者的失效模式完全解耦。这是”性能可以退化,正确性不能妥协”的教科书范例。
  3. 一致性维护必须是后台、增量、幂等、单调的stabilize 周期运行(而非 join 时一次性完成)是为了让并发加入的竞态在多次重试中自然收敛;fix_fingers 每轮只修一项是为了摊平开销;所有更新都要求”候选更近”以保证单调收敛。任何”加入时立刻广播全网”的设计都会在 churn 下产生指针环与查询死循环。
  4. 副本放在后继链上是”零额外信息”的容错设计:因为查找时 owner 崩溃后查询天然漂移到”第一个存活后继”,而那个节点恰好就是副本持有者,二者精确重合。$r = \Omega(\log N)$ 可以把”某个 key 的副本全部失效”的概率压到 $O(1/N^2)$,从而在 50% 节点同时崩溃时仍保持查找成功(实测 10000 次查找全部成功)。
  5. DHT 的一切建立在”哈希均匀 + 非对抗”的假设上:负载均衡、$O(\log N)$ 跳数、$O(1/N)$ 的迁移量都是概率性结论(”with high probability”),一旦对手能挑选 key 或批量制造节点 ID(Sybil/eclipse 攻击),这些结论全部失效。可扩展性与安全性在这里第一次正面冲突(详见 Lecture 27)。
  6. 泛洪买的是简单与容错,付出的是完备性与带宽:Gnutella 的无中心设计让系统在节点任意进出时依然存活,但它无法回答”是真的没有,还是我没喊到”——“没有完备性保证”是它走向 super-peer 与 DHT 的根本原因

8.7 常见陷阱与注意事项

  1. 误以为 finger table 必须正确。很多同学在实现时会花大力气保证 finger 精确,却忽略了 successor 的正确性。正确做法:把全部一致性努力放在 successorsuccessor_list 上(它决定答案对不对),finger 只当作加速提示(它只决定快慢);fix_fingers 用陈旧结果也不会产生错误答案。
  2. 把区间开闭写反successor(k) 是”大于或等于 $k$”的第一个节点,判定条件是 $id \in (n, \text{successor}]$——左开右闭。写成 $[n, s)$ 会让 key 恰好等于节点 ID 时归属到前一个节点(本笔记的例子中 K38 就会错分给 N32 而不是 N38)。另一个致命细节:$a = b$ 时区间 $(a,b]$ 表示整个环(这是”大于等于”语义的自然推论);若实现成”空集”,单节点环永远无法接受新节点,实验会表现为”所有节点都指向同一个节点”。
  3. join 里立刻通知全网join 只做三件事(前驱置空、找后继、指向它),不通知任何人为什么错:并发加入时,两个新节点可能互相把对方设成前驱/后继,形成小环或指针绕圈,甚至让查询陷入死循环。正确做法:让 stabilize 逐步发现新节点,每次只接受”更近的候选”。
  4. 一次重算整张 finger table为什么错:$m$ 次查找、每次 $O(\log N)$ 条消息,全部集中在一个瞬间,会在 churn 高峰期造成带宽尖峰。正确做法:每轮只修一项(next 游标循环推进),$m$ 轮摊平。
  5. 用普通哈希 hash(key) mod N 做分片为什么错:节点数从 $N$ 变到 $N+1$ 时几乎所有 key 的归属都变了,系统要全量搬迁数据。正确做法:一致性哈希(环上”第一个 ≥ key 的节点”),加入/离开只迁移 $O(K/N)$ 个 key。
  6. 认为 Gnutella 一定找得到文件为什么错:TTL 是唯一的过期机制,半径之外的文件查不到;发起者拿到空结果时无法区分”文件不存在”与”喊得不够远”正确做法:需要完备性时用 DHT(Chord 的查找要么返回正确 owner,要么明确失败),或使用动态查询(逐步加大 TTL)并接受其代价。
  7. 混淆 TTL 与 Descriptor ID 去重的作用。TTL 限制深度(能走多远),Descriptor ID 去重限制重复(同一消息不从多路径反复处理)。两者缺一不可:只用 TTL,一个节点会从多条路径反复收到同一 Query 并反复转发,消息量再翻数倍;只用去重,消息会在环状拓扑里无限循环。另外:去重表本身是有状态的,必须按时间窗清理,否则内存无限增长。
  8. 在 churn 下用”一次稳定化”验证正确性为什么错Ring.maintain(rounds=0) 时断言立刻失败——最终一致需要足够多轮的后台维护才能收敛。正确做法:明确”稳定期”的长度(论文的结论是 $N$ 次 join 之间需要 $\Omega(\log^2 N)$ 轮稳定化),并在测试中显式运行这些轮次;生产系统里则要通过监控”环不一致率”来验证稳定化的速度跟得上 churn 速度。
  9. 以为虚拟节点能降低跳数。虚拟节点只改善负载均衡(99 分位从 4.8× 降到 1.6×),渐近跳数仍是 $O(\log(N \log N)) = O(\log N)$,而且每节点状态从 $O(\log N)$ 涨到 $O(\log^2 N)$。它是用内存换均衡,不是用内存换延迟。
  10. 忘记”非对抗假设”。所有 $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” 的原始场景):
    1. 只有 successor 指针(没有 finger、没有后继列表):N8 的后继仍是已死的 N14,它会把 key 30 的查询错误地交给 N42(它 finger/记忆中第一个能联系上的节点),而正确 owner 是”第一个存活且 $\ge 30$ 的节点” N38返回错误答案比返回失败更危险,因为调用者无法察觉。
    2. 有 finger table 但 finger 陈旧:纸面上 N8 的 finger 里恰好有 N42,而 30 不在 $(8, 42)$ 区间内,所以它会继续逼近;但由于 N32(原本的 owner)已死,查询必须沿 successor 链一个个爬过存活节点直到 N38,速度退化为 $O(N)$,但答案不会错——这就是”finger 错只影响速度”。
    3. 有长度为 $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。——这个想法错在哪?”

:这句话的每一部分单独看都对,但结论错在把”健壮”等同于”可用”。三处具体错误:

  1. 把”零状态”当成纯粹的优点:零状态意味着零方向信息。Chord 的 finger table 提供了”每一步距离至少减半”的信息,所以 $O(\log N)$ 跳就能到达;Gnutella 没有这个信息,只能问遍所有人——代价是 $O(d^{\text{TTL}})$ 条消息。本笔记 8.4.5 的实测:同样 2 万节点,Gnutella 一次查询 120470 条消息,Chord 约 14 条,相差四个数量级。当每条查询要消耗 12 万条消息时,”健壮”的系统会因为带宽耗尽而在用户层面不可用——Gnutella 的 Ping/Pong 一度占 50% 流量就是实证。
  2. 混淆了”容错”与”完备性”:Gnutella 能容忍节点任意进出,但它不保证查得到。TTL 耗尽后返回空结果,发起者无法区分”没有这份文件”与”没喊到那么远”;链式拓扑下 TTL=3 而文件在 10 跳之外就会漏查(8.2.8 的例子)。Chord 的 find_successor 要么返回正确的 owner,要么明确失败,语义是可组合的;Gnutella 的”可能没有”是不可组合的。
  3. 忽略了”简单”是相对的: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$ 之间。