阅读视图

发现新文章,点击刷新页面。
🔲 ☆

一次 DNS 解析超时引发的线上告警

周五下午四点半,离下班还有半小时,告警群突然炸了。某个核心服务的 P99 延迟从平时的 50ms 飙到了 2s,上游调用方开始批量超时。

第一反应是看最近有没有发版——没有。看数据库慢查询——正常。看 CPU 和内存——纹丝不动。Redis 延迟——也没问题。

折腾了二十分钟,最后发现问题出在一个完全没想到的地方:DNS 解析。

Go 的两套 DNS 解析器

在聊问题之前,先说一个很多 Go 开发可能不太注意的事:Go 有两套 DNS 解析器。

第一套是纯 Go 实现的,直接读 /etc/resolv.conf,自己构造 DNS 查询包,往 nameserver 发 UDP 请求。这是默认使用的解析器。

第二套是通过 cgo 调用系统的 getaddrinfo 函数,走操作系统的 DNS 解析流程。

可以通过环境变量 GODEBUG=netdns=goGODEBUG=netdns=cgo 来强制指定用哪个。不设的话,Go 会根据一些条件自动选择,大部分情况下会用纯 Go 的那个。

这两套有什么区别呢?最关键的一点:纯 Go 解析器不会读 /etc/nsswitch.conf,也不支持一些系统级的 DNS 扩展(比如 mDNS)。但好处是不依赖 cgo,交叉编译友好,而且是非阻塞的,不会占用系统线程。

平时这个差异感知不强,但在特定环境下,它会成为一个隐藏的坑。

罪魁祸首:search domain 和 ndots

我们的服务跑在 K8s 里。先来看一下容器里的 /etc/resolv.conf 长什么样:

nameserver 10.96.0.10
search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5

重点看两个东西:searchndots

search 是搜索域列表。当你解析一个"不完整"的域名时,系统会把这些后缀依次拼上去尝试解析。

ndots 是一个阈值,表示域名里点的个数。如果域名里的点少于 ndots 指定的值,就会被认为是"不完整"的,会先走 search 域拼接;只有拼接全部失败后,才会用原始域名去查。

K8s 默认把 ndots 设成了 5。

这意味着什么呢?假设我们的服务要请求 api.example.com,这个域名里有 2 个点,少于 5,所以它会被当成"不完整"的域名。实际的 DNS 查询过程变成了这样:

1. api.example.com.default.svc.cluster.local  → 查询失败
2. api.example.com.svc.cluster.local          → 查询失败
3. api.example.com.cluster.local              → 查询失败
4. api.example.com                            → 查询成功

一次域名解析,变成了 4 次 DNS 查询。而且前 3 次注定失败,纯粹是在浪费时间。

如果每次查询耗时 10ms,光 DNS 这一环就多了 30ms。平时这个开销不明显,但如果 DNS 服务器本身响应变慢了呢?

那天到底发生了什么

回到那个周五下午。后来查到的原因是:集群的 CoreDNS 那段时间在处理一波大量的 DNS 请求(另一个服务在做批量外部调用),导致 CoreDNS 的响应延迟从平时的几毫秒涨到了几百毫秒。

对于一个走 search domain 的外部域名请求,原本 4 次查询可能只需要 40ms,但 CoreDNS 变慢之后,每次查询要 500ms,4 次查询就是 2s。直接把接口的 P99 干上去了。

而且 Go 的纯 Go DNS 解析器,默认的超时策略是这样的:先发一个 UDP 请求,等 5 秒没回应就重试,总共重试次数取决于 resolv.conf 里的 attempts 配置(默认是 2)。如果再加上 search domain 的多次尝试,最坏情况下一次域名解析可以卡上几十秒。

tcpdump 在容器里抓包验证一下:

tcpdump -i eth0 port 53 -nn

输出如下:

16:31:02.001 IP 10.244.1.5.43210 > 10.96.0.10.53: A? api.example.com.default.svc.cluster.local.
16:31:02.503 IP 10.96.0.10.53 > 10.244.1.5.43210: NXDomain
16:31:02.504 IP 10.244.1.5.43210 > 10.96.0.10.53: A? api.example.com.svc.cluster.local.
16:31:03.008 IP 10.96.0.10.53 > 10.244.1.5.43210: NXDomain
16:31:03.009 IP 10.244.1.5.43210 > 10.96.0.10.53: A? api.example.com.cluster.local.
16:31:03.511 IP 10.96.0.10.53 > 10.244.1.5.43210: NXDomain
16:31:03.512 IP 10.244.1.5.43210 > 10.96.0.10.53: A? api.example.com.
16:31:03.520 IP 10.96.0.10.53 > 10.244.1.5.43210: A 93.184.216.34

清清楚楚,4 次查询,前 3 次全是 NXDomain。每次间隔 500ms 左右,加起来 1.5 秒。这还是 CoreDNS 没有特别慢的情况。

怎么解

知道原因之后,解法就比较清晰了。几个方向:

方案一:域名末尾加点

DNS 里有个概念叫 FQDN(Fully Qualified Domain Name),就是以点结尾的域名。一个以点结尾的域名会被认为是"完整"的,不会再走 search domain 拼接。

把代码里请求的外部域名从 api.example.com 改成 api.example.com.,多加一个点就行了:

// 之前
resp, err := http.Get("https://api.example.com/v1/data")

// 之后
resp, err := http.Get("https://api.example.com./v1/data")

这是最简单直接的改法。但有个问题:这个点容易被忽略,也不太符合日常习惯,代码 review 的时候可能被人当成 typo 删掉。而且如果域名是从配置中心读出来的,你得确保配置那边也带上这个点。

方案二:调低 ndots

在 K8s 的 Pod spec 里可以自定义 DNS 配置:

spec:
  dnsConfig:
    options:
      - name: ndots
        value: "2"

ndots 从 5 降到 2,这样 api.example.com 有 2 个点,不少于 ndots,就会直接用原始域名查询,不再走 search domain。

但这个改法有个副作用:集群内部的短域名解析会受影响。比如你用 my-service.default 这样的短域名访问集群内服务,它只有 1 个点,还是会走 search domain,没问题。但如果你用 my-service.default.svc 这种 3 段式的(2 个点),就不会走 search domain 了,会直接查 my-service.default.svc,而这个域名在公网上当然查不到。

所以降 ndots 的时候,得先盘一下自己的服务里是怎么调用集群内其他服务的。如果都是用短域名(1 个点以内),降到 2 就够了。

方案三:本地 DNS 缓存

在 Pod 里跑一个轻量的 DNS 缓存,比如 dnsmasq 或者用 K8s 的 NodeLocal DNSCache。第一次查询该慢还是慢,但后续的相同域名查询直接走本地缓存,毫秒级返回。

NodeLocal DNSCache 是 K8s 官方推荐的方案,它在每个节点上跑一个 DNS 缓存 Pod,所有 DNS 请求先到本地缓存,命中就直接返回,不命中再去 CoreDNS。

这个方案的好处是不用改代码,不用改 ndots,对业务完全透明。缺点是需要在集群层面做部署和维护。

我们最后怎么做的

三个方案不是互斥的,我们最后的做法是:

  • 短期:外部域名加点,快速止血
  • 中期:集群统一部署 NodeLocal DNSCache
  • 同时把 ndots 降到了 2,因为我们内部服务调用都是走的 K8s Service 短域名

上线之后,DNS 相关的延迟基本消失了。

额外聊两句

这次踩坑之后我去翻了一下 Go 标准库里 DNS 解析的代码,在 net/dnsclient_unix.go 里,有一段逻辑专门处理 search domain 的拼接:

func (conf *dnsConfig) nameList(name string) []string {
    // 如果域名以点结尾,直接返回
    if avoidDNS(name) {
        return nil
    }
    rooted := len(name) > 0 && name[len(name)-1] == '.'
    if rooted {
        return []string{name}
    }

    hasNdots := count(name, '.') >= conf.ndots
    // ...
    if hasNdots {
        // 点数够了,先查原始域名,再查 search domain
        names = append(names, name+".")
    }
    for _, suffix := range conf.search {
        names = append(names, name+"."+suffix+".")
    }
    if !hasNdots {
        // 点数不够,最后才查原始域名
        names = append(names, name+".")
    }
    return names
}

注意看,当点数够了(hasNdots 为 true)的时候,原始域名会被放在列表最前面,search domain 的拼接放后面。这意味着即使触发了 search domain 逻辑,也是先查原始域名,成功就直接返回,不会浪费时间。

但当点数不够的时候,原始域名被放到了最后面。这就是为什么外部域名请求会变慢——它得把 search domain 列表全试一遍,全失败了,才轮到那个正确的原始域名。

顺便说一句,这个行为不是 Go 自己发明的,是遵循 resolv.conf 的标准语义。Linux 的 man resolv.conf 里写得很清楚。只是平时不跑在 K8s 里的话,ndots 默认是 1,外部域名一般都有 1 个以上的点,根本不会触发这个逻辑,所以感知不到。

这大概就是很多"配置型的坑"的共性:平时好好的,换个环境就炸了,而且炸的方式和你写的代码完全无关,排查的时候很容易跑偏。

🔲 ☆

牙疼的时候别跟我聊阿德勒

前阵子读完了《被讨厌的勇气》。

说实话,读到一半的时候我是服气的。目的论那部分,说愤怒不是原因导致的结果,而是你先有了发火的目的,才制造出愤怒这个工具。我当时想,确实,我上次跟同事急眼,挂了电话气就消了大半。要是愤怒真是被触发的,哪能收得这么利索。

课题分离也挺好。别人怎么看你,是别人的课题。你管不了,也不该管。干净利落。

读完合上书,我觉得自己好像通透了一点。

然后当天晚上我牙疼了。


疼得睡不着那种。

我躺在床上,满脑子就一个念头:疼。不是"别人觉得我疼不疼"的疼,不是"我选择用疼来达到某种目的"的疼。就是牙神经在抗议,纯粹的、跟任何人都没关系的疼。

阿德勒说,一切烦恼皆来自人际关系。

我当时就想,大哥,你来疼一个试试。


我后来认真想了想这句话,越想越觉得它漂亮但不对。

如果这个宇宙只剩我一个人,没有任何人际关系了,我就没烦恼了?饿的时候呢?冷的时候呢?地震来了呢?

这些恐惧是刻在基因里的,不需要有观众。

甚至不用那么极端。就说写代码这件事。有时候一段逻辑死活调不通,我坐在电脑前,烦得想把键盘摔了。这烦躁是因为怕领导骂吗?不全是。更多的是一种"我觉得自己应该能搞定,但就是搞不定"的挫败。

那是我跟问题本身的较劲。跟别人无关。

有点像下棋,对面坐的不是人,是棋盘本身。你输了,你烦,不是因为丢脸,是因为你明明看到了一条路,就是走不通。

把这种烦恼也归到人际关系里,老实说,有点牵强了。


不过目的论那部分,我倒不是完全不认。

它有道理的地方在于,它提醒你,很多情绪确实不是"发生了什么"决定的,而是"你想要什么结果"决定的。

我以前写过一段话,大概意思是,你在饭店被服务员泼了汤,你大发雷霆,目的论会说你是先有了想发火的目的,才捏造出愤怒。因为如果老板这时候打电话来,你立马就能切换成正常人。

这个解释我现在依然觉得有意思。

但"有意思"和"能解释一切"是两回事。


真正让我犹豫的,是课题分离在亲密关系里的适用性。

理论上,课题分离是解决纠纷的神器。你做你的选择,我做我的选择,谁也别替谁操心。

但你试试对你妈说:"你担心我找不到对象,这是你的课题,不是我的。"

看她会不会拿拖鞋抽你。

我是说,在那种不太亲近的关系里,课题分离确实好用。同事觉得你不合群?那是他的课题。路人觉得你穿得丑?那是他的课题。干净,清爽,省心。

可到了真正亲密的关系里——你爸妈、你的伴侣、你最好的朋友——课题分离的那条线,就没那么好画了。

因为亲密关系的本质,好像就是课题的交织。

你关心一个人,就是在主动把自己的情绪交给对方的课题。你妈担心你,不是因为她分不清课题,是因为她愿意把你的事当成自己的事。你要是跟她说"这是我的课题",道理她可能懂,但她会觉得冷。

也许不只是觉得。

就是冷。


我有时候想,阿德勒的理论像一把很锋利的刀。

它能切掉很多不必要的纠缠——那些你本来就不该背的包袱、不该在意的眼光、不该为之愤怒的破事。这些它切得又快又准。

但刀太锋利了,有时候会连不该切的东西也一起切掉。

比如那种笨拙的、越界的、分不清你我的关心。
比如明知道是对方的课题,但还是忍不住多说一句的冲动。

这些东西不够"理性",不够"清醒"。但它们也许恰恰是亲密关系里最真的部分。


我大概不算阿德勒的信徒。

但我也不讨厌他。他说的很多东西在我看来是"八十分的真话"——大部分时候成立,少部分时候失灵。而那失灵的二十分,恰好是生活里最疼的地方。

牙疼的夜晚,算法调不通的下午,还有你妈明知道你不爱听但还是要唠叨的那些话。

这些东西不在任何一个漂亮的理论框架里。

但它们在。

🔲 ☆

为什么 UTF-8 能一统天下:字符编码的生存竞赛

前言

写代码这么多年, 乱码这玩意应该每个人都碰到过. 数据库里存的好好的中文, 取出来一看变成了一堆问号; 或者打开一个文本文件, 满屏的"锟斤拷". 每次碰到这种问题, 改一下编码就好了, 但一直没认真想过: 为什么会有这么多编码? UTF-8 又是凭什么成为事实标准的?

最近翻了翻字符编码的历史, 发现这里面还挺有意思的. 简单来说就是一个"从各自为政到大一统"的故事, 而 UTF-8 能赢, 靠的不是什么高深的技术, 而是一个朴素的设计哲学: 向前兼容.

ASCII: 一切的起点

故事要从 ASCII 说起.

上世纪 60 年代, 美国人搞了一套字符编码标准, 用 7 个 bit 来表示字符, 总共能编码 128 个字符. 包括英文大小写字母、数字、标点符号, 再加上一些控制字符(换行、回车这些).

128 个字符, 对英语来说完全够用了. 而且因为计算机存储以字节(8 bit)为单位, 7 bit 的 ASCII 正好塞进一个字节里, 最高位空着, 设为 0. 简洁, 高效, 没毛病.

但问题来了: 这世界上又不是只有英语.

各自为政的混乱时代

ASCII 只能表示 128 个字符, 连个 ü 都搞不定, 欧洲人第一个不答应. 于是大家开始打那个空闲的最高位的主意——把第 8 位也用上, 这样就有 256 个位置了, 多出来的 128 个位置各取所需.

这就是 ISO-8859 系列的由来. 西欧搞了 ISO-8859-1(又叫 Latin-1), 把法语的 é、德语的 ß 这些字符塞了进去; 东欧搞了 ISO-8859-2; 希腊搞了 ISO-8859-7; 以此类推, 一共搞出了十几个版本.

问题已经开始出现了: 同一个字节值, 在不同的编码下代表不同的字符. 比如 0xC0 这个字节, 在 Latin-1 里是 À, 在 ISO-8859-5 里就变成了一个西里尔字母. 你用 Latin-1 写的文件, 拿到一个装了 ISO-8859-5 的机器上打开, 就是乱码. 不过至少欧洲的字母语言还算是占了便宜, 256 个位置虽然挤了点, 勉强能塞下.

到了中日韩这边, 事情就彻底失控了.

光是常用汉字就有好几千个, 256 个位置怎么可能够? 所以只能用两个字节来表示一个字符. 中国大陆搞了 GB2312, 后来扩展成了 GBK, 再后来又搞了 GB18030; 台湾搞了 Big5; 日本搞了 Shift-JIS 和 EUC-JP; 韩国搞了 EUC-KR. 每个地区各搞一套, 互不兼容.

这时候的编码世界大概是这么个状态:

graph LR A["ASCII 128字符"] --> B["ISO-8859 系列"] A --> C["GB2312 / GBK"] A --> D["Big5"] A --> E["Shift-JIS"] A --> F["EUC-KR"] B --> B1["Latin-1 西欧"] B --> B2["ISO-8859-5 俄语"] B --> B3["ISO-8859-7 希腊语"]

每个方案都只管自己的地盘, 出了自己的地盘就抓瞎. 你要是写一个网页, 里面同时有中文和日文, 用哪个编码? 哪个都不行.

更要命的是, 这些双字节编码和 ASCII 的兼容方式也各不相同. 有的编码方案里, 第一个字节的范围和 ASCII 有重叠, 导致解析的时候分不清一个字节到底是一个独立的 ASCII 字符, 还是某个双字节字符的一部分. Shift-JIS 就有这个毛病, 写过处理日文文本程序的应该深有体会.

这就是 UTF-8 出现之前的世界: 一片混乱.

Unicode: 理想很丰满

有问题就有人想解决. 上世纪 80 年代末, 有人提出了一个大胆的想法: 搞一个统一的字符集, 把全世界所有的文字都收进来, 每个字符给一个唯一的编号. 这就是 Unicode.

Unicode 的思路很简单——给每个字符分配一个唯一的编号, 叫做"码点"(Code Point), 用 U+ 加十六进制数表示. 比如:

  • U+0041 是大写字母 A
  • U+4F60 是汉字
  • U+1F600 是 emoji 😀

目前 Unicode 已经收录了超过 14 万个字符, 覆盖了几乎所有现存的文字系统, 甚至包括一些已经消亡的古文字. 码点的范围从 U+0000U+10FFFF, 理论上可以容纳一百多万个字符.

