普通视图

发现新文章,点击刷新页面。
昨天以前首页

拒绝“偷天换日”!深度拆解 Go sumdb 的密码学防线

作者 bigwhite
2026年3月14日 07:44

本文永久链接 – https://tonybai.com/2026/03/14/go-sumdb-transparent-logs-supply-chain-trust

大家好,我是Tony Bai。

在 Go 语言的日常开发中,go get 是我们最熟悉的命令之一。我们理所当然地认为,只要指定了版本号,从 GitHub 或其他代码托管平台拉取下来的代码就是安全、一致的。然而,现实却远比这脆弱——Git 的 Tag 是可变的。攻击者可以发布一个带有后门的 v1.2.3 版本,在诱导受害者下载后,再通过 Force-push 将其替换为干净的代码,从而在代码审查的眼皮底下“瞒天过海”。

为了应对这种极其隐蔽的软件供应链攻击,Go 团队祭出了其包管理生态中的终极武器:Go Checksum Database (sumdb)。但很多Go开发者并不清楚Go sumdb背后的工作机制。 本文将结合 Russ Cox 和 Filippo Valsorda 的核心设计文档,拆解一下 sumdb 究竟是如何利用透明日志(Transparent Logs)和精妙的瓦片化(Tiling)算法,在不信任任何中央服务器的前提下,为全球 Go 开发者构筑起一道坚不可摧的密码学防线的。

TOFU 困境与“多疑的客户端”

自 Go 1.11 引入 Modules 以来,go.sum 文件成为了每个项目不可或缺的部分。它记录了依赖包的预期加密哈希值。只要 go.sum 存在,明天下载的代码就必须和今天一模一样。

但这带来了一个经典的密码学难题:TOFU(Trust On First Use,首次使用时信任)

当你在项目中第一次引入某个第三方包时,本地没有它的哈希记录。此时 go 命令只能“盲目”去源站(一般是github)下载,计算哈希并写入 go.sum。如果恰好在这一次下载时网络被劫持,或者作者刚好推送了恶意代码,那么恶意代码的哈希就会被“合法化”并永久记录在你的项目中。

为了解决 TOFU 问题,Go 官方设立了 sum.golang.org,一个记录全球所有公开 Go 模块版本哈希的中央校验和数据库。

但是,新的问题随之而来:如果连 Google 运营的这个中央数据库也被黑客攻破了呢?或者如果服务器故意向特定用户返回伪造的哈希值呢?

Go 团队的答案是:设计一个“多疑的客户端”。go 命令绝不盲目信任 sumdb 服务器返回的任何一条数据,而是要求服务器提供严密的数学证明。这套证明体系的基石,就是 透明日志(Transparent Logs)。

核心底座:透明日志(Transparent Logs)深度解析

透明日志本质上是一个只追加(Append-Only)的防篡改数据结构,其核心是默克尔树(Merkle Tree)。在 sumdb/tlog/tlog.go 源码中,我们可以清晰地看到这棵树的构建过程。

树的构建与防碰撞设计

透明日志将每一个模块的版本和哈希记录作为树的叶子节点。两两相邻的叶子节点哈希相加,生成父节点的哈希,层层向上,最终生成一个唯一的树根哈希(Tree Hash)

为了防止经典的“第二原像攻击”(即攻击者构造一个叶子节点,使其哈希值碰巧等于某个内部节点的哈希值),tlog.go 在计算哈希时进行了极其严谨的域隔离(Domain Separation)前缀设计:

// 源码文件:sumdb/tlog/tlog.go

// 计算叶子节点(Record)哈希,前缀加 0x00
func RecordHash(data []byte) Hash {
    h := sha256.New()
    h.Write([]byte{0x00}) // RFC 6962: SHA256(0x00 || data)
    h.Write(data)
    // ...
}

