2026-10-03 16:59:00
有了 AI,现在学习成本很低,有很多开源项目都很值得学习,先从 Etcd 始。
搭建 Etcd 测试环境
本机电脑有 Docker 环境就好办了,创建 ~/Portable/etcd-launch/docker-compose.yml,内容如下:
services:
etcd1:
image: quay.io/coreos/etcd:v3.7.2
container_name: etcd1
command:
- /usr/local/bin/etcd
- --name=etcd1
- --data-dir=/etcd-data
- --listen-client-urls=http://0.0.0.0:2379
- --advertise-client-urls=http://etcd1:2379
- --listen-peer-urls=http://0.0.0.0:2380
- --initial-advertise-peer-urls=http://etcd1:2380
- --initial-cluster=etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
- --initial-cluster-state=new
- --initial-cluster-token=etcd-cluster-1
ports:
- "2379:2379"
volumes:
- etcd1-data:/etcd-data
networks:
- etcd-net
etcd2:
image: quay.io/coreos/etcd:v3.7.2
container_name: etcd2
command:
- /usr/local/bin/etcd
- --name=etcd2
- --data-dir=/etcd-data
- --listen-client-urls=http://0.0.0.0:2379
- --advertise-client-urls=http://etcd2:2379
- --listen-peer-urls=http://0.0.0.0:2380
- --initial-advertise-peer-urls=http://etcd2:2380
- --initial-cluster=etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
- --initial-cluster-state=new
- --initial-cluster-token=etcd-cluster-1
ports:
- "22379:2379"
volumes:
- etcd2-data:/etcd-data
networks:
- etcd-net
etcd3:
image: quay.io/coreos/etcd:v3.7.2
container_name: etcd3
command:
- /usr/local/bin/etcd
- --name=etcd3
- --data-dir=/etcd-data
- --listen-client-urls=http://0.0.0.0:2379
- --advertise-client-urls=http://etcd3:2379
- --listen-peer-urls=http://0.0.0.0:2380
- --initial-advertise-peer-urls=http://etcd3:2380
- --initial-cluster=etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
- --initial-cluster-state=new
- --initial-cluster-token=etcd-cluster-1
ports:
- "32379:2379"
volumes:
- etcd3-data:/etcd-data
networks:
- etcd-net
volumes:
etcd1-data:
etcd2-data:
etcd3-data:
networks:
etcd-net:
driver: bridge
启动
cd ~/Portable/etcd-launch
docker compose up -d
图像01
查看服务启动状态
$ docker compose ps
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
etcd1 quay.io/coreos/etcd:v3.7.2 "/usr/local/bin/etcd…" etcd1 28 seconds ago Up 26 seconds 0.0.0.0:2379->2379/tcp, [::]:2379->2379/tcp, 2380/tcp
etcd2 quay.io/coreos/etcd:v3.7.2 "/usr/local/bin/etcd…" etcd2 28 seconds ago Up 26 seconds 2380/tcp, 0.0.0.0:22379->2379/tcp, [::]:22379->2379/tcp
etcd3 quay.io/coreos/etcd:v3.7.2 "/usr/local/bin/etcd…" etcd3 28 seconds ago Up 26 seconds 2380/tcp, 0.0.0.0:32379->2379/tcp, [::]:32379->2379/tcp
查看节点健康状态
$ docker exec etcd1 etcdctl \
--endpoints=http://etcd1:2379,http://etcd2:2379,http://etcd3:2379 \
endpoint health
http://etcd2:2379 is healthy: successfully committed proposal: took = 42.461004ms
http://etcd3:2379 is healthy: successfully committed proposal: took = 42.715752ms
http://etcd1:2379 is healthy: successfully committed proposal: took = 44.439445ms
更详细的信息
$ docker exec etcd1 etcdctl \
--endpoints=http://etcd1:2379,http://etcd2:2379,http://etcd3:2379 \
endpoint status --write-out=json
格式化后:
[
{
"Endpoint": "http://etcd1:2379",
"Status": {
"header": {
"cluster_id": 9572721052113347000,
"member_id": 11090874293180130000,
"revision": 1,
"raft_term": 2
},
"version": "3.7.2",
"dbSize": 20480,
"leader": 15904053625288548000,
"raftIndex": 8,
"raftTerm": 2,
"raftAppliedIndex": 8,
"dbSizeInUse": 16384,
"storageVersion": "3.7.0",
"dbSizeQuota": 2147483648,
"downgradeInfo": {}
}
},
{
"Endpoint": "http://etcd2:2379",
"Status": {
"header": {
"cluster_id": 9572721052113347000,
"member_id": 15904053625288548000,
"revision": 1,
"raft_term": 2
},
"version": "3.7.2",
"dbSize": 20480,
"leader": 15904053625288548000,
"raftIndex": 8,
"raftTerm": 2,
"raftAppliedIndex": 8,
"dbSizeInUse": 16384,
"storageVersion": "3.7.0",
"dbSizeQuota": 2147483648,
"downgradeInfo": {}
}
},
{
"Endpoint": "http://etcd3:2379",
"Status": {
"header": {
"cluster_id": 9572721052113347000,
"member_id": 9754871488702160000,
"revision": 1,
"raft_term": 2
},
"version": "3.7.2",
"dbSize": 20480,
"leader": 15904053625288548000,
"raftIndex": 8,
"raftTerm": 2,
"raftAppliedIndex": 8,
"dbSizeInUse": 16384,
"storageVersion": "3.7.0",
"dbSizeQuota": 2147483648,
"downgradeInfo": {}
}
}
]
目前大多都看不懂是干什么的,先不用管,总之 etcd 集群在本机运行良好,先执行几个命令看下。
在第一个节点写入一个键值对:
$ docker exec etcd1 etcdctl put /demo/name dongdong
OK
从另一个节点读取:
$ docker exec etcd2 etcdctl get /demo/name
/demo/name
dongdong
从 Node-2、Node-3 都能读到 Node-1 设置的键值对,在 etcd 中,节点 Node 有专属的称呼:member(etcd 集群里的一个 etcd 实例,本例集群中有 3 个 members)
这里有两个知识点:
$ docker exec etcd1 etcdctl put /demo/name dondone
OK
$ docker exec etcd2 etcdctl get /demo/name
/demo/name
dondone
先写入几条测试数据
$ docker exec etcd1 etcdctl put /demo/key1 v1
$ docker exec etcd1 etcdctl put /demo/key2 v2
$ docker exec etcd1 etcdctl put /demo/key3 v3
$ docker exec etcd1 etcdctl put /demo/key4 v4
$ docker exec etcd1 etcdctl put /demo/key5 v5
$ docker exec etcd1 etcdctl put /demo/key6 v6
查询某前缀下有多少个 Key
$ docker exec etcd1 etcdctl \
get /demo \
--prefix \
--count-only \
--write-out=fields
输出如下,其中的 Count: 7 跟上文存进入的 Key 数量对的上。
"ClusterID" : 9572721052113345815
"MemberID" : 11090874293180130209
"Revision" : 9
"RaftTerm" : 2
"More" : false
"Count" : 7
看 etcd backend DB 大小
$ docker exec etcd1 etcdctl \
--endpoints=http://etcd1:2379 \
endpoint status -w json \
| jq '.[] | {
dbSize: .Status.dbSize,
dbSizeInUse: .Status.dbSizeInUse
}'
$ docker exec -it etcd1 etcdctl watch /demo --prefix
另启终端执行
$ docker exec etcd2 etcdctl put /demo/name dongdong
OK
$ docker exec etcd3 etcdctl put /demo/age 30
OK
$ docker exec etcd1 etcdctl put /demo/name xiaoming
OK
$ docker exec etcd2 etcdctl del /demo/age
1
可以看到监听的终端输出如下内容
PUT
/demo/name
dongdong
PUT
/demo/age
30
PUT
/demo/name
xiaoming
DELETE
/demo/age
两个知识点:
获取键值的详细信息
$ docker exec etcd1 etcdctl get /demo/name -w json | jq
{
"header": {
"cluster_id": 9572721052113345815,
"member_id": 11090874293180130209,
"revision": 13,
"raft_term": 2
},
"kvs": [
{
"key": "L2RlbW8vbmFtZQ==",
"create_revision": 2,
"mod_revision": 12,
"version": 4,
"value": "eGlhb21pbmc="
}
],
"count": 1
}
重点看几个值:
header.revision:13: 当前 etcd 全局 Revision,etcd 不仅每个 key 有单独维护的版本,整个 KV 存储也有一个全局递增的 revision,发生一次写事务,revision 通常就会向前推进。当前响应所看到的 etcd 数据库状态位于全局版本 13;create_revision: 2: 表示这个 key 第一次被创建时,etcd 的全局 revision 是 2;mod_revision: 12: 这个 key 最近一次被修改时,全局 revision 是 12;version: 4: 当前 key 自创建以来,被写入了多少次;执行一次
$ docker exec etcd1 etcdctl put /demo/name dongdong
返回
$ docker exec etcd1 etcdctl get /demo/name -w json | jq
{
"header": {
"cluster_id": 9572721052113345815,
"member_id": 11090874293180130209,
"revision": 14,
"raft_term": 2
},
"kvs": [
{
"key": "L2RlbW8vbmFtZQ==",
"create_revision": 2,
"mod_revision": 14,
"version": 5,
"value": "ZG9uZ2Rvbmc="
}
],
"count": 1
}
更新了 /demo/name 键的值,revision 按照预期增长 1,create_revision 因为未发生删除重建,所以保持不变,mod_revision 本次这个 Key 就是最后的修改,对应的就是全局的最新版本,和 revision 相等,version 也加一符合预期。
另外值得一提的是 etcd 的 key 和 value 本质上都是字节序列,可以存储二进制数据。etcdctl -w json 会把 key 和 value 用 Base64 编码,以便安全地放进 JSON 字符串里。
了解了 revision 概念后,可以通过命令从某个时间节点开始获取事件。
$ docker exec -it etcd1 etcdctl watch /demo --prefix --rev=13
DELETE
/demo/age
PUT
/demo/name
dongdong
$ docker exec -it etcd1 etcdctl watch /demo --prefix --rev=10
PUT
/demo/name
dongdong
PUT
/demo/age
30
PUT
/demo/name
xiaoming
DELETE
/demo/age
PUT
/demo/name
dongdong
可以看到 watch 可以获取历史事件(虽说用途不同,但从表象看,感觉跟 Redis AOF 和 Kafka offset 都很像呢 🤔)
为什么能看到过去的事件? etcd 是 MVCC 存储,每次写入都会产生新的全局 revision,旧版本不会立刻被覆盖,所以可以按 revision 读取历史,也支持 Watch 从某个历史 revision 开始。
历史保存在哪里? 历史版本保存在 etcd 的后端数据库里,底层主要是 bbolt 持久化存储。
历史能保存多久? 不是固定时间。历史会一直保留,直到被 compaction(压缩) 清理。 可以手动压缩,也可以配置自动压缩,例如按时间或 revision 数保留。
本篇 etcd 的学习暂到这里,了解了 etcd 的基础功能,接下来看需要看往哪个方向再探索。
2026-10-03 11:03:00
前些天在大众点评看到一家位于昌平评分颇高的北京铜火锅餐馆,餐桌上摆放着消毒后带包装的餐具。
几年前我不在意是否收费,直接打开使用,两年前跟我老弟学到一招,可以让店员拿免费的餐具,店员一般都会拿免费的餐具、没有免费餐具也会告知我直接用这个带包装的就行,不收费。
在这个餐馆,店员明确告知没有免费餐具,我再三询问得知必须使用这个收费 1.5 元每套的餐具,在换家店和使用收费餐具之间二选一,饥肠辘辘的我被迫选择了后者,这是我第一次遇到拒绝提供免费餐具的餐馆。
第二天,我拨打 12315 消费者投诉举报热线进行反馈,再之后的一天,我接到工作人员的电话回访,了解我的就餐情况,希望我提供消费凭证,并询问是否有赔付的诉求。
我告知工作人员并无赔付诉求,希望市场监督管理局可以现场去核实情况,让商家遵守规章制度,免费提供餐具。
后续再遇到拒绝提供免费餐具的餐馆,我将现场提醒其不合规,以下是北京地区餐饮案例解读,建议全文阅读并背诵:
政策文件:《餐饮行业不公平格式条款认定详细解读》
概览
对于我遇到情况,原文(四、“消毒餐具工本费一元”或“消毒餐具另收费”。)有明确的记录,重点信息摘录如下:
为消费者免费提供餐具,是餐厅应当履行的义务。
向消费者提供消毒餐具,是餐饮企业的法定义务,其也应当承担消毒餐具产生的费用。
餐饮企业不应当把相关费用转嫁到消费者身上。
如果餐饮企业在提供免费消毒餐具同时,又提供收费消毒餐具,由消费者自行选择以满足个性化要求,亦属合理市场行为。
2026-09-27 21:09:00
中秋三天的假期接近尾声,在家宅了两天,还是出去走走。坐地铁来到雍和宫地铁站,本想到地坛公园逛书市,还没出地铁站就发现不太对劲儿,这人多到离谱,果断放弃地坛公园,往五道营胡同那边走。
依然人多,远离主路往南边儿走,就有很多居民区的胡同,适合转转。
在交道口北二条看到一个雷霆告示:「禁止在此处倒大便」
继续走走,看到老大爷们的聊天角,很有一种破败的动漫风。
北京的中秋时节,树叶都还没有黄,胡同就是不要设置目的地,也不用看地图,走到哪里就去哪里。
三四点钟,有些饿了,想着都到了簋街,索性去胡大饭店尝尝小龙虾。
等餐的时间比较长,3.50 到店,四十多分钟陆续上菜,麻辣小龙虾网上都说很辣,我就选择了蒜蓉口味的。
小龙虾有滋味,肉质比较好,比我前段时间购买的海底捞的小龙虾强 N 倍。香辣猪蹄也出乎意料的很好吃!
买一份龙虾伴侣素面,沾上蒜蓉小龙虾的酱,小龙虾面这不就做好了
胡大饭店是 24H 营业的,目测下午 5 点之后就不要再 walk in,2 人在大众点评上排队会 2 小时起步,3 小时能吃上都算不错。
吃了 9 分饱还没吃完,剩下一些牛蛙和猪蹄打包带走,步行前往雍和宫地铁站的途中,路过同日升粮行,这家店也有些年头,它家的二八酱可以作为北京土特产带回家。
没几步就到了雍和宫,打道回府,抓住了中秋假期的尾巴~
2026-09-24 19:15:00
上一篇介绍幂等键留下一些问题,幂等键有了,如果两个请求同时到达,应该如何处理?
未仔细考虑过并发问题的代码可能也试图使用缓存进行存在性检验,但两个请求同时到达时,会发生下面的流程。
请求 A:检查 Redis,不存在
请求 B:检查 Redis,不存在
请求 A:执行兑换
请求 B:执行兑换
请求 A:写入幂等结果
请求 B:写入幂等结果
两个请求都通过了存在性验证,走到业务流程,兑换了两次,问题出在这里:
if !exists(key) {
result := doBusiness()
save(key, result)
}
exists 和 save 是两个独立操作。从单个请求的视角看,这段代码没有问题:
以并发视角出发,第一步和第三步之间则存在一个明显的时间窗口。
请求 A 检查完成后,请求 B 完全可以在请求 A 写入结果之前,也完成一次检查。于是两个请求都认为「我是第一次请求」。
所以面对并发问题,真正需要保证的是对于同一个 Idempotency Key,同一时刻只能有一个请求获得业务执行权。
Redis 的 SET ... NX 很适合完成这个动作。
例如:
SET idempotency:{key} processing NX EX 300
其中:
NX:Key 不存在时才写入;EX 300:设置 5 分钟过期时间;processing:表示该请求正在处理中。这个操作在 Redis 中是原子的,于是两个请求同时到达时:
请求 A:SET ... NX -> 成功
请求 B:SET ... NX -> 失败
只有请求 A 获得执行权,请求 B 不再进入业务流程。
伪代码大概变成:
// 1. 原子抢占执行权
ok := setNX(key, "PROCESSING", 5*time.Minute)
if !ok {
return handleDuplicateRequest(key)
}
result, err := doBusiness()
if err != nil {
// 只有能够确认业务没有产生副作用时,
// 才可以安全释放 PROCESSING。
//
// 如果业务结果处于“不确定”状态,
// 不能简单 DEL,否则重试可能导致业务再次执行。
return handleBusinessFailure(key, err)
}
// 2. 保存成功结果,并设置更长的结果保留时间
saveResultWithTTL(key, result, 24*time.Hour)
return result
相比之前:
if !exists(key) {
result := doBusiness()
save(key, result)
}
原来的 “先检查、再决定是否执行” 存在竞争窗口,而 SET ... NX 将 “检查是否不存在 + 抢占执行权” 合并成了一个原子操作。
如果只做到这里,又会马上遇到几个新的问题,假设请求 A 抢占成功:
SET key processing NX EX 300
然后开始执行业务。
这时请求 B 带着相同的 Idempotency Key 过来了,它发现 Processing Key 已经存在。
注意:这里的 Processing 状态和 SUCCESS 状态,数据 TTL 是不同的,Processing 根据业务可能只有几分钟,而产生结果的 SUCCESS 状态时间会长很多。
问题来了:
所以幂等记录至少需要区分 不存在、PROCESSING、SUCCESS
请求第一次到达时,通过 SET ... NX 原子地把状态从 “不存在” 变成 PROCESSING。
业务执行成功后,再把状态更新为 SUCCESS,并保存原请求对应的响应结果。
例如:
{
"status": "success",
"http_status": 200,
"response": {
"code": 0,
"message": "success"
}
}
这样后续相同请求再次到达时,就不需要重新执行业务,可以直接返回第一次请求的结果。
假设请求 A 仍然处于 PROCESSING:
请求 A:PROCESSING
请求 B:再次请求
请求 B 此时有几种处理方式。
一种方式是立即返回:
HTTP/1.1 409 Conflict
例如:
{
"code": "request_in_progress",
"message": "A request with the same Idempotency-Key is still being processed"
}
客户端稍后重试即可。另一种方案是服务端短暂等待,请求 A 执行完成后,把 A 的结果直接返回给 B。
例如等待几十到几百毫秒,并轮询 Redis:
B -> 发现 PROCESSING
-> 等待
-> 查询
-> 等待
-> 查询
-> 发现 SUCCESS
-> 返回 A 的结果
这种方式客户端体验更好,但服务端实现也更复杂,而且会占用连接和计算资源。
实际项目中,更推荐这样判断:
短业务:可短暂等待
长业务:直接返回 processing/conflict,让客户端重试
前面的:
SET key processing NX EX 300
必须设置 TTL,因为服务可能在设置完 PROCESSING 后的处理过程中崩溃,如果缓存永不过期,那么这个 Idempotency Key 就永久处于处理中,没人知道它是不是还在运行。
之后所有重试都会被拒绝,本质上也是一种故障恢复机制。
然后就会出现新的问题:如果业务执行时间超过了 PROCESSING 的 TTL 怎么办?
例如 TTL 是 5 分钟:
00:00 请求 A 获得执行权
00:05 Redis Key 过期
00:06 请求 B 到达,重新 SET ... NX 成功
00:07 请求 A 仍然在执行业务
此时 A、B 又同时开始执行了。
所以 TTL 不能简单地拍脑袋设置。
对于执行时间可控的普通 HTTP API,可以让 TTL 明显大于业务正常执行时间。
对于耗时可能非常长的任务,则需要考虑:
本文使用 SET ... NX,只是为了原子地确定:谁是这个 Idempotency Key 对应请求的第一次执行者。
还有一个更加重要的问题。
即使 SET ... NX 本身是原子的,也不意味着整个业务流程是原子的。
例如兑换业务:
1. Redis 抢占 Idempotency Key
2. 数据库事务:扣减积分 + 创建兑换记录
3. 数据库 COMMIT 成功
4. Redis 保存 SUCCESS
假设数据库事务已经 COMMIT 成功,但服务在 Redis 保存 SUCCESS 之前崩溃,此时可能出现:
Redis:PROCESSING
数据库:兑换已经成功
客户端:没有收到成功响应
过一段时间 PROCESSING 过期,请求重新执行。
如果业务本身没有其他保护机制,就可能再次扣积分。
所以 Idempotency Key 能解决的是 HTTP 请求级别的重复提交问题,搭配 SET ... NX 能看住服务的大门,内部系统的业务 ‘幂等建设’ 仍需要仔细考量服务可能在任何阶段挂掉后的恢复机制,数据库层增加最终约束进行兜底。
下一篇,仍是关于 Idempotency Key ,还需要继续学习解决一个问题:
如果两个请求使用了相同的 Idempotency Key,但请求参数却不一样,应该怎么办?
2026-09-23 10:17:23
2026 年 9 月 22 日 Anthropic 发布 Claude Opus 5.5,同时赠送了一张重置卡(引入用户自主点击重置卡)。
同一天,OpenAI 发布 GPT-6 Sol 和 GPT-6 Luna 模型,也赠送了一张重置卡。
Yes 工程师提前过中秋!
2026-09-22 09:19:23
早上骑车路过一个施工现场,远远就看到有个人站在渣土车上 ‘观察’,直直的站着,两脚就踩着车斗的两个边框上(比图中更加夸张)。我猜是为了指导挖掘机填土,(如果有)安全员,看了肯定直呼哇塞。