但 Unicode 本身只是一个"字符集", 它只负责给字符编号, 并不管这些编号在计算机里怎么存储. 编号和存储是两码事. 就好比你给全国每个人分配了一个身份证号, 但身份证号怎么印到卡上、用什么材质、多大字号, 那是另一个问题.

所以, Unicode 需要一种"编码方案"来把码点转换成实际的字节序列. 而这种编码方案, 不止一种.

UTF-32: 简单粗暴

最直接的方式: 每个字符固定用 4 个字节表示. 码点是多少, 直接存进去, 不够的高位补零.

A 的码点是 U+0041, 存成 00 00 00 41. 的码点是 U+4F60, 存成 00 00 4F 60.

优点是实现简单, 定长编码, 随机访问方便——想取第 N 个字符, 直接偏移 N*4 个字节就行.

缺点也很明显: 浪费空间. 一个 ASCII 字符本来 1 个字节就够了, 现在要用 4 个字节, 前面 3 个字节全是零. 对于以英文为主的文本, 文件体积直接膨胀到原来的 4 倍. 这谁受得了?

而且还有个字节序的问题. 00 00 00 41 这 4 个字节, 到底是高位在前(大端)还是低位在前(小端)? 不同的 CPU 架构有不同的偏好, 所以 UTF-32 还分 UTF-32BE 和 UTF-32LE. 为了标明字节序, 文件开头还得加一个 BOM(Byte Order Mark). 越搞越复杂.

UTF-16: 折中方案

UTF-16 做了个折中: 常用字符(BMP, 基本多语言平面, 码点在 U+0000U+FFFF 之间的)用 2 个字节表示, 不常用的(如一些 emoji 和古文字)用 4 个字节.

看起来比 UTF-32 省空间了, 但问题也不少:

  • 对于纯英文文本, 还是比 ASCII 大一倍
  • 变长编码(2 字节或 4 字节), 随机访问的优势没了
  • 同样有字节序问题, 分 UTF-16BE 和 UTF-16LE
  • 最致命的: 和 ASCII 不兼容. 一个 ASCII 字符 A 在 UTF-16 里是 00 41, 中间那个 00 字节, 在 C 语言里就是字符串结束符 \0. 任何处理 C 风格字符串的程序, 看到这个 00 就认为字符串结束了. 直接崩.

Java 和 Windows 早期选择了 UTF-16 作为内部编码, 这在当年 Unicode 字符还没那么多的时候看起来是个合理的选择. 但随着 emoji 和更多字符的加入, UTF-16 "定长 2 字节"的假设被打破了, 留下了不少历史包袱.

UTF-8: 为什么是它

终于轮到主角了.

UTF-8 是 1992 年由 Ken Thompson 和 Rob Pike 在一张餐巾纸上设计出来的(没错, 就是发明 Unix 和 Go 语言的那两位). 它的核心思想是: 用变长的字节序列来编码 Unicode 码点, 而且要和 ASCII 完全兼容.

编码规则如下:

码点范围 字节数 字节1 字节2 字节3 字节4
U+0000U+007F 1 0xxxxxxx
U+0080U+07FF 2 110xxxxx 10xxxxxx
U+0800U+FFFF 3 1110xxxx 10xxxxxx 10xxxxxx
U+10000U+10FFFF 4 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

表里的 x 就是实际码点值的二进制位, 从低位往高位填充.

来实际算一个. 汉字"你"的码点是 U+4F60, 转成二进制是 0100 1111 0110 0000, 一共 15 个有效位. 查上面的表, 15 位落在第三行(3 字节编码, 最多容纳 16 个有效位), 所以编码模板是 1110xxxx 10xxxxxx 10xxxxxx.

把二进制位从右往左填进去:

码点二进制: 0100 1111 0110 0000
拆分:       0100   111101   100000
填充:    1110-0100 10-111101 10-100000
十六进制:    E4       BD       A0

所以"你"的 UTF-8 编码就是 E4 BD A0, 3 个字节. 用 Python 验证一下:

>>> '你'.encode('utf-8')
b'\xe4\xbd\xa0'

没毛病.

为什么这个设计能赢

看完编码规则, 可能觉得也就那样, 无非是个变长编码嘛. 但仔细想想, 这里面藏了好几个精妙的设计:

1. 完全兼容 ASCII

码点 U+0000U+007F 的字符, 编码后还是单字节, 而且字节值和 ASCII 一模一样. 这意味着任何合法的 ASCII 文本, 同时也是合法的 UTF-8 文本, 不需要做任何转换.

这一点太重要了. 互联网上大量的协议、配置文件、源代码都是 ASCII 的, 如果新编码不兼容 ASCII, 推广的阻力会大得多. UTF-16 就是因为不兼容 ASCII, 在很多场景下推不动.

2. 自同步(Self-synchronizing)

看编码规则, 每种字节都有明确的特征:

  • 单字节字符: 最高位是 0
  • 多字节序列的首字节: 以 110111011110 开头
  • 后续字节: 以 10 开头

这意味着你随便从一个 UTF-8 字节流的中间位置开始读, 都能快速判断当前字节是不是一个字符的起始位置. 如果以 10 开头, 就往前找, 最多往前找 3 个字节就能找到首字节.

这个特性在处理损坏的数据、网络传输中截断的数据时非常有用. 一个字节损坏, 最多影响一个字符, 不会像某些编码那样导致后面所有字符全部解析错误, 产生"雪崩效应".

3. 没有字节序问题

UTF-8 的编码单元是字节, 不是多字节整数, 所以不存在大端小端的问题. 不需要 BOM, 不需要检测字节序, 写入的字节序列在任何机器上读出来都一样.

UTF-16 和 UTF-32 就没这个待遇了, 文件开头不加 BOM, 解码器就得猜.

4. 排序保持一致

UTF-8 编码后的字节序列, 按字节排序的结果和按码点排序的结果是一致的. 这意味着你不需要先解码再排序, 直接按字节比较就行. 对于数据库索引、文件系统排序这些场景, 这个特性省了不少事.

5. 空间效率合理

对于英文文本, UTF-8 和 ASCII 一样, 1 字节一个字符, 零浪费. 对于中文文本, 3 字节一个字符, 虽然比 GBK 的 2 字节多了 50%, 但考虑到中文文本里通常也夹杂着不少 ASCII 字符(标点、数字、英文术语), 实际差距没那么大.

而且在互联网场景下, 英文内容占了大头, UTF-8 对英文的零开销使得它在全球范围内的平均空间效率非常优秀.

编码检测: 一个有意思的副产品

顺便说一个 UTF-8 的附加好处: 它比较容易被正确检测.

因为 UTF-8 的字节模式有严格的约束(首字节和后续字节的格式是固定的), 一段随机的二进制数据碰巧是合法 UTF-8 的概率其实挺低的. 所以当你拿到一个不知道编码的文件时, 尝试按 UTF-8 解码, 如果能成功解码且没有非法序列, 那它大概率就是 UTF-8.

相比之下, GBK 和 Latin-1 的字节范围大量重叠, 光看字节值很难分辨. 这也是为什么浏览器早期经常猜错编码, 导致乱码的原因之一.

那些"锟斤拷"是怎么来的

提到乱码, 顺便解释一下"锟斤拷"这个经典乱码.

Unicode 里有一个特殊字符叫 U+FFFD (REPLACEMENT CHARACTER, 就是那个 ), 专门用来表示"这里有个字符, 但我解码失败了". 它的 UTF-8 编码是 EF BF BD.

当一段 GBK 编码的文本被错误地当作 UTF-8 解码时, 很多字节序列是非法的 UTF-8, 解码器就会用 U+FFFD 来替代. 这样就产生了一堆 EF BF BD 字节.

然后, 如果你再把这堆字节用 GBK 去解码——两个字节一组, EF BF 对应"锟", BD EF 对应"斤", BF BD 对应"拷". 于是经典的"锟斤拷"就诞生了.

说白了就是编码和解码用的方案对不上, 来回折腾了两次, 就变成了这副鬼样子.

现状

现在回头看, UTF-8 的胜出几乎是必然的. 它做对了最关键的一件事: 不破坏已有的生态.

兼容 ASCII 意味着大量现有的工具、库、协议不需要修改就能处理 UTF-8 文本. C 语言的 strlenstrcpy 这些函数, 虽然不能正确计算 UTF-8 字符串的字符数, 但至少不会因为遇到 \0 而提前截断(和 UTF-16 形成鲜明对比). 文件路径、环境变量、命令行参数, 这些系统级的字符串处理, 切换到 UTF-8 的成本最低.

据 W3Techs 的统计, 截至目前互联网上超过 98% 的网页使用 UTF-8 编码. Linux 系统的默认 locale 是 UTF-8, Go 语言的源码和字符串默认是 UTF-8, Rust 的 String 类型内部就是 UTF-8. 新的协议和标准也基本都默认 UTF-8.

当然 UTF-8 也不是完美的. 对于 CJK 文字(中日韩)来说, 每个字符 3 字节确实不如 GBK 的 2 字节紧凑. 但在一个全球化的网络环境下, "能正确处理所有语言的文字且与 ASCII 兼容"这张牌, 比"某种语言省一个字节"重要得多.

编码的故事到这基本就讲完了. 从 ASCII 的 128 个字符, 到各国自扫门前雪的混战, 再到 Unicode 一统编号、UTF-8 一统编码, 花了大概三十年. 本质上就是一个兼容性打败一切的故事——在技术标准的世界里, 能和已有生态和平共处的方案, 往往比"理论上更优"的方案活得更久.

以上, 简单聊了聊字符编码这些事, 溜了溜了.

🔲 ☆

禅与摩托车维修艺术:一场关于「良质」的公路旅行

前言

说来惭愧, 这本书在我的书架上吃灰了大概有一年. 当时买它完全是因为书名, 禅? 摩托车? 维修? 这三个词放在一起, 怎么看都不像是能组成一本书的. 就好比你跟我说「量子力学与红烧肉烹饪指南」, 我第一反应肯定是——这是个什么东西?

但翻开之后, 才发现自己被书名骗了. 这哪里是什么维修手册, 分明是一个中年男人骑着摩托车横穿美国, 一路上跟你聊哲学的故事. 而且聊的还不是那种让人昏昏欲睡的学院派哲学, 是那种会让你在深夜突然坐起来, 觉得「好像有点道理」的哲学.

好, 既然读完了, 那就来聊聊这本书到底在说什么.

一趟公路旅行, 两种世界观

故事的框架其实很简单: 一个父亲带着儿子, 和另一对夫妻约翰夫妇, 一起骑摩托车从明尼苏达到旧金山. 沿途的风景描写是有的, 但更多的是主人公脑子里翻涌的思考, 作者把这些思考叫做「肖陶扩」, 也就是一种旅途中的哲学漫谈.

最有意思的切入点, 是两种截然不同的态度.

主人公自己动手维修摩托车, 遇到问题会拆开来分析、诊断、修理. 而他的朋友约翰呢, 骑的是一辆更贵的宝马摩托, 但约翰对机械这东西完全不感兴趣, 摩托车坏了就送修理铺, 甚至连看都不想看一眼. 这并不是因为约翰懒, 而是他从心底里排斥「技术」这个东西.

读到这里的时候, 我突然就想到了身边的很多人. 有些同事对自己用的工具、框架, 总是抱着一种好奇心, 想知道它底层到底是怎么运转的. 而有些人呢, 工具能跑就行, 别跟我说原理, 我不想知道. 这两种人我都见过, 说不上谁对谁错, 但确实是两种完全不同的世界观.

作者把这两种态度归纳为「古典认知」和「浪漫认知」:

  • 古典认知: 关注事物的内在形式, 喜欢分析结构和原理. 看到摩托车, 想的是发动机怎么运转, 点火时序是什么.
  • 浪漫认知: 关注事物的外在表象, 重视直觉和感受. 看到摩托车, 想的是风驰电掣的自由感.

乍一看, 这不就是理科生和文科生的区别嘛. 但作者说, 这两种认知方式的割裂, 才是现代人精神困境的根源. 科技让生活变好了, 但很多人对科技又有一种本能的疏离感, 觉得它冰冷、没有人情味. 就像约翰, 享受着摩托车带来的便利, 却不愿意和它发生任何「深层联系」.

良质: 一个说不清道不明的东西

全书最核心的概念, 就是「良质」(Quality). 说实话, 读第一遍的时候, 我觉得作者在故弄玄虚. 什么叫良质? 你倒是给个定义啊.

但作者偏偏不给定义. 他说, 良质是不可定义的. 一旦你定义了它, 它就不是良质了. 这话听着是不是很像老子说的「道可道, 非常道」? 没错, 这也是书名里「禅」字的由来.

作者用了一个很巧妙的例子来说明. 他在大学教写作课的时候, 做过一个实验: 让学生评价两篇文章哪篇写得好. 结果大部分学生的判断是一致的. 也就是说, 大家都能「感受」到哪篇文章的质量更好, 但如果你追问「好在哪里」, 每个人给出的理由又不一样.

良质就是这么一个东西——你知道它在那儿, 你能感受到它, 但你说不清楚它到底是什么.

这让我想到了写代码. 你有没有过这样的体验: 看到一段代码, 说不出具体哪里不对, 但就是觉得「这代码写得不行」. 又或者看到一段代码, 逻辑清晰, 命名规范, 结构优雅, 你会由衷地觉得「写得真好」. 这个「好」和「不行」, 是不是也是一种良质的感知?

如果按照作者的思路来画个图的话, 大概是这样的:

graph TD A["良质 Quality"] --> B["古典认知"] A --> C["浪漫认知"] B --> D["理性分析"] B --> E["逻辑推理"] C --> F["直觉感受"] C --> G["审美体验"] D --> H["技术实践"] F --> H E --> H G --> H H --> I["真正的好作品"]

良质不属于古典, 也不属于浪漫, 它在两者之上. 或者说, 良质是连接理性和感性的那座桥. 一个真正好的作品, 无论是一篇文章、一段代码, 还是一辆修好的摩托车, 都是理性和感性共同作用的结果.

关于「卡住」这件事

书中有一大段在讲「卡住」这件事, 我觉得特别有共鸣.

作者说, 修摩托车的时候, 经常会遇到卡住的情况. 比如一颗螺丝拧不下来, 你越使劲越拧不动, 最后把螺丝拧滑丝了. 这时候, 大部分人会暴跳如雷, 然后把扳手往地上一扔, 骂骂咧咧地走了.

但作者说, 卡住其实是好事.

为什么? 因为卡住意味着你现有的认知模型不够用了, 你需要跳出来, 用新的视角看问题. 那颗拧不下来的螺丝, 也许不是你力气不够, 而是你用错了工具, 或者根本就不应该从这个方向拧.

这段话让我一下子就想到了 debug 的经历. 有时候一个 bug 查了一下午, 各种 log 加了一堆, 就是找不到原因. 越查越烦躁, 越烦躁越找不到. 然后你站起来去接杯水, 或者上个厕所, 回来一看——哦, 原来是这里写错了.

作者的意思是, 当你被卡住的时候, 不要急着往前冲, 先停下来, 泡杯茶, 想想是不是方向错了. 卡住不是终点, 而是一个信号, 告诉你该换条路走了.

他还列了几种导致「卡住」的陷阱, 我觉得每一种都很精准:

价值陷阱: 你对结果的期望影响了你的判断. 比如你已经花了三个小时修一个 bug, 沉没成本让你不愿意承认当前的方案是错的.

真理陷阱: 你过于依赖逻辑和推理, 忽略了直觉. 所有证据都指向 A, 但你的直觉告诉你是 B, 这时候要不要相信直觉?

肌肉陷阱: 身体状态影响思维. 困了累了饿了, 都会让你做出糟糕的判断. 别硬撑, 先休息.

看, 修摩托车和写代码, 其实是一回事.

「用心」这件事

全书读下来, 如果非要我用一个词来概括, 我会选「用心」.

作者反复强调的一件事就是: 不管你做什么, 修摩托车也好, 写文章也好, 做饭也好, 写代码也好, 最重要的是你有没有「用心」在做.

他举了一个修理工的例子. 你把摩托车送到修理铺, 修理工一边听着收音机一边干活, 手上在拧螺丝, 脑子里想的是今晚吃什么. 修好了, 你骑回去, 过两天又出问题了. 为什么? 因为他在修车的时候, 心不在焉, 他和这辆摩托车之间没有建立起任何联系.

而如果一个人用心去修, 他会观察、会思考、会和这个机器「对话」. 他知道哪个零件有点松了, 哪个地方发出的声音不太对. 这种用心, 就是良质的来源.

这让我想到了很多东西. 为什么有些人写的代码, 看起来就是比别人的舒服? 不是因为他用了什么高级的设计模式, 而是你能感觉到他在写每一行代码的时候都是「在的」, 都经过了思考. 变量的命名、函数的拆分、异常的处理, 处处透着一股用心.

反过来, 那些让你看了头疼的代码, 往往不是因为写的人技术差, 而是因为他在写的时候「不在」——心思不在代码上, 只想着赶紧写完交差.

斐德洛的悲剧

书中还穿插了一条暗线, 就是主人公过去的另一个人格——斐德洛. 斐德洛是一个极其聪明的人, 他疯狂地追问「良质到底是什么」, 追问到了哲学的尽头, 最终精神崩溃, 被送去做了电击治疗.