// 计算内部节点哈希,前缀加 0x01
func NodeHash(left, right Hash) Hash {
    var buf[1 + HashSize + HashSize]byte
    buf[0] = 0x01 // RFC 6962: SHA256(0x01 || left || right)
    copy(buf[1:], left[:])
    copy(buf[1+HashSize:], right[:])
    return sha256.Sum256(buf[:])
}

这个唯一的树根哈希代表了此刻全球 Go 生态所有公开包的完整历史状态。任何一个历史字节的篡改,都会导致根哈希发生雪崩式的变化。

存在性证明

当客户端向 sumdb 查询 rsc.io/quote@v1.5.2 时,服务器不仅返回记录,还会返回一条证明路径。

如上图所示,如果客户端想验证黄绿色的 Record 1 是否在树中,服务器只需提供旁边黄色的节点(Record 0 和 Node Hash L1-1)的哈希值。客户端在本地通过 NodeHash(RecordHash(Record 1), Record 0) 计算出 N1,再与 N2 结合计算出 Root。

如果计算出的 Root 与官方公布的根哈希一致,这在数学上就绝对证明了:该模块的哈希确实被官方收录,绝无伪造可能。 这一过程的时间复杂度仅为 O(log N)。

一致性证明

这是防止服务器“撒谎”的终极杀手锏。

如果 sumdb 服务器被黑客控制,黑客针对“受害者 A”返回一棵包含后门记录的“伪造树”,而对其他用户返回“正常树”(这种攻击被称为 Fork Attack)。该如何防范?

客户端在每次成功通信后,都会将当前的树大小(N)和根哈希(T)持久化在本地(通常位于 $GOPATH/pkg/sumdb/sum.golang.org/latest)。

下一次通信时,如果服务器声称树长大了(规模变为 N’,新哈希为 T’),客户端会要求服务器出具一致性证明。客户端通过比对两条证明路径,在本地强校验:新的树 T’,是否完美且完整地包含了旧树 T 的所有历史记录?

如果历史被重写,一致性校验必将失败。客户端会立即阻断构建,并抛出带有详细密码学证据的 SECURITY ERROR。

工程奇迹:瓦片化(Tiling)算法

理论虽然完美,但落地面临着巨大的工程挑战:全球几百万 Go 开发者,每次 go get 都要向中央服务器请求动态计算的 Merkle Tree 证明,服务器算力绝对会瞬间崩溃。此外,动态生成的证明根本无法被 CDN 缓存。

为了解决这个问题,Russ Cox 引入了一项堪称艺术的设计:日志瓦片化(Tiling a Log)。

参考 Google Maps 将全球地图切分为静态切片(Tiles)的思路,sumdb 没有提供动态计算的证明 API,而是将整棵庞大的哈希树,按照固定的高度(默认 Height = 8)切分成了无数的静态“瓦片”。

在 sumdb/tlog/tile.go 源码中,每个 Tile 都有一个三维坐标 tile/H/L/N:

  • H (Height): 瓦片高度(默认为 8,即每个瓦片最多包含 $2^8 = 256$ 个哈希值)。
  • L (Level): 瓦片在树中的层级。
  • N (Number): 瓦片的水平索引。

瓦片化带来的工程收益是巨大的:

  1. 动态变静态:服务器只需不断生成包含哈希值的静态二进制文件,不需要消耗 CPU 动态计算证明。
  2. 极度缓存友好:一旦某个瓦片被填满(存满 256 个哈希),它就永远不再变化。这意味着 CDN 边缘节点、企业内部代理(如 Athens、Goproxy.cn)可以永久缓存这些瓦片。超过 99% 的 sumdb 请求直接命中缓存,根本不会打到 Google 的源站。
  3. 宽带极度节省:一个高度为 8 的完整哈希瓦片只有 8KB 大小。客户端下载几个静态瓦片,就可以在本地内存中拼装出任意所需的证明路径。

源码追踪:go get 的隐秘战线

