MoreRSS

site iconYasking | 东东修改

博主毕业后北漂两三年,2017 年初回到哈尔滨从事软件开发,2021 年初又回到北京。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Yasking | 东东的 RSS 预览

学习 Etcd 笔记:启动与基础命令

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)

这里有两个知识点:

  1. "/demo/name" 这样像是目录一样的 Key 写法是常见约定俗成和推荐风格,类似于 Redis Key(module:thing)使用冒号分隔;
  2. Put 命令既可以用于创建,也可用于更新;
$ 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
    }'
  • dbSize: backend 数据库文件当前大小
  • 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

两个知识点:

  1. 终端 watch 的是 /demo 前缀,不是某一个具体 key;
  2. 写请求可以发给任意 member,watch 仍然会看到整个集群最终产生的事件;

Revision 修订概念

获取键值的详细信息

$ 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 消费者投诉举报热线进行反馈,再之后的一天,我接到工作人员的电话回访,了解我的就餐情况,希望我提供消费凭证,并询问是否有赔付的诉求。

我告知工作人员并无赔付诉求,希望市场监督管理局可以现场去核实情况,让商家遵守规章制度,免费提供餐具。

后续再遇到拒绝提供免费餐具的餐馆,我将现场提醒其不合规,以下是北京地区餐饮案例解读,建议全文阅读并背诵:

政策文件:《餐饮行业不公平格式条款认定详细解读》

概览

  1. “如甲方需减少订席数,须提前十五天告知乙方,否则乙方将按原订席数全额收费。”
  2. “请保管好自己的物品,谨防被盗,丢失本店概不负责。”或“公共场所请您携带好您的随身物品,如有丢失自负。”
  3. “餐厅有权接受或拒绝顾客自带酒水和食品。如果顾客不接受餐厅建议将被视为自动放弃食品卫生投诉权利。”
  4. “消毒餐具工本费一元”或“消毒餐具另收费”
  5. “禁止自带酒水。”
  6. “包间最低消费xx元。”

对于我遇到情况,原文(四、“消毒餐具工本费一元”或“消毒餐具另收费”。)有明确的记录,重点信息摘录如下:

《餐饮行业不公平格式条款认定详细解读》中关于“消毒餐具工本费一元”条款的截图

为消费者免费提供餐具,是餐厅应当履行的义务。

向消费者提供消毒餐具,是餐饮企业的法定义务,其也应当承担消毒餐具产生的费用。

餐饮企业不应当把相关费用转嫁到消费者身上。

如果餐饮企业在提供免费消毒餐具同时,又提供收费消毒餐具,由消费者自行选择以满足个性化要求,亦属合理市场行为。

北京・中秋假期的尾巴(2026)

2026-09-27 21:09:00

中秋三天的假期接近尾声,在家宅了两天,还是出去走走。坐地铁来到雍和宫地铁站,本想到地坛公园逛书市,还没出地铁站就发现不太对劲儿,这人多到离谱,果断放弃地坛公园,往五道营胡同那边走。

图 01

依然人多,远离主路往南边儿走,就有很多居民区的胡同,适合转转。

在交道口北二条看到一个雷霆告示:「禁止在此处倒大便」

图 02

继续走走,看到老大爷们的聊天角,很有一种破败的动漫风。

图 03
图 04

北京的中秋时节,树叶都还没有黄,胡同就是不要设置目的地,也不用看地图,走到哪里就去哪里。

图 05

三四点钟,有些饿了,想着都到了簋街,索性去胡大饭店尝尝小龙虾。

图 06

等餐的时间比较长,3.50 到店,四十多分钟陆续上菜,麻辣小龙虾网上都说很辣,我就选择了蒜蓉口味的。

图 07

小龙虾有滋味,肉质比较好,比我前段时间购买的海底捞的小龙虾强 N 倍。香辣猪蹄也出乎意料的很好吃!

图 08

买一份龙虾伴侣素面,沾上蒜蓉小龙虾的酱,小龙虾面这不就做好了

图 09

胡大饭店是 24H 营业的,目测下午 5 点之后就不要再 walk in,2 人在大众点评上排队会 2 小时起步,3 小时能吃上都算不错。

吃了 9 分饱还没吃完,剩下一些牛蛙和猪蹄打包带走,步行前往雍和宫地铁站的途中,路过同日升粮行,这家店也有些年头,它家的二八酱可以作为北京土特产带回家。

图 10

没几步就到了雍和宫,打道回府,抓住了中秋假期的尾巴~

高鲁棒性 API 设计之 Idempotency Key:并发请求与执行一致性

2026-09-24 19:15:00

上一篇介绍幂等键留下一些问题,幂等键有了,如果两个请求同时到达,应该如何处理?

未仔细考虑过并发问题的代码可能也试图使用缓存进行存在性检验,但两个请求同时到达时,会发生下面的流程。

请求 A:检查 Redis,不存在
请求 B:检查 Redis,不存在

请求 A:执行兑换
请求 B:执行兑换

请求 A:写入幂等结果
请求 B:写入幂等结果

两个请求都通过了存在性验证,走到业务流程,兑换了两次,问题出在这里:

if !exists(key) {
    result := doBusiness()
    save(key, result)
}

exists 和 save 是两个独立操作。从单个请求的视角看,这段代码没有问题:

  1. 检查幂等键是否存在;
  2. 不存在则执行业务;
  3. 保存执行结果。

以并发视角出发,第一步和第三步之间则存在一个明显的时间窗口。

请求 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 状态时间会长很多。

问题来了:

  1. 这个 Key 是已经执行成功了?
  2. 还是正在执行?
  3. 还是上一次执行到一半服务挂了?

所以幂等记录至少需要区分 不存在、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 对应请求的第一次执行者。

Redis 原子,不等于业务原子

还有一个更加重要的问题。

即使 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,但请求参数却不一样,应该怎么办?

Memos: 风云不语,只是一个劲儿的赠送重置卡

2026-09-23 10:17:23

2026 年 9 月 22 日 Anthropic 发布 Claude Opus 5.5,同时赠送了一张重置卡(引入用户自主点击重置卡)。

01
02

同一天,OpenAI 发布 GPT-6 Sol 和 GPT-6 Luna 模型,也赠送了一张重置卡。

03

Yes 工程师提前过中秋!

Memos: 看着一点也危险

2026-09-22 09:19:23

01

早上骑车路过一个施工现场,远远就看到有个人站在渣土车上 ‘观察’,直直的站着,两脚就踩着车斗的两个边框上(比图中更加夸张)。我猜是为了指导挖掘机填土,(如果有)安全员,看了肯定直呼哇塞。