这条线读起来有点压抑, 但也很真实. 一个人如果把某个问题想得太深, 深到了现有知识体系无法容纳的程度, 确实是有可能被这个问题吞噬的.

好在主人公最后和斐德洛达成了某种和解. 他不再试图给良质下定义, 而是学会了在生活中去感受它、实践它. 就像书的结尾, 他和儿子骑着摩托车, 沿着海岸线前进, 一切都很平静.

读到这里, 我突然明白了为什么这本书叫「禅与摩托车维修艺术」. 禅不是坐在那里冥想, 禅是你全神贯注做一件事情时的那种状态. 修摩托车可以是禅, 写代码可以是禅, 做饭可以是禅. 关键不在于你做的是什么, 而在于你做的时候, 心在不在.

写在最后

这本书我前前后后翻了大概两周, 不算长, 但确实需要时间消化. 有些段落读起来会觉得作者在绕, 但如果你耐下心来, 会发现他绕了一大圈, 最后都会回到那个核心的点上.

如果让我给这本书挑个毛病, 那就是——它确实不好读. 哲学的部分有时候过于抽象, 如果没有一定的哲学基础, 读起来可能会比较吃力. 但好在作者一直在用修摩托车这个具象的例子来做类比, 所以即使看不懂哲学的部分, 也能 get 到他想表达的意思.

最后, 用书中一句让我印象最深的话来结尾吧:

佛陀或耶稣坐在电脑和变速器的齿轮旁边, 会像坐在山顶和莲花座上一样自在.

这句话放到我们的语境里就是: 不要觉得写代码是一件「low」的事情, 也不要觉得技术是冰冷的. 当你用心去做的时候, 在键盘上敲出的每一行代码, 和山顶上的禅修, 没什么区别.

好了, 就聊到这. 下次修 bug 的时候, 记得泡杯茶先.

🔲 ☆

谁还需要 Kafka 啊?我用两个 UNIX 信号手捏了一个消息队列!

天下苦复杂中间件久矣!

看看我们现在的项目,动辄就要引入 Kafka、RabbitMQ、RocketMQ……光是部署这些环境、调优 JVM、配置集群,就能让一个好好的周末泡汤。你有没有在某个深夜抓狂时想过:“老夫就是想在两个进程之间传个字符串,难道就没有那种最极简、最硬核、最不需要第三方依赖的办法吗?”

如果你没这么想过,恭喜你,你是一个情绪稳定的正常程序员。

但我不仅想了,我还真干了!今天,我要带大家玩一把“文艺复兴”,彻底抛弃所有高级网络协议和中间件,仅用操作系统最底层的两个 UNIX 信号,从零手撸一个消息队列! 不管你是想轻松学点底层 IPC(进程间通信)知识,还是想复习一下快忘光的二进制位运算,亦或只是单纯想看我怎么一本正经地折腾没用的东西,这篇文章都绝对合你的胃口。

准备好了吗?我们要开始“作妖”了。


处在 IPC 鄙视链底端的“信号”

提到进程间通信(IPC, Inter-Process Communication),大家脑子里蹦出来的肯定是:

  1. Socket(套接字):老大哥,网络通信的绝对霸主。
  2. Pipe(管道):也就是我们常用的 | 符,比如 echo "hello" | grep "h",简单粗暴。
  3. Shared Memory(共享内存):性能怪兽,但处理同步问题能让人掉光头发。

相比之下,Signal(信号) 简直就是处于 IPC 鄙视链的绝对底端。为啥?因为它本来就不是设计用来传数据的!

按照 UNIX 系统的设定,信号就像是操作系统给进程发的“短消息通知”,通常只代表一个动作:

  • SIGKILL:阎王让你三更死,谁敢留人到五更。(强制结束,无法捕获)
  • SIGTERM:温柔一刀,给你个机会料理后事。(优雅停机)
  • SIGINT:你在终端疯狂按 Ctrl+C 产生的就是这玩意。

信号本身不携带任何数据载荷。这就好比我给你打了个响指,你只知道我打了响指,但没法通过响指本身知道我中午想吃黄焖鸡还是兰州拉面。

但是!(重点来了)

UNIX 系统非常贴心地留了两个“用户自定义信号”:SIGUSR1SIGUSR2。这就给了我们搞事情的绝佳机会。

脑洞大开:把信号变成摩斯密码

既然信号不能带数据,那我们怎么传消息?
很简单,回到计算机最本质的世界:0 和 1

只要是消息,不管多长多复杂,在内存里最终都是由 0 和 1 组成的二进制串。我们手头刚好有两个自定义信号,那不如这样约定:

  • 收到 SIGUSR1,就代表我给你发了一个 0
  • 收到 SIGUSR2,就代表我给你发了一个 1

这就是我们的“摩斯密码”!

以小写字母 h 为例:
它在 ASCII 码表里的十进制值是 104,转换成二进制就是 01101000
如果我们想把 h 发送给另一个进程,只需要按顺序给那个进程发送 8 次信号:
SIGUSR1 (0) -> SIGUSR1 (0) -> SIGUSR1 (0) -> SIGUSR2 (1) -> SIGUSR1 (0) -> SIGUSR2 (1) -> SIGUSR2 (1) -> SIGUSR1 (0)
(注:这里为了方便,我们假设从最低位 LSB 开始发送)

只要发送端负责把字母“拆”成位,接收端负责把位“拼”回字母,这不就成了吗?!


核心魔法:位运算的拆与拼

要实现这个脑洞,我们需要用一点点位运算(Bitwise Operations)。别一听位运算就跑,其实特别简单。这次我们用 Go 语言 来演示,因为 Go 处理并发和系统级 API 简直顺滑得不像话。

1. 发送端:怎么把一个字节“拆”成 8 个位?

假设我们有一个字节 byte,要想知道它第 i 位是 0 还是 1,我们可以用这个黄金公式:
bit = (byte >> i) & 1

原理剖析:

  • >> 是右移操作符。它能把二进制串整体向右推 i 个位置,原来在第 i 位的数据,就被推到了最右边(最低位)。
  • & 1 是按位“与”操作。因为 1 的二进制是 00000001,任何数和它做“与”操作,都会把前面的高位全部清零,只保留最右边那一位!

h (104, 01101000) 开刀,我们从第 0 位(最右侧)一直剥到第 7 位(最左侧):

  • 第 0 位:(104 >> 0) & 1 -> 104 与 1 -> 结果是 0
  • 第 1 位:(104 >> 1) & 1 -> 52 与 1 -> 结果是 0
  • 第 2 位:(104 >> 2) & 1 -> 26 与 1 -> 结果是 0
  • 第 3 位:(104 >> 3) & 1 -> 13 与 1 -> 结果是 1
  • 第 4 位:(104 >> 4) & 1 -> 6 与 1 -> 结果是 0
  • 第 5 位:(104 >> 5) & 1 -> 3 与 1 -> 结果是 1
  • 第 6 位:(104 >> 6) & 1 -> 1 与 1 -> 结果是 1
  • 第 7 位:(104 >> 7) & 1 -> 0 与 1 -> 结果是 0

完美!我们得到了序列 0, 0, 0, 1, 0, 1, 1, 0,接下来只要把它们换成 SIGUSR1SIGUSR2 发出去就行了。

2. 接收端:怎么把 8 个位“拼”回一个字节?

接收端的工作正好相反,它要用到的武器是左移操作(<<)。
一开始,我们搞一个空字节,值为 0。然后监听信号。

  • 如果来了个 0,不管它。
  • 如果来了个 1,我们就把它往左移 i 个位置,然后“加”到我们的空字节上。

公式:accumulator += (bit << position)

position 走到 8 的时候,说明凑齐了一桌麻将(一个字节),直接把这个字节转成字符打印出来,然后将 positionaccumulator 清零,准备迎接下一个字节。


废话少说,Show Me The Code!

先写接收端 (Consumer)

这哥们的主要任务就是老老实实呆在后台,监听我们要给它发的信号。

// consumer.go
package main

import (
    &quot;fmt&quot;
    &quot;os&quot;
    &quot;os/signal&quot;
    &quot;syscall&quot;
)

func main() {
    // 打印 PID,不然发送端不知道要把信号打给谁
    fmt.Printf(&quot;😎 消费者已启动!我的进程 PID 是: %d\n&quot;, os.Getpid())
    fmt.Println(&quot;🎧 正在竖起耳朵等待 UNIX 信号...&quot;)

    // 注册通道,专门截获 SIGUSR1 和 SIGUSR2
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGUSR1, syscall.SIGUSR2)

    var accumulator byte = 0
    var position int = 0
    var buffer []byte // 用来存拼好的字符

    for sig := range sigCh {
        var bit byte = 0
        if sig == syscall.SIGUSR2 {
            bit = 1 // SIGUSR2 就是 1
        }

        // 拼图游戏开始,把 bit 推到正确的位置上并累加
        accumulator += (bit &lt;&lt; position)
        position++

        // 收集满 8 个龙珠,召唤一个 Byte
        if position == 8 {
            if accumulator == 0 {
                // 我们约定,收到一个完全是 0 的字节(NULL),代表一句话说完了
                fmt.Printf(&quot;\n✨ 收到完整消息: %s\n&quot;, string(buffer))
                buffer = []byte{} // 清空,准备听下一句
            } else {
                // 没说完就先存进 buffer
                buffer = append(buffer, accumulator)
                fmt.Printf(&quot;%c&quot;, accumulator) // 实时打印看看效果
            }

            // 重置状态
            accumulator = 0
            position = 0
        }
    }
}

再写发送端 (Producer)

发送端就是个无情的发报机,把我们的命令行参数拆碎了发射出去。

// producer.go
package main

import (
    &quot;fmt&quot;
    &quot;os&quot;
    &quot;strconv&quot;
    &quot;syscall&quot;
    &quot;time&quot;
)

func main() {
    if len(os.Args) &lt; 3 {
        fmt.Println(&quot;❌ 姿势不对!正确用法: go run producer.go &lt;PID&gt; &lt;你想发送的骚话&gt;&quot;)
        return
    }

    targetPid, _ := strconv.Atoi(os.Args[1])
    message := os.Args[2]

    fmt.Printf(&quot;🚀 准备向 PID %d 发射消息: [%s]\n&quot;, targetPid, message)

    for i := 0; i &lt; len(message); i++ {
        b := message[i]

        // 庖丁解牛:拆解 8 个位
        for j := 0; j &lt; 8; j++ {
            bit := (b &gt;&gt; j) &amp; 1

            sig := syscall.SIGUSR1
            if bit == 1 {
                sig = syscall.SIGUSR2
            }

            // 发送信号!咻!
            syscall.Kill(targetPid, sig)

            // ⚠ 极其关键的一步:休眠!
            // 如果不睡一会儿,操作系统的内核可能会把密集发送的相同信号合并成一个
            // 那样你的数据就全丢了。这就是底层 IPC 的残酷。
            time.Sleep(2 * time.Millisecond) 
        }
    }

    // 消息发完了,最后发 8 个 0(NULL),告诉接收方“我说完了”
    for j := 0; j &lt; 8; j++ {
        syscall.Kill(targetPid, syscall.SIGUSR1)
        time.Sleep(2 * time.Millisecond)
    }

    fmt.Println(&quot;✅ 消息发送完毕,深藏功与名。&quot;)
}

见证奇迹的时刻

打开两个终端窗口。
在窗口 A 运行:

$ go run consumer.go
😎 消费者已启动!我的进程 PID 是: 8848
🎧 正在竖起耳朵等待 UNIX 信号...

在窗口 B 运行:

$ go run producer.go 8848 &quot;Hello, UNIX!&quot;
🚀 准备向 PID 8848 发射消息: [Hello, UNIX!]
✅ 消息发送完毕,深藏功与名。

此时,你会在窗口 A 看到字符一个一个地蹦出来:

H e l l o ,   U N I X !
✨ 收到完整消息: Hello, UNIX!

是不是有种黑客帝国里字符雨的快感?!


玩得再大点:三层架构的 Broker

既然搞了,干脆贯彻到底,弄个正儿八经的 Pub/Sub(发布/订阅)架构!

我们可以写一个中转站(Broker)进程。

graph TD P1{Producer1} --> B{Broker} P2{Producer2} --> B{Broker} B --> C1{Consumer1} B --> C2{Consumer2}

Broker 到底是个啥角色?
其实,Broker 本质上就是一个“缝合怪”:它对外兼具了 Consumer 和 Producer 的功能。

  1. 作为接收方:它监听系统发来的 SIGUSR1SIGUSR2,按照我们上面的逻辑,把位拼成完整的字符串消息。
  2. 缓冲与路由:拼好一条消息后,它不打印,而是塞进自己内部的一个内存队列(比如 Go 的 Channel)。
  3. 作为发送方:它后台跑一个死循环,一旦发现队列里有消息,就去查找已注册的下游 Consumer 进程的 PID,然后把消息重新拆成 0 和 1 的信号,像机关枪一样发送过去。

这样一来,Producer 甚至不需要知道 Consumer 的 PID,只需要把信号发给 Broker 就行了,彻底实现了系统的解耦!(听上去是不是非常像大厂里的微服务架构介绍?)


灵魂拷问:这玩意能上生产环境吗?

如果你觉得这套架构很帅,打算明天去公司把它整合到你们的核心支付业务系统里去……那我劝你最好先准备好离职报告。

永远,绝对,不要在生产环境中这么做!

为什么?因为用 UNIX 信号当消息队列,缺陷多到令人发指:

  1. 慢如老牛:每发送一个“位”的数据,都会触发一次由系统空间到用户空间的上下文切换。为了防止信号丢失,我们在每次发送后都加了 Sleep。发一个字符要 16 毫秒,发一段 100 字的消息要将近 2 秒。这在现代软件里简直是世纪末的灾难。
  2. 极不可靠:信号是没有持久化机制的,发丢了就是丢了,没法重试,更没有“消息确认(ACK)”。
  3. 不支持并发:如果两个 Producer 同时向一个 Broker 狂发信号,0 和 1 就会严重交错污染。Broker 解析出来的绝对是一串毫无逻辑的外星文乱码。

那我们今天费这么大劲折腾这玩意,图个啥?

图的是看透本质的爽感

在这个大家都在卷各种上层框架、中间件 API 的时代,我们很容易迷失在各种高大上的术语里。但当我们扒掉 Kafka、RabbitMQ 华丽的封装外衣,下沉到操作系统的深水区时,你会发现,所有的“消息”、“通信”、“流转”,归根结底,也不过就是底层内存和 CPU 的 0 与 1 的游戏。

搞明白位运算,搞明白进程间的最原始交互方式,能在你以后排查那些极其诡异的架构 Bug 时,提供意想不到的直觉与灵感。

退一万步讲,下次面试官再问你:“除了常用中间件,你还了解哪些 IPC 方式?”
你就可以微微一笑,靠在椅背上淡淡地说:“我曾经仅用两个 UNIX 信号就手写了一个消息队列,虽然毫无卵用,但是非常酷。”

Happy Hacking!

🔲 ☆

软件License授权原理

概述

在 ToB 软件交付或一些收费的桌面软件(如 IDEA、Navicat)中,我们经常会接触到 License(许可证)

通俗的讲,License 就是软件的“驾驶证”。当你购买了软件,厂商发给你一个 License 文件(或者激活码),软件读取这个文件,验证你是否有权使用,以及可以使用多久、可以使用哪些功能。

虽然 SaaS 模式(账号登录验证)越来越流行,但在很多私有化部署、离线环境或对数据隐私要求极高的场景下,License 依然是目前最主流的授权保护方式。

今天我们就来聊聊 License 是怎么设计出来的,以及它是如何一步步升级来对抗破解者的。

License 的核心能力与要求

设计一个合格的 License 系统,通常需要满足以下几个核心诉求:

  1. 完整性(防篡改):用户不能随意修改 License 里的内容(比如把过期时间从 2025 年改成 2099 年)。
  2. 唯一性(防复制/机器绑定):A 客户购买的 License,不能直接拷贝给 B 客户使用。
  3. 时效性(过期控制):能够精确控制授权的开始和结束时间。
  4. 功能控制(按需授权):可以限制用户只能使用标准版功能,或者解锁高级版功能。

为了实现这些目标,我们的 License 实现方案经历了几个版本的迭代。

迭代一:明文配置文件(裸奔版)

这是最原始的思路。我们定义一个 JSON 文件作为 License,里面记录授权信息。

License 文件内容:

{
  "company": "字节跳动",
  "expireTime": "2025-12-31",
  "modules": ["user", "order"]
}

验证逻辑:
程序读取文件,解析 JSON,判断 CurrentTime > expireTime,如果是则停止服务。

存在的问题:
这就好比把家里的钥匙放在大门口的地毯下。任何懂一点电脑的用户,用记事本打开这个文件,把 2025 改成 2999,破解就完成了。

结论: 毫无安全性,只能防君子不能防小人。

迭代二:对称加密(虽然加密了,但钥匙在锁上)

为了防止用户修改文件,我们决定对文件内容进行加密。使用 AES 或 DES 等对称加密算法。

生成逻辑(厂商端):

  1. 准备一个密钥 SecretKey = "123456"
  2. 使用该密钥将 JSON 内容加密成乱码字符串。

License 文件内容:
U2FsdGVkX1+... (一串看不懂的密文)

验证逻辑(客户端):

  1. 代码中硬编码写死密钥 SecretKey = "123456"
  2. 软件启动时,用密钥解密 License 文件。
  3. 解密成功则校验时间,解密失败则认为 License 非法。