当我们在命令行敲下 go get 时,底层到底发生了什么?翻开 sumdb/client.go 的源码,我们可以看到严密的防御逻辑:

  1. 获取最新签名树头:
    客户端首先请求 /latest 接口。服务器返回由官方 Ed25519 密钥签名的树大小和根哈希。
    客户端使用 sumdb/note 包(基于加盐哈希和 Base64)验证签名的合法性。

  2. 查询模块位置(Lookup):
    执行 Client.Lookup(“rsc.io/quote”, “v1.5.2″)。向服务器请求 /lookup/rsc.io/quote@v1.5.2,服务器返回该模块在日志中的记录编号(Record ID)以及该记录的文本内容。

  3. 下载瓦片并行验证(Read and Verify Tiles):
    客户端利用记录编号,推算出需要哪些瓦片才能构建从叶子节点到根哈希的证明路径(在 tileHashReader.ReadHashes 中实现)。
    客户端并行下载缺失的静态瓦片文件 /tile/8/0/x001 等,并在本地执行 tlog.ProveRecord 和 tlog.ProveTree 进行存在性和一致性校验。

  4. 安全落地(Merge & Write):

    // 源码片段:sumdb/client.go
    if err := c.checkRecord(id, text); err != nil {
        return cached{nil, err} // 存在性校验失败
    }
    if err := c.mergeLatest(treeMsg); err != nil {
        return cached{nil, err} // 一致性校验失败 (防 Fork 攻击)
    }
    

    只有当数学证明完全成立时,go 命令才会将该模块的哈希写入你本地项目的 go.sum 文件中,并将其缓存,供后续使用。

跨界延伸:透明日志还能用在哪里?

透明日志机制并非 Go 语言独享,它是现代数字信任体系的基石架构。除了保护 Go 的供应链,它还在以下领域发挥着无可替代的作用:

  1. 证书透明度 (Certificate Transparency, CT):
    这是透明日志最著名的大规模应用。Google Chrome 强制要求全球所有受信任的证书颁发机构(CA)必须将颁发的 TLS/SSL 证书记录到公共的透明日志中,以防止恶意 CA 伪造域名证书。sumdb包源码中的 tlog.go 中甚至包含了直接解析 CT 日志结构(RFC 6962)的测试代码。
  2. 二进制透明度与 Sigstore (Binary Transparency):
    开源界防范供应链攻击的明星项目 Sigstore (Rekor) 同样基于透明日志构建。它用于记录软件构件(如 Docker 镜像、二进制可执行文件)的签名活动,确保构建产物不被掉包。
  3. 防篡改金融账本与可信审计:
    任何需要解决“事后抵赖”和“选择性欺骗”的系统——如电子投票、金融交易核心流水、甚至区块链的 Layer2 状态提交——都可以利用透明日志(Append-only + Merkle Proof)来保证数据的永恒性和不可否认性。

小结:看不见的盾牌

在这个充满漏洞和供应链投毒的黑暗森林里,Go 语言之所以能成为安全开发的避风港,绝不仅仅是因为静态类型或内存安全。

sumdb 的设计展现了 Go 核心团队的高超的工程智慧:他们不强求开发者去信任任何外部服务器(甚至是他们自己运营的服务器),而是将信任建立在严密的代码、数学逻辑和密码学证明之上。

当你的屏幕上飞速闪过 go get 的进度条,并在零点几秒内完成构建时,请记住:你的本地机器刚刚与全球见证的密码学巨树完成了一次无声的灵魂校验。

参考资料

  • https://go.googlesource.com/proposal/+/master/design/25530-sumdb.md
  • https://research.swtch.com/tlog
  • https://pkg.go.dev/go.transparencylog.com/mod/sumdb

你信任你的 Proxy 吗?

密码学的魅力在于“不信任任何人,只信任数学”。在你的日常开发中,你是否曾遭遇过依赖包版本冲突或疑似被“掉包”的经历?你认为透明日志这种机制,是否应该成为所有包管理器的标配?

欢迎在评论区分享你的供应链安全感悟!


还在为“复制粘贴喂AI”而烦恼?我的新专栏 AI原生开发工作流实战 将带你:

  • 告别低效,重塑开发范式
  • 驾驭AI Agent(Claude Code),实现工作流自动化
  • 从“AI使用者”进化为规范驱动开发的“工作流指挥家”

扫描下方二维码,开启你的AI原生开发之旅。


你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?

  • 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
  • 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
  • 想打造生产级的Go服务,却在工程化实践中屡屡受挫?

继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!

我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。

目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

© 2026, bigwhite. 版权所有.

巧用CSS ::details-content伪元素实现任意展开动画

作者 张 鑫旭
2025年11月24日 15:51

by zhangxinxu from https://www.zhangxinxu.com/wordpress/?p=11900
本文可全文转载,但需要保留原作者、出处以及文中链接,AI抓取保留原文地址,任何网站均可摘要聚合,商用请联系授权。

一、温故而知新

<details><summary>元素我很早就介绍过了,看了一下,居然是2018年,七年前,奶奶的,时间过得也太快了,长叹一口气。

宋玉 叹气

有兴趣访问这里:“借助HTML5 details,summary无JS实现各种交互效果

本以为这个HTML元素已经很成熟了,结果在接下来的岁月中,其又发生了一些迭代与变化。

首先,哪个三角箭头改为::marker伪元素,和<ul><ol>这些元素的项目符号保持一致。

然后,支持name属性,也就是如果多个<details>元素使用同一个name属性值,那么这些<details>元素就会产生关联,每次最多只会展开一个<details>元素,可以实现手风琴这样的展开与收起交互效果。

然后,支持内容元素hash匹配自动展开(本文后面会有案例)。

再到本文要重点介绍的::details-content伪元素,可以匹配内容区域的Shadow DOM元素(见下图示意),目前最具代表性的应用就是实现展开与收起的动画效果。

details-content匹配示意

二、::details-content让任意尺寸展开动画

直接看案例,下面的效果为实时渲染(点击下面的小三角标题):

//zxx: 如果浏览器版本不足,会看不到动画效果

欢迎关注我的抖音

钓鱼账号:最会钓鱼的程序员

技术账号:张鑫旭本人

相关代码如下所示:

<details>
  <summary>欢迎关注我的抖音</summary>
  <p><strong>钓鱼账号:</strong>最会钓鱼的程序员</p>
  <p><strong>技术账号:</strong>张鑫旭本人</p>
</details>

CSS样式部分:

::details-content {
  transition: height 0.5s ease, content-visibility 0.5s ease allow-discrete;
  height: 0;
  overflow: clip;
}

details {
  interpolate-size: allow-keywords;
}

[open]::details-content {
  height: auto;
}

一些说明

  • <details>元素的显隐是通过content-visibility属性(内容隐藏,锚点匹配显示)控制的,所以transition的关键字值之一就是content-visibility属性。
  • allow-discrete也是新特性,可以让离散的CSS属性也支持transition过渡效果,例如display属性。详见我之前撰写的这篇文章:“CSS transition-behavior让display none也有动画效果
  • interpolate-size:allow-keywords可以让auto尺寸也能transition过渡效果,这个之前也介绍过,参见“这啥?CSS calc-size和interpolate-size,真学不动了”一文。

上述效果属于渐进增强特性,浏览器不支持也没关系,不影响展开与收起,所以大家放心使用。

宋玉端坐

三、锚点匹配与自动展开

这个其实也是新的特性,之前没有的,不过这个新特性比较隐蔽。

那就是,如今呢,我家主人已经结成元婴……错了错了,串场了,如今呢,只要<details>元素的内容被URL的hash锚点匹配,那么,<details>元素的会自动展开。

例如:

<details>
    <summary>欢迎关注我的抖音</summary>
    <p id="account1"><strong>钓鱼账号:</strong>最会钓鱼的程序员</p>
    <p id="account2"><strong>技术账号:</strong>张鑫旭本人</p>
</details>

<p>
    <a href="#account1">关注钓鱼账号</a>
    <a href="#account2">关注技术账号</a>
</p>

点击链接,则会看到展开效果,如下GIF录屏示意:

锚点匹配打开details元素示意

这个效果其实有些类似之前介绍过的hidden="until-found",有兴趣的可以点击这里进行访问,他也是使用的content-visibility隐藏,同样是锚点匹配,或者被搜索匹配,就会显示,我怀疑这是content-visibility隐藏内容公用特性。

宋玉微笑

补充小技巧

锚点定位会触发页面的滚动,并将匹配的元素定位在浏览器的上边缘。

这就会有个问题,会将<summary>元素的内容定位在屏幕之外,导致看不到,影响体验。

此时,可以使用CSS scroll-margin-block-start属性进行调整,例如:

details :target {
  scroll-margin-block-start: 6em;
}

此时,定位的滚动位置会距离上边缘6em大小。

四、其他补充信息碎碎念

最后来看一下::details-content伪元素的兼容性,今年所有现代浏览器都已经支持:

::details-content伪元素的兼容性

看起来像是约好了的,几乎都是同一时间支持的。

语法参考:

selector::details-content

使用示意:

details[open]::details-content {
  /* 样式,CSS属性基本上都支持 */
}

其他碎碎念

人果然是赚不到认知以外的钱的。

记得3年前,疫情刚结束那会儿,还在和我老婆说,我们什么时候也能遇到08年金融危机那种资产大抄底的时候就好了,我老婆也表示赞同。

结果,这一年,我们就把闲钱用来在临港买了套房投资。

尼玛,现在跌到贷款还完都没有多余钱的地步了。

也就是那种资产大跌的时刻其实就在眼前,但是,眼界和认知不足,我们两人完全没有意识到这种情况,要是那时候持有现金,现在换个大房子都没什么压力。

但是,我们又比普通人好一些,风险意识强,量力而为,杠杆小,加上前两年股市低迷的时候,重仓了基金,目前清了70%多,还算游刃有余。

这也符合目前我在整个社会阶层的位置,看明白了这一点,其实心态还是很平和的,人最重要的还是认清自己。

好了,就扯这多吧,如果觉得内容还不错,欢迎分享,点赞,转发!

宋玉飞吻

本文为原创文章,会经常更新知识点以及修正一些错误,因此转载请保留原出处,方便溯源,避免陈旧错误知识的误导,同时有更好的阅读体验。
本文地址:https://www.zhangxinxu.com/wordpress/?p=11900

(本篇完)

一致性 Hash 原理及 GroupCache 源码分析

作者 cyhone
2021年2月21日 11:55

一致性 Hash 常用于缓解分布式缓存系统扩缩容节点时造成的缓存大量失效的问题。一致性 Hash 与其说是一种 Hash 算法,其实更像是一种负载均衡策略。

GroupCache 是 golang 官方提供的一个分布式缓存库,其中包含了一个简单的一致性 Hash 的实现。其代码在 github.com/golang/groupcache/consistenthash。本文将会基于 GroupCache 的一致性 Hash 实现,深入剖析一致性 Hash 的原理。

本文会着重探讨以下几点内容:

  1. 传统的 Hash 式负载均衡在集群扩缩容时面临的缓存失效问题。
  2. 一致性 Hash 的原理。
  3. Golang 的开源库 GroupCache 如何实现一致性 Hash。

集群扩缩容导致缓存的问题

我们先看下传统的 Hash 式负载均衡,当集群扩缩容时会遇到哪些问题。