存在的问题:
对称加密的核心缺陷在于:加密和解密用的是同一个密钥
为了让软件能运行,你必须把密钥写在代码里。破解者只需要反编译你的 Jar 包或 exe 文件,全局搜索一下 "AES" 或者 "Key" 相关的字符串,就能拿到密钥。拿到密钥后,他就可以自己生成任意有效期的 License 了。

结论: 安全性略有提升,但对于稍有经验的逆向工程师来说,形同虚设。

迭代三:非对称加密 + 数字签名(行业标准版)

为了解决“密钥必须下发给客户端”的问题,我们需要引入 非对称加密(RSA)数字签名 技术。这是目前大多数 License 系统的核心原理。

原理:
非对称加密有一对密钥:私钥(Private Key)公钥(Public Key)

  • 私钥:保存在厂商手里,绝不泄露,用于签名
  • 公钥:埋在客户端代码里,用于验签

生成逻辑(厂商端):

  1. 准备明文授权信息(JSON)。
  2. 对 JSON 内容进行 Hash 计算(如 MD5 或 SHA256),得到摘要。
  3. 使用 私钥 对摘要进行加密,生成 “数字签名”
  4. 明文信息 + 数字签名 打包发给用户,这就是 License 文件。

验证逻辑(客户端):

  1. 软件读取 License 文件,拆解出 明文信息数字签名
  2. 客户端使用代码里内置的 公钥 解密 数字签名,得到 摘要A(如果解密失败,说明签名被篡改)。
  3. 客户端对 明文信息 进行同样的 Hash 计算,得到 摘要B
  4. 对比 摘要A摘要B
    • 如果相等:说明明文没有被篡改,且确实是由拥有私钥的厂商签发的。
    • 如果不等:说明被篡改了。

是如何解决之前的问题的?
破解者手里只有公钥。公钥只能用来解密(验证),不能用来加密(生成签名)。所以破解者即使改了明文里的过期时间,因为他没有私钥,无法生成对应的新签名,软件校验时就会发现 摘要A != 摘要B,从而拒绝启动。

新的问题:
虽然防篡改解决了,但防复制没解决。A 公司买了一个 License,把它发给 B 公司,B 公司也能通过验证(因为 License 本身是合法的)。

迭代四:机器特征绑定(进阶版)

为了解决“一证多用”的问题,我们需要把 License 和运行软件的机器硬件绑定。

生成逻辑(厂商端):

  1. 要求用户在部署服务器上运行一个脚本,获取服务器的唯一指纹(Machine ID)。指纹通常由 CPU序列号 + 网卡MAC地址 + 主板序列号 组合而成。
  2. 用户将 Machine ID 发给厂商。
  3. 厂商将 Machine ID 写入到 License 的明文 JSON 中。
    {
     "expireTime": "2025-12-31",
     "machineId": "CPU-BF31-MAC-8821" // 绑定机器
    }
  4. 使用私钥生成签名。

验证逻辑(客户端):

  1. 使用公钥验签(确保文件没被改过)。
  2. 新增步骤:软件获取当前服务器的硬件信息,计算出本地的 Local Machine ID
  3. Local Machine ID 与 License 文件中的 machineId 对比。如果不一致,说明 License 是从别的机器拷贝过来的,拒绝启动。

最终版本的局限性:为什么还是防不住?

到了“迭代四”,我们已经拥有了一个包含 RSA 签名校验 + 机器绑定 + 有效期控制 的完善 License 系统。这已经能防住绝大部分普通用户和初级黑客。

但是,它依然不是绝对安全的。为什么?

因为代码最终是在用户的机器上运行的,客户端是没有秘密的

  1. 公钥替换攻击
    破解者虽然拿不到你的私钥,但他可以生成一对自己的“私钥B”和“公钥B”。他修改你的客户端程序(比如修改 Jar 包),把你内置的“公钥A”换成他的“公钥B”。然后他就可以用“私钥B”随意签发 License 了。

  2. 暴力修改判断逻辑(爆破)
    最终的代码里,总会有类似这样的一行判断:

    if (license.verify()) {
        run();
    } else {
        exit();
    }

    破解者不需要搞懂你的加密算法,他只需要通过反编译工具(如 Javassist)找到这行代码,把它改成:

    if (true) { // 强制为真
        run();
    }

    或者直接删除 else 分支。这就是所谓的“暴力破解”。

应对方案(且战且退):
为了对抗上述攻击,厂商通常会引入 代码混淆(Obfuscation)加壳 技术,增加反编译的难度,或者在程序中埋入多个隐蔽的校验点。但这本质上是一场“猫鼠游戏”,只能增加破解成本,无法从根本上杜绝破解。

总结

License 的本质不是为了实现“绝对安全”,而是为了提高破解门槛

一个成熟的 License 方案(RSA + 签名 + 机器绑定)足以挡住 99% 的普通用户和非专业人士,保障厂商的商业利益。对于那 1% 精通逆向工程的高手,单纯靠技术手段是防不住的,这时候就需要法律手段(律师函警告)来补位了。

🔲 ☆

认知觉醒之人类简史

概述

最近读完了赫拉利的《人类简史》。说实话,刚翻开这本书的时候,我是带着一种“看热闹”的心态。毕竟,书里讲的智人怎么打败尼安德特人、几千年前的农业革命、几百年前的工业革命,这些宏大的历史叙事,离我实在是太远了。

作为一个现代社畜,我更关心的其实是下个月的生活费够不够,以及年底的年终奖能发多少。

但是,随着阅读的深入,我发现我错了。这根本不是一本历史书,它是在揭穿我们当下生活里最大的骗局:现实是虚构的

它就像《黑客帝国》里的红色药丸,吞下它,原本坚固的世界观开始崩塌。

核心观点解析

这本书里有三个颠覆认知的观点,彻底重塑了我对世界的看法。

1. 讲故事:人类统治地球的终极算法

如果让你赤手空拳去和一只黑猩猩单挑,你必输无疑。但如果是1000个人对战1000只黑猩猩,人类完胜。

赫拉利指出,智人胜出的终极武器不是智力,而是讲故事的能力(虚构能力)

通俗的讲,你无法说服一只猴子拿手里的香蕉去换一张纸币。因为猴子只相信看得见、摸得着的东西(实体)。
但人类会。只要你告诉他:“这张纸代表了国家信用,可以换无数个香蕉。”他就信了。

这就是人类社会运行的底层协议:共同想象

赫拉利一针见血地指出:“公司、国家、人权、金钱、法律,这些全都是智人编造出来的故事。

这让我感到背脊发凉。以前我以为“公司”是一个实实在在的实体。但仔细一想:

  • 把办公室拆了
  • 把员工散了
  • 把产品销毁了

“公司”依然存在。它存在于法律文书里,存在于我们的共同想象里。

那些让我痛苦的东西——KPI、职级(P6/P7/P8)、社会地位,本质上和原始人相信的“图腾”没有区别。它们都是为了让人类进行大规模协作而发明出来的“紧箍咒”。

2. 农业革命:历史上最大的骗局

我们从小受到的教育是:从采集狩猎到农耕是巨大的进步。但赫拉利却说,那是陷阱。

做一个简单的对比(Diff):

  • 采集时代:每天工作4小时,食物多样,身体强壮,焦虑感低。
  • 农业时代:每天劳作10小时,食物单一(小麦/稻米),担心旱灾虫灾,长期弯腰导致脊椎病变。

到底是谁驯化了谁?
不是人类驯化了小麦,是小麦驯化了人类。 小麦利用人类,成功地将自己的基因繁衍到了全球各地,而人类却为此付出了惨痛的代价。

这让我联想到了现代生活。

我们有了智能手机、外卖、高铁,我们以为生活“升级”了。但结果呢?我们比祖先更忙、更焦虑,连发呆的时间都成了奢侈品。

我们买了大房子,为了还房贷要工作30年。
到底是我们拥有了房子,还是房子拥有了我们?

以前我以为进步就是拥有更多。现在我知道,如果那个“更多”是以牺牲自由和快乐为代价的,那就是一种退化。

3. 快乐的本质:生物算法的跑步机

怎么在这个充满故事的“母体”(Matrix)里清醒地活着?

赫拉利给出了一个残酷的真相:生物学上没有“快乐”这个东西,只有“快感”。

演化(Evolution)根本不在乎你快不快乐,它只在乎你能不能繁衍。

系统设定了这样一个机制:

  1. 当你得到想要的东西(升职、加薪、买房)时,它会给你一点多巴胺奖励。
  2. 这个奖励会迅速消失(GC回收),让你陷入新的空虚。
  3. 逼你去追求下一个目标。

这就是快乐的跑步机。你在上面狂奔,以为终点有幸福,其实你永远停在原地。

总结:做一个清醒的玩家

看穿了这一切,并不意味着我要辞职去深山老林里当原始人。

现在的我,依然在这个社会里工作、赚钱。但我正在尝试给自己的大脑换一套操作系统:

  1. 更新认知驱动:我知道钱很重要,因为它能调动资源。但我更知道,钱本质上只是一堆数据,它不值得我用健康和尊严去换。
  2. 跳出生物算法:当焦虑来袭时,我要意识到,那不是“我”的声音,那是基因为了生存对我发出的指令(Signal)。
  3. 既入戏又出戏:利用“故事”来协作,但不被“故事”所奴役。

真正的觉醒,不是因为你赢了游戏,而是因为你知道了这只是一场游戏

世界不会因为你看穿了它而崩塌,但你会因为看穿了它而轻盈。

与其在虚构的意义之网里作茧自缚,不如做一个清醒的玩家。


当然,道理都懂。从“想到”到“做到”之间,还有一道巨大的鸿沟(Gap)。但这至少是一个开始,看见围墙,才有翻越围墙的可能。

🔲 ☆

高德地图红绿灯倒计时之实现原理

概述

相信大家在开车导航时都注意到了,高德地图(以及其他导航软件)现在能在路口精准地显示红绿灯的倒计时,甚至还能告诉你“需要等待 2 轮红灯”。

很多人第一反应是:“高德是不是接入了交警的红绿灯后台数据?”

答案是:极少部分是接入的,绝大部分是“算”出来的。

如果全靠接入,考虑到全国各地红绿灯系统的封闭性、多样性和数据安全,这几乎是不可能完成的任务。实际上,高德是利用海量的用户轨迹数据,通过算法反向推演出红绿灯的周期。这其中的核心技术,参考了其公开的专利 CN114463969A(红绿灯周期时长的挖掘方法)。

今天我们就来硬核拆解一下,这个“读心术”是如何实现的。

核心原理

通俗的讲,红绿灯的本质是一个周期性函数。只要我们观察到足够多的样本,就能拟合出这个函数。

想象一下,你站在路口,虽然看不见红绿灯,但你发现:

  1. 每隔 60 秒,就有一大波车停下来(红灯)。
  2. 每隔 60 秒,又有一大波车同时起步(绿灯)。

高德做的就是这个“观察者”。它利用海量的用户手机 GPS 数据,分析车辆在路口的 “停车-起步” 行为,进而推算出红绿灯的 周期时长红绿相位

这个过程主要分为三个核心步骤:

  1. 样本筛选:找到经过该路口的车辆轨迹。
  2. 起停波识别:识别车辆何时停车,何时起步。
  3. 周期挖掘:通过起步时间的规律,算出红绿灯周期。

参考专利地址CN114463969A – Method and apparatus for mining traffic signal light cycle

实现细节

1. 数据清洗与事件提取

并不是所有经过路口的轨迹都有用。我们需要筛选出“受红绿灯影响”的轨迹。

  • 有效样本:在路口前速度降为 0,且停留超过一定时间,然后加速通过的车辆。
  • 无效样本:直接绿灯通过的车辆(没有停车特征,无法判断红灯起始点),或者右转车辆(通常不受红绿灯限制)。

对于每一辆有效车辆 i,我们可以提取出一个关键事件:起步时间 t_start
这个 t_start 近似等于绿灯亮起的时间(当然有延迟,后面会说怎么校准)。

2. 聚类形成“起步波”

单个车辆的数据可能存在误差(比如司机走神了,绿灯亮了 3 秒才走)。但如果有 10 辆车在 10:00:05 ~ 10:00:10 之间起步,我们就可以认为这是一个 “绿灯起步波”

我们需要将时间轴上离散的起步点,聚合为一个个簇(Cluster)。

3. 周期计算 (核心数学逻辑)

假设红绿灯周期是 T。那么理想情况下,所有“起步波”的时间差应该是 T 的整数倍。
例如:

  • 第一波起步:10:00:00
  • 第二波起步:10:02:00 (间隔 120s)
  • 第三波起步:10:03:00 (间隔 60s)

可以推断,周期 T 很有可能是 60s(因为 120 和 60 都是 60 的倍数)。

算法会尝试搜索一个最佳的 T,使得所有观测到的起步时间 t 满足以下公式:

t ≈ t_base + k * T + error

其中:

  • t_base 是基准时间
  • k 是整数轮次
  • error 是误差

4. 误差修正(排队论)

这是最难的一点。车辆起步时间 != 绿灯亮起时间
如果一辆车排在第 10 位,它起步的时间可能比绿灯亮起晚 20 秒。

高德的算法会考虑 排队位置。通过 GPS 坐标,可以算出车辆距离停车线的距离。

修正后的绿灯时间 = 实际起步时间 - (排队距离 / 饱和流率)

Golang 代码示例

下面用一段 Go 代码来模拟这个“周期挖掘”的核心逻辑。为了简化,我们假设已经提取到了车辆的起步时间列表。(这个代码只是表达思路使用, 与实际无关)

package main

import (
    "fmt"
    "math"
    "sort"
)

// TrafficLightMiner 模拟红绿灯挖掘器
type TrafficLightMiner struct {
    StartTimes []int64 // 收集到的车辆起步时间戳(秒)
}

// AddTrajectory 添加一条轨迹的起步时间
func (m *TrafficLightMiner) AddTrajectory(startTime int64) {
    m.StartTimes = append(m.StartTimes, startTime)
}

// CalculateCycle 挖掘红绿灯周期
// 原理:寻找一个周期 T,使得绝大多数的时间间隔都是 T 的整数倍
func (m *TrafficLightMiner) CalculateCycle() int {
    if len(m.StartTimes) < 2 {
        return 0
    }

    // 1. 先对时间进行排序
    sort.Slice(m.StartTimes, func(i, j int) bool {
        return m.StartTimes[i] < m.StartTimes[j]
    })

    // 2. 计算相邻起步波的时间差 (Diff)
    // 在实际场景中,这里需要先做聚类,把同一轮红绿灯的车归为一组,取中位数作为该轮的起步时间
    // 这里为了简化,假设输入已经是经过聚类处理后的每轮首车起步时间
    var diffs []int64
    for i := 1; i < len(m.StartTimes); i++ {
        diff := m.StartTimes[i] - m.StartTimes[i-1]
        diffs = append(diffs, diff)
    }

    // 3. 寻找众数或者最大公约数思想的拟合
    // 常见的红绿灯周期通常在 30s - 180s 之间
    // 我们尝试在这个范围内搜索得分最高的周期
    bestCycle := 0
    maxScore := 0.0

    for t := 30; t <= 180; t++ {
        score := m.evaluateCycle(t, diffs)
        if score > maxScore {
            maxScore = score
            bestCycle = t
        }
    }

    return bestCycle
}

// evaluateCycle 评分函数:计算某个周期 T 对数据的解释程度
func (m *TrafficLightMiner) evaluateCycle(T int, diffs []int64) float64 {
    hits := 0.0
    tolerance := 3.0 // 容忍误差 3秒

    for _, diff := range diffs {
        // 计算 diff 是否是 T 的倍数
        // 例如 diff = 122, T = 60, remainder = 2 (在误差允许范围内)
        remainder := math.Mod(float64(diff), float64(T))

        // 处理余数接近 T 的情况 (例如余数 59 相当于 -1)
        if remainder > float64(T)/2 {
            remainder = float64(T) - remainder
        }

        if remainder <= tolerance {
            hits++
        }
    }

    // 返回命中率
    return hits / float64(len(diffs))
}

func main() {
    miner := TrafficLightMiner{}

    // 模拟数据:假设红绿灯周期是 60秒
    // 车辆分别在以下时间点起步 (包含一些随机误差和跳过的轮次)
    // 第1轮: 100s
    // 第2轮: 161s (误差+1s)
    // 第3轮: 没有车通过 (跳过)
    // 第4轮: 280s (100 + 60*3 = 280)
    // 第5轮: 342s (100 + 60*4 + 误差2s)

    simulatedData := []int64{100, 161, 280, 342, 400, 521}

    for _, t := range simulatedData {
        miner.AddTrajectory(t)
    }

    cycle := miner.CalculateCycle()

    fmt.Println("分析样本起步时间:", simulatedData)
    fmt.Printf("计算出的红绿灯周期为: %d 秒\n", cycle)

    // 实际应用中,算出周期后,还需要算出“红灯开始时间”和“偏移量”才能做倒计时
}

优缺点分析

优点:

  1. 覆盖广:不需要依赖政府设备,只要有路口有车在跑,就能算出来。
  2. 成本低:纯软件实现,不需要铺设硬件传感器。
  3. 实时性:如果路口临时改为交警手控(周期错乱),算法检测到数据方差变大,会自动降级不显示,避免误报。

缺点:

  1. 依赖车流:如果是半夜或者偏僻路口,没有车经过,就没有数据样本,也就无法计算。
  2. 受排队影响:如果路口常年严重拥堵,车辆一次绿灯过不去(溢出),起步时间就不代表绿灯开始时间,会导致计算偏差。
  3. 右转干扰:虽然有筛选,但部分路口允许红灯右转,如果清洗不干净,会干扰算法判断。

简单总结,你在高德地图上看到的每一个倒计时数字,背后都是无数辆车的轨迹汇聚而成的“群体智慧”。这就是大数据的魅力。

🔲 ☆

Golang并发之死锁检测

概述

来看一段简单的测试代码时

package main

import "fmt"

func main() {
    // 创建一个带缓冲的 channel,但没有放入任何数据
    c := make(chan bool, 1)

    // 直接尝试读取,导致死锁
    fmt.Println(<-c)
}

运行程序,控制台立马给了一记响亮的耳光:
fatal error: all goroutines are asleep - deadlock!

看到这个报错,第一反应是:“哇,Go 语言真贴心,竟然能自动检测死锁!”。这让我产生了一种错觉:只要我的代码发生了死锁,Runtime(运行时)一定会第一时间告诉我。

但事实真的如此吗?Golang 自带的死锁检测真的是“银弹”吗?

答案是否定的。Go 的死锁检测机制其实非常“原始”且“有限”。今天我们就来扒一扒它的底裤,看看它到底检测了什么,又漏掉了什么。

并不是银弹:Runtime 无法检测的死锁

Go Runtime 的检测逻辑其实非常简单粗暴,它只关注全局死锁。也就是说,只有当整个程序所有的 Goroutine 都睡着了,它才会报警。

只要你的程序里还有任何一个 Goroutine 在干活(比如正在空转、正在等待网络请求、或者只是单纯的死循环),Runtime 就会认为程序还在正常运行,哪怕你的核心业务逻辑已经死锁卡死了。

下面给几个经典的“漏网之鱼” Demo。

场景一:有一个“旁观者”在运行

这是最常见的场景。假设你的业务逻辑 G1 和 G2 互相卡死(AB-BA 死锁),但只要有一个 G3(比如心跳上报、日志监控)还在跑,Runtime 就不会报错。

package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    var mu1, mu2 sync.Mutex

    // G1: 占有锁1,想要锁2
    go func() {
        mu1.Lock()
        defer mu1.Unlock()
        time.Sleep(100 * time.Millisecond) // 让子弹飞一会儿
        mu2.Lock() // <--- 卡死在这里
        mu2.Unlock()
    }()

    // G2: 占有锁2,想要锁1
    go func() {
        mu2.Lock()
        defer mu2.Unlock()
        time.Sleep(100 * time.Millisecond)
        mu1.Lock() // <--- 卡死在这里
        mu1.Unlock()
    }()

    // G3: 一个无辜的旁观者
    // 只要这个协程还在运行,Runtime 就认为系统是健康的
    go func() {
        for {
            time.Sleep(time.Second)
            fmt.Println("系统监控: 我还在跑,Runtime 别慌...")
        }
    }()

    select {} // 主协程阻塞
}

结果:控制台会无限打印“系统监控…”,而 G1 和 G2 早就已经死锁了,由于没有触发 fatal error,这种故障在生产环境中极难被第一时间发现。

场景二:HTTP Server 掩盖死锁

在 Web 开发中,http.ListenAndServe 会启动一个主循环来监听端口。这个监听动作本身就代表“程序正在运行”。因此,后台发生的任何死锁,Runtime 都不会报错。

package main

import (
    "fmt"
    "net/http"
)

func main() {
    ch := make(chan int) // 无缓冲 channel

    // 生产者:写入数据,因为没有消费者,这里会永久阻塞
    go func() {
        fmt.Println("准备写入数据...")
        ch <- 1 
        fmt.Println("写入成功") // 永远不会执行到这里
    }()

    // 启动一个 HTTP 服务
    // 这会让 Runtime 认为程序一切正常
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Hello"))
    })

    fmt.Println("Server started at :8080")
    http.ListenAndServe(":8080", nil)
}

结果:网页能正常访问,但后台那个协程已经永久挂起了。这就是典型的局部死锁

深入原理:Runtime 到底检测了什么?

为什么 Go 检测不到上面这些死锁?我们需要看看源码。

Go 的死锁检测主要逻辑在 runtime/proc.gocheckdead() 函数中。它不是去构建复杂的“资源分配图”(Resource Allocation Graph)来检测环路(因为那样太耗费性能了),而是采用了一种计数器的方式。

简单来说,Runtime 维护了几个关键指标:

  1. mcount(): 系统线程数。
  2. runningG: 正在运行的 Goroutine 数量。
  3. timers: 系统中活跃的定时器数量。
  4. netpoll: 网络轮询器是否有事件等待。

检测逻辑伪代码如下:

func checkdead() {
    // 1. 如果还有线程在运行(比如正在执行系统调用),不算死锁
    if mcount() > 0 { return }

    // 2. 如果还有 Goroutine 在 running 状态,不算死锁
    if runningG > 0 { return }

    // 3. 如果还有定时器没触发,说明可能在 sleep 等待唤醒,不算死锁
    if timers > 0 { return }

    // 4. 如果网络轮询器里还有东西,也不算死锁
    if netpollWaiters > 0 { return }

    // 5. 如果以上都没有,说明所有人都在睡觉,也没人定了闹钟,也没人等电话
    // 那就是真的死透了
    throw("all goroutines are asleep - deadlock!")
}

实际源码内容(基于 1.18 版本):

// Check for deadlock situation.
// The check is based on number of running M's, if 0 -> deadlock.
// sched.lock must be held.
// 检查死锁情况。
// 检查主要基于正在运行的 M (系统线程) 的数量,如果是 0,则可能死锁。
// 调用此函数前必须持有全局调度锁 sched.lock。
func checkdead() {
    // 1. 防御性检查:确保调度器的锁已经被锁住,保证并发安全
    assertLockHeld(&sched.lock)

    // For -buildmode=c-shared or -buildmode=c-archive it's OK if
    // there are no running goroutines. The calling program is
    // assumed to be running.
    // 2. 特殊模式豁免
    // 如果 Go 是以库的形式被 C/C++ 程序调用 (c-shared/c-archive),
    // 即使 Go 这边没有协程在跑,宿主程序可能还在运行,所以不能报死锁。
    if islibrary || isarchive {
        return
    }

    // If we are dying because of a signal caught on an already idle thread,
    // freezetheworld will cause all running threads to block.
    // And runtime will essentially enter into deadlock state,
    // except that there is a thread that will call exit soon.
    // 3. 恐慌状态豁免
    // 如果程序已经在 Panic 处理流程中了(panicking > 0),
    // 此时系统会冻结所有线程,看起来像死锁,但其实是在打印崩溃堆栈。
    // 为了避免死锁报错掩盖了真正的 Panic 原因,直接返回。
    if panicking > 0 {
        return
    }

    // If we are not running under cgo, but we have an extra M then account
    // for it. (It is possible to have an extra M on Windows without cgo to
    // accommodate callbacks created by syscall.NewCallback. See issue #6751
    // for details.)
    // 4. 修正运行中的 M 数量 (run0)
    // 在没有 CGO 的情况下,Windows 可能会有额外的 M 用于处理系统回调。
    // 如果有这种情况,我们将基准运行线程数 run0 设为 1,表示允许这种情况存在。
    var run0 int32
    if !iscgo && cgoHasExtraM {
        mp := lockextra(true)
        haveExtraM := extraMCount > 0
        unlockextra(mp)
        if haveExtraM {
            run0 = 1
        }
    }

    // 5. 核心判断:计算正在干活的 M (线程)
    // mcount(): 总线程数
    // nmidle: 处于空闲状态的 M
    // nmidlelocked: 处于锁定状态(Locked to G)且空闲的 M
    // nmsys: 处于系统调用或系统任务中的 M
    // run 表示:除去空闲和系统的,真正正在执行 Go 代码的线程数。
    run := mcount() - sched.nmidle - sched.nmidlelocked - sched.nmsys

    // 如果正在运行的线程数 > run0 (通常是0),说明还有线程在干活,没死锁,返回。
    if run > run0 {
        return
    }

    // 数据一致性校验:如果算出来运行线程是负数,说明 Runtime 内部状态乱了,直接抛异常。
    if run < 0 {
        print("runtime: checkdead: nmidle=", sched.nmidle, " nmidlelocked=", sched.nmidlelocked, " mcount=", mcount(), " nmsys=", sched.nmsys, "\n")
        throw("checkdead: inconsistent counts")
    }

    // 6. 遍历所有 G (Goroutine) 检查状态
    // 既然没有 M 在跑了,我们看看 G 都在干嘛。
    grunning := 0
    forEachG(func(gp *g) {
        // 忽略系统级 Goroutine (如 GC 的后台标记 worker,sysmon 等)
        if isSystemGoroutine(gp, false) {
            return
        }

        s := readgstatus(gp)
        // 检查 G 的状态
        switch s &^ _Gscan {
        case _Gwaiting,
            _Gpreempted:
            // 如果 G 处于等待中 (Waiting) 或 被抢占 (Preempted),
            // 说明这是一个有效的、未完成的用户任务,计数器 +1。
            grunning++
        case _Grunnable,
            _Grunning,
            _Gsyscall:
            // 如果发现有 G 还是 Runnable/Running/Syscall 状态,
            // 但前面的步骤却判断没有 M 在运行,这属于 Runtime 的逻辑矛盾。
            // 理论上 checkdead 不应该在这种状态下被调用或者走到这一步。
            print("runtime: checkdead: find g ", gp.goid, " in status ", s, "\n")
            throw("checkdead: runnable g")
        }
    })

    // 7. 检查是否主协程退出了
    // 如果遍历完发现没有用户 G 了 (grunning == 0),
    // 比如 main 函数调用了 runtime.Goexit() 导致主协程结束但其他协程也没了,
    // 抛出特定错误。
    if grunning == 0 { // possible if main goroutine calls runtime·Goexit()
        unlock(&sched.lock) // 解锁,避免打印日志时挂死
        throw("no goroutines (main called runtime.Goexit) - deadlock!")
    }

    // Maybe jump time forward for playground.
    // 8. Go Playground 特殊处理
    // Go Playground 为了快进时间(比如 sleep 1小时不用真等1小时),
    // 在这里会修改 faketime。如果能调整时间唤醒某个 timer,就唤醒它并返回,不算死锁。
    if faketime != 0 {
        when, _p_ := timeSleepUntil()
        if _p_ != nil {
            faketime = when
            for pp := &sched.pidle; *pp != 0; pp = &(*pp).ptr().link {
                if (*pp).ptr() == _p_ {
                    *pp = _p_.link
                    break
                }
            }
            mp := mget()
            if mp == nil {
                // There should always be a free M since
                // nothing is running.
                throw("checkdead: no m for timer")
            }
            mp.nextp.set(_p_)
            notewakeup(&mp.park)
            return
        }
    }

    // There are no goroutines running, so we can look at the P's.
    // 9. 最后一道防线:检查定时器 (Timer)
    // 走到这里说明:没有 M 在跑,所有 G 都在睡觉。
    // 唯一的希望就是定时器了。如果某个 P (处理器) 上挂着定时器,
    // 说明程序只是在 Sleep,时间到了自然会醒,所以不算死锁。
    for _, _p_ := range allp {
        // 如果 P 的定时器堆里有任务
        if len(_p_.timers) > 0 {
            return
        }
    }

    // 10. 宣判死刑
    // 没有 M 运行,所有 G 都在睡觉,没有定时器会响,也不是库模式。
    // 程序彻底死掉了。
    getg().m.throwing = -1 // 标记不打印完整的堆栈信息 (避免刷屏)
    unlock(&sched.lock)    // 解锁
    throw("all goroutines are asleep - deadlock!")
}

结论:
Go 的检测机制本质上是一个“兜底策略”。它检测的是“程序彻底停止运转”这一极端状态,而不是检测“逻辑上的死锁”。只要还有一个 G 在呼吸,它就认为你没死。

如何避免与检测死锁?

既然自带的检测不靠谱,我们在开发中就需要依靠规范和工具。

1. 避免死锁的建议

  • 规范锁的顺序:这是最根本的解决之道。如果多个协程需要获取多把锁,必须保证所有协程获取锁的顺序是一致的(例如永远先拿 A 锁,再拿 B 锁)。
  • 减小锁粒度:只在临界区加锁,不要把耗时的 IO 操作(如文件读写、网络请求)放在锁里面。
  • 使用 Select + Timeout:不要傻傻地一直等 channel 或锁。

    select {
    case req <- data:
        // 成功
    case <-time.After(time.Second):
        // 超时降级,避免卡死
        fmt.Println("timeout")
    }
  • 严禁 RWMutex 递归:读锁内部不要再去申请读锁,这在有写锁等待时会导致死锁。

2. 检测死锁的方法

对于 Runtime 无法检测的隐形死锁,我们需要主动出击:

  • pprof (神器)
    这是排查 Go 并发问题最有效的工具。在程序中开启 pprof:

    import _ "net/http/pprof"
    go func() { http.ListenAndServe(":6060", nil) }()

    当怀疑死锁时,访问 http://localhost:6060/debug/pprof/goroutine?debug=1。查看 Goroutine 的堆栈信息,如果你发现大量的 Goroutine 停留在 semacquire(等待锁)或者 chan send 状态,那就是死锁了。

  • go-deadlock 库
    这是一个第三方的 debug 库 (github.com/sasha-s/go-deadlock)。你可以用它替换原生的 sync.Mutex。如果在开发环境中锁等待超过指定时间(默认 30s),它会自动 dump 出堆栈信息,明确告诉你死锁发生在哪里。


总结:Go 语言自带的死锁检测只是最后一道防线,防止程序在彻底无响应时变成“僵尸进程”。在复杂的业务开发中,我们绝不能依赖它来发现 Bug,良好的编码规范和熟练使用 pprof 才是王道。

🔲 ☆

为什么我写的越来越少了

概述

细心的朋友可能发现了,最近博客的更新频率明显降低了。

前几天看到一篇文章《Blogging in 2025: Screaming into the void》,读完之后深有感触。文中提到了“对着虚空呐喊”的感觉,这其实也正是我当下的心境。

回想以前,写博客是一件非常有成就感的事情,但自从 AI 技术爆发式增长以来,我发现自己提笔(或者说敲键盘)的动力在逐渐消失。今天就来聊聊,为什么在 AI 时代,我写的越来越少了。

核心原因分析

仔细思考了一下,导致这种变化的并不是懒惰,而是整个技术环境和获取信息的逻辑发生了根本性的改变。主要可以归纳为以下几点:

1. AI 的全能与降维打击

通俗的讲,AI 现在太强了。

以前遇到一个棘手的 Bug 或者一个晦涩的配置,我们需要查阅官方文档、逛 Stack Overflow,然后整理成一篇博文,既是记录也是分享。但现在,AI 能做的实在太多了。无论是写代码、查文档,还是解释复杂的概念,它都能在几秒钟内给出高质量的答案。

当一个工具能瞬间完成你过去需要几个小时才能总结出来的成果时,继续手工“造轮子”的挫败感是很强的。

2. 投入产出比(ROI)的失衡

写一篇高质量的技术博客,成本其实很高。你需要:

  1. 构思大纲
  2. 编写代码 demo
  3. 组织通俗易懂的语言
  4. 排版 markdown

在 AI 出现之前,这些投入是值得的,因为它们创造了“稀缺性”的内容。但现在,投入大量精力来做这件事的意义已经不大了。如果我花费一下午写的内容,读者直接问 ChatGPT 就能得到更好、更定制化的答案,那么我的投入又有什么价值呢?

3. 知识内化方式的改变

以前写博客,很大程度上是为了“整理思路,巩固自己的知识”。我们常说“教是最好的学”,通过输出倒逼输入。

但在有了 AI 之后,很多东西都不再需要通过“写成文章”这种重度方式来巩固了。

  • 以前:必须把知识点记下来,因为怕忘了找不到。
  • 现在:学习的成本变低了,检索的成本更是几乎为零。

当然,这并不是说不需要学习了,而是说学习的路径变了。很多知识变成了“即用即查”的临时态,不再需要通过博客这种形式固化下来。

4. 读者的流失与“虚无感”

这可能是一个更扎心的事实:现在阅读博客的人真的少了。

大家都习惯了快节奏的信息流,或者习惯了直接向 AI 索取答案。别说是陌生读者,就连我自己,现在也不怎么阅读自己以前写的博客内容了。以前遇到问题会去翻自己的博客找解决方案,现在我也是直接问 AI。

既然连作者本人都不怎么看了,那坚持更新又是在写给谁看呢?这大概就是那篇文章里说的“Screaming into the void”(对着虚空呐喊)吧。


虽然文章写的少了,但对技术的探索并不会停止。也许未来的某一天,我会找到一种新的记录方式,或者,只是单纯的享受技术本身,而不再执着于“记录”这个形式。

🔲 ☆

众包体验

最近这段时间,我给自己找了份新“兼职”——美团众包。

先说清楚,倒真不是因为缺钱缺到揭不开锅。我也不是为了什么“体验生活”这种听起来高高在上的理由。我只是想短暂逃离那个恒温 26 度的办公室,去粗粝的现实里摩擦一下,看看脱下了“白领”这层皮,我到底是谁。

但这几天的经历,像一记闷棍,狠狠敲在了我的天灵盖上。

1. 门禁卡与拦路虎:当尊严被“穿”在身上

社会上现在特别流行一种政治正确的口号:“职业不分高低贵贱”。

听听就好,谁信谁天真。这句话就像是橱窗里的精美展示品,摆在那里是为了好看,而不是为了让你用的。现实生活的逻辑,从来都是赤裸裸的“看人下菜碟”。