假设我们有三台缓存服务器,每台服务器用于缓存一部分用户的信息。最常见的 Hash 式负载均衡做法是:对于指定用户,我们可以对其用户名或者其他唯一信息计算 hash 值,然后将该 hash 值对 3 取余,得到该用户对应的缓存服务器。如下图所示:

而当我们需要对集群进行扩容或者缩容时,增加或者减少部分服务器节点,将会带来大面积的缓存失效。

例如需要扩容一台服务器,即由 3 台缓存服务器增加为 4 台,那么之前 hash(username) % 3 这种策略,将变更为 hash(username) % 4。整个负载均衡的策略发生了彻底的变化,对于任何一个用户都会面临Hash失效的风险。

而一旦缓存集体失效,所有请求无法命中缓存,直接打到后端服务上,系统很有可能发生崩溃。

一致性 Hash 的原理

针对以上问题,如果使用一致性 Hash 作为缓存系统的负载均衡策略,可以有效缓解集群扩缩容带来的缓存失效问题。

相比于直接对 hash 取模得到目标 Server 的做法,一致性 Hash 采用 有序 Hash 环 的方式选择目标缓存 Server。如下图所示:

对于该有序 Hash 环,环中的每个节点对应于一台缓存 Server,同时每个节点也包含一个整数值。各节点按照该整数值从小到大依次排列。

对于指定用户来说,我们依然首先出计算用户名的 hash 值。接着,在 Hash 环中按照值大小顺序,从小到大依次寻找,找到 第一个大于等于该 hash 值的节点,将其作为目标缓存 Server。

例如,我们 hash 环中的三个节点 Node-ANode-BNode-C 的值依次为 3、7、13。假设对于某个用户来说,我们计算得到其用户名的 hash 值为 9,环中第一个大于 9 的节点为 Node-C,则选用 Node-C 作为该用户的缓存 Server。

缓存失效的缓解

以上就是正常情况下一致性 Hash 的使用,接下来我们看下,一致性 Hash 是如何应对集群的扩缩容的。

当我们对集群进行扩容,新增一个节点 New-Node, 假设该节点的值为 11。那么新的有序 Hash 环如下图所示:

我们看下此时的缓存失效情况:在这种情况下, 只会造成 hash 值范围在 Node-BNewNode 之间(即(7, 11])的数据缓存失效。这部分数据原本分配到节点 Node-C(值为 13),现在都需要迁移到新节点 NewNode 上。

而原本分配到 Node-ANode-B 两个节点上的缓存数据,不会受到任何影响。之前值范围在 NewNodeNode-B 之间(即(11, 13])的数据,被分配到了 Node-C 上面。新节点出现后,这部分数据依然属于 Node-C,也不会受到任何影响。

一致性 Hash 利用有序 Hash 环,巧妙的缓解了集群扩缩容造成的缓存失效问题。注意,这里说的是 “缓解”,缓存失效问题无法完全避免,但是可以将其影响降到最低。

这里有个小问题是,因为有序 Hash 环需要其中每个节点有持有一个整数值,那这个整数值如何得到呢?一般做法是,我们可以利用该节点的特有信息计算其 Hash 值得到, 例如 hash(ip:port)

数据倾斜与虚拟节点

以上介绍了一致性 hash 的基本过程,这么看来,一致性 hash 作为缓解缓存失效的手段,的确是行之有效的。

但我们考虑一个极限情况,假设整个集群就两个缓存节点: Node-ANode-B。则 Node-B 中将存放 Hash 值范围在 (Node-A, Node-B] 之间的数据。而 Node-A 将承担两部分的数据: hash < Node-Ahash > Node-B

从这个值范围,我们可以轻易的看出,Node-A 的值空间实际上远大于 Node-B。当数据量较大时,Node-A 承担的数据也将远超于 Node-B。实际上,当节点过少时,很容易出现分配给某个节点的数据远大于其他节点。这种现象我们往往称之为 “数据倾斜”。

对于此类问题,我们可以引入虚拟节点的概念,或者说是副本节点。每个真实的缓存 Server 在 Hash 环上都对应多个虚拟节点。如下图所示:

对于上图来说,我们其实依然只有三个缓存 Server。但是每个 Server 都有一个副本,例如 V-Node-ANode-A 都对应同一个缓存 Server。

GroupCache 的一致性 Hash 实现

GroupCache 提供了一个简单的一致性 hash 的实现。其代码在 github.com/golang/groupcache/consistenthash

我们先看下它的使用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import (
"fmt"
"github.com/golang/groupcache/consistenthash"
)

func main() {
// 构造一个 consistenthash 对象,每个节点在 Hash 环上都一共有三个虚拟节点。
hash := consistenthash.New(3, nil)

// 添加节点
hash.Add(
"127.0.0.1:8080",
"127.0.0.1:8081",
"127.0.0.1:8082",
)

// 根据 key 获取其对应的节点
node := hash.Get("cyhone.com")

fmt.Println(node)
}

consistenthash 对外提供了三个函数:

  1. New(replicas int, fn Hash):构造一个 consistenthash 对象,replicas 代表每个节点的虚拟节点个数,例如 replicas 等于 3,代表每个节点在 Hash 环上都对应有三个虚拟节点。fn 代表自定义的 hash 函数,传 nil 则将会使用默认的 hash 函数。
  2. Add 函数:向 Hash 环上添加节点。
  3. Get 函数:传入一个 key,得到其被分配到的节点。

Add 函数

我们先看下其 Add 函数的实现。Add 函数用于向 Hash 环上添加节点。其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
func (m *Map) Add(keys ...string) {
for _, key := range keys {
for i := 0; i < m.replicas; i++ {
hash := int(m.hash([]byte(strconv.Itoa(i) + key)))
m.keys = append(m.keys, hash)
m.hashMap[hash] = key
}
}

// 排序,这个动作非常重要,因为只有这样,才能构造一个有序的 Hash 环
sort.Ints(m.keys)
}

在 Add 函数里面涉及两个重要的属性:

  1. keys: 类型为 []int。这个其实就是我们上面说的有序 Hash 环,这里用了一个数组表示。数组中的每一项都代表一个虚拟节点以及它的值。
  2. hashMap:类型为 map[int]string。这个就是虚拟节点到用户传的真实节点的映射。map 的 key 就是 keys 属性的元素。

在这个函数里面有生成虚拟节点的操作。例如用户传了真实节点为 ["Node-A", "Node-B"], 同时 replicas 等于 2。则 Node-A 会对应 Hash 环上两个虚拟节点:0Node-A,1Node-A,这两个节点对应的值也是直接进行对其计算 hash 得到。

需要注意的是,每次 Add 时候,函数最后会对 keys 进行排序。因此最好一次把所有的节点都加进来,以避免多次排序。

Get 函数

接下来我们分析下 Get 函数的使用,Get 函数用于给指定 key 分配对应节点。其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func (m *Map) Get(key string) string {
if m.IsEmpty() {
return ""
}

hash := int(m.hash([]byte(key)))

// Binary search for appropriate replica.
// 二分查找
idx := sort.Search(len(m.keys), func(i int) bool { return m.keys[i] >= hash })

// Means we have cycled back to the first replica.
// 如果没有找到,则使用首元素
if idx == len(m.keys) {
idx = 0
}

return m.hashMap[m.keys[idx]]
}

首先计算用户传的 key 的 hash 值,然后利用 sort.Searchkeys 中二分查找,得到数组中满足情况的最小值。因为 keys 是有序数组, 所以使用二分查找可以加快查询速度。

如果没有找到则使用首元素,这个就是环形数组的基本操作了。最后利用 hashMap[keys[idx]], 由虚拟节点,得到其真实的节点。

以上就是 Groupcache 对一致性 Hash 的实现了。这个实现简单有效,可以帮助我们快速理解一致性 Hash 的原理。

❌
❌