我想讲一个发生在我身上的真实故事,讽刺程度拉满。

【事件回放】

那是一个普通的周二晚上,我接了一个单子,终点恰好是我自己居住的高档小区。

  • 平时:我开着车,或者穿着便装刷卡进门。保安大哥通常会离得老远就敬礼,甚至笑容满面地帮我拉开单元门,那一声“您好”喊得中气十足。
  • 那天:我骑着贴满反光条的电动车,戴着那顶醒目的黄色头盔,手里提着外卖袋,正准备从那个我走了无数遍的大门进去。

结果,我被拦住了。

保安大哥——那个平时对我笑脸相迎的人——此刻横跨一步,用一种我从未见过的、充满审视和不耐烦的眼神看着我:“干嘛的?外卖不让进。”

我试图沟通:“师傅,帮帮忙,这单马上超时了。我就送到 3 号楼,我跑着去跑着回,两分钟。”

他的回答冷硬得像一块石头:“超时是你自己的事。规定就是规定,外卖员不能进,打电话让业主出来拿。”

我当时急了,脱口而出:“我就住这儿,我对路很熟……”

他直接打断了我,语气里满是鄙夷:“你一看就是送外卖的,别废话,赶紧出去。”

【荒诞的对比】

就在我们僵持的那几十秒里,一位业主骑着电动车长驱直入。他没戴头盔,车也没上牌,但保安不仅没拦,还殷勤地按开了道闸。

这一刻,我感到一种巨大的割裂感:

  1. 白天的我:西装革履,是这里的“尊贵业主”,是拥有话语权的人。
  2. 晚上的我:换上黄马甲,就成了这里的“闯入者”,是需要被防范、被管理的底层劳力。

明明是同一个人,同一个身体,同一个灵魂。仅仅因为我换了一身衣服,换了一种谋生工具,我就从“人”变成了“麻烦”。

这让我深刻意识到,所谓的“社会地位”,其实大部分时候是附着在你的衣着、座驾和职业标签上的。

那个保安针对的不是我这个具体的人,而是“外卖员”这个符号。在他的认知逻辑里,阻拦外卖员是展示他权力的时刻,也是他维护小区“档次”的方式。这是一种弱者对弱者的为难,因为他无法对开豪车的业主发火,只能把生活的怨气撒在比他看起来更卑微的人身上。

2. 也是有温度的:底层江湖的“抱团取暖”

虽然在小区的门禁处碰了一鼻子灰,但在路上,在那些被城市折叠的角落里,我却意外地感受到了久违的温情。

作为一个新手(小白),我对很多商家的位置简直是一头雾水。有些店铺藏在老旧居民楼的深处,有些在地下迷宫般的美食城角落,导航在那里经常失灵,我就像个无头苍蝇一样乱转。

那种焦虑是生理性的:眼看着配送倒计时从绿色变成刺眼的红色,每一秒跳动都像是在催命。

在这些崩溃的时刻,拯救我的往往是路边的陌生同行。

  • 指路:有次我在一个巷子里迷路,问了一个正急着取餐的小哥。他明明急得满头大汗,还是停下来,指着一个不起眼的铁门说:“别走正门,那是坑!走这个侧门上二楼,左拐到底就是!”
  • 传授经验:还有次我不知道怎么把两杯奶茶固定在箱子里,旁边一个大叔直接走过来,教我怎么用隔板卡住:“这样放,过减速带都不会洒。”

这让我非常触动。你要知道,在外卖这个行业里:

  • 时间就是金钱:每一秒的耽搁,都可能意味着这一单白跑了,甚至还要倒贴罚款。
  • 竞争关系:理论上,我们是在抢同一个池子里的单子。

但他们依然愿意停下来,哪怕只为了帮我节省几十秒。

为什么?我觉得原因有三点:

  1. 感同身受的苦:大家都在这泥潭里滚过,都知道那个倒计时的红色数字有多搞人心态。也没谁天生就是活地图,都是从小白熬过来的。
  2. 底层的体面:正因为被外界冷眼相看,这个群体内部反而生出一种江湖义气。既然上面的人不给面子,那我们自己得互相帮衬。
  3. 纯粹的善意:这种善意没有功利心,不是为了社交,不是为了拓展人脉,纯粹就是看你着急,搭把手。

这种粗粝的、带着汗水味的善意,比写字楼里那些客套的“改天请你吃饭”,要真实一万倍。

3. 只有吃饱了,才配谈诗和远方

晚上骑着车,穿行在空旷的马路上,耳边只有风声呼啸。

那时候,我的脑子里在想什么?

并没有想什么宏大的叙事,也没有想公司的战略规划。我的脑子里只有非常具体、非常琐碎的算计:

  • 这单能挣 4 块 5 还是 5 块 2?
  • 电瓶车的电量能不能撑到下一个换电柜?
  • 前面路口有没有交警查头盔?
  • 这一单送完,我就能买一瓶冰可乐喝了。

我突然理解了那个著名的“马斯洛需求层次理论”。

我们在朋友圈里发着精修的旅行照,在咖啡馆里谈论着“人生的意义”、“自我实现”、“内心的宁静”,那是因为我们已经站在了金字塔的腰部以上。我们已经默认解决了温饱问题,所以才有闲情逸致去痛苦、去迷茫、去矫情。

但对于此刻骑在车上的我,或者是对于千千万万以此为生的骑手来说,“生存”这个词,是具象化的:

  • 它是多跑一单就能给孩子买个新书包;
  • 它是少得一个差评就能保住这一周的奖金;
  • 它是希望能平平安安跑完这一天,别出车祸。

当生活变得捉襟见肘,当你的眼前只有一单又一单的任务时,你的视野会被强制收窄。你看不见远处的风景,你只能看见脚下的路和手机屏幕上的路线图。

贫穷最可怕的地方,不在于没钱,而在于它会剥夺你“思考”的权利。 它让你陷入一种为了生存而无限循环的忙碌中,耗尽你的心力,让你没有余力去规划未来,去提升自己,去追求那些“无用”的美好。

4. 两个我,一种人生

这次“职业分身”的体验,虽然不长,但后劲很大。

我开始审视我的生活,我发现我其实活在一种微妙的双重人格里:

  • 白天的我:坐在明亮的写字楼里,讨论着千万级的项目,喝着几十块一杯的咖啡,觉得保安的问好是理所当然,觉得快递慢了一点就不可理喻。
  • 晚上的我:在寒风里瑟瑟发抖,为了几块钱狂奔,被小区保安呵斥,被顾客嫌弃,却因为陌生人的一句指路而感动半天。

到底哪一个才是真实的世界?

我想,两个都是。只是我们平时太习惯待在自己的舒适区里,以至于忘了墙外面还有另一个世界。

最后,我想给自己,也给看这篇文章的朋友们几点真诚的建议:

  1. 别太高估自己的“尊贵”:脱了那身西装,离了那个平台,我们可能什么都不是。保安敬礼敬的是你的房子和车子,不是你这个人。
  2. 保持对他人的基本尊重:下次外卖晚了点,或者小哥找不到路,别急着发火。他们可能刚刚在寒风里等了半小时商家出餐,或者刚刚被某个小区的门禁刁难过。多一句“谢谢”,多一句“注意安全”,真的能温暖一个人很久。
  3. 珍惜当下的选择权:如果你现在有时间迷茫“人生的意义”,说明你还算幸运。因为这个世界上有很多人,光是为了“活着”,就已经花光了所有力气。

白天和晚上的我都是我,但又好像不是一个我。

也许,这就是生活的真相。我们都在扮演不同的角色,但无论拿的是什么剧本,努力生活的人,都值得一个五星好评。

🔲 ☆

离线临时密码的生成

前言

最近看到朋友家装了新的只能门锁, 可以使用指纹或密码解锁. 密码解锁没什么, 但其在手机端可以生成一个临时密码倒是引起了我的兴趣. 这个场景是这样的:

  1. 密码锁不能联网
  2. 生成密码时, APP与密码锁没有通讯(不在一起, 且密码锁不能联网)
  3. APP 和密码锁仅在首次配对的时候进行过通讯
  4. 临时密码有效期为30分钟, 从密码的生成时间开始计算
  5. 手机端可以多次生成临时密码, 每个都不一样且有效

那么问题来了, 密码锁是如何验证密码是否有效的呢?

实现方案思考

方案一: 预生成

思路:

  1. APP与密码锁在首次配对的时候, 预先生成1万个临时密码
  2. 每次获取临时密码时, 从预生成列表中获取下一个即可

问题:

  1. 密码无法与时间绑定, 无法实现"临时"的需求
  2. 密码用尽后需要重新配对

方案二: TOTP

思路:

  1. 基于动态密码 TOTP 方案实现
  2. 在配对的时候同步共享密码与计数器
  3. 计数器每30分钟+1

问题:

  1. TOTP为固定时间窗口, 无法实现动态窗口
  2. 计数器每30分钟+1, 无法多次生成临时密码

方案三: 增强型 TOTP

到这里其实已经有方案了, TOTP是固定窗口, 那多个固定窗口连在一起, 不就是个滑动窗口么.

思路:

  1. 仍然基于 TOTP 的方案, 在配对时同步共享密钥和计数器
  2. 计数器每5秒+1
  3. 密码验证时, 验证当前时间向前推30分钟的所有 TOTP密码, 若与其中一个匹配即为可用

若计数器每5秒+1, 则30分钟可产生的密码数量为: (30min / 5s)=360

这个方案是我想到能够满足此场景的. 优势:

  1. 双方无需存储密码队列, 无存储开销
  2. 能够把握30分钟的滑动窗口, 误差为5秒
  3. 每5秒可生成一个新的动态密码, 支持重复生成

潜在问题:

  1. 时间同步: 密码锁长时间无对外通讯, 时间同步可能产生误差. 可在每次通讯时调整, 或适当放宽验证的时间范围以平衡时钟误差
  2. 计算资源: 每次密码验证需要进行360次 TOTP 计算. 可考虑在密码锁中维护一个密码队列, 每5秒生成密码添加到队列中, 并将旧密码出队列. 这样验证密码的时候就不需要频繁计算了

以上, 就是基于这个场景进行的设想, 其具体是如何实现的俺就不晓得了.

☑️ ☆

nginx 记录完整的 request 及 response

前言

在开发的过程中, 经常会有抓包的需求, 查看请求体和响应体. 使用 charles 等抓包工具会遇到一些麻烦, 如:

  1. localhost 请求无法捕获
  2. 有些工具配置代理比较麻烦, 如docker配置代理后需要重启
  3. https协议需要代理端配置证书进行解密, 比较麻烦

    于是, 我就在想, 能否直接在服务端将所有的请求体和响应体打印出来, 不就完美解决这个问题了么?

一般来说, 通过nginx代理请求, 所有的请求都过nginx, nginx自身也有https的私钥, 会进行解密.

那么问题来了, 如何通过nginx打印请求呢?

中间探索的过程就不提了, 直接上结果.

lua打印

这里直接使用openresty/openresty镜像了, 当然如果直接使用nginx编译lua插件也是可以的.

在配置文件中添加如下lua脚本以打印完整请求内容:

# 将请求信息暂存, 放到最后一起打印
access_by_lua_block {
  ngx.req.read_body()
  local req_body = ngx.req.get_body_data()
  local req_uri = ngx.var.request_uri
  local req_method = ngx.var.request_method
  if req_body and #req_body > 1024 * 1024 then
    req_body = "Request too large"
  end
  if not req_body then
    req_body = "No request body"
  end
  local headers = ngx.req.get_headers()
  local header_str = ""
  for k, v in pairs(headers) do
    header_str = header_str .. string.format("%s: %s\n", k, v)
  end
  ngx.ctx.req_info = {
    uri = req_uri,
    body = req_body,
    method = req_method,
    headers = header_str
  }
}

body_filter_by_lua_block {
  local req_info = ngx.ctx.req_info or {}
    local req_uri = req_info.uri or "Unknown URI"
    local req_body = req_info.body or "Unknown Request Body"
    local req_method = req_info.method or "Unknown Method"
    local req_headers = req_info.headers or "Unknown Headers"
    local resp_headers = ngx.resp.get_headers()
    local resp_header_str = ""
    for k, v in pairs(resp_headers) do
        resp_header_str = resp_header_str .. string.format("%s: %s\n", k, v)
    end

    if ngx.ctx.buffered_resp_body == nil then
        ngx.ctx.buffered_resp_body = ""
        ngx.ctx.buffered_resp_size = 0
    end

    local current_chunk = ngx.arg[1] or ""
    ngx.ctx.buffered_resp_body = ngx.ctx.buffered_resp_body .. current_chunk
    ngx.ctx.buffered_resp_size = ngx.ctx.buffered_resp_size + #current_chunk

    if ngx.ctx.buffered_resp_size > 1024 * 1024 then
        ngx.ctx.buffered_resp_body = "Response body too large to log"
    end
    if ngx.arg[2] then
        ngx.log(ngx.ERR, string.format(
            "Request Method: %s\nRequest URI: %s\nRequest Headers:\n%sRequest Body: %s\nResponse Headers:\n%sResponse Body: %s\n",
            req_method,
            req_uri,
            req_headers,
            req_body,
            resp_header_str,
            ngx.ctx.buffered_resp_body
        ))
    end
}

这段逻辑在location中.

完整内容可参考: https://github.com/hujingnb/docker_composer/tree/master/openresty

简单记录一下, 以方便后续获取请求使用

🔲 ☆

为什么不建议使用goto

前提

最近在公司代码review过程中, 看到同事的代码中大量使用了goto, 我给出了"不用 goto"的建议. 但其给出的理由是使用goto更简单. 确实, 使用goto可以使得逻辑更简单直接, 但前提是不乱用goto, 而在公司的项目中又很难保证这一点.

问题

使用goto带来的最直观的问题就是逻辑的复杂度直线升高. 举个例子来展现goto是如何一步步导致逻辑破败不堪的. (当然, 这个例子是我臆想出来的场景)

首先, 我们有一个创建订单并验证支付的需求:

package main

import "fmt"

func main() {
    fmt.Println("处理订单开始")

    fmt.Println("Step 1: 创建订单")

    fmt.Println("Step 2: 验证订单")

    fmt.Println("Step 3: 验证付款信息")

    fmt.Println("Step 4: 订单完成")

    fmt.Println("处理订单结束")
}

此时逻辑很清楚吧. 现在, 我们要对验证订单的结果进行处理, 如果验证失败, 则进行错误处理, 很合理吧:

package main

import "fmt"

func main() {
    fmt.Println("处理订单开始")

    fmt.Println("Step 1: 创建订单")

    var validErr error
    fmt.Println("Step 2: 验证订单")
    if validErr != nil {
        goto Fail
    }

    fmt.Println("Step 3: 验证付款信息")

    fmt.Println("Step 4: 订单完成")
    goto End
Fail:
    fmt.Println("验证付款失败")
End:
    fmt.Println("处理订单结束")
}

现在, 新的需求来了:

  1. 付款信息处理可能因各种原因失败, 需要重试, 最多重试3次
  2. 验证订单也可能失败(异步接口验证, 网络抖动等), 需要重试, 最多重试3次
  3. 若订单验证失败, 需要提示并重新创建订单
package main

import "fmt"

func main() {
    fmt.Println("处理订单开始")

CreatOrder:
    fmt.Println("Step 1: 创建订单")

    validRetryNum := 0
ValidOrder:
    var validErr error
    fmt.Println("Step 2: 验证订单")
    if validErr != nil {
        validRetryNum++
        if validRetryNum <= 3 {
            goto ValidOrder
        }
        fmt.Println("订单验证失败")
        goto CreatOrder
    }

    checkRetryNum := 0
CheckOrder:
    var checkErr error
    fmt.Println("Step 3: 验证付款信息")
    if checkErr != nil {
        checkRetryNum++
        if checkRetryNum <= 3 {
            goto CheckOrder
        }
        goto CheckErr
    }

    fmt.Println("Step 4: 订单完成")
    goto End

CheckErr:
    fmt.Println("付款信息验证失败")
End:
    fmt.Println("处理订单结束")
}

再来:

  1. 验证付款信息失败, 可能是因为没有付款等, 需要进行付款处理的逻辑
  2. 验证订单付款时, 订单可能认为取消支付, 需要处理资源清理等
package main

import "fmt"

func main() {
    fmt.Println("处理订单开始")

CreatOrder:
    fmt.Println("Step 1: 创建订单")

    validRetryNum := 0
ValidOrder:
    var validErr error
    fmt.Println("Step 2: 验证订单")
    if validErr != nil {
        validRetryNum++
        if validRetryNum <= 3 {
            goto ValidOrder
        }
        fmt.Println("订单验证失败")
        goto CreatOrder
    }

    checkRetryNum := 0
CheckOrder:
    var checkErr error
    var paid, cancel bool
    fmt.Println("Step 3: 验证付款信息")
    if checkErr != nil {
        checkRetryNum++
        if checkRetryNum <= 3 {
            goto CheckOrder
        }
        goto CheckErr
    }
    if cancel {
        goto CancelOrder
    }
    if !paid {
        goto ProcessOrder
    }

    fmt.Println("Step 4: 订单完成")
    goto End

CheckErr:
    fmt.Println("付款信息验证失败")
    goto End

CancelOrder:
    fmt.Println("取消订单支付")
    goto End

ProcessOrder:
    fmt.Println("处理付款信息")
    goto CheckOrder

End:
    fmt.Println("处理订单结束")
}

再来

  1. 增加处理失败的日志记录
  2. 增加订单验证失败的日志记录
  3. 不管是取消订单支付, 还是付款处理失败, 都需要进行一些清理工作
package main

import "fmt"

func main() {
    fmt.Println("处理订单开始")

CreatOrder:
    fmt.Println("Step 1: 创建订单")

    validRetryNum := 0
    checkRetryNum := 0
    var checkErr error
    var validErr error
    var paid, cancel bool
ValidOrder:
    fmt.Println("Step 2: 验证订单")
    if validErr != nil {
        validRetryNum++
        if validRetryNum <= 3 {
            goto ValidOrderErrorLog
        }
        fmt.Println("订单验证失败")
        goto CreatOrder
    }

CheckOrder:
    fmt.Println("Step 3: 验证付款信息")
    if checkErr != nil {
        checkRetryNum++
        if checkRetryNum <= 3 {
            goto CheckOrderErrorLog
        }
        goto CheckErr
    }
    if cancel {
        goto CancelOrder
    }
    if !paid {
        goto ProcessOrder
    }

    fmt.Println("Step 4: 订单完成")
    goto End

ValidOrderErrorLog:
    fmt.Println("记录订单验证失败")
    goto ValidOrder

CheckOrderErrorLog:
    fmt.Println("记录付款验证失败")
    goto CheckOrder

CheckErr:
    fmt.Println("付款信息验证失败")
    goto CleanOrder

CancelOrder:
    fmt.Println("取消订单支付")
    goto CleanOrder

ProcessOrder:
    fmt.Println("处理付款信息")
    goto CheckOrder

CleanOrder:
    fmt.Println("订单关闭的清理工作")
    goto End

End:
    fmt.Println("处理订单结束")
}

现在, 如果你还觉得逻辑清晰, 那我只能说一句"牛".

代码演进到现在, 逻辑已经十分混乱了, 逻辑的混乱会导致一系列问题:

  1. 难以理解, 逐步增加后续迭代的成本
  2. 造成额外的心智负担
  3. 追踪困难
  4. 如果是if for 在逻辑上是自上而下的, 但引入 goto会导致逻辑上下横跳
  5. 等等

可能有人会觉得我举的例子有些极端, 实际中没有人会这么做. 那是因为例子总是简单化的, 现实中的场景实际上要更加复杂:

  1. 大段逻辑分散: 例子中的所有单条print语句, 在实际项目中都可能会对应一大段的逻辑
  2. 最小改动原则: 对现有项目进行改动的时候(尤其是需求要的比较急, 要求改动最小实现功能), 对现有代码的改动越小, 则风险越小. 因此逻辑会越堆越难以理解, 直至最后无法使用
  3. 每个人的水平不同: 同一份代码会由团队中的不同人在不同时间维护, 即使你自信自己的水平, 也无法保证代码在未来不会向着这个方向发展

使用场景

当然, 我也不是把goto一棒子打死, 同事在反驳我的时候也给出了强有力的理由"Go 标准库也存在大量使用goto的地方". 比如:

func ParseMAC(s string) (hw HardwareAddr, err error) {
    if len(s) < 14 {
        goto error
    }

    if s[2] == ':' || s[2] == '-' {
        if (len(s)+1)%3 != 0 {
            goto error
        }
        n := (len(s) + 1) / 3
        if n != 6 && n != 8 && n != 20 {
            goto error
        }
        // ...
    } else if s[4] == '.' {
        if (len(s)+1)%5 != 0 {
            goto error
        }
        n := 2 * (len(s) + 1) / 5
        if n != 6 && n != 8 && n != 20 {
            goto error
        }
        // ...
    } else {
        goto error
    }
    return hw, nil

error:
    return nil, &AddrError{Err: "invalid MAC address", Addr: s}
}

这种其实是可以接受的, 能够简化流程, 但是, 但是, 不要忘记我不建议使用goto最重要的一点:

  1. 你无法保证自己拥有掌控goto的实力
  2. 即使你自信, 也无法保证同事有掌控goto的实力

一旦在使用过程中产生破窗效应, 使用goto破坏的速度一定是比不用要快的多. 代码很快就会脱离掌控.

因此, 为了避免这种情况, 最好的方式就是在最开始杜绝掉.

最后的最后, goto如果能够好好用的话, 确实能够带来一定的便利性, 前提是项目由你一人开发, 或你拥有掌控权可以拒绝某些腐败代码进入代码库.

goto并不可怕, 可怕的是不加限制的乱用goto.


欢迎来辩…

☑️ ☆

docker 多架构接口数据交换

前言

docker 的仓库支持一个 tag 下多个架构镜像, 这是如何实现的呢? 抓包看看其数据交互流程

前提

错误处理

执行命令buildx报错:

ERROR: Multi-platform build is not supported for the docker driver.
Switch to a different driver, or turn on the containerd image store, and try again.
Learn more at https://docs.docker.com/go/build-multi-platform/

修复: 执行命令docker buildx create --use desktop-linux . 参考链接

OCI参考文档

构建

Dockerfile:

FROM hub.hujingnb.com/hj-public/debian

RUN date > /tmp/date.txt

构建命令:

docker buildx build --platform linux/amd64,linux/arm64 -t hub.hujingnb.com/hj-public/test1:new --push .

抓包接口调用(2张图连续的, 1张图放不下了):

image-20241018145931938

image-20241018150006086

接口调用说明:

method 接口 说明 OCI 对应
HEAD /v2/hj-public/debian/manifests/latest 判断远端此镜像是否存在(每个架构) /v2/<name>/manifests/<reference>
GET /v2/hj-public/debian/manifests/sha256:520ce6d85... 获取镜像 manifests(多架构的). 内容参考: 多架构manifest /v2/<name>/manifests/<reference>
GET /v2/hj-public/debian/manifests/sha256:d15e83b3... 获取镜像 manifests(每个架构. 内容参考: 镜像manifests /v2/<name>/manifests/<reference>
GET /v2/hj-public/debian/blobs/sha256:8ea8... 获取上一步拿到的所有 blob /v2/<name>/blobs/<digest>
中间步骤和普通的镜像上传一样. 可参考之前的文章
HEAD /v2/hj-public/test1/manifests/new 判断远端 manifests 是否存在. /v2/<name>/manifests/<reference>
PUT /v2/hj-public/test1/manifests/new 上传manifests. 内容参考: 多架构manifest /v2/<name>/manifests/<reference>

至此, 一次多架构镜像构建并上传就完成了.

手动创建 manifests

我们也可以手动创建多架构, 而不是用 buildx. 命令如下:

# 假设仓库中已经存在: image:amd64 image:arm64 2个镜像
docker manifest create --insecure --amend  image:new image:arm64 image:amd64
docker manifest annotate image:new image:arm64 --os=linux --arch=arm64
docker manifest annotate image:new image:amd64 --os=linux --arch=amd64
docker manifest push --insecure --purge image:new

此时的接口调用, 直接跳到上面的最后2步: HEAD/PUT manifests接口.

拉取

其实在上一步构建的时候已经能够看到拉取的接口调用了. 在每次构建的时候, 要把不同架构的基础镜像拉倒本地进行构建.

这里就简单放一张接口调用流程, 命令: docker pull --platform=linux/arm64 hub.hujingnb.com/hj-public/test1:new:

image-20241018161603623

总结

单独镜像的推拉, 可以参考之前的文章

可以看到, 实现多架构时, 与非多架构镜像的唯一区别, 就是额外加了一个 manifests 类型, 用来将单架构镜像整合为多架构.

附件

多架构manifest
{
    "schemaVersion": 2,
    "mediaType": "application/vnd.oci.image.index.v1+json",
    "manifests":
    [
        {
            "mediaType": "application/vnd.oci.image.manifest.v1+json",
            "digest": "sha256:b4df0c22aa74c1aa7e1941619c4c63b2e3b1b1dc57436ecc6515e547b6888dab",
            "size": 481,
            "platform":
            {
                "architecture": "386",
                "os": "linux"
            }
        },
        {
            "mediaType": "application/vnd.oci.image.manifest.v1+json",
            "digest": "sha256:651dd02a84abdd528e35e73d483aec5c361078bf169919bd3dac7bfe66d19290",
            "size": 481,
            "platform":
            {
                "architecture": "amd64",
                "os": "linux"
            }
        },
        {
            "mediaType": "application/vnd.oci.image.manifest.v1+json",
            "digest": "sha256:9dac568c16fc9d22304b66eb1be48e849c912c0c1f233b7c8233eee5834fc082",
            "size": 481,
            "platform":
            {
                "architecture": "arm",
                "os": "linux",
                "variant": "v5"
            }
        },
        {
            "mediaType": "application/vnd.oci.image.manifest.v1+json",
            "digest": "sha256:e30e0f9e2580058251db6012e2886fa8b3971d980ae04c0d2e304190df601b4a",
            "size": 481,
            "platform":
            {
                "architecture": "arm",
                "os": "linux",
                "variant": "v7"
            }
        },
        {
            "mediaType": "application/vnd.oci.image.manifest.v1+json",
            "digest": "sha256:d15e83b3662501593be46a5a2aef02c2f5b4a1826aa5bef8cd21e7047a497af8",
            "size": 481,
            "platform":
            {
                "architecture": "arm64",
                "os": "linux"
            }
        },
        {
            "mediaType": "application/vnd.oci.image.manifest.v1+json",
            "digest": "sha256:bddc1b85037e49dcbeef083c2b2868c73e23d7443e3c13bc177762b90ddaf80f",
            "size": 481,
            "platform":
            {
                "architecture": "mips64le",
                "os": "linux"
            }
        },
        {
            "mediaType": "application/vnd.oci.image.manifest.v1+json",
            "digest": "sha256:e0d4150147fe4f2ecaf11fd532bd3d707d8632024f6743da58141b122a305887",
            "size": 481,
            "platform":
            {
                "architecture": "ppc64le",
                "os": "linux"
            }
        },
        {
            "mediaType": "application/vnd.oci.image.manifest.v1+json",
            "digest": "sha256:ed6e2e64d1f0a99ca795c481ca08ff62fc8e1efca022ae22c5604bb27e6c76c1",
            "size": 481,
            "platform":
            {
                "architecture": "s390x",
                "os": "linux"
            }
        },
        {
            "mediaType": "application/vnd.oci.image.manifest.v1+json",
            "digest": "sha256:785cf1dd62c0b02ce1fbf6cf615ef6eba5d4087385613ddcf26c221daac8e6a0",
            "size": 566,
            "annotations":
            {
                "vnd.docker.reference.digest": "sha256:b4df0c22aa74c1aa7e1941619c4c63b2e3b1b1dc57436ecc6515e547b6888dab",
                "vnd.docker.reference.type": "attestation-manifest"
            },
            "platform":
            {
                "architecture": "unknown",
                "os": "unknown"
            }
        },
        {
            "mediaType": "application/vnd.oci.image.manifest.v1+json",
            "digest": "sha256:520be2ba49e9d29e94745580f96858c3960f5fcf1eaa2b8b6e2574d5bba4bc91",
            "size": 566,
            "annotations":
            {
                "vnd.docker.reference.digest": "sha256:651dd02a84abdd528e35e73d483aec5c361078bf169919bd3dac7bfe66d19290",
                "vnd.docker.reference.type": "attestation-manifest"
            },
            "platform":
            {
                "architecture": "unknown",
                "os": "unknown"
            }
        },
        {
            "mediaType": "application/vnd.oci.image.manifest.v1+json",
            "digest": "sha256:edbe0bc83fdd4b6516399b43dcb4422c9b5e78a1a485fc8870554b7f1b59a501",
            "size": 566,
            "annotations":
            {
                "vnd.docker.reference.digest": "sha256:9dac568c16fc9d22304b66eb1be48e849c912c0c1f233b7c8233eee5834fc082",
                "vnd.docker.reference.type": "attestation-manifest"
            },
            "platform":
            {
                "architecture": "unknown",
                "os": "unknown"
            }
        },
        {
            "mediaType": "application/vnd.oci.image.manifest.v1+json",
            "digest": "sha256:3d456f67aef005b6da1b9ae91c778314b6d79553917d7d37e6aa484eb8ea5a91",
            "size": 566,
            "annotations":
            {
                "vnd.docker.reference.digest": "sha256:e30e0f9e2580058251db6012e2886fa8b3971d980ae04c0d2e304190df601b4a",
                "vnd.docker.reference.type": "attestation-manifest"
            },
            "platform":
            {
                "architecture": "unknown",
                "os": "unknown"
            }
        },
        {
            "mediaType": "application/vnd.oci.image.manifest.v1+json",
            "digest": "sha256:fe2aa90b281550a68cdc80208979c65acf8f64ca7ba709fe07fc38ed4eae7400",
            "size": 566,
            "annotations":
            {
                "vnd.docker.reference.digest": "sha256:d15e83b3662501593be46a5a2aef02c2f5b4a1826aa5bef8cd21e7047a497af8",
                "vnd.docker.reference.type": "attestation-manifest"
            },
            "platform":
            {
                "architecture": "unknown",
                "os": "unknown"
            }
        },
        {
            "mediaType": "application/vnd.oci.image.manifest.v1+json",
            "digest": "sha256:5308441e25778d9e467114c998ed3faf9c4bc18df17702fdbe7fcf495ff44eb9",
            "size": 566,
            "annotations":
            {
                "vnd.docker.reference.digest": "sha256:bddc1b85037e49dcbeef083c2b2868c73e23d7443e3c13bc177762b90ddaf80f",
                "vnd.docker.reference.type": "attestation-manifest"
            },
            "platform":
            {
                "architecture": "unknown",
                "os": "unknown"
            }
        },
        {
            "mediaType": "application/vnd.oci.image.manifest.v1+json",
            "digest": "sha256:264d85222bd6ff7a724dd33fa5071e6c3e1a30361989d52f2f7ca191ac7b9b20",
            "size": 566,
            "annotations":
            {
                "vnd.docker.reference.digest": "sha256:e0d4150147fe4f2ecaf11fd532bd3d707d8632024f6743da58141b122a305887",
                "vnd.docker.reference.type": "attestation-manifest"
            },
            "platform":
            {
                "architecture": "unknown",
                "os": "unknown"
            }
        },
        {
            "mediaType": "application/vnd.oci.image.manifest.v1+json",
            "digest": "sha256:8f06e8c4e23717acbe73a0cd2f6ef9678b0c8c0387d67f7e604c85d43ba22cbf",
            "size": 566,
            "annotations":
            {
                "vnd.docker.reference.digest": "sha256:ed6e2e64d1f0a99ca795c481ca08ff62fc8e1efca022ae22c5604bb27e6c76c1",
                "vnd.docker.reference.type": "attestation-manifest"
            },
            "platform":
            {
                "architecture": "unknown",
                "os": "unknown"
            }
        }
    ]
}

其中后面几个 annotations 类型的内容, 是每个架构下的 manifests 证明文件. 用于镜像签名.

镜像manifests
{
    "schemaVersion": 2,
    "mediaType": "application/vnd.oci.image.manifest.v1+json",
    "config": {
        "mediaType": "application/vnd.oci.image.config.v1+json",
        "digest": "sha256:fe997a1b6216319619839e4ffe4bf083b70e58e751d5a26837254dc113b743f6",
        "size": 579
    },
    "layers": [{
        "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
        "digest": "sha256:c1e0ef7b956a07c7b090256aa16cbb0550a34d0625d1d23c5b1a76e92a58d01e",
        "size": 49584978
    }]
}
☑️ ☆

docker推拉时的数据交换详解

前言

docker用了这么久了, 有没有想过, 在执行docker pushdocker pull命令的时候, 数据是如何传递的呢? 换句话说, 如果要实现一个镜像仓库, 针对推拉的服务, 如何实现接口呢?

根据OCI 分发规范文档 的描述, 已经对整个推拉过程中要调用的接口有描述了. 但是, 纸上学来终觉浅, 我还是决定针对harbor仓库进行推拉操作的抓包, 看看实际上接口调用是怎样的.

以下所有抓包, 均为本地搭建的 harbor仓库(在实现上可能与OCI 略有差异).

pull

执行命令: docker pull hub.hujingnb.com:8090/library/alpine:2.6

抓包结果如下(层在本地没有, 需要下载):

image-20240324150001778

根据OCI文档对流程进行说明(第1个请求用于建立连接, 跳过):

METHOD 接口 说明 OCI 对应
GET /v2 尝试获取仓库的API 版本. 此接口在响应头中标记了获取权限的地址:
Www-Authenticate Bearer realm="http://hub.hujingnb.com:8090/service/token",service="harbor-registry"
GET /service/token 根据上一步提供的地址, 获取访问令牌
HEAD /v2/library/alpine/manifests/2.6 从这一步开始拉取镜像. 检查镜像是否存在, 若不存在返回状态码404. 若存在, 同时将镜像的哈希返回到响应头中:
Docker-Content-Digest sha256:5c4217c00be9ecba7735157998a4ac0475b5381c8c885396baa6f798f7f839db
/v2/<name>/manifests/<reference>
GET /v2/library/alpine/manifests/sha256:5c42… 获取镜像的manifests信息 /v2/<name>/manifests/<reference>
GET /v2/library/alpine/blobs/sha256:c954… 根据上一步拿到的镜像结果, 下载各个层信息 (在这一步会校验, 若层在本地已经存在, 则跳过下载) /v2/<name>/blobs/<digest>

通过以上接口调用, 就已经拿到了一个镜像的所有信息.

push

执行命令: docker push hub.hujingnb.com:8090/library/alpine:3.1

抓包结果:

image-20240324154525050

根据OCI文档对流程进行说明(第1个请求用于建立连接, 跳过):

METHOD 接口 说明 OCI 对应
GET /v2 同上
GET /service/token 同上
HEAD /v2/library/alpine/blobs/sha256:0f25… 判断层是否存在. 若在远端不存在, 则返回404 /v2/<name>/blobs/<digest>
POST /v2/library/alpine/blobs/uploads/ 开始一次层上传. 响应202成功, 在响应头的Location字段标明上传地址 /v2/<name>/blobs/uploads
PATCH /v2/library/alpine/blobs/uploads/6c82… 使用上一步返回的Location地址执行上传任务, 分片上传 /v2/<name>/blobs/uploads/<reference>
PUT /v2/library/alpine/blobs/uploads/6c82… 标识本次上传完毕 /v2/<name>/blobs/uploads/<reference>
HEAD /v2/library/alpine/blobs/sha256:0f25… 在层上传完毕后, 再次调用接口检查是否上传成功. 返回200说明存在 /v2/<name>/blobs/<digest>
重复操作上传所有层
PUT /v2/library/alpine/manifests/3.1 所有层上传完毕, 推送镜像manifests信息 /v2/<name>/manifests/<reference>

以上, 一个镜像就成功的推送到镜像仓库了. 推送基本上就是把pull的操作反过来.

其他

更具体的, 可以查看OCI 分发规范, 拢共也没多少, 可以通读一遍. 在这个位置 定义了一些接口规范, 可以看一下.

对于仓库的实现, 可以查看harbor 源码, 也可以查看distribution 来了解其实现.

其实整个看下来, 倒也不觉得神秘了, 无非就是一些数据的上传, 全部使用HTTP协议, 散会

☑️ ☆

一种计数算法

前言

常见的一个问题: 给定一个整形数组, 统计其中有多少唯一的元素.

常见的思路有哪些呢?

  1. 元素去重并统计, 利用哈希表进行去重计数.
  2. 数组排序后统计

以上空间复杂度均与元素数量关联, 如果允许损失精度, 是否可以使用较低的空间占用来统计呢?

  • 利用布隆过滤器是其中的一种

但是, 我在这篇文章看到了一种全新的思路. 尽管并不建议在生产环境中使用, 但仍不失为一种思路.

统计思路

这种思路简单说就是: "采样". 就像是统计一个湖泊中鱼的数量, 并不会一次性将所有的鱼都捞上来. 而是先钓n 条鱼上来, 给他们都做上记号. 几天后再钓 n 条鱼上来, 看其中有多少个有记号的鱼. 从而来估算整个湖泊中鱼的总数.

这个思路是否也可以在这个问题上借鉴呢?

通常的统计方式如下:

func count(arr []int) int {
    m := make(map[int]bool)
    for _, i := range arr {
        m[i] = true
    }
    return len(m)
}

好, 现在开始利用"采样"的思路, 丢失精确度, 降低空间复杂度.

我们将一半的元素放到hash表中保存:

func count(arr []int) int {
    m := make(map[int]bool)
    for _, i := range arr {
        if rand.Intn(2) == 0 {
            m[i] = true
        }
    }
    return len(m) * 2
}

但, 如此有个问题, 元素在数组中出下的次数, 会影响其最终放入hash的概率. 次数越多, 概率越大. 这显然会影响计算的公平性.

一个非常简单的解决思路: 在随机之前, 我们将该元素从hash中先删除. 这样, 影响此元素是否在hash中出现的, 就只是其最后一次随机的结果了.

func count(arr []int) int {
    m := make(map[int]bool)
    for _, i := range arr {
        delete(m, i)
        if rand.Intn(2) == 0 {
            m[i] = true
        }
    }
    return len(m) * 2
}

当然, 现在的内存使用应该是数组长度的一半. 我们可以增加随机率来进一步降低内存占用.

func count(arr []int, p int) int {
    m := make(map[int]bool)
    for _, i := range arr {
        delete(m, i)
        if rand.Intn(p) == 0 {
            m[i] = true
        }
    }
    return len(m) * p
}

至此, 基本思路已经介绍完了, 但实际的内存占用仍然与数组大小关联. 可以看到, 随机率越高, hash中保存的元素就会越少. 我们是否可以动态的来调整呢? 让随机率随着数组长度的增加而增加.

func count(arr []int, maxSize int) int {
    p := 2
    m := make(map[int]bool)
    for _, i := range arr {
        delete(m, i)
        if rand.Intn(p) == 0 {
            m[i] = true
        }
        if len(m) >= maxSize {
            p *= 2
            // 因为随机率增大了一倍
            // 之前的元素也需要再次进行随机, 使得其满足现有的随机率
            for k, _ := range m {
                if rand.Intn(2) == 0 {
                    delete(m, k)
                }
            }
        }
    }
    return len(m) * p
}

以上, 就是整个算法的全部内容了. 这是一个实现简单, 思路也简单的算法. 它给我打开了新的视野

☑️ ☆

md5算法实现

前言

md5算法是我们经常会用到的一个hash函数, 虽然已经被证明是不安全的了, 但其应用依然十分广泛.

哈希函数具有如下特点:

  1. 将任意长度的字符串映射为固定长度
  2. 源数据微小的改动会导致结果差异巨大
  3. 不可逆
  4. 暴力破解困难

你有没有好奇过, 哈希函数是如何做到这些的呢? 本文就拿md5举例, 看一看它具体的计算过程.

注意: 本文仅设计计算过程, 不涉及证明过程. 也就是说只介绍How, 不介绍Why , 满足一下好奇心即可

计算过程

不管是文件还是字符串, 在计算时都是一个byte数组, 因此无需进行区分.

md5的计算过程大致分为如下几步(看起来稍稍费点脑子)

1. 进行数据填充

首先将准备计算的数据准备好, 步骤如下:

  1. 在数据后面拼接一个 int64 类型的数据, 这个数据是源数据的位长度
  2. 源数据与长度中间填充数据 100… , 使得填充后的长度是 64(字节) 的倍数
    • 填充的数据最短为 1 个字节, 最长为 64 个字节

这里以字符串hello举例, 其16进制表示为: 68656c6c6f

  1. 其共有40位, 也就是16进制的28. 因为是 int64 类型, 因此共8字节, 以小端序表示为: 2800000000000000
  2. 源数据5字节, 算上长度的8字节共13字节, 因此填充数据的长度为64-13=51

填充后的数据为: 68656c6c6f8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002800000000000000

共64字节.

2. 对数据进行分组计算并迭代

到这一步就是重头戏了, 主要计算逻辑全部都在这里, 认真看咯.

首先, 我们知道, md5的计算结果是一个32位的16进制数字, 也就是16个字节. 我们将这16个字节分成4组, 每组4个字节也就是一个int类型的数字.

假设这4个数字分别为a, b, c, d, 我们所有的计算结果, 都是对这4个数字进行的.

image-20231023221702209

将上一步拼装好的数据, 按照每64字节进行拆分

image-20231023222214355

针对每一组进行迭代计算, 其中每组的计算过程大致如下:

  1. 进行4组不同的计算规则, 每组规则计算16次. 共16*4轮迭代计算
  2. 每组计算中, 以a, d, c, b的顺序, 依次迭代每个数字(计算规则不同)

以第一组第一次迭代为例:

image-20231023223934267

  • n的计算规则为: (b & c) | (!b & d)
    • 每组迭代计算规则不同:
      • 第二组: (b & d) | (c & !d)
      • 第三组: b ^ c ^ d
      • 第四组: c ^ (b | !d)
  • m的计算规则为: n + data[i] + a + sin(i+1)*2^32
    • 其中, data[i]为取原数据的第i个数字 (原数据64个字节, 可转为16个 int)
      • 第一组: i
      • 第二组: (5i+1)%16
      • 第三组: (3i+5)%16
      • 第四组: 7i % 16
  • o 的计算规则为: (m << calcNum[i%4]) | (m >> (32 - calcNum[i%4]))
    • 其中calcNum 是一组魔数, 每组不同
  • num 的计算规则为: o+b
  • 最终, 将num赋值给a, 完成本次计算. (本次计算的结果为下一次迭代计算的输入)

而这, 仅仅是64次迭代中的一次, 还要进行64次, 才会完成对这64个字节的计算. (剩余的迭代计算规则不再赘述, 具体可查看下面的代码实现)

最后, 当对这64个字节迭代计算完成后, 依次对数据后面所有组进行相同的迭代, 知道所有源数据计算完成.

3. 输出结果

上一步计算完成后, 我们将得到4个经过很多次迭代的int数字. 而这4个数字, 就是我们计算的结果了, 我们只需要简单的将其转为16进制输出即可. (以上所有的数字与字节的转换, 均按照小端序进行)

code

希望到这里, 你没有看的云里雾里, 当然了, 我也知道自己没怎么写明白…

下面是我用Go实现的一版md5算法, 一共才100行左右. 算法的细节都在里面了, 你可以查看代码分析其具体计算流程, 也可以拿到本地跑一下, 或者依据此Go版本实现, 用其他编程语言试着实现一版.

package main

import (
    "encoding/binary"
    "encoding/hex"
    "fmt"
    "math"
)

func main() {
    fmt.Println(md5("hello"))
}

func md5(str string) string {
    // 填充数据
    data := md5GetPaddingData(str)
    // 过程计算结果 (不要问我为什么是这4个数字, 我也不知道, 就是魔数. 应该是推导出这些魔术的碰撞率比较低)
    result := [4]uint32{0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476}
    // 遍历所有分组
    for i := 0; i < len(data); i += 64 {
        // 计算当前分组的结果
        tmpGroup := md5CalcGroup(result, data[i:i+64])
        for j := 0; j < 4; j++ { // 将结果加到 result 上
            result[j] += tmpGroup[j]
        }
    }
    // 输出结果并将结果转为 16 进制
    md5Byte := make([]byte, 0)
    for _, num := range result {
        md5Byte = binary.LittleEndian.AppendUint32(md5Byte, num)
    }
    return hex.EncodeToString(md5Byte)
}

// 对 md5 计算的数据进行填充
func md5GetPaddingData(str string) []byte {
    data := []byte(str)
    // 计算数据长度 (byte 单位, 所以要乘以 8)
    var lenBuf = make([]byte, 8)
    binary.LittleEndian.PutUint64(lenBuf, uint64(len(data)*8))
    // 计算填充长度
    paddingLen := 64 - (len(data)+len(lenBuf))%64
    // 填充 1 和 0
    data = append(data, 0x80)
    for i := 0; i < paddingLen-1; i++ {
        data = append(data, 0x00)
    }
    // 在最后拼接长度
    data = append(data, lenBuf...)
    return data
}

// 对每组数据进行计算
func md5CalcGroup(lastResult [4]uint32, data []byte) [4]uint32 {
    if len(data) != 64 {
        panic("data length must be 64")
    }
    // 将 result 数组临时复制一份, 防止函数内部修改
    result := [4]uint32{}
    for i, num := range lastResult {
        result[i] = num
    }
    // 计算常量表
    constTable := [64]uint32{}
    for i := 0; i < 64; i++ {
        // sin(i+1) * 2^32
        constTable[i] = uint32(math.Abs(math.Sin(float64(i+1))) * (1 << 32))
    }
    // 将当前分组按照 4 字节一组, 分为 4 组. 并将其转为整形方便后续运算
    calcData := [16]uint32{}
    for i := 0; i < 16; i++ {
        calcData[i] = binary.LittleEndian.Uint32(data[i*4 : i*4+4])
    }

    // 供下面每轮计算使用
    // 获取本轮计算用到的4个结果数 (顺序返回, 0,3,2,1,0,3,2,1,0...)
    // 以及更新的结果数下标
    gotResIndex := func(i int) (uint32, uint32, uint32, uint32, int) {
        resIndex := 4 - i%4
        if resIndex >= 4 {
            resIndex = 0
        }
        return result[resIndex], result[(resIndex+1)%4], result[(resIndex+2)%4], result[(resIndex+3)%4], resIndex
    }
    // 进行第一轮计算
    calcNum := [4]uint32{7, 12, 17, 22} // 同理这4个魔数我也不知道为什么
    for i := 0; i < 16; i++ {
        // 本轮计算的结果数
        a, b, c, d, resIndex := gotResIndex(i)
        n := (b & c) | (^b & d)
        m := n + calcData[i] + constTable[i] + a
        o := (m << calcNum[i%4]) | (m >> (32 - calcNum[i%4]))
        result[resIndex] = o + b
    }
    // 第二轮计算
    calcNum = [4]uint32{5, 9, 14, 20} // 同理这4个魔数我也不知道为什么
    for i := 0; i < 16; i++ {
        a, b, c, d, resIndex := gotResIndex(i)
        n := (b & d) | (c & ^d)
        m := n + calcData[(5*i+1)%16] + constTable[i+16] + a
        o := (m << calcNum[i%4]) | (m >> (32 - calcNum[i%4]))
        result[resIndex] = o + b
    }
    // 第三轮计算
    calcNum = [4]uint32{4, 11, 16, 23} // 同理这4个魔数我也不知道为什么
    for i := 0; i < 16; i++ {
        a, b, c, d, resIndex := gotResIndex(i)
        n := b ^ c ^ d
        m := n + calcData[(3*i+5)%16] + constTable[i+32] + a
        o := (m << calcNum[i%4]) | (m >> (32 - calcNum[i%4]))
        result[resIndex] = o + b
    }
    // 第四轮计算
    calcNum = [4]uint32{6, 10, 15, 21} // 同理这4个魔数我也不知道为什么
    for i := 0; i < 16; i++ {
        a, b, c, d, resIndex := gotResIndex(i)
        n := c ^ (b | ^d)
        m := n + calcData[(7*i)%16] + constTable[i+48] + a
        o := (m << calcNum[i%4]) | (m >> (32 - calcNum[i%4]))
        result[resIndex] = o + b
    }
    return result
}

以上, 就是md5值计算的全部过程了, 不需要证明, 仅仅好奇. 具体做的时候没有人会闲到自己实现md5算法, 所有语言都带着常用的hash函数实现.

🔲 ☆

GPU扫盲

前言

相信对于软件工程师来说, CPU并不陌生. 人工智能以及机器学习带火了GPU. 经常听到的就是, GPU计算比CPU快, 但具体是怎么快的却从未刨根问底. 之前在听到GPU的时候, 我有过这样的疑问:

  1. GPU是什么?
  2. 为什么比CPU快? 快在哪里? 如果各方面碾压那CPU不就淘汰了?
  3. 是否可以基于GPU实现操作系统?

这篇文章不涉及GPU的具体原理, 仅做大概描述, 将其与CPU区别说明白就好.

是什么

要了解GPU是什么, 我们可以从它的来历入手.

计算机图形学和动画设计的出现后,产生了第一批计算密集型工作负载,例如,电子游戏动画需要应用程序处理数据以显示数千个像素,每个像素都有自己的颜色、亮度和移动方式. 当时 CPU 上的几何数学计算导致了性能问题.

于是硬件制造商开始认识到,将CPU的能力减弱可以减轻 CPU 的压力并提高性能. 于是, GPU出现了. 如今,与 CPU 相比,图形处理单元(GPU)工作负载处理一些计算密集型应用(比如机器学习和人工智能)时更高效。

从 GPU 的名字 图形处理单元也能够大致猜测出它的主要用途, 就是对图形与显示进行处理的.

为什么

CPU 和 GPU 的出发点不同, 要解决的问题也不同.

对于 CPU 来说, 要进行大量逻辑处理, 比如:

  1. 中断
  2. 分支跳转
  3. 指令集
  4. 不同的数据类型
  5. 任务流水线
  6. 分支预判
  7. mmu, TLB
  8. 等等

而对于 GPU 要处理的任务就简单的多, 数据类型单一且不需要中断. 对于 GPU 来说, 其实就是要计算出屏幕上一个个像素点的值.

通常来说, CPU 内部的计算单元为8个, 也就是通常说的8核. 大一些的话也就是32核. 而对于 GPU 来说, 计算单元轻轻松松上千, 这样在计算屏幕像素点的时候, 不同核并行计算提高渲染速度. 同时也是针对页面渲染, GPU 的所有计算单元同一时间接收同一个计算任务, 只是处理数据不同.

相比 CPU 来说, GPU 的指令集就简单的多, 且没有那么多逻辑操作. 简单来说, GPU 就是接收计算任务并行计算.

当然, 有个前提是不同计算任务之间没有依赖关系, 所以才能够并行计算嘛.

那么为什么CPU不能拥有上千计算单元呢?因为体积的原因, CPU内部拥有内部缓存、控制单元等等, 留给计算单元的空间就小的多了, 而GPU只有简单的控制单元, 剩余的空间都被大量的计算单元填充.

解惑

GPU 在最开始的设计上就是为了图形处理设计的. 但因为其适合大规模并行计算任务的特性, 慢慢的也被用于处理其他一些任务, 比如: 天气预报, 机器学习等等.

回到我们最开始的问题, 既然GPU这么快, 是否可以基于GPU实现操作系统? 答案呼之欲出, 不能. 因为GPU内部逻辑过于简单, 而操作系统又需要应对十分复杂的场景, 因此GPU只能作为一个配角来复制CPU执行.

CPU与GPU的最初设计目的就不同


OK, 本篇文章仅对 GPU 做简单介绍, 有个大致印象即可, 主要目的是满足好奇心, 也不需要具体的探究其实现原理.

❌