普通视图

发现新文章,点击刷新页面。
昨天以前luozhiyun`s Blog 我的技术分享

拆解一下 opencode context 做了哪些优化

作者 luozhiyun
2026年4月6日 22:47

context 是怎么构建的?

opencode 的 context也是分为这几部分:

  • System Prompt:
    • environment:包含模型名称、工作目录、git 状态、平台、日期等;
    • skills:加载当前 agent 可用的 skill 工具描述
    • system:项目内向上查找的 AGENTS.md / CLAUDE.md / CONTEXT.md,全局 ~/.claude/CLAUDE.mdconfig.instructions 中配置的文件或 URL
  • Messages(历史消息 + 本轮消息):主要查询本次 session 的信息,从新的往旧的数据进行查找,分别拼接 user message 和 assistant message ;
┌─────────────────────────────────────────┐
│  System Prompt                          │
│  ├── 环境信息(模型名、目录、平台、日期)      │
│  ├── Skills 列表                        │
│  └── 指令文件(AGENTS.md / CLAUDE.md)   │
├─────────────────────────────────────────┤
│  Messages(历史 + 本轮)                 │
│  user → assistant → user → assistant…  │
└─────────────────────────────────────────┘

除此之外,上下文窗口管理还有三层机制防止上下文溢出(下面也会详细介绍):

  • Compaction(压缩):如果token 数接近模型上限,那么会用全量历史调一次模型,生成结构化摘要,然后在对话中插入一个"压缩点"。下次构建 context 时,遇到这个标记就截断,只保留摘要 + 之后的消息。
  • Prune(裁剪):对话结束后异步执行,从最新消息往前遍历,超过 40,000 token 保护边界之外的旧工具调用结果,将输出替换为 "[Old tool result content cleared]"。工具调用的结构(input/output 字段)保留,只清空 output 内容,这样不会破坏 Anthropic 要求的 tool_use/tool_result 配对。
  • 图片/媒体单独处理:对不支持工具结果中含图片的接口(非 Anthropic/OpenAI/Bedrock),自动把图片提取出来变成单独一条合成 user 消息注入

Prompt caching

Prompt Caching 是大模型提供商提供的一种优化机制:将某些内容(通常是较长且重复的前缀)在服务端缓存起来,后续请求命中缓存后跳过对该部分的重新计算,从而降低延迟和成本(缓存命中的 token 通常价格更低)。

Prompt Caching 通过一般是在调用的时候通过给 context 打标记实现,比如当使用 Claude 的时候会选择前两条 system + 最后两条对话打标记,因为System prompt 内容几乎不变,最后两条对话消息是模型下一步推理的直接上文,命中率最高。

打了标记后,Anthropic API 会在服务端把这段内容写入缓存,下次相同请求直接复用,不重新计算。

大致装配好之后是这样:

messages = [
  { role: "system", content: system[0] },  // ← cache 标记
  { role: "system", content: system[1] },  // ← cache 标记
  ...历史对话消息,
  ...最后两条                               // ← cache 标记
]

context 体积控制 prune & compact

compact 是“总结历史,重建上下文”。
prune 是“保留历史骨架,清空很老的 tool 输出正文”。

  • compact: 用一条 summary 替代一大段旧对话
  • prune: 不替代消息,只把旧 tool result 的大文本删掉,换成占位符

Compact

  • 启动一个专门的 compaction agent
  • 把当前有效历史送给模型
  • 让模型生成一份可继续工作的总结
  • 把这份总结存成一条 assistant message,并标记 summary: true

之后在正常对话里,filterCompacted() 会以这条 summary 为边界,只保留“summary 之后的有效历史”,见 message-v2.ts (line 810)。

什么时候用 compact
主要在上下文接近/超过模型限制时。触发点在 prompt.ts (line 547):

  • 如果最近一次完成的 assistant message token 太多
  • SessionCompaction.isOverflow(…) 返回 true
  • 就创建 compaction 任务
      if (task?.type === "compaction") {
        const result = await SessionCompaction.process({
          messages: msgs,
          parentID: lastUser.id,
          abort,
          sessionID,
          auto: task.auto,
          overflow: task.overflow,
        })
        if (result === "stop") break
        continue
      }

Prune

SessionCompaction.prune({ sessionID })
  • 倒着扫描旧消息
  • 只关注 tool 的 completed output
  • 保留最近一部分 tool 输出
  • 对更老的 tool 输出打上 time.compacted
  • 后续 toModelMessages() 会把这类输出替换成 [Old tool result content cleared],见 message-v2.ts (line 638)

tool result 防膨胀机制

第一层是“单次工具输出截断”
在 truncation.ts (line 11):

  • 最大 2000 行
  • 最大 50KB
  • 超过就截断
  • 完整输出落盘到 Global.Path.data/tool-output/…

这意味着一次工具调用就算返回特别大的文本,也不会原样塞回会话上下文。MCP 工具返回文本时就会走这层,见 prompt.ts (line 894) 附近 Truncate.output(…)。

最后会输出成:

    const hint = hasTaskTool(agent)
      ? `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
      : `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
    const message =
      direction === "head"
        ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
        : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`

第二层是“跨多轮的旧 tool output 清理”

就是上面提到到 prune:

  • 倒序扫描旧的 completed tool outputs
  • 保留最近的一部分
  • 更老的输出标记为 compacted
  • 之后在 message-v2.ts (line 638) 会被替换成 [Old tool result content cleared]

isOverflow

SessionCompaction.isOverflow() 在 compaction.ts (line 25) 会根据:

  • input tokens
  • output tokens
  • cache tokens

判断是否接近上下文上限。
一旦超限,就自动创建 compaction 任务,把旧历史总结掉。

doom loop 检测

在 processor.ts (line 126):

  • 如果连续 3 次调用同一个 tool
  • 输入还完全相同
  • 会触发权限确认 doom_loop

这防的是 agent 在循环里重复打同一个工具,导致历史疯狂增长。

拆解一下 opencode context 做了哪些优化最先出现在luozhiyun`s Blog

AI Agent 的上下文系统:Context Engineering 指南

作者 luozhiyun
2026年3月22日 17:51

什么是 context engineering

所有LLM都受到有限上下文窗口的限制,这迫使模型在“一次可以看到什么”方面做出艰难的权衡。上下文工程就是将这个窗口视为一种稀缺资源,并围绕它设计一切(检索、记忆系统、工具集成、提示等),以确保模型只将其有限的注意力预算花在有价值的token。

context engineering overview

看起来和 Prompt Engineering 差不多,但是侧重点是不一样的。Prompt Engineering 侧重是提示词文本本身,Context Engineering 是模型看到的整个输入上下文系统,也就是prompt 只是 context 的一部分,真正决定模型输出质量的,往往不只是那段 instruction,而是整个上下文构造过程

在早期使用逻辑逻辑模型(LLM)进行工程设计时,prompt是人工智能工程工作中最重要的组成部分,因为大多数日常聊天交互之外的应用场景都需要针对一次性分类或文本生成任务优化的prompt。顾名思义,prompt工程的主要重点在于如何编写有效的prompt,尤其是系统prompt。然而,随着我们朝着构建功能更强大的智能体方向发展,这些智能体需要在多轮推理和更长的时间跨度内运行,我们需要管理整个上下文状态(系统指令、工具、MCP、外部数据、消息历史记录等)的策略。

image-20260322174658060

也就是在单轮请求场景中,模型的输入主要只有两块:System prompt、User message,只是做一个简单的输入然后回答。但是对于 agent 场景要复杂的多,因为 agent 不是纯聊天,它的执行流程很长,一般会有:

  1. 先看上下文
  2. 决定是否调用工具
  3. 拿到 Tool result
  4. 再把结果放回上下文
  5. 继续下一轮推理

所以 agent 是一个循环式工作流,不是一次性输入输出,那么在多轮工作流中,就需要从这些上下文中输出中“捞出”并“整理”出最正确的素材,而怎么筛选出最正确的内容就是Context Engineering 要做的事情。

为什么 Context Engineering 这么重要?

虽然现在的模型号称拥有百万级甚至千万级的上下文窗口(Context Window),但它们对信息的处理并不是“一视同仁”的。随着上下文变长,模型提取和处理信息的能力会像有机物腐烂一样逐渐变差,这种现象也叫 context rot。

典型的代理循环包含两个主要步骤:模型调用 ->工具执行,这个循环会持续到LLM决定结束,这些返回都会不断的拼接到模型的 context 里面。特别是工具调用后返回的结果会作为Observation拼接到模型里面,这部份内容经常会特别长,这样长的Observation不断地拼接到上下文message中,最后很有可能超过了模型最长能够接受的上下文长度(比如128K~1M)。

如果没有有效的 Context Engineering 来应对,Context Rot 会导致以下几个层面的严重后果:

  1. 准确性的“雪崩” (Accuracy Collapse):这是最直接的后果。随着上下文变长,模型提取关键事实的能力并非线性下降,而是可能在某个临界点突然跳水。模型可能记得某个“关键词”,但会完全搞错它在句子里的逻辑关系(例如:把“A 公司收购了 B”记成“B 公司收购了 A”)。
  2. 指令漂移与“性格”崩坏 (Instruction Drift):模型在 Prompt 开头设定的规则、约束和语气,会随着 Context Rot 的加剧而失效。你原本要求“严禁输出代码”,但当对话进行到 50 轮,上下文堆满了之前的讨论时,模型可能会因为抓取不到开头的强约束而开始输出代码。
  3. Agent 的“逻辑死循环” (Recursive Failure):Agent 忘记了自己已经尝试过某个 API 调用并失败了,由于上下文腐败,它会反复尝试同一个错误动作,直到耗尽预算。
  4. 调试的“不可预测性” (Non-Deterministic Flakiness):当上下文较短时,模型表现完美;当上下文变长,模型开始报错。这种 Bug 具有随机性,因为 Context Rot 受干扰项的位置、语义相似度等复杂因素影响,导致开发者很难通过简单的测试找到失效边界。

The Best Practice of Context Engineering

Context Engineering 对上下文的管理并不是简单的“复制粘贴”,而是一套精密的信息物流系统。其核心目标是:在不超出 Token 限制的前提下,将最高价值的信息精准送达模型最敏感的“注意力区域”。

Compression 压缩

其本质是:在尽量保留原始语义(Information Integrity)的前提下,通过算法减少传递给模型的 Token 数量。一般有几种做法:

  • 级联摘要(Incremental Summarization):将历史对话分成块,让模型(通常用一个更小、更便宜的模型)将每一块总结成几句话;
  • Token 级硬裁剪(Selective Context / Pruning): 语言中存在大量冗余(如“the”, “a”, “is” 以及重复的礼貌用语),利用小模型(如 GPT-2 或 Llama-8B)计算概率,删掉那些“即便删了,模型也能猜出来”的低信息量 Token;
  • 精炼 Tool Output:Agent 调用工具(如搜索、运行代码)后会有大量噪音,比如原始: {"status": 200, "data": {"user": {"id": 1, "name": "Alice", "bio": "Extremely long bio text..."}}},可以裁剪成:Found user: Alice (ID: 1)
  • 语义软压缩:使用专门的算法(如微软的 LLMLingua)重新编排 Prompt,将原本松散的句子重构成极度紧凑的、只有 AI 能读懂的“密文”。

Sub-agent architectures 子代理架构

子代理架构提供了另一种绕过上下文限制的方法。与其让一个代理尝试维护整个项目的状态,不如让专门的子代理在清晰的上下文窗口中处理特定的任务。主代理负责协调高层计划,而子代理则执行深入的技术工作或使用工具查找相关信息。每个子代理可能进行广泛的探索,使用数万个或更多令牌,但最终只返回其工作的精简摘要(通常包含 1000 到 2000 个令牌)。

如果你让一个 Agent 重构整个项目:

  1. 主代理(Manager): 维护项目全局目标。
  2. 子代理 A(Linter): 专门扫描语法错误,只向主代理报告错误列表。
  3. 子代理 B(Researcher): 专门读取文档,只返回 API 调用规范。
  4. 子代理 C(Coder): 接收 A 和 B 的精炼结论,在干净的窗口里编写代码。

这种方式确保了编写代码的 Agent 不会被上千行的“语法报错日志”或“冗长的库文档”干扰注意力。

Use the File System as Context 使用文件作为上下文的补充

因为在 Agent 的多轮交互当中即使现在 context 可以达到 200M 以上的大小,但是依然可能会不够,因为 Tool result 可能会非常的大,尤其是在 Agent 与网页或 PDF 等非结构化数据交互时,很容易超出上下文限制。并且 Agent 在多轮交互过程中,需要保存各种 reasoning 信息,成功或失败的调用 tool 的结果都需要保存,导致再长的context也不够用。

img

为了解决这个问题,许多 Agent 系统都采用了上下文截断或压缩策略。但过度压缩不可避免地会导致信息丢失。所以不管是 Claude 还是 Manus 都建议将文件作为外部的 context 来使用。可以利用文件系统来存储 Agent 的中间思考状态,解决长时程任务中的 Context Rot 问题。

比如可以让 Agent 在该文件中实时记录:

  • 当前已完成的任务步骤。
  • 已确认的事实(例如:“auth.py 的报错是因为版本不兼容”)。
  • 接下来的行动计划。

然后提供一套能够精准操作文件系统的工具,Agent 后续可以通过headtailgrep等命令渐进式地查看,或一次性读取整个文件。这种方式既减少了上下文占用,又保留了完整信息。

context中的实战tips

contex 拼接要按顺序

由于模型存在“中间信息丢失(Lost in the Middle)”的倾向,必须将最重要的信息放在 Prompt 的两端。顺序上通常是:

System / Global Instructions
User Profile / Long-term Memory
Relevant Conversation History
Current Task / Current Question
Retrieved Knowledge / Tool Results
Working Summary / Constraints / Output format

核心逻辑是:

  • 先告诉模型“你是谁、要遵守什么”
  • 再告诉它“用户是谁、长期背景是什么”
  • 再告诉它“前面聊到了哪里”
  • 再明确“这一轮到底要做什么”
  • 再给“这轮任务所需的工具调用结果或知识库相关检索”
  • 最后提醒“回答时关注什么、输出成什么样”

如果中间数据太长,建议在底部 Query 之前增加一句:请基于上述 <context> 里的信息回答以下问题:

tool result / retrieval docs 放在当前问题后面

因为模型最容易根据上下文有关联的链路来理解内容例如:

问题 -> 证据 -> 回答

而不是下面这样:

证据A -> 旧history -> 证据B -> memory -> question

后者很容易让模型搞不清哪些证据是给当前任务用的。

结构化标记 Structured Tagging

使用明确的 XML 标签 或 Markdown 标记 是目前公认最有效的隔离方式,因为它能显著降低模型对“数据”和“指令”的混淆。比如这样:

<system_instructions>
你是一个代码审计专家。请遵循 <security_policy> 进行分析。
</system_instructions>

<security_policy>
1. 严禁泄露 API Key。
2. 优先检查 SQL 注入漏洞。
</security_policy>

<context_data>
[此处存放 RAG 检索到的代码片段或文档]
</context_data>

<tool_outputs>
[此处存放上一步执行 grep 或 linter 的原始输出]
</tool_outputs>

<user_query>
基于以上背景,分析 src/auth.py 的安全性。
</user_query>

信息精炼:防止“Context 污染”

在拼接之前,必须对各部分内容进行预处理,提升信号密度:

  • 工具结果去噪: 如果 API 返回了 2000 行 JSON,只抽取核心的 data 字段,丢弃 headersmetadata 等噪音。
  • 历史消息“关键帧”化: 保留最近几轮完整对话,更早的对话只保留 Summary
  • 去重(Deduplication): RAG 检索时经常会召回重复或高度相似的片段,拼接前需通过语义对比或简单的哈希值过滤。

Token 预算动态分配

在拼接逻辑中,建议为各部分设置权重(Weights),防止某一部分过长导致“爆仓”或挤掉核心指令。

模块 建议权重/策略 溢出处理
System Prompt 100% 保留(最高优先级) 绝不截断
Current Query 100% 保留(最高优先级) 绝不截断
RAG Context 40% 预算 按相似度评分从低到高丢弃
History 30% 预算 采用滑动窗口或摘要化
Tool Results 20% 预算 只保留最新结果,旧结果仅保留结论

选择性注入

并非所有上下文都需要同时存在于 context window 中。透过 LLM 驱动的路由逻辑,系统可以根据当前查询的性质和业务领域,动态决定注入哪些知识片段。例如,当使用者询问财务问题时,系统注入财务相关文件与对话历史;当话题转向技术问题时,动态替换为技术文件。

Reference

https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents

https://manus.im/zh-cn/blog/Context-Engineering-for-AI-Agents-Lessons-from-Building-Manus

https://docs.langchain.com/oss/python/langchain/context-engineering#the-agent-loop

https://weaviate.io/blog/context-engineering

https://zhuanlan.zhihu.com/p/2012088406826562496

https://research.trychroma.com/context-rot

https://blog.langchain.com/context-engineering-for-agents/

AI Agent 的上下文系统:Context Engineering 指南最先出现在luozhiyun`s Blog

从对话到知识:AI Agent 记忆系统的设计与实现

作者 luozhiyun
2026年3月8日 16:07

随着 AI Agent 技术演进,从目前来看 AI Agent 架构大概被划分越来越清晰,我们参考《The Rise and Potential of Large Language Model Based Agents: A Survey》这篇论文里面的 Agent 架构定义,大概划分为以下几个部分:

image-20260227103552679

  1. 感知区(Perception):负责将现实世界的原始数据转化为 Agent 能理解的语言,包括图像、文本、音频、触觉、位置信息等特征提取;
  2. 决策区(Brain-Decision Making):让 Agent 具备逻辑链条的推演,能够将复杂目标拆解从而得到答案;
  3. 存储区(Brain-Memory & Knowledge):让 Agent 具备记忆能力,记忆内部存储了 Agent 的知识和技能,并且可以通过类似于 RAG(检索增强生成),去查说明书或数据库将新知识内化;
  4. 行动区(Action):让 Agent 具备与外界交互的能力,可以调用外部 API,甚至通过物理实体感知环境做出行动;

那么我们本篇文章讨论的“记忆”其实是更广泛的存储区这块功能。对于 AI Agent 记忆来说,记忆其实就有点像脑容量,其核心必要性体现在以下三点:

  • 上下文一致性 (Contextual Consistency)

    Agent 需要记住之前的对话内容,才能理解当前的指令。例如,如果你先说“帮我订一张去上海的机票”,接着说“改到明天”,Agent 必须记得“去上海的机票”这个前提。

  • 长期偏好学习 (Personalization):

    通过记忆,Agent 可以学习用户的习惯(如:你偏好 Python 而不是 Java,或者你习惯在周五下午复盘)。

  • 复杂任务拆解 (Task Decomposition & Planning)

    在执行多步任务(如:写代码 -> 测试 -> 找 Bug -> 修复)时,Agent 需要记录每一步的状态,确保不会陷入循环或丢失进度。

记忆分类

在 AI Agent 领域记忆通常效仿人类的认知结构,分为以下层次:

  • 短期记忆 (Short-term Memory):利用大模型的 Context Window(上下文窗口),将最近的几轮对话记录直接放入 Prompt 中发送给模型,抑或是工具调用结果、中间推理状态、任务临时变量,但是受限于模型能够处理的最大 Token 数量,一旦对话过长,旧的信息就会被“挤出”。

    这部份数据我们可以存储在内存中,配合TTL(Time To Live)机制进行自动清理。这种设计的优势在于访问速度极快,但也意味着工作记忆的内容在系统重启后会丢失。这种特性正好符合工作记忆的定位,存储临时的、易变的信息。

  • 长期记忆 (Long-term Memory):这相当于人类的“经验仓库”,可以存储海量信息并在需要时检索,可以通过各种数据库进行存储,一般来说可以做如下分类:

    • 情境记忆 (Episodic Memory):记录 Agent 过去的经历和日志,它存储了之前决策周期的序列。例如:“上次我尝试解决这个问题时发生了什么?”这有助于 Agent 从过去的成功或失败中学习。
    • 语义记忆 (Semantic Memory):存储关于世界和 Agent 自身的事实性知识,它不依赖于具体的经历。例如,“北京是中国的首都”或者用户的基本偏好。在技术实现上,这通常对应于 RAG(检索增强生成)所调用的外部知识库。
    • 程序性记忆 (Procedural Memory):存储“如何做”的技能和规则。写在 Agent 代码中的逻辑,例如 Prompt 模板、工具调用说明或决策流程。

query-construction.png

所以我这里借用一下 langchain 官方的一张图,agent memory 的存储其实就是选用合适存储的过程,针对不同数据类型将自然语言查询转化为特定数据库查询的方法。

记忆操作

记忆的核心操作其实就两个:

  1. 存储(Storage):在短期记忆或长期记忆中保留编码信息的过程;
  2. 提取(Retrival):也可称为回忆,即在需要时访问并使存储的信息重新进入意识的过程;

存储

对于存储关于世界和 Agent 自身的事实性知识,我们通过 RAG(检索增强生成)所调用的外部知识库来实现,这部份我们单独拿出来说。这里我们先说说记录 Agent 过去的经历和日志的情境记忆 (Episodic Memory)。

我们将这种Episodic Memory分为三部份来进行存储:

  • 向量库保存当前记忆正文和 metadata(主存)
  • 关系数据库保存每次变更历史,方便查询(审计日志)
  • 将记忆进行关系提取以“实体-关系”图(Knowledge Graph)的形式存储;

存储的核心主要包含两个关键阶段:

  • 提取阶段(Extraction Phase):

    系统从当前的对话消息和历史背景中,动态地提取出“显著信息”(Salient Information)。它不是简单地存储对话记录,而是将其转化为简练、事实性的“记忆片断”。

  • 更新与整合阶段(Update Phase):

    当新的记忆提取出来后,将其与现有的相似记忆进行对比:

    • 添加(Add): 存储全新的事实。
    • 更新(Update): 如果用户信息发生了变化(例如:用户以前说喜欢咖啡,现在改说喜欢茶),系统会自动覆盖或修正旧记忆。
    • 冲突解决: 自动处理矛盾信息,确保记忆库的一致性。

提取阶段(Extraction Phase)

做提取核心原因是把“原始对话”压缩成“可检索、可更新、可复用”的结构化记忆。

  1. 降噪
    原始聊天里有大量寒暄和上下文噪声,facts 只保留真正值得记住的信息(偏好、身份、计划等)。
  2. 提升检索命中
    向量库里存短而清晰的事实,比存整段对话更容易在 search 时命中相关内容。
  3. 支持记忆演化
    提取出 facts 后,系统才能做 ADD / UPDATE / DELETE / NONE,处理“用户改口/信息过期/冲突事实”。
  4. 降低 token 和成本
    后续回答时注入的是少量关键 facts,不是整段历史,响应更稳、成本更低。
  5. 个性化更可靠
    facts 是“可解释”的记忆单元,能更稳定地驱动个性化回答,而不是靠模型在长上下文里猜。

我们这里使用 LLM 抽取,让模型按固定格式输出{"facts":[...]},灵活、效果好,是现在最常见方案。主要是分成这么几步来实现:

  1. 首先要对消息做 parse_messages,拼成 user:/assistant:/system
  2. 然后就是选取 prompt ,提取阶段我们需要根据 user message 和 agent message 来做抽取,两者的 prompt 是不同的;
  3. LLM 要求返回 JSON:{"facts":[...]}
  4. 对每条 fact 做向量检索找旧记忆,再进入到下一个更新与整合阶段;

我们来看个例子具体怎么提取的:

对话输入:

  • user: 我叫小王,在北京做后端开发。我乳糖不耐受,平时喜欢跑步。
  • assistant: 收到,小王。我擅长 Python 和系统设计,回答会尽量简洁。我个人偏好用表格总结。

user 会提取出:

{
  "facts": [
    "名字是小王",
    "在北京做后端开发",
    "乳糖不耐受",
    "平时喜欢跑步"
  ]
}

assistant 会提取出:

{
  "facts": [
    "擅长 Python 和系统设计",
    "回答风格尽量简洁",
    "偏好用表格总结"
  ]
}

再看一个“无可提取信息”的例子,对话输入:

  • user: 今天天气不错。
  • assistant: 是的。
{"facts": []}

更新与整合阶段(Update Phase)

这一阶段会用第一阶段提取出来的 facts 来进行记忆的更新与整合。主要是分成这么几步来实现:

  1. 每条 fact 先做 embedding,再查相似旧记忆;
  2. 对数据进行合并和去重,整理去新旧记忆;
  3. 然后让 LLM 做更新决策,把“旧记忆+新 facts+输出 JSON 约束”拼成 prompt,再让 LLM 返回:ADD / UPDATE / DELETE / NONE

我们在让 LLM 做更新决策的时候需要根据 4 个明确模块,降低 LLM 自由发挥空间:

  1. 放入“操作规则与判定标准”

    在我们给定的 UPDATE_MEMORY_PROMPT 里面需要定义了 ADD/UPDATE/DELETE/NONE 的语义和多个 few-shot 示例,让 LLM 具体了解到更新规则;

  2. 放入“当前记忆状态”

    如果有旧记忆,就把旧记忆数组包在代码块里;否则明确写 Current memory is empty。这样 LLM 是在“当前状态机”上做增删改,而不是凭空生成。比如这样拼接 prompt:

       if retrieved_old_memory_dict:
           current_memory_part = f"""
       Below is the current content of my memory which I have collected till now. You have to update it in the following format only:
       {retrieved_old_memory_dict}
       else:
           current_memory_part = """Current memory is empty. """
  3. 放入“新 facts 输入”

    把新抽取的 facts 明确告诉模型:你只需要判断这些新事实对当前记忆该怎么处理。比如这样:

    The new retrieved facts are mentioned in the triple backticks. You have to analyze the new retrieved facts and determine whether these facts should be added, updated, or deleted in the memory.
  4. 最后强约束输出格式

    函数把目标输出 schema 写死为:{"memory":[{"id","text","event","old_memory"}]},并加“Do not return anything except JSON format”这能显著提高可解析性,方便后续程序按 event 执行。

我们举个完整例子:

假设旧记忆是:

[
  {"id": "0", "text": "喜欢奶酪披萨"},
  {"id": "1", "text": "是后端工程师"}
]

新 facts 是:

["喜欢鸡肉披萨", "在准备转管理岗"]

然后 LLM 可能返回:

{
  "memory": [
    {
      "id": "0",
      "text": "喜欢奶酪和鸡肉披萨",
      "event": "UPDATE",
      "old_memory": "喜欢奶酪披萨"
    },
    {
      "id": "1",
      "text": "是后端工程师",
      "event": "NONE"
    },
    {
      "id": "2",
      "text": "在准备转管理岗",
      "event": "ADD"
    }
  ]
}

后续程序按 event 执行真正落库(新增/更新/删除)。

Graph 存储

再来将一下Graph存储怎么做。Graph核心优势在于它不再是零散的“事实点”,而是形成了“知识网”。在处理复杂逻辑、跨时空关联和深度偏好挖掘时,这种方式比简单的纯文本记忆要强大得多,并且不像向量数据库只能进行相似度进行检索,而是可以沿着已知的节点和边,像找地图一样寻找关联。

我们来举例几个场景:

  1. 复杂的人际关系网(社交/CRM 场景)

    如果一个 AI 助理只记录纯文本,它可能记得“王总喜欢红酒”和“李女士是王总的太太”。但当你要策划一场晚宴时,基于图的记忆能迅速通过“配偶”关系推导出两者的关联,AI 就可以根据提问信息进行实体和关系的抽取:

    • 实体: 王总李女士红酒
    • 关系: [王总] --(配偶)--> [李女士][王总] --(偏好)--> [红酒]
  2. 跨 session 的逻辑排产与项目追踪

    在长期的项目管理中,任务之间存在前置、后置和依赖关系。比如根据我们的文档 AI 可以抽离出下面实体和关系:

    • 实体: 模块 A 设计前端开发后端 API张工
    • 关系: [前端开发] --(依赖于)--> [后端 API][张工] --(负责)--> [后端 API]

    如果张工今天请假了,基于图的记忆能立刻感知到:这不仅会耽误“后端 API”,还会连锁反应导致“前端开发”停滞。

  3. 个性化推荐中的“归因”与“反转”

    传统的向量检索(Vector Search)有时会因为语义接近而产生误导,但图结构可以做到精准的时间戳与状态管理。比如用户在 2023 年说“我最讨厌吃香菜”,但在 2024 年说“我尝试了香菜拌牛肉,竟然觉得不错”,那么可以抽取出:

    • 实体:时间态度物品
    • 关系:[用户] --(2023 态度: 厌恶)--> [香菜][用户] --(2024 态度: 接受)--> [香菜]

    图结构可以带标签(如时间、强度)。当 AI 决定今天点餐建议时,它能通过有向边的“时间戳”属性,识别出最新的态度已经覆盖了旧的态度,从而避免因为检索到旧文本而一直提醒你“别放香菜”。

同样的我们也需要分几步通过约束和关系的抽取让我们产生的结果更加可控:

  1. LLM 抽实体+类型

    这一步主要是做主体的提取相应实体和类型,规范输出结果,主要用于后续入库时给节点打 label/type(以及默认类型回退),比如输入文本:
    我叫小王,在字节跳动做后端开发,住在北京。

    得到结果大致会变成:

    {
     "name": "extract_entities",
     "arguments": {
       "entities": [
         {"entity": "小王", "entity_type": "person"},
         {"entity": "字节跳动", "entity_type": "organization"},
         {"entity": "后端开发", "entity_type": "profession"},
         {"entity": "北京", "entity_type": "location"}
       ]
     }
    }
  2. LLM 抽关系三元组

    这一步是为了把上一步抽取的实体和类型让 LLM输出 source/relationship/destination,比如上面的例子这里会生成:

    {
     "name": "establish_relationships",
     "arguments": {
       "entities": [
         {"source": "小王", "relationship": "works_at", "destination": "字节跳动"},
         {"source": "小王", "relationship": "has_profession", "destination": "后端开发"},
         {"source": "小王", "relationship": "lives_in", "destination": "北京"}
       ]
     }
    }
  3. 用实体 embedding 在图里查相近旧节点/关系,再用 LLM 判定要删哪些旧关系,再执行 ADD / UPDATE / DELETE

    这里我举例说明一下,比如用户先后两次输入:

    1. 我在字节跳动做后端,住在北京。(首次输入)

      抽到关系后入图:

      • (小王, works_at, 字节跳动)
      • (小王, lives_in, 北京)
    2. 我现在在字节工作,搬到北京市朝阳区了。(过了一段时间后)

      新实体可能是:小王 / 字节 / 北京市朝阳区

    接下来就会检索和新旧关系的判定

    1. 查 字节 最相近旧节点

      • 与图中 字节跳动 相似度很高(假设 0.93,阈值 0.7)
      • 复用旧节点 字节跳动,不新建 字节
    2. 查 北京市朝阳区 最相近旧节点

      • 与 北京 也许中等相似(如 0.76)
      • 是否复用取决于阈值和语义;常见会保留成新节点(更具体地名)
    3. 拿这些相近节点的旧关系给 LLM 看

      • 旧关系里有 (小王, lives_in, 北京)
      • 新信息是“搬到北京市朝阳区”
      • 删除判定阶段可能删掉旧 lives_in -> 北京,新增 lives_in -> 北京市朝阳区

    最终图可能变成:

    • (小王, works_at, 字节跳动)(保留)
    • (小王, lives_in, 北京市朝阳区)(新增)
    • (小王, lives_in, 北京)(删除或保留,取决于模型判定)

提取(Retrival)

对记忆的提取也是分两块进行提取:

  1. 向量库检索,先把 query 做 embedding,然后调用具体向量库进行召回;
  2. 图检索,先抽实体,再用图数据库里的向量相似度查关系;
  3. 最后把结果分别返回;

这里就是常规逻辑。

RAG 知识检索增强

上面我们有提到过,当需要关于世界和 Agent 自身的事实性知识,它不依赖于具体的Agent经历。例如,“北京是中国的首都”或者用户的基本偏好,在技术实现上,这通常对应于 RAG(检索增强生成)所调用的外部知识库。

RAG 核心思想是:在生成回答之前,先从外部知识库中检索相关信息,然后将检索到的信息作为上下文提供给大语言模型,从而生成更准确、更可靠的回答。

一个完整的 RAG (Retrieval-Augmented Generation,检索增强生成) 应用流程可以分为两个核心阶段:离线数据处理 (Ingestion)在线检索生成 (Inference)

  • 离线阶段:数据准备与索引 (Data Ingestion)

    这是 RAG 的“地基”,目的是将非结构化的知识变成 AI 能够理解和检索的格式。

    • 文档加载 (Loading): 从 PDF、Word、Markdown 或数据库中提取文本。

    • 文本分割 (Chunking): 将长文章切分为较小的、语义完整的段落(Chunks)。

    为什么? 因为 LLM 有上下文窗口限制,且过长的信息会稀释检索精度。

    • 向量化 (Embedding): 调用 Embedding 模型(如 OpenAI text-embedding-3 或本地的 BGE),将文本转换为高维向量。

    • 向量存储 (Vector Storage): 将这些向量连同原始文本存储在向量数据库中(如 Pinecone, Milvus, Chroma)。

  • 在线阶段:检索 (Retrieval)

    当用户提出问题时,系统开始“翻书”。

    • 查询向量化: 将用户的提问(Query)转换成同一维度的向量。
    • 向量检索: 在数据库中寻找与提问向量相似度最高(通常用余弦相似度计算)的前 k 个文档片段。

数据的写入

通过我们上面的简单介绍,应该可以知道写入流程是这样:

任意格式文档 → MarkItDown转换 → Markdown文本 → 智能分块 → 向量化 → 存储检索

下面我们简单的讨论一些细节。

MarkItDown转换

MarkItDown 是微软(Microsoft)开源的一款非常实用的工具。它主要的目的是用来处理多模态的数据,无论是 PDF, Word (docx), PowerPoint (pptx), Excel (xlsx) 还是图片、音频内容,将各种格式的非结构化数据,一键转换为干净、标准的 Markdown 格式。

对于图片数据,它会调用多模态模型通常配置指向一个多模态大模型(如 GPT-4oClaude 3.5 Sonnet),模型会分析图片中的场景、物体、文字(OCR)以及图表趋势,将生成的描述文字。比如 PDF 里面有一张图片,那么会抽取成:

![图片描述:一张显示 2023 年第二季度销售额增长 15% 的柱状图。](image.png)

对于音频内容,MarkItDown 一般会结合 OpenAI Whisper 等语音识别模型将音频中的对话或旁白完整转录为文本,转录后的文本会作为该音频文件的“代表内容”存入 Markdown 结果中,使其可以被向量化并检索。

智能分块

在 RAG 应用中,分块(Chunking) 是决定检索质量的生死线。如果分块太小,会丢失上下文;如果分块太大,会引入过多噪音并导致 LLM 无法处理。

目前市面上主流的几种分块策略有:

  • 基于句法结构的语义分块:利用文档自身的层级结构(如 # 标题、## 子标题)进行切分。 识别 Markdown 或 HTML 的标题标签,将属于同一标题的内容聚合成一个块;
  • 递归字符分块:按“优先级顺序”寻找分隔符进行拆分,比如可以预设一个分隔符列表(如 ["\n\n", "\n", " ", ""]),首先尝试按段落(\n\n)切,如果某一段还是太长,再按句子(\n)切,依然太长,就按空格切;
  • 语义相似度分块:这种是根据文字的意思进行拆分,它会将文档拆成单个句子,然后计算相邻两个句子的 Embedding(向量),计算它们的余弦相似度。如果两个句子之间的“语义断层”很大(相似度低于阈值),就说明这里是主题转换点,在此处切断;
  • 代理分块:利用大模型(LLM)来决定哪里该切。让 LLM 阅读文本,然后询问 LLM:“这段话里有几个独立的主题?请在主题转换处插入切分符。”

其实上面智能程度和计算成本是成反比的,越只能的策略通常来说也越贵。

策略 智能程度 计算成本 适用场景
固定字符 极低 性能要求极高的基准测试
递归结构 通用场景(推荐首选)
语义相似度 缺乏明显格式的非结构化论文/报告
Agentic/LLM 极高 高价值、高准确度要求的核心文档

数据的检索

RAG系统将数据存好之后,核心的竞争力还是在检索。RAG 的基本思路是根据用户输入检索出最相关的内容,但是用户输入是不可控的,可能存在冗余、模糊或歧义等情况,如果直接拿着用户输入去检索,效果可能不理想。所以我们可以通过一些策略来优化查询效果。

查询扩展策略 (Query Expansion Techniques)

查询扩展(Query Expansion) 就是把用户的原始提问“整容”或“分身”,变成更多、更丰富的表达方式。它的存在是为了解决 RAG 系统中的一个顽疾:词项不匹配(Term Mismatch)。比如用户搜“番茄”,但文档里写的是“西红柿”,基础检索可能就会完美错过。

查询扩展有多种不同的实现,比如:

多查询(Multi-Query)

这是最常见的扩展方式。让 LLM 站在不同角度,把你的问题重写成 3-5 个意思相近的问题。比如提问:“如何让猫爱上喝水?”,可以被扩展成:

  • “猫咪饮水习惯的诱导方法有哪些?”

  • “增加宠物猫饮水量的实用技巧。”

  • “哪些因素会影响猫对水源的偏好?”

后退提示 (Step-back Prompting)

它是 Google DeepMind 团队在论文 Take a Step Back: Evoking Reasoning via Abstraction in Large Language Models 中提出的一种新的提示技术。

基本原理简单来说就是,如果你的问题太细节,检索效果往往不好。查询扩展会先退一步,问一个更宏观的原理。比如提问:“为什么我的 2023 款 MacBook Pro 跑 Python 特别烫?”,后退一步可能是:

  • “笔记本电脑在高负载运行代码时的散热机制和性能限制因素是什么?”

帮助系统先检索到大框架知识,辅助回答具体细分问题。

假设文档 (HyDE)

HyDE 是 Luyu Gao 在 Precise Zero-Shot Dense Retrieval without Relevance Labels ,它的核心思想是"用答案找答案"。传统的检索方法是用问题去匹配文档,但问题和答案在语义空间中的分布往往存在差异——问题通常是疑问句,而文档内容是陈述句。HyDE 与其用一个“问题”去搜“答案”,不如先编一个“假答案”,然后用“假答案”去搜“真答案”

比如提问:“那个两个粒子互相感应的物理现象叫什么?”,检索效果差往往是因为 Query(问题)Document(文档) 处于不同的语义空间,因为文档通常很长且是陈述句: “当两个或多个粒子以特定的方式结合在一起时,它们的状态就变得不可分割。即使你把这两个粒子分别放在宇宙的两端,它们依然保持着这种奇……”。

所以,我们可以让LLM 生成假答案: “这种现象通常指量子纠缠,即两个粒子在空间上分离但状态紧密关联……”,带着这段话去搜。因为假答案里包含了“量子纠缠”、“空间分离”、“状态关联”等学术词汇,它能精准地在论文中找到对应的章节。

RAG Fusion

最后还需要提一下 RAG Fusion,这是它的论文地址 https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf 。比如当你用查询扩展生出了 5 个问题,去检索得到了 5 份不同的答案排名,这时候就会出现矛盾:文档 A 在问题 1 里排第一,在问题 2 里排第十。RAG Fusion 就是那个负责“打分合并”的裁判。它利用 RRF(倒数排名融合) 算法进行打分。

比如现在有个原始问题: “如何在北京申请居住证?”,然后我们扩展成:

  • 分身 1: 北京居住证办理流程是什么?

  • 分身 2: 北京居住证申请需要什么材料?

  • 分身 3: 外地人在北京办居住证的条件。

然后我们得到检索结果,文档 A(《北京人口管理条例》):在分身 1 搜到排第 3,分身 2 搜到排第 2,分身 3 搜到排第 5。文档 B(一篇非官方博客):在分身 1 搜到排第 1,但在其他两个搜索里都没出现。

经过 RRF 计算,文档 A 虽然没有拿过第一,但因为它在三个维度都被认定为高度相关,最终总分会反超文档 B。这样就过滤掉了偶然性极高的干扰信息。

查询重写(Query Rewriting)

Xinbei Ma 等人在论文Query Rewriting for Retrieval-Augmented Large Language Models提出了一种 Rewrite-Retrieve-Read 的方法,对用户的输入进行改写,以改善检索效果。在传统的 RAG(检索 -> 阅读)流程中,用户的原始输入往往不是“搜索引擎友好”的,比如包含大量的冗余、代词或模糊表达等。

查询重写主要思想就是使用一个专门的“重写器”(Rewriter)将原始查询转化为一个或多个更适合搜索引擎的检索词(Search Queries),然后使用这些优化后的词去数据库中捞取知识。

总结

总而言之,AI Agent 的记忆系统是其迈向高度智能的核心支柱,通过构建包含短期工作记忆与长期经验库的多层架构,结合基于大模型的事实提取、动态更新机制及知识图谱技术,并配合深度优化的 RAG 检索流程,Agent 能够实现精准的上下文维持与知识内化,从而在复杂场景中提供更具一致性、个性化且可靠的智能支持。

Reference

https://github.com/mem0ai/mem0

https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents https://blog.langchain.com/how-we-built-agent-builders-memory-system/

https://arxiv.org/abs/2309.02427

https://arxiv.org/abs/2504.19413

https://www.youtube.com/watch?v=cHQyugatz6M

https://www.aneasystone.com/archives/2024/06/advanced-rag-notes.html

从对话到知识:AI Agent 记忆系统的设计与实现最先出现在luozhiyun`s Blog

如何正确预估redis写入容量

作者 luozhiyun
2026年2月20日 13:33

全局视角

我们先以一个全局的视角看看 redis 的数据是怎么存放的:

redisDb (数据库)
  └── dict (全局字典)
       └── ht[0] (哈希表数组)
            └── [Bucket] ──> dictEntry (节点)
                               ├── key: [ SDS ("mykey") ]
                               └── val: [ redisObject ]
                                            ├── type: REDIS_STRING
                                            ├── encoding: EMBSTR (或 RAW)
                                            └── ptr ──> [ SDS ("hello") ]

Redis 的顶层存储核心是用全局字典(Global Dict,也叫 Keyspace)来管理所有的数据,Dict 采用的是双哈希表结构来保存数据主要是用来做渐进式 rehash,双哈希表结构用ht[0]ht[1]来表示,通常数据只在 ht[0] 中,当哈希表需要扩容或缩容时,Redis 会一边处理请求,一边分批将数据从 ht[0] 迁移到 ht[1]

哈希表其实就是一张大 bucket 数组,每个 bucket 是 dictEntry,由 dictht 数据结构来进行管理:

typedef struct dictht {
    // 哈希表的槽
    dictEntry **table;
    // 哈希表槽个数,是2的整数次幂
    unsigned long size;
    // size-1,计算出一个key的hash后,直接 hash & sizemask即可算出所属的槽
    unsigned long sizemask;
    // 已使用大小
    unsigned long used;
} dictht;

在全局字典中,每一个键值对都被封装在一个 dictEntry 结构体中:

  • Key(键):始终是一个指向 SDS (Simple Dynamic String) 结构的指针。即使你设置的是数字键,Redis 也会将其转为字符串 SDS 存储,SDS 结构下面我们会说;
  • Value(值):始终是一个 redisObject 结构体(或其指针)。redisObject 就像一个通用容器,它封装了所有 Redis 数据类型(String, List, Hash 等)。

redisObject

在 Redis 中使用 redisObject 统一来管理底层的数据结构,无论底层是SDSziplistdict统一用 redisObject 来进行封装,然后通过 type 来进行标识。

在 Redis 的 C 语言源码中,它的定义如下(以 64 位系统为例):

字段名 占用空间 作用说明
type 4 bits 逻辑类型:标识它是 String、List、Hash、Set 还是 ZSet。
encoding 4 bits 物理编码:标识底层具体是用什么实现的(如 ziplist、skiplist、int 等)。
lru / lfu 24 bits 对象热度:记录最后一次被访问的时间(LRU)或访问频率(LFU),用于内存淘汰。
refcount 4 bytes 引用计数:记录有多少地方引用了这个对象。为 0 时对象被销毁。
ptr 8 bytes 数据指针:指向底层真实数据的内存地址。

合计算下来,一个 redisObject 固定占用 16 字节。

这样做就是统一了接口,当你执行 DEL 命令时,Redis 不需要关心你删的是 String 还是 List,它只需要操作 redisObject 这个通用结构。

除此之外它有三大作用:

  1. 类型检查与多态

    当你输入 LPOP key 时,Redis 会先检查这个 redisObjecttype 是不是 REDIS_LIST。如果不是,直接返回错误。如果是,它会根据 encoding 字段去调用对应的函数(比如是从 linkedlist 弹出还是从 listpack 弹出)。

  2. 内存管理与共享

    通过 refcount 的引用计数来控制内存的释放,当引用计数归零,Redis 才会真正释放内存。

  3. 内存淘汰(LRU/LFU 算法)

    LRU 模式就会通过时间戳来看该对象是否应该被淘汰。LFU 模式它根据数据被访问的频率来决定淘汰对象,高 16 位存时间,低 8 位存访问计数。 如果这个字段很久没更新,当 Redis 内存不足时,它就会优先被“踢出”内存。

虽然在全局字典看来,所有的 Value 都是一个 redisObject,但 redisObject 内部通过 typeptr 指向了完全不同的底层世界:

命令示例 redisObject -> type redisObject -> ptr 指向的内容
SET key "val" REDIS_STRING 指向一个 SDS(可能是 int, embstr 或 raw)
HSET user:1 name "A" REDIS_HASH 指向一个 Dictlistpack/ziplist
LPUSH list "item" REDIS_LIST 指向一个 quicklist(由多个 listpack 组成的双端链表)
SADD tags "java" REDIS_SET 指向一个 Dict (value 为 NULL) 或 intset
ZADD rank 100 "A" REDIS_ZSET 指向一个 zset 结构(内含 Skiplist + Dict

String

Redis 设计了简单动态字符串(Simple Dynamic String,SDS)的结构,用来表示字符串,动态字符串结构如下图所示:

sds

SDS 大致由两部分构成:header以及 数据段,其中 header 还包含3个字段 len、alloc、flags。len 表示数据长度,alloc 表示分配的内存长度,flags 表示了 sds 的数据类型。

在以前的版本中,sds 的header其实占用内存是固定8字节大小的,所以如果在redis中存放的都是小字符串,那么 sds 的 header 将会占用很多的内存空间。

但是随着 sds 的版本变迁,其实在内存占用方面还是做了一些优化:

  1. 在 sds 2.0 之前 header 的大小是固定的 int 类型,2.0 版本之后会根据传入的字符大小调整 header 的 len 和 alloc 的类型以便节省内存占用。
  2. header 的结构体使用 __attribute__ 修饰,这里主要是防止编译器自动进行内存对齐,这样可以减少编译器因为内存对齐而引起的 padding 的数量所占用的内存。

目前的版本中共定义了五种类型的 sds header,其中 sdshdr5 是没用的,所以没画:

sds_header

当执行 SET key value 时,对于 key 来说存放方式就是:

DictEntry
   │
   └── key (指针) 
        │
        ▼ 
  ┌──────────────────────────────────────────────────────────────┐
  │ [ SDS Header ] [           SDS Body (buf)           ] [ \0 ] │
  └──────────────────────────────────────────────────────────────┘
  ▲              ▲                                      ▲
  │              │                                      │
  │              │                                      └── 结尾 (1 byte)
  │              │
  │              └── 你的 1MB 甚至 512MB 的数据
  │
  └── 这里的元数据结构会根据大小变化
      (sdshdr8 -> sdshdr16 -> ... -> sdshdr64)

对于 value 来说,Redis 会根据 value 的情况选择以下三者之一:

  1. int 编码
  • 适用场景:如果字符串内容可以转为 long 类型的整数
  • 实现方式:直接将整数值存在 redisObjectptr 指针位置(指针 8 字节,正好存下一个 long)。
  • 优点零额外内存分配。不需要 SDS,不需要额外的内存块。
  1. embstr 编码
  • 适用场景:长度小于等于 44 字节 的字符串。
  • 实现方式redisObject 结构体与 SDS 结构体在内存中是连续的一块空间。
  • 优点
    • 只需一次内存分配/释放。
    • 利用 CPU 缓存局部性(连续内存读取更快)。
  • 阈值由来:16 字节 (robj) + 3 字节 (sdshdr8) + 44 字节 (data) + 1 字节 (\0) = 64 字节。这正好是常见的 CPU Cache Line 大小。
  1. raw 编码
  • 适用场景:长度大于 44 字节 的字符串。
  • 实现方式redisObjectSDS 是两块独立的内存区域,通过指针连接。
  • 优点:适合大字符串,扩容时不需要重新分配整个 redisObject

image-20260213152933556

所以我们可以看到 key 和 value 其实是分两部分存储:

  • Value (值):可能会因为 RAW 编码 而导致 redisObjectSDS 分离(不挨着)。

  • Key (键):永远没有 redisObject 包装,它直接就是一个 SDS。所以 Key 的 Header 和数据永远是连在一起的,没有任何例外。

容量估算

jemalloc

在估算容量之前,我们来看看 redis 使用的 jemalloc 是怎么做内存分配的。

jemalloc 预先定义了一系列固定的内存块大小(称为 Size Class)。当 Redis 请求分配 N 字节时,jemalloc 会查找第一个大于等于 N 的规格(Size Class)内存块进行分配。

为了减少浪费,jemalloc 的规格设计得很科学,并不是单纯的 2 的幂次方(2, 4, 8, 16…),而是更加细密:

规格区间 具体的 Size Class (字节)
8B – 128B 8, 16, 32, 48, 64, 80, 96, 112, 128
128B – … 160, 192, 224, 256, 320 …

举个具体的例子:

假设你在 Redis 里存一个简单的字符串,算上 SDS 头部等开销,Redis 向系统申请了 20 字节

  1. 查找:jemalloc 看了看手里的规格表:8, 16, 32…
  2. 判定:16 字节装不下 20 字节。
  3. 取整:下一个规格是 32 字节
  4. 分配:给 Redis 分配 32 字节 的内存块。

结果

  • 实际使用:20 字节。
  • 实际占用:32 字节。
  • 浪费:12 字节(这被称为内部碎片)。

为什么要这样做?(好处)

虽然看起来浪费了一点点空间(内部碎片),但对整个系统来说,收益巨大:

  1. 速度极快: 不需要每次都去计算哪里有空闲内存。jemalloc 维护了许多“桶”(Bin),比如“32字节桶”、“64字节桶”。要 20 字节?直接从“32字节桶”里拿一个出来就行,O(1) 复杂度。
  2. 减少外部碎片: 当你释放这 32 字节后,它会干干净净地回到“32字节桶”里。下一个申请 17~32 字节的请求来了,可以直接复用这块内存,严丝合缝。
  3. 缓存友好: 数据按照固定大小排列,更容易被 CPU 缓存(Cache Line)命中。

以 string 为例估算分析

所以根据我们上面的介绍,应该知道一个 String 键值对的总内存占用主要由三部分组成:

image-20260213154831600

  1. 全局字典节点 (dictEntry):固定 24 字节

    • 包含三个指针(Key 指针、Value 指针、Next 指针),各占 8 字节。
  2. 键 (Key):SDS 结构

    • 包含:SDS Header + Data + 1 (\0)
    • 需要注意的是 redis 使用的是 jemalloc 来做内存分配,jemalloc 会将结果向上取整到最近的分配阶梯(如 8, 16, 32, 48, 64 字节)
  3. 值 (Value):取决于编码方式,上面我们有介绍,就不细说了 int、embstr、raw 编码;

    编码方式 计算公式 说明
    INT 16 字节 只有 redisObject,数值直接存在指针里。
    EMBSTR $malloc(16 + 3 + len(Val) + 1)$ redisObject 与 SDS 连续分配,整体向上取整。
    RAW $16 + malloc(3 + len(Val) + 1)$ redisObject 与 SDS 分开分配,各自取整后再求和。

实例估算:存储 SET "key" "value"

我们来算一下这个极小键值对实际占了多少地儿:

  1. dictEntry: 24 字节
  2. Key ("key"):
    • 长度为 3,计算:3(Header) + 3(Data) + 1(\0) = 7 字节。
    • jemalloc 向上取整为 8 字节
  3. Value ("value"):
    • 长度为 5,采用 EMBSTR 编码。
    • 计算:16(robj) + 3(Header) + 5(Data) + 1(\0) = 25 字节。
    • jemalloc 向上取整为 32 字节
  4. 总计:24 + 8 + 32 = 64 字节。

所以我们可以看到个有趣的事实,存储 8 字节的原始数据,Redis 实际需要 64 字节,膨胀率高达 8 倍

估算建议

实测采样法

不要试图用数学公式去死算每一个字节(因为 jemalloc 和 struct padding 很难完全算准),而是采用 “小规模采样 + 线性推演”

我们可以启动一个空的 Redis 实例,记录初始内存 used_memory(通常在 1MB 左右,是 Redis 自身的启动开销)。编写脚本,写入 10,000 个 具有代表性的 Key-Value 数据(长度和类型要符合你的生产场景)。

然后计算初始内存使用 和 最终内存使用的差值,然后计算出单挑数据消耗,将单条数据消耗 X 预计总数据量就可以得到最终的预估结果。

经验法则:估算膨胀系数

如果你没法做测试,只能盲算,必须根据 Key/Value 的平均大小 来应用不同的膨胀系数。

  1. 小对象场景(最容易翻车)

    • 场景:Key = 10 字节,Value = 10 字节。

    • 原始数据:20 字节。

    • Redis 实际占用:约 64 ~ 80 字节

    • 膨胀系数3倍 ~ 4倍

      • 原因dictEntry (24B) + redisObject (16B) 即使什么都不存就已经 40B 了。加上 jemalloc 的 8B/16B/32B 对齐,开销巨大。
  2. 中等对象场景

    • 场景:Key = 30 字节,Value = 500 字节。
    • 膨胀系数1.1倍 ~ 1.2倍
      • 原因:此时数据的占比上来,头部元数据(Overhead)的占比下降。
  3. 大对象场景

    • 场景:Key = 50 字节,Value = 10 KB。

    • 膨胀系数接近 1.05倍

      • 原因:jemalloc 对大内存块的分配非常精准(Page 对齐),且元数据占比可忽略不计。

如何正确预估redis写入容量最先出现在luozhiyun`s Blog

如何用agent skill来编排workflow?

作者 luozhiyun
2026年1月25日 15:31

本文章的实践代码提交在:https://github.com/luozhiyun993/skill-workflow

本文将深度解析 Agent Skill 的模块化设计:从 Skill 间的层级调用、工具脚本的自动化执行,到 Subagent 的专业化分工。我们将通过“小红书爆款生产线”这一实战案例,展示如何利用文件传递状态追踪清单模式,解决复杂任务中上下文过载与输出不可控的痛点。告别臃肿的单一 Prompt,让你的 Agent Workflow 变得可验证、可断点续传且高度精准。

skill 有哪些玩法?

skill 调用 skill

有时候任务比较复杂,我们就可以抽取出不同的 skill,通过 skill 之间的调用来简化单个 skill 的复杂度,或者可以把一些公用到的 skill 抽取出来,变成单一的 skill。

比如我们每次在开发完之后都需要:运行测试,本地合并到基础分支、推送并创建 Pull Request,那么我们就可以创建一个 finishing-a-development-branch skill,然后在其他的 skill 里面指定调用:

### Step 5: Complete Development

After all tasks complete and verified:
- Announce: "I'm using the finishing-a-development-branch skill to complete this work."
- **REQUIRED SUB-SKILL:** Use finishing-a-development-branch skill
- Follow that skill to verify tests, present options, execute choice

skill 调用工具脚本

比如我们可以在 skill 里面指定使用方法,运行脚本,以及输出结果是什么,让 agent 自动执行:

## 使用方法

这是一个基于 TypeScript 的脚本 Skill。

### 运行脚本

# 在项目根目录下运行
npx ts-node .claude/skills/demo.ts

### 输出结果

脚本运行后,会在 workflow-agent/outputs/demo/ 目录下生成两个文件:

1.  demo_[timestamp].json: 原始数据。
2.  tdemo_analysis_[timestamp].md: Claude 生成的分析报告。

创建可验证的中间输出

当 Claude 执行复杂、开放式的任务时,它可能会出错。假设你让克劳德根据电子表格更新 PDF 中的 50 个表单字段,我们就可以通过添加一个中间的 changes.json 文件,在应用更改之前对其进行验证。工作流程变为:分析 → 创建文件验证 → 执行 → 验证。

这一步特别重要:所有中间结果都保存成本地文件

三个好处:

  • 可追溯:出问题了能看到每一步的输出
  • 可断点续传:中途停了,下次从上次的位置继续
  • 可人工干预:不满意某一步的结果,手动改完让 Agent 继续

比如我们可以这样在 SKILL 里面指定文件的存放目录以及存放格式:

## Instructions

When this skill is invoked:

1. Create the `./input` directory if it doesn't exist
2. Get the user's input message (passed as arguments or prompt for it)
3. Generate a timestamp-based filename (format: `YYYY-MM-DD_HH-MM-SS.txt`)
4. Save the input to `./input/<timestamp>.txt`
5. Confirm the file has been saved with the full path

skill 调用 subagent

skill 里面是可以调用 subagent 的,subagent 有几个优势是:context 独立,可以并发执行,并且是可以进行专业化分工的,那么我们就可以在 skill 在有需要的时候调用 subagent,提升执行效率,比如下面我创建了一个 go-file-author-attribution agent,那么在 skill 里面就可以指明调用:

 **Batch Process Files**
   - For each eligible file, use the Task tool to invoke the `go-file-author-attribution` agent
   - Pass the author name and file path to the agent
   - Process files sequentially to avoid conflicts

但是如果这样简单的调用,有时候会把一大段内容直接塞给 subagent,上下文窗口很快就撑满了。但如果只传路径,subagent 自己去读文件,上下文就干净很多。

Subagent 之间只传文件路径,不传内容,这条规则很重要。

比如可以设置一个 writer-agent 启动时只需要三个参数:source 文件路径、analysis 文件路径、outline 文件路径。它自己读取内容,写完保存到指定路径,返回输出文件路径。

这样做还有个好处:可以并行启动多个 subagent。三个 writer-agent 同时跑,各自处理一个提纲方案,互不干扰。

对于复杂的要求可以使用 reference

在 skill 里面通常来说,不建议把所有的信息都平铺到 SKILL.md 里面,因为上下文太长会浪费很多不必要的 token,并且让 agent 不够聚焦,那么我们可以使用 reference 的方式提供外部的文档提供:

## References

See `references/` folder for detailed documentation:
- `bdi-ontology-core.md` - Core ontology patterns and class definitions
- `rdf-examples.md` - Complete RDF/Turtle examples
- `sparql-competency.md` - Full competency question SPARQL queries
- `framework-integration.md` - SEMAS, JADE, LAG integration patterns

常见的 pattern

清单模式

将复杂的操作分解成清晰的、循序渐进的步骤。对于特别复杂的流程,提供一份清单 checklist,这样可以让 agent 逐步勾选完成,如下所示:

image-20260125151914743

## Research synthesis workflow

Copy this checklist and track your progress:

Research Progress:
- [ ] Step 1: Read all source documents
- [ ] Step 2: Identify key themes
- [ ] Step 3: Cross-reference claims
- [ ] Step 4: Create structured summary
- [ ] Step 5: Verify citations

**Step 1: Read all source documents**

Review each document in the sources/ directory. Note the main arguments and supporting evidence.

**Step 2: Identify key themes**

Look for patterns across sources. What themes appear repeatedly? Where do sources agree or disagree?

**Step 3: Cross-reference claims**

For each major claim, verify it appears in the source material. Note which source supports each point.

**Step 4: Create structured summary**

Organize findings by theme. Include:
- Main claim
- Supporting evidence from sources
- Conflicting viewpoints (if any)

**Step 5: Verify citations**

Check that every claim references the correct source document. If citations are incomplete, return to Step 3.

除此之外,也可以让 claude 在 workflow 里面去执行代码,比如把代码放入到 scripts 中,我们可以看一下 claude pdf skill 的目录结构:

.
├── forms.md
├── LICENSE.txt
├── reference.md
├── scripts
│   ├── check_bounding_boxes_test.py
│   ├── check_bounding_boxes.py
│   ├── check_fillable_fields.py
│   ├── convert_pdf_to_images.py
│   ├── create_validation_image.py
│   ├── extract_form_field_info.py
│   ├── fill_fillable_fields.py
│   └── fill_pdf_form_with_annotations.py
└── SKILL.md

在 SKILL.md 里面直接指明什么时候去调用脚本: `python scripts/check_fillable_fields <file.pdf>

下面提供一个demo:

## PDF form filling workflow

Copy this checklist and check off items as you complete them:

Task Progress:
- [ ] Step 1: Analyze the form (run analyze_form.py)
- [ ] Step 2: Create field mapping (edit fields.json)
- [ ] Step 3: Validate mapping (run validate_fields.py)
- [ ] Step 4: Fill the form (run fill_form.py)
- [ ] Step 5: Verify output (run verify_output.py)

**Step 1: Analyze the form**

Run: python scripts/analyze_form.py input.pdf

This extracts form fields and their locations, saving to fields.json.

**Step 2: Create field mapping**

Edit fields.json to add values for each field.

**Step 3: Validate mapping**

Run: python scripts/validate_fields.py fields.json

Fix any validation errors before continuing.

**Step 4: Fill the form**

Run: python scripts/fill_form.py input.pdf fields.json output.pdf

**Step 5: Verify output**

Run: python scripts/verify_output.py output.pdf

If verification fails, return to Step 2.

循环验证模式

通过 Run validator → fix errors → repeat 这种循环模式来不断提升输出的质量

Gemini_Generated_Image_ghyq6pghyq6pghyq

## Content review process

1. Draft your content following the guidelines in STYLE_GUIDE.md
2. Review against the checklist:
   - Check terminology consistency
   - Verify examples follow the standard format
   - Confirm all required sections are present
3. If issues found:
   - Note each issue with specific section reference
   - Revise the content
   - Review the checklist again
4. Only proceed when all requirements are met
5. Finalize and save the document

比如上面的例子中,使用 STYLE_GUIDE.md 作为验证器,agent 通过通过读取和比较来执行检查,不通过则循环修改之后再进行验证。

条件工作流模式

我们可以在 md 里面引导 agent 做出条件选择,运行符合条件的 workflow :

Gemini_Generated_Image_ghyq6pghyq6pghyq

## Document modification workflow

1. Determine the modification type:

   **Creating new content?** → Follow "Creation workflow" below
   **Editing existing content?** → Follow "Editing workflow" below

2. Creation workflow:
   - Use docx-js library
   - Build document from scratch
   - Export to .docx format

3. Editing workflow:
   - Unpack existing document
   - Modify XML directly
   - Validate after each change
   - Repack when complete

Examples pattern

我们可以在 skill 里面提供示例以提升 agent 的能力,最好可以明确 input/output 这样更明确,如下所示:

## Commit message format

Generate commit messages following these examples:

**Example 1:**
Input: Added user authentication with JWT tokens
Output:

feat(auth): implement JWT-based authentication

Add login endpoint and token validation middleware

**Example 2:**
Input: Fixed bug where dates displayed incorrectly in reports
Output:

fix(reports): correct date formatting in timezone conversion

Use UTC timestamps consistently across report generation

模板 pattern

比如我们现在输出的结果就是需要按照一定要求输出,那么我们可以在 skill 提供模版,让 agent 按照模版输出:

## Report structure

ALWAYS use this exact template structure:

# [Analysis Title]

## Executive summary
[One-paragraph overview of key findings]

## Key findings
- Finding 1 with supporting data
- Finding 2 with supporting data
- Finding 3 with supporting data

## Recommendations
1. Specific actionable recommendation
2. Specific actionable recommendation

实战:用 skill 解决 workflow 编排任务

一般的情况,我们用 传统workflow的做法(比如在dify里),需要这么做:

  • 打开可视化编辑器
  • 拖一个“输入节点”
  • 连接到“LLM节点”,配置prompt
  • 再连接到“调用API节点”
  • 最后连接到“输出节点”
  • 测试、调试、再测试…

但是如果用 skill 就完全不需要这样,比如可以简单的用我上面讲的 pattern 就足够实现一套比较复杂的 workflow了。

比如目前要搭建一个一个小红书热门爆款写作的workflow,首先是从热门网站爬取,然后分析爆款热点,再来写作,最后输出到小红书,那么整个 workflow 的编排任务也可以通过 skill 来完成。

那么我们可以这样编排 workflow:

.claude/
├── skills/
│   ├── workflow-runner/         # 核心编排引擎
│   │   ├── SKILL.md             # 解析 YAML 并调度任务的指令
│   │   └── workflow_schema.json  # 约束 workflow.yaml 的格式
│   ├── web-scraper/             # 基础采集工具
│   │   ├── SKILL.md             # 爬虫调用指令
│   │   └── scripts/             # 存放 Python/Playwright 爬虫脚本
│   └── xhs-utils/               # 小红书专用工具箱
│       ├── SKILL.md             # 包含格式化、Emoji 注入、标签生成逻辑
│       └── templates/           # 爆款文案模板库
├── agents/                      # 专门化的 Sub-agents 定义
│   ├── crawl-agent.md           # 负责从乱码网页中清洗出有效信息的 Agent
│   ├── trend-analyst-agent.md   # 负责拆解爆款逻辑、提炼“钩子”的 Agent
│   └── xhs-writer-agent.md      # 负责不同人格化写作的文案 Agent
└── workspace/                   # 运行时的中转站 (执行过程中动态生成)
    └── xhs-factory/             # 存放 raw_data, analysis, drafts 等中间文件

我上面这套 workflow 可以利用到 skill 和 subagent 相互协调来实现。skill 主要用来运行脚本和润色;subagent 因为有单独的context,所以将拆分的任务并发执行,提升处理效率。

image-20260119153625117

第一步:执行编排 workflow-runner (编排器) ,它会通过读取配置,我把它定义为 xhs_vlog.yaml,它里面规定了执行步骤,以及输出到什么文件夹:

name: "小红书爆款文案生产线"
version: "1.0"
workspace: "workspace/xhs-factory"

steps:
  # 步骤 1:爬取小红书热门内容
  - id: scraping_stage
    type: skill
    skill: web-scraper
    params:
      target: "xiaohongshu_trending"  # 爬取小红书首页热门
      limit: 20                        # 爬取20篇热门笔记
      output_dir: "{{workspace}}/raw_data"

  # 步骤 2:清洗数据
  - id: cleaning_stage
    type: agent
    agent: crawl-agent
    depends_on: [scraping_stage]
    params:
      input: "{{steps.scraping_stage.output}}"
      output: "{{workspace}}/cleaned_data.json"

  # 步骤 3:趋势分析
  - id: analysis_stage
    type: agent
    agent: trend-analyst-agent
    depends_on: [cleaning_stage]
    params:
      input: "{{steps.cleaning_stage.output}}"
      output: "{{workspace}}/analysis/hooks_and_patterns.json"

  # 步骤 4:文案创作(并行生成3种风格)
  - id: writing_stage
    type: agent
    agent: xhs-writer-agent
    mode: parallel                    # 并行执行
    depends_on: [analysis_stage]
    params:
      styles: ["干货风", "吐槽风", "故事风"]
      analysis: "{{steps.analysis_stage.output}}"
      output_dir: "{{workspace}}/drafts"

  # 步骤 5:格式化文案
  - id: formatting_stage
    type: skill
    skill: xhs-utils
    depends_on: [writing_stage]
    params:
      drafts_dir: "{{steps.writing_stage.output}}"
      output_dir: "{{workspace}}/final"

然后通过设置 run_state.json文件的方式每完成一个步骤,agent 必须强制更新这个文件,然后上一步和下一步通过 ouput 来进行对接,每一步完成之后会标记状态和完成时间,比如这样:

{
  "workflow_file": ".claude/workflows/xhs_vlog.yaml",
  "workspace": "workspace/xhs-factory",
  "current_step_id": "writing_stage",
  "global_context": {},
  "steps": {
    "scraping_stage": {
      "status": "completed",
      "output": "workspace/xhs-factory/raw_data",
      "timestamp": "2026-01-19T14:17:19.344205",
      "error": null
    },
    "cleaning_stage": {
      "status": "completed",
      "output": "workspace/xhs-factory/cleaned_data.json",
      "timestamp": "2026-01-19T14:22:17.638192",
      "error": null
    },
    "analysis_stage": {
      "status": "completed",
      "output": "workspace/xhs-factory/analysis/hooks_and_patterns.json",
      "timestamp": "2026-01-19T14:29:11.210193",
      "error": null
    },
    "writing_stage": {
      "status": "completed",
      "output": "workspace/xhs-factory/drafts",
      "timestamp": "2026-01-19T14:34:22.027580",
      "error": null
    },
    "formatting_stage": {
      "status": "pending",
      "output": null,
      "timestamp": null,
      "error": null
    }
  }
}

第二步:原子执行 web-scraper (Skill),Skill 会调用运行 Python 脚本进行网站的爬取,脚本运行成功并生成文件后,Runner 立即将 scrapping_stage 标记为 completed,并写入文件到当前项目的 raw_data 文件夹;

第三步:启动 crawl-agent 批量的对抓取的页面进行数据清洗,并且在 crawl-agent.md 文件中还用示例的方式指出了输出格式:

[
  {
    "id": "note_0",
    "title": "绝绝子!这个方法让我一周瘦了5斤",
    "content": "姐妹们,今天分享一个超好用的减肥方法...",
    "likes": 12000,
    "comments": 456,
    "favorites": 0,
    "tags": ["减肥", "健康", "生活方式"],
    "published_at": null
  } 
]

第四步:启动并行创作xhs-writer-agent,启动多个 subagent 完成不同风格的文案写作工作,比如我在 agent 里面规定了三种风格,可以根据自己的运营经验进行微调:

### 干货风
- **标题**:数字+动词+效果(如"3招让你的皮肤嫩到发光✨")
- **开头**:直接抛出核心价值,吸引读者
- **正文**:步骤拆解,每步用 emoji 标记,内容具体可操作
- **结尾**:总结+互动引导(如"姐妹们快试试吧💕")
- **长度**:300-500字

### 吐槽风
- **标题**:痛点+共鸣(如"姐妹们,别再踩这些坑了!😭")
- **开头**:描述痛点场景,引发共鸣
- **正文**:吐槽+解决方案+对比,情绪化表达
- **结尾**:反转或金句收尾
- **长度**:250-400字

第五步:执行汇总格式化 xhs-utils (Skill),只有当 run_state.json 显示所有创作子任务都为 completed 时,才会触发最后的格式化 Skill。

最终生成的文件全部都通过文件来传递,可以极大的减少 token 的消耗:

└── workspace
    └── xhs-factory
        ├── analysis
        │   └── hooks_and_patterns.json
        ├── cleaned_data.json
        ├── drafts
        │   ├── 吐槽风.md
        │   ├── 干货风.md
        │   └── 故事风.md
        ├── final
        │   ├── 吐槽风_final.md
        │   ├── 干货风_final.md
        │   └── 故事风_final.md
        ├── raw_data
        │   ├── note_0.json
        │   ├── ....
        │   └── note_9.json
        └── run_state.json

总结

Agent Skill 的核心魅力在于它将大模型的逻辑能力与软件工程的模块化思想深度融合。通过这篇文章的实践,我们可以体会到几个比较有用的实践:

  • 告别“上下文焦虑”: 通过 Subagent + 文件传递 的模式,我们将原本臃肿的单一对话拆解为独立任务。Subagent 之间只传路径、不传内容,这不仅极大地节省了 Token 成本,更保证了每个节点都能在“干净”的上下文中发挥极致的专业性。
  • 变“不可控”为“可验证”: 引入 中间输出 (JSON/Markdown)清单模式 (Checklist),让 Agent 的执行过程从“黑盒”变成了“白盒”。你不仅可以随时通过本地文件追溯错误,还能实现断点续传和人工微调。
  • 低成本的灵活性: 相比于 Dify 等可视化工具的繁琐配置,基于 Skill 的编排只需要几段简单的指令和 YAML 配置。这种“以文档驱动流程”的方式,让开发者能像写代码一样快速迭代 AI 的业务逻辑。

Reference

https://x.com/dotey/status/2010176124450484638

https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices

如何用agent skill来编排workflow?最先出现在luozhiyun`s Blog

LangGraph 是如何让LLM产生确定性输出的?

作者 luozhiyun
2026年1月10日 20:46

像经常用 LLM 的同学都知道现在最头疼的问题就是幻觉问题,在金融或精密计算领域,不确定性意味着风险。 如果 Agent 负责分析 NVDA 或 TSLA 的财报,开发者希望它在处理相同数据时,逻辑推导链条是严密的,而不是在不同时间给出自相矛盾的结论。或是需要 LLM 输出 JSON 来触发一个 API,我们不会希望 LLM 在 JSON 里多加了一个逗号或改变了字段名。

最后我还尝试用 LangGraph 的理念自己写了一个 smallest-LangGraph

LangGraph 可以做什么?

传统的 LangChain 核心逻辑是 DAG(有向无环图)。我们可以轻松定义 A -> B -> C 的步骤,但如果你想让 AI 在 B 步骤发现结果不满意,自动跳回 A 重新执行,LangChain 的普通 Chain 很难优雅地实现。并且在复杂的长对话或多步骤任务中,维护一个全局的、可持久化的“状态快照”非常困难。

所以为了解决这些问题,LangGraph 就诞生了。LangGraph 的主要有这些核心优势:

  1. 支持“循环(Cycles)”与“迭代”

    思考 -> 2. 行动 -> 3. 观察结果 -> 4. 如果不满意,回到第1步。 LangGraph 允许你定义这种闭环逻辑,这在长任务、自我修正代码、多轮调研场景下是刚需。

  2. 状态管理

    LangGraph 引入了 State 的概念,所有节点共享同一个 TypedDict,你可以精确定义哪些数据是追加的(operator.add),哪些是覆盖的。并且它可以自动保存每一步的状态。即使程序崩溃或需要人工审核,你也可以从特定的“存档点”恢复,而不需要从头运行。

  3. 人机协作

    LangGraph 允许你将流程设计为“在某处强制停下”,等待人类信号后再继续。这在 LangChain 的线性模型中极难实现,但在 LangGraph 的状态机模型中只是一个节点属性。

  4. 高度可控

    “如果工具返回报错,必须走 A 路径。” 这种确定性对于生产环境的后端服务至关重要。不能让模型乱输出,在生产环境上严格把控输出结果是很重要的。

LangGraph 结构

由于 LangGraph 的核心思想是将 Agent 的工作流建模为一张有向图(Directed Graph)。所以 LangGraph 有如下几个结构组成

  • 全局状态(State)

    这个状态通常被定义为一个 Python 的 TypedDict,它可以包含任何你需要追踪的信息,如对话历史、中间结果、迭代次数等,所有的节点都能读取和更新这个中心状态。

  • 节点(Nodes)

    每个节点都是一个接收当前状态作为输入、并返回一个更新后的状态作为输出的 Python 函数。

  • 边(Edges)

    边负责连接节点,定义工作流的方向。最简单的边是常规边,它指定了一个节点的输出总是流向另一个固定的节点。而 LangGraph 最强大的功能在于条件边(Conditional Edges)。它通过一个函数来判断当前的状态,然后动态地决定下一步应该跳转到哪个节点。

基于上面的概念,我们来做一个例子,假设我们要开发一个 Agent:它先翻译一段话,然后自己检查是否有语法错误,如果有,就打回重新翻译;如果没有,就结束。

首先,我们先定义状态 (State):

from typing import TypedDict, List

class AgentState(TypedDict):
    # 原始文本
    input_text: str
    # 翻译后的文本
    translated_text: str
    # 反思反馈
    feedback: str
    # 循环次数(防止死循环)
    iterations: int

定义节点逻辑 (Nodes):

def translator_node(state: AgentState):
    print("--- 正在翻译 ---")
    # 这里通常会调用 LLM
    new_text = f"Translated: {state['input_text']}" 
    return {"translated_text": new_text, "iterations": state.get("iterations", 0) + 1}

def critic_node(state: AgentState):
    print("--- 正在自检 ---")
    # 模拟检查逻辑,如果包含 'bad' 字符就认为不合格
    if "bad" in state['translated_text']:
        return {"feedback": "发现不当词汇,请重试"}
    return {"feedback": "OK"}

定义路由逻辑 (Conditional Edges):

def should_continue(state: AgentState):
    if state["feedback"] == "OK" or state["iterations"] > 3:
        return "end"
    else:
        return "rephrase"

构建图 (Graph Construction):

from langgraph.graph import StateGraph, END

# 1. 初始化图
workflow = StateGraph(AgentState)

# 2. 添加节点
workflow.add_node("translator", translator_node)
workflow.add_node("critic", critic_node)

# 3. 设置入口点
workflow.set_entry_point("translator")

# 4. 连接节点
workflow.add_edge("translator", "critic")

# 5. 添加条件边 (根据 critic 的反馈决定去向)
workflow.add_conditional_edges(
    "critic",
    should_continue,
    {
        "rephrase": "translator", # 如果不 OK,回到翻译节点
        "end": END                # 如果 OK,结束
    }
)

# 6. 编译成可执行应用
app = workflow.compile()

通过上面这种编排方式,可以让 LLM 概率性输出产生确定性的输出,通过各种限制节点,很好的控制了 LLM 的访问的节点。

下面我给出完整的例子,大家可以用这个例子去尝试一下:

from typing import TypedDict, List
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, END
from langchain_core.messages import SystemMessage, HumanMessage

llm = ChatOpenAI(
    temperature=0.6,
    model="glm-4.6V",
    openai_api_key="",
    openai_api_base="https://open.bigmodel.cn/api/paas/v4/"
)

class AgentState(TypedDict):
    # 原始文本
    input_text: str
    # 翻译后的文本
    translated_text: str
    # 反思反馈
    feedback: str
    # 循环次数(防止死循环)
    iterations: int

def translator_node(state: AgentState):
    """翻译节点:负责将中文翻译成英文"""
    print(f"\n--- [节点:翻译器] 第 {state.get('iterations', 0) + 1} 次尝试 ---")

    iters = state.get("iterations", 0)
    feedback = state.get("feedback", "无")

    # 构建提示词:如果是重试,带上反馈建议
    system_prompt = "你是一个专业的翻译官。请将用户的中文翻译成地道、优雅的英文。"
    if iters > 0:
        system_prompt += f" 注意:这是第二次尝试,请参考之前的反馈进行改进:{feedback}"

    response = llm.invoke([
        SystemMessage(content=system_prompt),
        HumanMessage(content=state["input_text"])
    ])

    return {
        "translated_text": response.content,
        "iterations": iters + 1
    }

def critic_node(state: AgentState):
    """评审节点:检查翻译质量"""
    print("--- [节点:评审员] 正在检查翻译质量... ---")

    system_prompt = (
        "你是一个严苛的英文编辑。请评价以下翻译是否准确、地道。"
        "如果翻译得很好,请只回复关键词:【PASS】。"
        "如果翻译有改进空间,请直接指出问题并给出改进建议。"
    )

    user_content = f"原文:{state['input_text']}\n译文:{state['translated_text']}"

    response = llm.invoke([
        SystemMessage(content=system_prompt),
        HumanMessage(content=user_content)
    ])

    return {"feedback": response.content}

# 4. 定义路由逻辑
def should_continue(state: AgentState):
    """判断是继续修改还是直接结束"""
    if "【PASS】" in state["feedback"] or state["iterations"] >= 3:
        if state["iterations"] >= 3:
            print("!!! 达到最大尝试次数,停止优化。")
        return "end"
    else:
        print(f">>> 反馈建议:{state['feedback']}")
        return "rephrase"

# 1. 初始化图
workflow = StateGraph(AgentState)

# 2. 添加节点
workflow.add_node("translator", translator_node)
workflow.add_node("critic", critic_node)

# 3. 设置入口点
workflow.set_entry_point("translator")

# 4. 连接节点
workflow.add_edge("translator", "critic")

# 5. 添加条件边 (根据 critic 的反馈决定去向)
workflow.add_conditional_edges(
    "critic",
    should_continue,
    {
        "rephrase": "translator", # 如果不 OK,回到翻译节点
        "end": END                # 如果 OK,结束
    }
)

# 6. 编译成可执行应用
app = workflow.compile()

# 7. 运行时交互
if __name__ == "__main__":
    print("=== LangGraph 智能翻译 Agent (输入 'exit' 退出) ===")
    while True:
        user_input = input("\n请输入想要翻译的中文内容: ")
        if user_input.lower() == 'exit':
            break

        # 初始状态
        initial_state = {
            "input_text": user_input,
            "iterations": 0
        }

        # 运行图并获取最终状态
        final_state = app.invoke(initial_state)

        print("\n" + "=" * 30)
        print(f"最终翻译结果:\n{final_state['translated_text']}")
        print("=" * 30)

LangGraph 是如何管理状态的?

State Reducer 自动合并 state

Reducer 在 LangGraph 中就是一种更新状态的处理逻辑,如果没有指定默认行为是 用新值覆盖旧值。想要指定 Reducer 只需要通过 typing.Annotated 字段绑定一个 Reducer 函数即可。

比如使用 operator.add 定义这是一个“追加型”字段:

from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, END
import operator

# 定义状态结构 (类似 Go 的 Struct)
class AgentState(TypedDict):
    # 使用 Annotated 和 operator.add 定义这是一个“追加型”字段
    # 每次节点返回消息,都会 append 到这个列表,而不是覆盖它
    messages: Annotated[list[str], operator.add]

    # 普通字段,默认行为是 Overwrite (覆盖)
    # 适合存储状态机当前的步骤或分析结论
    current_status: str

    # 计数器,也可以使用 operator.add 实现增量累加
    retry_count: Annotated[int, operator.add]

Checkpointer + Thread 持久化状态

在 LangGraph 中,Checkpointer 是一个持久化层接口,这意味着历史的对话记录,可以被自动持久化到数据库(如 SQLite 或其他外部数据库)中。这使得即使应用程序重启或用户断开连接,对话历史也能被保存和恢复,从而实现“真正的多轮记忆”。

LangGraph 提供了多种 Checkpointer 以便应对不同的使用场景:

  • MemorySaver 保存在内存,适用开发调试、单元测试;

  • SqliteSaver 保存在本地的.db文件,轻量级应用、边缘计算适合单机部署;

  • PostgresSaver 保存在 PostgreSQL,适合用在生产环境、多实例部署;

  • RedisSaver 适合处理高频、短时会话;

LangGraph 通过 thread_id 会话的唯一标识,结合 Checkpointer 就可以实现状态的隔离:

首先指定一个 指定一个 thread_id,所有相关的状态都会被保存到这个线程中。

config = {"configurable": {"thread_id": "conversation_1"}}
graph.invoke(input_data, config)

编译的时候传入 Checkpointer 即可。

# 创建 checkpointer
checkpointer = InMemorySaver()
# 编译图时传入 checkpointer
graph = builder.compile(checkpointer=checkpointer)

完整示例:

from langgraph.checkpoint.memory import InMemorySaver
from langchain_openai import ChatOpenAI 
from langgraph.graph import StateGraph, START, END, MessagesState 

llm = ChatOpenAI(
    temperature=0.6,
    model="glm-4.6v",
    openai_api_key="",
    openai_api_base="https://open.bigmodel.cn/api/paas/v4/"
)

# 定义节点函数
def call_model(state: MessagesState):
    response = llm.invoke(state["messages"])
    return {"messages": response}

# 构建图
builder = StateGraph(MessagesState)
builder.add_node("agent", call_model)
builder.add_edge(START, "agent")

# 创建 checkpointer
checkpointer = InMemorySaver()
# 编译图时传入 checkpointer
graph = builder.compile(checkpointer=checkpointer)

# 第一次对话
config = {"configurable": {"thread_id": "user_123"}}
response1 = graph.invoke(
    {"messages": [{"role": "user", "content": "你好,我的名字是张三"}]},
    config
)
print(f"AI: {response1['messages'][-1].content}")

# 第二次对话(相同 thread_id)
response2 = graph.invoke(
    {"messages": [{"role": "user", "content": "我的名字是什么?"}]},
    config  # 使用相同的 thread_id
)
print(f"AI: {response2['messages'][-1].content}")

# 获取当前的状态信息
print(f"AI: {graph.get_state(config)}")

除此之外,可以 graph.get_state() / graph.get_state_history() 拿到当前/历史状态;也可以基于 checkpoint 做 replay、update_state(时间旅行能力通常要求启用 checkpointer)。

Super-step 原子循环单元

由于一个 node 也可以连接多个 node,多个 node 也可以连接到 一个 node,所以 LangGraph 设计了 Super-step 来作为原子循环单元。比如下面的例子:

  graph.set_entry_point("n1")
  graph.add_edge("n1", "n2")
  graph.add_edge("n1", "n3")
  graph.add_edge("n2", "n4")
  graph.add_edge("n3", "n4")
  graph.add_edge("n4", END)

LangGraph 只分了三步就执行完了该循环。如下图,第二步的时候会 n2、n3 节点并行执行。

graph active nodes in each superstep

并且每个 super-step 都会自动保存一个 checkpoint,这就是持久化机制的基础。即使程序中断,也能从最后一个 super-step 的 checkpoint 恢复执行。

Human-in-the-loop 人机协同

Human-in-the-loop 本质上就是让 agent “关键时刻”暂停,它的底层靠的是 interrupt + 持久化(checkpoint):暂停时把状态存起来,恢复时从存档续跑。

比如我们想要是线一个场景就是让 AI 去判断是否应该要人工审核,如过需要人工审核,那么就 interrupt 进行中断,然后等人工输入之后根据执行逻辑进行恢复,然后配合Command(resume=...) 恢复。

基本流程可以是这样:

import uuid
from langgraph.types import interrupt, Command

def ask_human(state):
    answer = interrupt("Do you approve?")
    return {"approved": answer}

config = {"configurable": {"thread_id": str(uuid.uuid4())}}

# 第一次跑:会中断,返回 __interrupt__
graph.invoke({"input": "x"}, config=config)

# 人给了答复后:用 Command(resume=...) 恢复
graph.invoke(Command(resume=True), config=config)

这个例子中interrupt()暂停图执行,把一个值(必须可 JSON 序列化)抛给调用方,并依赖 checkpointer 持久化状态;然后你用同一个 thread_id 重新调用图,并传入 Command(resume=...) 来继续。

接下来我们看一个完整的例子,设计一个常见的场景,当模型觉得需要“找专家/找人类”时,会调用一个工具 human_assistance,而这个工具会用 interrupt() 把流程暂停下来,等你在命令行里输入专家建议后,再用 Command(resume=...) 把图唤醒继续跑。

from langgraph.checkpoint.memory import InMemorySaver
from langchain_openai import ChatOpenAI
from typing_extensions import TypedDict
from typing import Annotated
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.types import Command, interrupt
from langchain_core.tools import tool
from langgraph.prebuilt import ToolNode, tools_condition 
from langchain_core.messages import HumanMessage

llm = ChatOpenAI(
    temperature=0.6,
    model="glm-4.6v",
    openai_api_key="",
    openai_api_base="https://open.bigmodel.cn/api/paas/v4/"
)

class State(TypedDict):
    messages: Annotated[list, add_messages]

@tool
def human_assistance(query: str) -> str:
    """Request assistance from a human."""
    human_response = interrupt({"query": query})
    return human_response["data"]

tools = [human_assistance]
llm_with_tools = llm.bind_tools(tools)

def chatbot(state: State):
    message = llm_with_tools.invoke(state["messages"])
    return {"messages": [message]}

tool_node = ToolNode(tools=tools)

graph_builder = StateGraph(State)
graph_builder.add_node("tools", tool_node)

graph_builder.add_conditional_edges(
    "chatbot",
    tools_condition,
)

graph_builder.add_node("chatbot", chatbot)
graph_builder.add_edge(START, "chatbot")
graph_builder.add_edge("chatbot", END)

memory = InMemorySaver()
graph = graph_builder.compile(checkpointer=memory)

config = {"configurable": {"thread_id": "test_thread_123"}}

# 第一步:用户提出一个需要“人工协助”的问题
print("--- 第一阶段:AI 运行并遇到 interrupt ---")
initial_input = HumanMessage(content="你好,帮我找个专家回答我的问题")

for event in graph.stream({"messages": [initial_input]}, config, stream_mode="values"):
    if "messages" in event:
        event["messages"][-1].pretty_print()

# 此时,你会发现程序停止了,因为它卡在 `human_assistance` 的 `interrupt` 处。

# 第二步:模拟人类(你)在一段时间后看到了请求并回复
print("\n--- 第二阶段:模拟人类介入并提供答案 ---")

# 我们构造一个 Command 对象来“唤醒”它
# resume 里的内容会直接成为 interrupt() 函数的返回值
expert_input = input("专家建议: ")
human_feedback = {"data": expert_input}

for event in graph.stream(
    Command(resume=human_feedback), # 这里是恢复运行的关键
    config,
    stream_mode="values"
):
    if "messages" in event:
        event["messages"][-1].pretty_print()

snapshot = graph.get_state(config)

print(snapshot.values)

LangGraph如何轻松实现 Agent 多种执行范式

ReAct

ReAct由Shunyu Yao于2022年提出[1],其核心思想是模仿人类解决问题的方式,将推理 (Reasoning)行动 (Acting) 显式地结合起来,形成一个“思考-行动-观察”的循环。

img

ReAct范式通过一种特殊的提示工程来引导模型,使其每一步的输出都遵循一个固定的轨迹:

  • Thought (思考): 这是智能体的“内心独白”。它会分析当前情况、分解任务、制定下一步计划,或者反思上一步的结果。
  • Action (行动): 这是智能体决定采取的具体动作,通常是调用一个外部工具API 。
  • Observation (观察): 这是执行Action后从外部工具返回的结果,例如或API的返回值。

智能体将不断重复这个 Thought -> Action -> Observation 的循环,将新的观察结果追加到历史记录中,形成一个不断增长的上下文,直到它在Thought中认为已经找到了最终答案,然后输出结果。

from typing import Annotated, Literal
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool

# --- 1. 双手:定义查天气的工具 ---
@tool
def get_weather(city: str):
    """查询指定城市的天气"""
    # 这里模拟后端 API 返回数据
    if "北京" in city:
        return "晴天,25度"
    return "阴天,20度"

tools = [get_weather]
tool_node = ToolNode(tools)

# --- 2. 记忆:定义存储对话的状态 ---
class State(TypedDict):
    messages: Annotated[list, add_messages]

# --- 3. 大脑:定义思考逻辑 ---
model = ChatOpenAI(
    temperature=0.6,
    model="glm-4.6v",
    openai_api_key="",
    openai_api_base="https://open.bigmodel.cn/api/paas/v4/"
).bind_tools(tools)

def call_model(state: State):
    # 大脑看一眼目前的对话,决定是直接说话还是去用手拿工具
    return {"messages": [model.invoke(state["messages"])]}

# --- 4. 路由:判断下一步是干活还是结束 ---
def should_continue(state: State):
    last_message = state["messages"][-1]
    # 如果大脑发出的指令包含“调用工具”,就去 tools 节点
    if last_message.tool_calls:
        return "tools"
    # 如果大脑直接说话了,就结束
    return END

# --- 5. 编排图(把脑和手连起来) ---
workflow = StateGraph(State)

workflow.add_node("agent", call_model)
workflow.add_node("tools", tool_node)

workflow.set_entry_point("agent")

# 条件边:agent 运行完,判断是去 tools 还是结束
workflow.add_conditional_edges("agent", should_continue)

# 普通边:tools 运行完(干完活了),必须把结果拿回给 agent 看
workflow.add_edge("tools", "agent")

app = workflow.compile()

# --- 6. 执行测试 ---
for chunk in app.stream({"messages": [("user", "北京今天天气怎么样?")]}):
    print(chunk)

Plan-and-Solve

Plan-and-Solve 顾名思义,这种范式将任务处理明确地分为两个阶段:先规划 (Plan),后执行 (Solve)。Plan-and-Solve Prompting 由 Lei Wang 在2023年提出。其核心动机是为了解决思维链在处理多步骤、复杂问题时容易“偏离轨道”的问题。

Plan-and-Solve 将整个流程解耦为两个核心阶段:

  1. 规划阶段 (Planning Phase): 首先,智能体会接收用户的完整问题。它的第一个任务不是直接去解决问题或调用工具,而是将问题分解,并制定出一个清晰、分步骤的行动计划
  2. 执行阶段 (Solving Phase): 在获得完整的计划后,智能体进入执行阶段。它会严格按照计划中的步骤,逐一执行。每一步的执行都可能是一次独立的 LLM 调用,或者是对上一步结果的加工处理,直到计划中的所有步骤都完成,最终得出答案。

img

import operator
from typing import Annotated, List, Tuple, Union
from typing_extensions import TypedDict
from pydantic import BaseModel, Field
from langgraph.graph import StateGraph, START, END
from langchain_openai import ChatOpenAI

# 1. 定义状态 (State)
class PlanExecuteState(TypedDict):
    input: str            # 原始问题
    plan: List[str]       # 当前待办清单
    past_steps: Annotated[List[Tuple], operator.add] # 已完成的步骤和结果
    response: str         # 最终答案

# 2. 定义结构化输出模型 (用于 Planner)
class Plan(BaseModel):
    """步骤清单"""
    steps: List[str] = Field(description="为了回答问题需要执行的步骤")

# 3. 定义节点逻辑
model = ChatOpenAI(
    temperature=0.6,
    model="glm-4.6v",
    openai_api_key="",
    openai_api_base="https://open.bigmodel.cn/api/paas/v4/"
)

planner_model = model.with_structured_output(Plan, method="function_calling")

# --- 节点 A: 规划者 ---
def planner_node(state: PlanExecuteState):
    plan = planner_model.invoke(f"针对以下问题制定计划: {state['input']}")
    return {"plan": plan.steps}

# --- 节点 B: 执行者 (这里简化了工具调用) ---
def executor_node(state: PlanExecuteState):
    step = state["plan"][0] # 取当前第一步
    print(f"--- 正在执行: {step} ---")
    # 模拟工具执行结果
    result = f"已完成 {step} 的查询,结果为: [模拟数据]"
    return {"past_steps": [(step, result)], "plan": state["plan"][1:]}

# --- 节点 C: 重规划者 (决定是继续还是结束) ---
def replanner_node(state: PlanExecuteState):
    if not state["plan"]:  # 如果清单空了,让 AI 生成最终总结
        summary = model.invoke(
            f"请基于已完成的步骤和结果给出最终答案:{state['past_steps']}"
        )
        return {"response": summary.content}
    return {"response": None}

# 4. 路由逻辑
def should_continue(state: PlanExecuteState):
    if state["response"]:
        return END
    return "executor"

# 5. 编排图
workflow = StateGraph(PlanExecuteState)

workflow.add_node("planner", planner_node)
workflow.add_node("executor", executor_node)
workflow.add_node("re-planner", replanner_node)

workflow.set_entry_point("planner")
workflow.add_edge("planner", "executor")
workflow.add_edge("executor", "re-planner")

# 循环逻辑:根据 re-planner 的判断决定是否回 executor
workflow.add_conditional_edges("re-planner", should_continue)

app = workflow.compile()

# 6. 测试
input_query = {"input": "对比北京和上海的天气,哪个更热?"}
for event in app.stream(input_query):
    print(event)

Reflection

Reflection 机制的核心思想,正是为智能体引入一种事后(post-hoc)的自我校正循环,使其能够像人类一样,审视自己的工作,发现不足,并进行迭代优化。 Reflection 框架是Shinn, Noah 在2023年提出,其核心工作流程可以概括为一个简洁的三步循环:执行 -> 反思 -> 优化

  1. 执行 (Execution):首先,智能体使用我们熟悉的方法(如 ReAct 或 Plan-and-Solve)尝试完成任务,生成一个初步的解决方案或行动轨迹。这可以看作是“初稿”。
  2. 反思 (Reflection):接着,智能体进入反思阶段。它会调用一个独立的、或者带有特殊提示词的大语言模型实例,来扮演一个“评审员”的角色。这个“评审员”会审视第一步生成的“初稿”,并从多个维度进行评估,例如:
    • 事实性错误:是否存在与常识或已知事实相悖的内容?
    • 逻辑漏洞:推理过程是否存在不连贯或矛盾之处?
    • 效率问题:是否有更直接、更简洁的路径来完成任务?
    • 遗漏信息:是否忽略了问题的某些关键约束或方面? 根据评估,它会生成一段结构化的反馈 (Feedback),指出具体的问题所在和改进建议。
  3. 优化 (Refinement):最后,智能体将“初稿”和“反馈”作为新的上下文,再次调用大语言模型,要求它根据反馈内容对初稿进行修正,生成一个更完善的“修订稿”。

img

from typing import TypedDict
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END

class ReflectionState(TypedDict):
    prompt: str
    draft: str
    critique: str
    final: str
    iteration: int

llm = ChatOpenAI(
    temperature=0.6,
    model="glm-4.6v",
    openai_api_key="",
    openai_api_base="https://open.bigmodel.cn/api/paas/v4/",
)

MAX_ITERS = 2

def generate_draft(state: ReflectionState):
    msg = llm.invoke(f"请写一段简短答案:{state['prompt']}")
    return {"draft": msg.content, "iteration": 0}

def reflect_on_draft(state: ReflectionState):
    prompt = (
        "你是严格的审稿人。请指出这段答案的问题并给出改进建议。"
        "如果没有明显问题,请只输出 NO_ISSUES。\n\n"
        f"答案:\n{state['draft']}"
    )
    critique = llm.invoke(prompt)
    print(f"--- 正在执行: reflect,critique:\n {critique.content} ---")
    return {"critique": critique.content}

def revise_draft(state: ReflectionState):
    prompt = (
        "请根据以下反馈重写答案,保持简短清晰:\n\n"
        f"反馈:\n{state['critique']}\n\n"
        f"原答案:\n{state['draft']}"
    )
    revision = llm.invoke(prompt)
    print(f"--- 正在执行: revise,原答案:\n{state['draft']},改进后:\n{revision.content} ---")
    return {"draft": revision.content, "iteration": state["iteration"] + 1}

def finalize(state: ReflectionState):
    return {"final": state["draft"]}

def should_reflect(state: ReflectionState):
    if state["critique"].strip() == "NO_ISSUES":
        return "finalize"
    if state["iteration"] >= MAX_ITERS:
        return "finalize"
    return "revise"

workflow = StateGraph(ReflectionState)
workflow.add_node("generate", generate_draft)
workflow.add_node("reflect", reflect_on_draft)
workflow.add_node("revise", revise_draft)
workflow.add_node("finalize", finalize)

workflow.add_edge(START, "generate")
workflow.add_edge("generate", "reflect")
workflow.add_conditional_edges("reflect", should_reflect)
workflow.add_edge("revise", "reflect")
workflow.add_edge("finalize", END)

app = workflow.compile()

if __name__ == "__main__":
    input_data = {"prompt": "用三句话解释什么是 LangGraph。"}
    result = app.invoke(input_data)
    print("最终答案:")
    print(result["final"])

大致流程就是,首先里面需要有两个角色:写稿人和审稿人,然后用 should_reflect 来判断是否需要重写,然后用 MAX_ITERS 来限制一下最大撰写次数。

  [START]
     |
     v
  (generate_draft)
     |
     v
  (reflect_on_draft) -- NO_ISSUES --> (finalize) --> [END]
           |
           | iteration >= MAX_ITERS
           +-----------------------> (finalize) --> [END]
           |
           +-- else --> (revise_draft) --+
                                         |
                                         v
                                 (reflect_on_draft)

Multi-Agent Pattern

Multi-Agent 模式是将复杂的任务拆解为多个专门化、独立且可协同的微服务,每个服务(Agent)只负责一个特定的领域。

因为单个 Prompt 包含太多工具和指令会导致 LLM “迷失”,模型表现下降。所以通过使用doge Agent 进行职责分离,不同的 Agent 可以使用不同的 Prompt、不同的模型(如 GPT-4o 负责决策,Llama-3 负责写代码),甚至不同的工具集。

比如下面的例子中:一个“PM” Agent 负责拆解任务,并将子任务分发给不同的“员工(Workers)”。

img

img

from typing import Annotated, Literal
from typing_extensions import TypedDict

from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages

# Multi-agent pattern: a supervisor routes work between specialist agents.

llm = ChatOpenAI(
    temperature=0.4,
    model="glm-4.6v",
    openai_api_key="",
    openai_api_base="https://open.bigmodel.cn/api/paas/v4/",
)

class State(TypedDict):
    messages: Annotated[list, add_messages]
    next: str
    turn: int

MAX_TURNS = 6

def _call_agent(system_prompt: str, messages: list, name: str):
    response = llm.invoke([{"role": "system", "content": system_prompt}] + messages)
    return {
        "messages": [
            {"role": "assistant", "name": name, "content": response.content}
        ],
        "turn": 1,
    }

def supervisor(state: State):
    if state["turn"] >= MAX_TURNS:
        return {"next": "finish"}

    system = (
        "You are a supervisor managing a team: researcher, writer, critic. "
        "Choose who should act next or finish. "
        "Respond with exactly one word: researcher, writer, critic, finish."
    )
    response = llm.invoke([{"role": "system", "content": system}] + state["messages"])
    decision = response.content.strip().lower()
    for option in ("researcher", "writer", "critic", "finish"):
        if option in decision:
            return {"next": option}
    return {"next": "finish"}

def researcher(state: State):
    system = (
        "You are a researcher. Gather key facts and constraints for the task. "
        "Be concise and list only essential points."
    )
    return _call_agent(system, state["messages"], "researcher")

def writer(state: State):
    system = (
        "You are a writer. Produce a clear, structured response using the context. "
        "If facts are missing, note assumptions."
    )
    return _call_agent(system, state["messages"], "writer")

def critic(state: State):
    system = (
        "You are a critic. Identify gaps, risks, or unclear parts in the draft, "
        "then suggest improvements."
    )
    return _call_agent(system, state["messages"], "critic")

def route_next(state: State) -> Literal["researcher", "writer", "critic", "finish"]:
    return state["next"]

builder = StateGraph(State)
builder.add_node("supervisor", supervisor)
builder.add_node("researcher", researcher)
builder.add_node("writer", writer)
builder.add_node("critic", critic)

builder.add_edge(START, "supervisor")
builder.add_conditional_edges(
    "supervisor",
    route_next,
    {
        "researcher": "researcher",
        "writer": "writer",
        "critic": "critic",
        "finish": END,
    },
)
builder.add_edge("researcher", "supervisor")
builder.add_edge("writer", "supervisor")
builder.add_edge("critic", "supervisor")

app = builder.compile()

if __name__ == "__main__":
    user_input = "创建一款广告招商的帖子"
    initial_state = {
        "messages": [{"role": "user", "content": user_input}],
        "next": "supervisor",
        "turn": 0,
    }
    for event in app.stream(initial_state):
        for value in event.values():
            if "messages" in value:
                msg = value["messages"][-1]
                name = msg.get("name", "assistant")
                print(f"[{name}] {msg['content']}")

Reference

https://datawhalechina.github.io/

https://www.philschmid.de/agentic-pattern

https://blog.dailydoseofds.com/p/5-agentic-ai-design-patterns

https://zhuanlan.zhihu.com/p/1972437682400519404

https://www.zhihu.com/people/yuan-chelsea

https://ywctech.net/ml-ai/langchain-langgraph-agent-part2

LangGraph 是如何让LLM产生确定性输出的?最先出现在luozhiyun`s Blog

从 MCP 到 Agent Skills

作者 luozhiyun
2025年12月28日 21:35

什么是 agent skills?

Agent Skills 是一种轻量级、开放的格式,用于扩展 AI Agent 的能力和专业知识。本质上,一个 Skill 就是一个包含 SKILL.md 文件的文件夹

Agent Skills 的作用在于:

  • 专业知识封装:将特定领域的程序化知识(procedural knowledge)和公司/团队/用户特定的上下文打包
  • 按需加载:Agent 启动时只加载 Skill 的 namedescription,任务匹配时才加载完整指令
  • 可执行能力:可以包含脚本、工具,扩展 Agent 的实际操作能力
  • 版本化管理:Skills 就是文件,易于编辑、版本控制和分享

那么这里就有个问题,为什么有了 MCP 之后还需要 Agent Skills?

这个问题其实有过很多争论,有开发者评论说:"Skill 和 MCP 是两种东西,Skill 是领域知识,告诉模型该如何做,本质上是高级 Prompt;而 MCP 对接外部工具和数据。" 也有人认为:"从 Function Call 到 Tool Call 到 MCP 到 Skill,核心大差不差,就是工程实践和表现形式的优化演进。"

其实我还是觉得要从 MCP 和 Agent Skills 设计上区分他们到底有什么不同。

MCP 其实就是指的提供了一个远程的接口,可以用这个接口来接外部世界:能读取数据库、能访问 API、能执行命令行;

Agent Skills 更像一个操作手册,主要存在本地的文件里面,不需要调用外部接口,主要是用来告诉 AI 有那些领域知识,然后教 AI 如何正确、高效地使用这些手,按照什么步骤去完成特定任务。

除此之外,Agent Skills 解决了 MCP 无法解决的三个核心问题:

  1. 节省 token

    在使用 mcp 工具的时候,通常工具的定义(名字、参数、描述)全部塞进 AI 的提示词(Prompt)里,AI 才能知道怎么调用。这会极大地消耗 Token,可能占用数万个 token。据社区开发者反馈,仅加载一个 Playwright MCP 服务器就会占用 200k 上下文窗口的 8%,这在多轮对话中会迅速累积,导致成本飙升和推理能力下降。

    而对于 Agent Skills 来说通过渐进式披露(Progressive Disclosure)机制,智能体按需逐步加载,既确保必要时不遗漏细节,又避免一次性将过多内容塞入上下文窗口,来解决这个问题。

  2. 解决“会用工具但不懂业务”的问题(业务流程固化)

    AI 只懂 MCP 是不会理解业务的,比如 MCP 提供了 delete_database()(删除数据库)的工具。这很强大,但也危险。AI 可能因为你的一个模糊指令直接删库。这个时候就可以写一个 Skill,规定:

    当用户要求删除数据库时,必须严格执行以下流程:

    1. 先调用 MCP 工具 check_backup() 确认有备份。
    2. 再调用 MCP 工具 send_alert() 给管理员发通知。
    3. 最后才允许调用 delete_database()
  3. 降低开发门槛

    开发一个 MCP Server 需要后端开发能力,提供接口。Skills 只需要提供 SKILL.md 即可。比如你是资深运营,你可以写一个“小红书文案 Skill”,里面不需要代码,只需要写清楚:“第一步先分析竞品,第二步提取关键词,第三步套用这个模板…”。

所以综上 Agent Skills 至少为开发者带来三大核心价值:

  • 能力复用:一次编写,在 Copilot、Cursor、Claude 等多个 Agent 产品中使用,还可跨团队共享或通过 GitHub 公开发布。

  • 知识沉淀:将团队最佳实践固化为版本化的 Skills,如代码审查规范、部署流程、数据分析模板等,确保工作流程的一致性。

  • 提升效率:通过明确的指导让 Agent 更准确地执行复杂任务,减少试错和修正,提供一致的输出质量。

设计理念

Agent Skills 最核心的创新是渐进式披露(Progressive Disclosure)机制。AI 在使用 Agent Skills 的时候并没有将整个知识库加载到人工智能有限的上下文窗口中,而是以智能的、高效的层级方式加载信息。

  • 第一层:元数据(Metadata):首先只看到每个可用Agent Skills的名称和描述,也就是 Frontmatter buff。这种轻量级信息使其能够快速识别哪些技能可能与用户的请求相关,而不会消耗大量资源。
  • 第二层:技能主体(Instructions):一旦确定了相关技能,AI 就会读取主 SKILL.md 文件。该文件包含执行任务的分步指令和核心逻辑。
  • 第三层:附加资源(Scripts & References):如果说明中提到了其他文件(例如用于数据验证的 Python 脚本或报告模板),AI 会根据需要访问这些特定资源。

这种分层方法使得整个 Agent Skills 系统具有极高的可扩展性。关键在于,当Agent Skills执行脚本时,代码本身永远不会进入上下文窗口;只有脚本的输出才会进入。

社区开发者分享的实践案例充分证明了渐进式披露的威力。在一个真实场景中:

  • 传统 MCP 方式:直接连接一个包含大量工具定义的 MCP 服务器,初始加载消耗16,000 个 token
  • Skills 包装后:创建一个简单的 Skill 作为"网关",仅在 Frontmatter 中描述功能,初始消耗仅500 个 token

如何实现一个

一个最简单的 agent skill 其实只需要包含一个 SKILL.md 文件即可,其他的 scripts、 references、assets 都是可选的。

my-cool-skill/          <-- 技能文件夹
├── SKILL.md            <-- 核心文件:包含元数据和指令(必须)
├── scripts/            <-- (可选) 包含 Python/Bash 脚本
│   └── analyze.py
└── templates/          <-- (可选) 包含模板文件
│   └── report.md
└── assets/             <-- Optional: 一些模版资源

编写 SKILL.md

文件内容分为两部分:YAML 头信息 (Frontmatter)Markdown 正文

A. Frontmatter 头信息(必填)

Frontmatter 必须用 — 包裹起来,像下面这样,最短可以只包含 name 和 description 两个字段:

---
name: skill-name
description: A description of what this skill does and when to use it.
---

name 字段规则

  • 长度:1-64 字符
  • 字符:仅允许小写字母、数字和连字符 (a-z, -)
  • 不能以连字符开头或结尾
  • 不能包含连续的连字符 (--)
  • 必须与父目录名完全一致

description 字段规则

  • 长度:1-1024 字符
  • 应说明技能的功能和使用时机
  • 包含关键词帮助 Agent 识别适用场景

包含可选字段:

---
name: pdf-processing
description: Extract text and tables from PDF files, fill forms, merge documents.
license: Apache-2.0
metadata:
  author: example-org
  version: "1.0"
---

B. 正文 (Instructions)

这是 AI 加载技能后看到的具体操作指南。包括步骤、规则、输入输出格式等。

比如我想要写个示例:

构建一个“代码审查专家”技能。
假设你想创建一个技能,专门用来按你团队的风格审查 Python 代码。

那么我们可以写一个这样的 SKILL.md

---
name: python-code-review
description: 当用户要求审查 Python 代码,或者需要检查代码质量、寻找 bug 时使用此技能。不要用于其他语言。
---

# Python Code Review Guidelines

作为 Python 代码审查专家,请遵循以下步骤审查代码:

## 1. 核心原则
- **类型提示 (Type Hints)**: 所有函数必须包含参数和返回值的类型提示。
- **文档字符串 (Docstrings)**: 使用 Google 风格的文档字符串。
- **错误处理**: 检查是否使用了裸露的 `except:`,必须捕获特定异常。

## 2. 审查清单
请按以下顺序检查代码:
1. 运行静态分析逻辑(如果在 scripts/ 文件夹中有 lint 脚本,请优先参考)。
2. 检查变量命名是否符合 snake_case。
3. 寻找潜在的 N+1 查询问题(如果涉及数据库)。

## 3. 输出格式
请以以下格式输出审查报告:

**🔍 审查摘要**
- 评分: [1-10]
- 主要问题: [摘要]

**📝 详细建议**
| 行号 | 问题 | 建议修改 | 优先级 |
|------|------|----------|--------|
| 12   | 缺少类型提示 | `def func(a: int) -> str:` | 高 |

最佳实践 (Best Practices)

为了让 Skill 更聪明、更好用:

  1. 精确的 Description: AI 只有在 description 与用户请求匹配时才会加载这个技能,尽量应包含:

    • 动作关键词:split, extract, convert, merge
    • 领域关键词:PDF, document, pages
    • 场景关键词:when you need to divide, when working with
    ❌ 坏的写法: "一个帮助代码的工具"
    ✅ 好的写法: "当用户需要根据 PEP8 标准审查 Python 代码并生成表格报告时使用。"
  2. 提供清晰的示例,在 SKILL.md 正文中提供:

    • 常见用例的示例
    • 不同参数组合的效果
    • 预期的输出格式
  3. 原子化: 一个 Skill 最好只做一件事(例如:一个 Skill 做代码审查,另一个 Skill 做文档生成),不要把所有功能塞进一个文件。

  4. 对于复杂的 Skill,将详细文档分离,可以使用 References 目录:

    data-analysis/
    ├── SKILL.md           # 简要说明和快速开始
    ├── scripts/
    │   └── analyze.cs
    └── references/
       ├── REFERENCE.md   # 详细 API 参考
       ├── examples.md    # 更多示例
       └── algorithms.md  # 算法说明

安装和使用 Agent Skills

步骤一:下载

Agent Skills 和 MCP 一样都是 anthropics 公司提出来的,所以他们也提供了很多好用的 skills 供大家使用,如果选择将官方 Skills 安装到当前项目,就在终端输入这条命令:

openskills install anthropics/skills

安装成功后,你就会在Cursor、Trae等工具的文件管理区看到 .claude/skills 的文件夹。

当然也可去下面三方的收集网站上面下载别人写好的 skills:

https://skillsmp.com

https://agentskills.so/

步骤二:配置

先在项目根目录创建一个 AGENTS.md 文件,然后运行

openskills sync

确认后按回车键,你选择的 Skills 就会写进之前空白的 AGENTS.md 文档中。它将作为 Cursor、Trae 等 Coding Agent 接下来使用 Skills 的指导文件。

步骤二:调用

Skills 是可以被自动调用的,如果你想手动调用,可以直接在提示词中指定要调用的具体 Skills,比如:

调用 frontend-design skills,用HTML开发一个视频剪辑软件的SaaS介绍页

最后

最后在举个例子 MCP 如何协同 Agent Skills 一起完成工作。想象一下要实现一个自动化的金融分析代理:

  • MCP 层: IT 部门将内部财务数据库和报表 API 以安全的 MCP 服务器的形式对外开放。这使得 AI 能够直接访问财务数据并生成文件。
  • Agent Skill 层:该技能包含财务的领域知识,包含公司专有分析方法的精确分步说明。它告诉 AI 要查询哪些数据库表(通过 MCP 工具)、如何执行特定计算、如何构建分析以及最终演示文稿应采用何种格式。

在这种情况下,MCP 处理底层标准化的工具调用任务,而Agent Skills负责协调高层智能工作流程。 Agent Skills是一种可重用的资产,它捕捉了分析师的独特专业知识,使其能够立即扩展。这种强大的组合使开发人员能够构建健壮的系统,其中任务逻辑与其所用工具的实现完全分离。

Reference

https://agentskills.io

https://agentskills.so/

https://skillsmp.com

https://www.cnblogs.com/sheng-jie/p/19381647

https://zhuanlan.zhihu.com/p/1986802048608527579

https://www.zhihu.com/question/1890546618509538123

https://agentskills.so/skills-blogs/agent-skills-compare-mcp

从 MCP 到 Agent Skills最先出现在luozhiyun`s Blog

探究Rust中有趣的设计

作者 luozhiyun
2025年12月20日 17:50

这篇文章主要是看一下Rust有哪些比较有意思的设计,相比其他语言之下为什么要这么设计。

变量的可变性

Rust 的变量在默认情况下是不可变的,但是可以通过 mut 关键字让变量变为可变的,让设计更灵活。也就是说,如果我们这么写,编译会报错:

fn main() {
    let x = 5;
    println!("The value of x is: {}", x);
    x = 6;
    println!("The value of x is: {}", x);
}

报错:

error[E0384]: cannot assign twice to immutable variable `x`
 --> src/main.rs:4:5
  |
2 |     let x = 5;
  |         - first assignment to `x`
3 |     println!("The value of x is: {}", x);
4 |     x = 6;
  |     ^^^^^ cannot assign twice to immutable variable 

这其实和 C++ 和 Go(以及 Java、Python 等绝大多数主流语言)的设计哲学完全相反。C++ 和 Go 默认就是可变的,除非加上 const 表示是个常量。

Rust 这样做主要是为了让代码变得清晰点,降低心智负担 。一个变量往往被多处代码所使用,其中一部分代码假定该变量的值永远不会改变,而另外一部分代码却无情的改变了这个值,在实际开发过程中,这个错误是很难被发现的,特别是在多线程编程中。再来就是编译器优化,如果编译器知道一个变量绝不会变,它可以更激进地进行常量折叠、寄存器分配等优化。

在 Rust 中,可变性很简单,只要在变量名前加一个 mut 即可:

fn main() {
    let mut x = 5;
    println!("The value of x is: {}", x);
    x = 6;
    println!("The value of x is: {}", x);
}

但是 Rust 提供了 Shadowing 的功能:

fn main() {
    let x = 5;
    // x = x + 1; // 报错,不能修改

    let x = x + 1; // 合法!这是一个全新的 x,它遮蔽了旧的 x

    let x = "Hello"; // 甚至可以改变类型!
    println!("{}", x);
}

这点很有意思,在很多语言是不可以这么重复声明变量的。我觉得还是和不可变性有关,既然都不可变了,重复声明也是安全的,并且复用同一个变量名,而不需要想出 x_str, x_int, x_final 这种名字,相对来说代码会简洁一些。

所有权 (Ownership)

现在内存管理一般分为两类:

  1. 手动管理派 (C / C++),申请 (malloc/new),需要手动负责释放 (free/delete),但是这是很痛苦的,有时候忘记释放就会内存泄露,或者释放两次就会导致崩溃或为定义的行为;
  2. 垃圾回收派 (Java / Go / Python),有一个 Runtime 里的 GC (Garbage Collector) 盯着内存,不再用的就自动回收。自动回收也有缺点,需要STW,例如在游戏后端或高频交易中,几毫秒的 GC 卡顿可能就是灾难;

而 Rust 使用所有权 (Ownership)来控制。编译器在编译阶段通过一套严格的规则,自动在合适的地方插入 free 代码。没有运行时 GC,也不依赖手动管理。这个编译器定义的所有权规则有以下几条:

  1. Rust 中每一个值都被一个变量所拥有,该变量被称为值的所有者
  2. 一个值同时只能被一个变量所拥有,或者说一个值只能拥有一个所有者
  3. 当所有者(变量)离开作用域范围时,这个值将被丢弃(drop)

比如这个例子:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1; // 赋值操作

    // println!("{}, world!", s1); // ??? 这里能打印 s1 吗?
}

在 C++ 中,如果 s1 申请的是一个堆上的对象,如果是浅拷贝 (Shallow Copy),s1s2 指向堆上的同一块内存。如果函数结束,析构函数执行两次,导致 Double Free 错误。

在 Rust 中,由于所有权的存在,这一行 let s2 = s1;代码执行后,s1 会当场死亡!发生 所有权转移 (Move)。Rust 认为:堆上的那块 "hello" 内存,现在归 s2 管了。所以如果你后面再用 s1编译直接报错

报错:

error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:28
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 |
5 |     println!("{}, world!", s1);
  |                            ^^ value borrowed here after move

如果你确实需要两个独立的字符串数据(深拷贝),你需要显式调用 .clone()

let s1 = String::from("hello");
let s2 = s1.clone(); // 在堆上重新开辟内存,复制数据

println!("s1 = {}, s2 = {}", s1, s2); // s1 依然活着

除此之外,要注意栈上的数据 ,对于基本类型,基本类型(存储在栈上),Rust 会自动拷贝,其他的非基本类型会存储在堆上,不能自动拷贝。

let x = 5;
let y = x;

代码首先将 5 绑定到变量 x,接着拷贝 x 的值赋给 y,最终 xy 都等于 5,因为整数是 Rust 基本数据类型,是固定大小的简单值,因此这两个值都是通过自动拷贝的方式来赋值的,都被存在栈中,完全无需在堆上分配内存。

借用(Borrowing)

借用(Borrowing),就是允许你在不获取所有权 (Ownership) 的情况下访问数据。简单来说,借用就是创建数据的引用 (Reference)

借用有两种方式,不可变借用 : &T,可变借用:&mut T在任意给定的作用域中,你只能满足以下两个条件之一:

  1. 拥有 任意数量 的不可变引用 (&T)。
  2. 拥有 唯一一个 可变引用 (&mut T)。

即:要么多读,要么独写,绝不能同时存在,这个规则非常像 读写锁(Read-Write Lock)

比如下面就是合法的借用 (多读):

fn main() {
    let s = String::from("hello"); // s 拥有所有权

    let r1 = &s; // 不可变借用 1
    let r2 = &s; // 不可变借用 2

    // 可以同时存在多个读者
    println!("{}, {}", r1, r2); 
} // 借用结束

合法的借用 (独写):

fn main() {
    let mut s = String::from("hello");

    let r1 = &mut s; // 可变借用
    r1.push_str(", world"); // 修改数据

    println!("{}", r1); 
}

非法的借用 (读写冲突):

fn main() {
    let mut s = String::from("hello");

    let r1 = &s;      // r1 借用以此只读
    let r2 = &mut s;  // 错误!不能在有不可变引用的同时创建可变引用
                      // 因为 r2 可能会改变 s,导致 r1 看到的数据失效或不一致

    println!("{}, {}", r1, r2);
}

需要注意的是,现在的 Rust 编译器非常聪明,它的“作用域”不再仅仅是花括号 {},而是看引用的最后一次使用位置,这叫做 Non-Lexical Lifetimes (NLL)

fn main() {
    let mut s = String::from("hello");

    let r1 = &s; 
    let r2 = &s;
    println!("{} and {}", r1, r2); 
    // --- r1 和 r2 的作用在这里就结束了!因为后面没再用过它们 ---

    let r3 = &mut s; // 现在可以了!
    // 因为上面的不可变引用已经不再使用了,Rust 判定冲突解除。
    println!("{}", r3);
}

Rust 这么做其实也是为了安全,我们看看 C++ 中常见的坑:

// C++ 伪代码
vector<int> v = {1, 2, 3};
int& element = v[0]; // 获取第一个元素的引用

v.push_back(4); 
// 危险!如果 push_back 导致 vector 扩容(重新分配内存),
// 原来的内存被释放,element 现在指向的是垃圾内存。
// 再次访问 element 会导致 Crash。

在 Rust 中:

  • element 是一个不可变借用 (&T)。
  • v.push_back 需要获取 v 的可变借用 (&mut T)。
  • 规则冲突:已经有 & 了,不能再借出 &mut
  • 结果:编译直接报错,阻止隐患。

字符串

&strString

在其他很多语言用 "hello" 这种方式创建的一般就叫字符串,但在 rust 里面不一样,它实际上是申明了一个只读的字符串字面量 &str,这意味着它是不可变的,数据直接硬编码在编译后的可执行文件 (Binary) 中(静态存储区),有点像 const。

let name = "Rust"; // 类型是 &str
println!("Hello, {}", name);
// name.push_str(" World"); // 报错!&str 不能修改

在 rust 里面只有使用 String::from(...) 声明的字符串才是我们常规意义上理解的字符串,比如可以对它进行修改拼接传递

  1. 修改字符串 (必须加 mut)

    fn main() {
       // 注意:如果要修改,必须加 mut 关键字
       let mut s = String::from("Hello");
    
       // 1. 追加字符串切片 push_str()
       s.push_str(", world"); 
    
       // 2. 追加单个字符 push()
       s.push('!'); 
    
       println!("{}", s); // 输出: Hello, world!
    }
  2. 字符串拼接 (连接两个字符串),有两种主要方式:使用 + 运算符或 format! 宏。

    fn main() {
       let s1 = String::from("Tick");
       let s2 = String::from("Tock");
    
       // 注意细节:
       // s1 必须交出所有权 (被移动了),后面不能再用了
       // s2 必须传引用 (&s2)
       let s3 = s1 + " " + &s2; 
       // 类似 C 语言的 sprintf,生成一个新的 String
       let s4 = format!("{} - {}", s1, s2);
    }
  3. String 可以自动假装成 &str

    fn main() {
       let s = String::from("Hello World");
    
       // 场景 1: 函数需要 String (拿走所有权)
       take_ownership(s); 
       // println!("{}", s); // 报错,s 已经被拿走了
    
       // --- 重新创建一个 s ---
       let s = String::from("Hello Again");
    
       // 场景 2: 函数需要 &str (只读借用) -> 【这是最常用的】
       // 虽然 s 是 String,但 &s 可以被当做 &str 用
       borrow_it(&s); 
       println!("s 还在: {}", s); // s 还在
    }
    
    fn take_ownership(input: String) {
       println!("我拿到了所有权: {}", input);
    } // input 在这里被释放
    
    fn borrow_it(input: &str) {
       println!("我只是借看一下: {}", input);
    }
  4. 转换回切片 (Slicing)

    let s = String::from("Hello World");
    
    let hello = &s[0..5]; // 提取前5个字节
    let world = &s[6..];  // 从第6个字节取到最后

需要注意的是,Rust 的字符串在底层是 UTF-8 编码的字节数组,不支持直接通过数组下标索引(Index)访问字符。比如这样是会报错的:

let s1 = String::from("hello");
let h = s1[0];

Rust 的 String 本质上是一个 Vec<u8>(字节向量)。对于纯英文: "hello",每个字母占 1 个字节。s[0] 确实是 'h'对于中文/特殊符号: "你好"。在 UTF-8 中,’你’ 占用 3 个字节。

为了强迫开发者意识到 “字符 ≠ 字节” 这一事实,Rust 干脆在编译阶段就禁止了 String[index] 这种写法。

所以,为了获取第 N 个字符 (最常用,安全),需要使用 .chars() 迭代器:

fn main() {
    let s1 = String::from("hello");

    // .chars() 把字符串解析为 Unicode 字符
    // .nth(0) 取出第 0 个元素
    // 结果是 Option<char>,因为字符串可能是空的
    match s1.chars().nth(0) {
        Some(c) => println!("第一个字符是: {}", c),
        None => println!("字符串是空的"),
    }
}

为什么要有这两个?

  • String 是为了当你需要在运行时动态生成修改或者持有字符串数据时使用的(比如从网络读取数据,拼接 SQL)。
  • &str 是为了高性能传递。当你只需要“看”一下字符串,而不需要拥有它时,用 &str。因为它只是传两个整数(指针+长度),不需要拷贝堆上的数据,速度极快。

枚举

Rust 的枚举和其他语言最大的不同应该就是能挂载数据。每一个枚举成员,都可以关联不同类型、不同数量的数据。

enum Message {
    Quit,                       // 没有关联数据
    Move { x: i32, y: i32 },    // 像 Struct 一样包含命名字段
    Chat(String),               // 包含一个 String
    ChangeColor(i32, i32, i32), // 包含三个 i32
}

fn main() {
    // 创建不同类型的消息
    let msg1 = Message::Move { x: 10, y: 20 };
    let msg2 = Message::Chat(String::from("你好"));
}

比如上面的 Message 这个枚举,Quit 成员没有挂载数据,其他三个都挂载了各不相同的数据类型。

然后我们就可以根据枚举挂载的不同数据,使用 match 来进行匹配,这有点像 C++ 里面的 switch-case:

fn process_message(msg: Message) {
    match msg {
        Message::Quit => {
            println!("玩家退出了");
        }
        Message::Move { x, y } => {
            println!("玩家移动到了: x={}, y={}", x, y);
        }
        Message::Chat(text) => {
            println!("玩家发送消息: {}", text);
        }
        Message::ChangeColor(r, g, b) => {
            println!("更改颜色为: R{} G{} B{}", r, g, b);
        }
    }
}

但是需要注意的是 match 会强制你处理所有可能的情况,如果你漏写了 Message::Quit,代码根本编译不过。这保证了你不会遗漏任何一种业务逻辑。如果不想在匹配时列出所有值的时候,可以使用特殊的模式 _ 替代:

let some_u8_value = 0u8;
match some_u8_value {
    1 => println!("one"),
    3 => println!("three"),
    5 => println!("five"),
    7 => println!("seven"),
    _ => (),
}

顺带提一下,match 其实也可以用来返回赋值,这点就和很多其他语言不同:

enum IpAddr {
   Ipv4,
   Ipv6
}

fn main() {
    let ip1 = IpAddr::Ipv6;
    let ip_str = match ip1 {
        IpAddr::Ipv4 => "127.0.0.1",
        _ => "::1",
    };

    println!("{}", ip_str);
}

这里匹配到 _ 分支,所以将 "::1" 赋值给了 ip_str

上面我们也说了枚举可以挂载数据,所以相应的 match 也能把数据解包出来:

enum Action {
    Say(String),
    MoveTo(i32, i32),
    ChangeColorRGB(u16, u16, u16),
}

fn main() {
    let actions = [
        Action::Say("Hello Rust".to_string()),
        Action::MoveTo(1,2),
        Action::ChangeColorRGB(255,255,0),
    ];
    for action in actions {
        match action {
            Action::Say(s) => {
                println!("{}", s);
            },
            Action::MoveTo(x, y) => {
                println!("point from (0, 0) move to ({}, {})", x, y);
            },
            Action::ChangeColorRGB(r, g, _) => {
                println!("change color into '(r:{}, g:{}, b:0)', 'b' has been ignored",
                    r, g,
                );
            }
        }
    }
}

上面当代码执行到 match action 时 Rust 发现 actionAction::Say 这一类,就会解构这个类型里面的 String,Action::Say(s)的意思是:“如果是 Say,把它肚子里的数据拿出来,赋值给变量 s。”,其他的同理。

Rust 的枚举类型还有一个特点就是结构比较紧凑,枚举 = 标签(tag)+ 数据(payload),比如这个枚举:

enum Message {
    Quit,                    // 0 字节 payload
    Move { x: i32, y: i32 }, // 8 字节
    Write(String),           // 24 字节(64位平台上,大概是 3 个指针)
}

tag 就是当前是 Quit/Move/Write 中的哪一个,数据(payload)就是容纳“所有变体里最大的那个数据”的空间,这里就是24字节的 String 类型,当然还有根据对齐要求,可能在 tag 和 payload 之间加 padding,那么一个枚举的结构就是:

size_of::<Message>() ≈ size_of::<payload最大> + size_of::<tag> + 对齐填充

所以可以看到枚举的结构比使用结构体更加紧凑。

最后,枚举甚至还能可以有自己的方法:

#![allow(unused)]
enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

impl Message {
    fn call(&self) {
        // 在这里定义方法体
    }
}

fn main() {
    let m = Message::Write(String::from("hello"));
    m.call();
}

null

Tony Hoare, null 的发明者,曾经说过一段非常有名的话:

我称之为我十亿美元的错误。当时,我在使用一个面向对象语言设计第一个综合性的面向引用的类型系统。我的目标是通过编译器的自动检查来保证所有引用的使用都应该是绝对安全的。不过在设计过程中,我未能抵抗住诱惑,引入了空引用的概念,因为它非常容易实现。就是因为这个决策,引发了无数错误、漏洞和系统崩溃,在之后的四十多年中造成了数十亿美元的苦痛和伤害。

所以 Rust 只有 Option 枚举,没有 null / nil / nullptr。Rust 标准库是这样定义的:

enum Option<T> {
    None,    // 相当于 null
    Some(T), // 包含一个值
}

Option 可以配合 match 来使用:

let some_number = Some(5);
let absent_number: Option<i32> = None;

// let sum = some_number + 1; // 报错!不能把 Option<i32> 和 i32 相加

// 必须处理为空的情况
match some_number {
    Some(i) => println!("数字是: {}", i),
    None => println!("没有数字"),
}

使用 Option 还有个好处就是编译器会做一种优化叫做 “空指针优化 (Null Pointer Optimization)”

对于 Option<&T>(引用类型的 Option)或 Option<Box<T>>(堆指针的 Option)Rust 编译器在底层依然把它看作一个指针,Some(ptr) 对应非零地址,None 对应 0 (null) 地址。所以在汇编层面,Rust 的 Option<&T> 和 C++ 的 T\* 是一模一样的,内存占用也是一样的(64位机器上都是8字节)。

所以把指针包在 Option 枚举里不会增加内存开销,也不会导致运行变慢。

泛型(Generics)

Rust 泛型其实是有点像C++的模版的,而不是类似 Java 或 C# 的泛型。 Java 泛型是在编译期进行检查,但在运行时被“擦除”。例如,List<String>List<Integer> 在 Java 虚拟机(JVM)看来,本质上都是 List<Object>。这样做优点是节省了代码空间,缺点是牺牲了很多性能,因为在运行的时候虚拟机必须进行大量的类型转换(Casting)。

Rust 泛型则不一样,它和C++的模版是一样的,会根据你传入的具体类型(比如 int/i32float/f64),生成多份不同的机器码。这样做的好处就是执行效率极高,没有 Java/Go 那种装箱(Boxing)或运行时类型断言的开销。

但是相对的,Rust 又比 C++ 模版又要使用上要舒服很多,Rust 泛型因为 Trait Bounds 的原因,所以编译器在定义阶段就能发现错误,而不是等到调用阶段。假设我们要写一个打印函数:

C++:

template <typename T>
void print_it(T value) {
    // 编译器在这里不检查 value 到底有没有 .print() 方法
    // 直到你在 main 函数里调用 print_it(int) 时,它才发现 int 没有 .print()
    value.print(); 
}

Rust:

// 必须显式约束:T 必须实现了 Debug trait
fn print_it<T: std::fmt::Debug>(value: T) {
    println!("{:?}", value);
}

// 如果你写成下面这样,编译器直接报错,甚至不需要你调用它:
// fn print_it<T>(value: T) {
//     println!("{:?}", value); // 错误!编译器说:T 没有实现 Debug,不能打印
// }

特征对象(Trait Objects)

Rust 的特征对象 (dyn Trait) ≈ C++ 的虚基类指针 (Base) ≈ Go 的 interface

在 Rust 中,如果你想编写一个函数,或者定义一个容器(如 Vec),让它可以接受多种不同类型的数据,只要这些数据实现了同一个 Trait,你有两种选择:

  1. 泛型(Generics)fn foo<T: Draw>(x: T)
    • 这是编译期决定的。
    • 编译器会为每个不同的类型生成不同的代码(单态化)。
    • 缺点:你不能在一个 Vec 里同时存 u8f64,因为 Vec 只能存一种类型。
  2. 特征对象(Trait Objects)fn foo(x: &dyn Draw)
    • 这是运行期决定的。
    • “类型擦除”(Type Erasure):编译器此时不再关心具体的类型是 u8 还是 f64,它只关心“这东西能 Draw”。
    • 优点:你可以在一个 Vec<Box<dyn Draw>> 里混存 u8f64

特征对象(Trait Objects)实现的方式其实和 C++ 的虚表实现很像,比如当你把一个具体类型(如 &u8)转换成特征对象(&dyn Draw)时,Rust 会生成一个胖指针(Fat Pointer)

这个胖指针包含两部分(占用 16 字节):

  1. data 指针:指向具体的数据(如堆上的 u8 值)。
  2. vtable 指针:指向该具体类型针对该 Trait 的虚函数表(Virtual Method Table)

当你调用 x.draw() 时,如果 x 是特征对象,机器码执行的逻辑如下:

  1. 读取 vtable:从胖指针的第二个字段找到 vtable 的地址。

  2. 查找方法:在 vtable 中找到 draw 方法对应的函数指针(比如偏移量为 0 的位置)。

  3. 跳转执行:调用该函数指针,并将胖指针的第一个字段(data 指针)作为 self 传进去。

举个例子,如果我们使用泛型来实现下面的 Screen 类,里面的 components 想要放多个元素:

pub struct Screen<T: Draw> {
    pub components: Vec<T>,
}

如果你这样写,Screen 实例被创建时,T 必须被确定为某一种具体的类型。这意味着 components 里的所有元素都必须是同一个类型。如果想要混装是不行的,如下面:

let screen = Screen {
    components: vec![
        10u8,     // 编译器推断 T 是 u8
        3.14f64,  // 报错!期望是 u8,但你给了 f64
    ],
};
// error[E0308]: mismatched types

只能全部都是同一类型:

let screen = Screen {
    components: vec![10u8, 20u8, 30u8], // 全是 u8,没问题
};

如果使用特征对象(Trait Objects),就可以实现混装,在列表中存储多种不同类型的实例。

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

这样是 ok 的:

let v: Vec<Box<dyn Draw>> = vec![
    Box::new(10u8),
    Box::new(3.14f64),
];

生命周期

生命周期就是控制有效作用域,防止 悬垂引用(Dangling Reference)的 。

fn main() {
    let r;                // ---------+-- r 的生命周期开始
    {                     //          |
        let x = 5;        // -+-- x 的生命周期开始
        r = &x;           //  | 试图让 r 指向 x
    }                     // -+-- x 在这里死了(被释放)!
                          //          |
    println!("r: {}", r); //          | r 依然活着,但它指向的 x 已经没了!
                          //          | -> 报错:借用了活得不够久的值
}                         // ---------+

比如这段代码,Rust 编译器会拒绝编译,因为它发现 r 活得比 x 久,为了安全考虑,然后就拒绝编译。

用过 C++ 的同学知道, 在线上经常会因为这种问题而导致程序的 panic,因为C++ 编译器通常会“相信”你,比如下面的例子编译器根本不会管你:

#include <iostream>
#include <string>

// C++ 代码
const std::string& get_dangling() {
    std::string s = "Hello";
    return s; // 危险!s 在这里会被销毁
}

int main() {
    // 这里拿到了一个引用,指向了一块已经被释放的栈内存
    const std::string& ref = get_dangling(); 

    // 运行时表现:
    // 1. 可能崩溃 (Segmentation Fault)
    // 2. 可能打印出乱码
    // 3. 可能打印出 "Hello" (如果在内存被覆盖前运气好) -> 这是最可怕的“未定义行为”
    std::cout << ref << std::endl; 
}

所以相对而言,Rust 编译器的严格管控,实际上是”为了你好“。

Rust 为了实现这种严格管控,就出现了生命周期标注这种东西。生命周期不是“运行时的计时器”,也不是你手动管理内存的东西。它是编译器做静态分析时用的标记/约束,表示:

  • 这个引用至少要活到哪里
  • 或者:两个引用之间谁不能比谁短

你写的 'a 这种符号,就是一种生命周期参数。大多数时候,Rust 编译器能自动推断生命周期,但在一些模糊的情况下,就需要手动标注。手动标注是以 ' 开头,名称往往是一个单独的小写字母,大多数人都用 'a 来作为生命周期的名称。

关于什么时候需要使用标注其实就一句话:输出的引用到底是从哪个输入里借来的有歧义的时候。具体来说,主要有以下 4 种场景

场景 1:结构体中包含引用

只要你的 struct 定义里包含字段是引用(而不是像 Stringi32 这样的拥有所有权的类型),你就必须给整个结构体加上生命周期参数。

// 报错:missing lifetime specifier
struct Book {
    author: &str, // 这是一个引用,编译器慌了:这个引用指向谁?能活多久?
}

// 正确
// 读作:Book 实例活多久,'a 就得活多久;author 引用的数据至少也要活 'a 这么久。
struct Book<'a> {
    author: &'a str,
}

场景 2:函数有多个引用参数,且返回值也是引用

如果你有两个输入引用,且返回一个引用。编译器就蒙了:“返回的这个引用,是借用了参数 A,还是参数 B?”

// 报错
// 编译器困惑:返回的 &str 到底是谁的?
// 如果 user 活 10s,data 活 5s,我该让返回值活多久?
fn choose_one(user: &str, data: &str) -> &str {
    if user.len() > 5 { user } else { data }
}

// 正确
// 显式告诉编译器:返回值的生命周期取 user 和 data 中较短的那个('a)
fn choose_one<'a>(user: &'a str, data: &'a str) -> &'a str {
    if user.len() > 5 { user } else { data }
}

如果返回值只跟其中一个参数有关,只标那个参数就行:

// 这里的 'a 表示返回值只和 x 有关,和 y 无关
fn verify<'a>(x: &'a str, y: &str) -> &'a str {
    x
}

当然,如果只有一个输入引用那就没有歧义:

fn capitalize(s: &str) -> &str { ... } 
// 编译器自动脑补为:
// fn capitalize<'a>(s: &'a str) -> &'a str { ... }

场景 3:在 impl 块中实现方法时

这是语法要求,防止你忘记这个结构体是有“保质期”的。

struct Book<'a> {
    author: &'a str,
}

// 必须写 impl<'a>,把 'a 声明出来
impl<'a> Book<'a> {
    // 这里的 &self 其实隐含了生命周期
    fn get_author(&self) -> &str {
        self.author
    }
}

注意:在方法内部,通常不需要给参数标生命周期,因为 Rust 有一条强大的规则:“如果是方法(有 &self),那么返回值的生命周期默认和 self 一样。”

Reference

https://course.rs/

探究Rust中有趣的设计最先出现在luozhiyun`s Blog

北大《区块链技术与应用》——ETH篇

作者 luozhiyun
2025年11月26日 20:17

课程地址:https://www.bilibili.com/video/BV1Vt411X7JF?spm_id_from=333.788.videopod.episodes&vd_source=f482469b15d60c5c26eb4833c6698cd5&p=2

ETH发展史

2013年底: Vitalik Buterin(V神)发布了以太坊白皮书。他的核心理念是:比特币像一个功能单一的计算器(可编程货币),而世界需要一个更通用的平台,像一台“世界计算机”(World Computer)。所以他提出了智能合约的概念,指的是运行在区块链上的、图灵完备的程序。通过使用以太坊虚拟机(EVM)提供的这样的沙盒环境,用于执行智能合约。

2015年7月"Frontier"(前线)版本上线。这是以太坊的第一个“创世区块”,标志着网络的正式启动,这是时候还仅仅是一个测试版本。采用的是工作量证明(PoW)的共识机制。

2016年初"Homestead"(家园)版本发布,这是第一个稳定版本,标志着以太坊不再是“测试版”,开始吸引DApp(去中心化应用)构建者。The DAO这个项目诞生了,它是一个去中心化的风险投资基金,通过智能合约管理,它筹集了当时价值约1.5亿美元的ETH。

同时也意味着危机,2016年6月,The DAO 合约遭到“重入攻击”(Re-entrancy Attack),导致约1/3的资金被盗。

所以这个时候社区面临一个哲学困境,是要接受损失,还是进行通过修改协议规则来回滚交易,追回被盗资金。最后社区投票支持硬分叉,追回了资金,成为了今天的主流链,也就是今天的 ETH。另一派坚持不回滚,保留了原始链,就成了另一个币 ETC。

我们接着跳过几年不这么重要的发展期来到PoS时代

为什么要从 PoW 转向 PoS 共识证明呢?我们都知道 PoW 用“工作”来换取记账权,工作量越大,越值得信赖,这就有个问题,需要巨大的电力和硬件成本,这是极度不环保的。

而 PoS 的核心理念是用“抵押”来换取记账权,你抵押的(Stake)越多,越值得信赖。参与者不再需要购买昂贵的矿机,而是需要购买并质押(锁定)网络的原生代币,将这些代币作为“保证金”或“押金”锁在网络中。如果一个验证者试图作恶(例如,提议无效区块、双重签名),网络会自动销毁他质押的“保证金,ETH 就是这样降低了约 99.95% 的能耗。

下面说一下ETH是怎么做到的:

2020年12月, 信标链(Beacon Chain)上线,这是一条独立运行的、采用 PoS 共识的全新区块链,它唯一的任务就是让验证者质押 ETH 并就 PoS 共识达成一致。此时,ETH 质押是单向的(只能存入,不能取出)。

2022年9月15日以太坊团队将原有的 PoW 链(现在称为“执行层”)的“引擎”——即 PoW 共识——拔掉。然后,将“执行层”接入到“信标链”(现在称为“共识层”)的 PoS 引擎上。这正式标志着以太坊进入了“PoS 时代”

合并完成后,2023年4月 "Shapella"(上海 + Capella)发布启用了质押提款(EIP-4895)。验证者终于可以取出他们质押的 ETH 和奖励,这次升级引入了 withdrawalsRoot(提款树根)字段到区块头中。

当然 ETH 的迭代远没有结束,我看社区还在继续讨论新的提案出来。比如 Pectra 、The Verge 与 The Purge 等,感兴趣的可以自行取查阅一下。

账户

这里用 BTC 和 ETH 进行对比,首先在 BTC 中,并不存在一个叫做“我的余额”的变量。它是由 UTXO 算出来的,所以假设钱包里有 5 BTC。这 5 BTC 在区块链上可能并不是一个“5”,而是:

  • 一笔 2 BTC 的“现金”(UTXO 1)
  • 一笔 1.5 BTC 的“现金”(UTXO 2)
  • 一笔 1.5 BTC 的“现金”(UTXO 3)

总余额 = UTXO 1 + UTXO 2 + UTXO 3 = 5 BTC

如果想支付 3 BTC,钱包会选择“消耗”掉 UTXO 1 (2 BTC) 和 UTXO 2 (1.5 BTC),总共 3.5 BTC。然后产生两个新的 UTXO:

  1. 一个 3 BTC 的 UTXO 发送给您的朋友(支付)。
  2. 一个 0.5 BTC 的 UTXO 发送回给您自己(找零)。

旧的 UTXO 1 和 UTXO 2 就被标记为“已花费”,不能再用了。

这种模型的优点: 简单、安全、隐私性相对较好(因为找零地址可以是新地址)、易于并行处理交易。 缺点: 难以实现复杂的逻辑(例如智能合约),因为它很难跟踪一个“账户”的复杂状态。

ETH 的设计更像是传统的银行系统。每个地址都是一个独立的“账户”。如果地址有 5 ETH,那么在以太坊的“全局账本”上,地址旁边就明确写着 balance: 5

如果要支付 3 ETH,发起一笔交易,声明:“从账户A转 3 ETH 到账户B”,网络验证账户余额(5 ETH)是否足够支付 3 ETH(以及手续费 Gas)。验证通过后,以太坊网络会:

  1. 将账户A的 balance 减去 3 ETH。
  2. 将账户B的 balance 加上 3 ETH。

为了防止余额数字被直接篡改,账户里面有 nonce 用来记数,每次交易完毕之后加一,防止重放攻击。

ETH 有两种账户:

外部账户 externally owned account,个人用户钱包,由私钥控制,可以发起交易;

合约账户 smart contract account 由代码(智能合约)控制,没有私钥,它不能主动发起交易,只能在被 EOA 或其他合约“调用”(发送消息)时被动执行其代码。

ETH模型的优点:使得智能合约(复杂的应用程序)成为可能。模型更直观,易于开发 DApps。缺点: 交易必须按顺序处理(因为有 nonce 机制防止重放攻击),这可能导致网络拥堵。

数据结构

image-20251125210438372

Merkel Patricia Trie

账户地址到账户状态的映射 , 账户地址是 160 位。

以太坊 (ETH) 的核心数据结构是 Modified Merkle Patricia Trie, 简称 MPT。我们可以把它拆解成两个关键概念的组合来理解:Merkle Tree (默克尔树)Patricia Trie (帕特里夏·树,或称压缩前缀树)

Merkle Tree (默克尔树):它是一种哈希树。树底部的每个“叶子”是数据块的哈希值。相邻的哈希值两两组合再哈希,层层向上,最终汇聚成一个“根哈希” (Root Hash),如下图:

这种树有一个特点是只要树中的任何一个数据发生(哪怕是 1 bit 的)改变,最终的“根哈希”都会变得完全不同。这使得节点只需比较这一个根哈希,就能快速验证彼此是否拥有完全相同的海量数据。

                                                 +-----------------+
                         |   Merkle Root   |  <- 最终的"指纹" (H_ABCD)
                         | (H_AB + H_CD)   |
                         +-----------------+
                                / \
                               /   \
                +---------------+   +---------------+
                |   Hash_AB     |   |   Hash_CD     |  <- 中间节点
                | (H(T1)+H(T2)) |   | (H(T3)+H(T4)) |
                +---------------+   +---------------+
                      / \                 / \
                     /   \               /   \
            +-------+ +-------+     +-------+ +-------+
            | H(T1) | | H(T2) |     | H(T3) | | H(T4) |  <- 叶子节点
            +-------+ +-------+     +-------+ +-------+
                |         |             |         |
            +-------+ +-------+     +-------+ +-------+
            |  T1   | |  T2   |     |  T3   | |  T4   |  <- 原始数据
            +-------+ +-------+     +-------+ +-------+

比如我们上图有四个数据块 T1, T2, T3, T4,然后计算哈希H(T1), H(T2), H(T3), H(T4)构成叶子节点,然后他们的父节点分别由他们拼接起来再哈希获得。如果有人把 T3 改成了 T3*,那么 H(T3) 会变,Hash_CD 也会变,最终 Merkle Root 会变得完全不同

Patricia Trie (称压缩前缀树)

它其实是 Trie 进化而来的,可以高效地存储和查找键值对 (Key-Value),特别是当“键”(Key) 有相同前缀时,它能极大压缩存储空间。举个例子,比如我们要存储以下几个键值对(以单词为例):

  • "romane": (值 1)
  • "romanus": (值 2)
  • "romulus": (值 3)
  • "rubens": (值 4)
  • "ruber": (值 5)
                                            (Root)
                        |
                      "r"
                      / \
                     /   \
                 "om"     "ub"
                 / \         / \
                /   \       /   \
              "an"  "ulus"  "e"   "ens"  (值 4)
              / \     |       |
             /   \  (值 3)    "r"
           "e"   "us"           |
            |     |           (值 5)
          (值 1) (值 2)

我们可以看到这个树基本上和 Trie 类似,唯一的区别就是对路径进行了压缩,对比普通前缀树会是 R -> O -> M -> A -> N -> E。看,R->OO->M 都是“单行道”,只有一个子节点。Patricia 的压缩它会把这些单行道合并。R 后面有两个分叉 ("o", "u"),所以 "r" 节点保留。但 "r" 之后的 "o" 和 "m" 都是单行道,所以它们被压缩成了 "om" 节点。同理,"rub" 被压缩成了 "ub" 节点。

使用 Patricia Trie结构对 ETH 来说主要有几点好处:

  1. ETH 需要跟踪数以亿计的账户,每个账户都有自己的状态(余额、nonce、合约代码等)。Patricia Trie 可以方便的用来将这些数据组织成 key value 对,比如 key 存的是账户地址 (0x...),value存的是该账户的状态信息。

  2. 并且以太坊的“键”(如账户地址)非常长(160位或更长)。如果使用标准的前缀树,从根节点到每个叶子节点都会有非常多的层级,而Patricia Trie它会把所有“没有分叉的单行道”路径压缩合并成一个节点,从而节省空间。

  3. Trie 树的最终形状和根哈希只取决于它所包含的“键值对”数据,而与插入这些数据的顺序无关。以太坊是一个全球分布式的系统。不同的节点在构建区块时,可能会以不同的顺序处理(本地缓存或插入)状态数据,所以这一点也是至关重要的。

  4. 最后就是 Patricia Trie 允许高效的状态更新,当一笔交易发生时(例如,A 转账给 B),通常只有极少数的“值”被改变了(A的余额减少,B的余额增加),种树形结构允许只更新从被修改的叶子节点到根节点的那条路径上的节点。

在 ETH 结构中,有三棵树都是使用的Patricia Trie结构:交易树 (Transaction Trie)、收据树 (Receipts Trie) 、状态树(State Trie),所以我们来看看 ETH 的 Merkel Patricia Trie 是怎么做的。

image-20251107212146532

ETH 它把键视为 16 进制的 nibble(半字节)序列,用三种节点(Branch / Extension / Leaf)压缩表示键空间,并对每个节点做序列化后取哈希,最后得到整棵树的根哈希,如上图所示。

nibble (半字节)序列简单说就是将输入的 字节流 (byte stream) 转换为 16 进制字符流。因为一个字节 (Byte) 包含 8-bit,而一个半字节 (Nibble) 包含 4-bit,所以每一个字节都会被精确地拆分成两个半字节 (nibbles)。一个 8-bit 的字节,比如 0x7A,就会被拆分成了两个 nibbles:[7, a]

下面我们看看三种节点(Branch / Extension / Leaf):

  • Branch 节点(branch) — 有 16 个子指针 + 一个可选值槽(用于恰好在此处结束的键):

    BranchNode:
    +----------------------------+
    | v0 | v1 | v2 | ... | v15 | value |
    +----------------------------+
    • v0..v15 共 16 个子指针是因为 Key 被拆分成了半字节 (nibbles) ,用十六进制表示,指向下一层节点(分别对应 nibble 0..15),如果没有对应路径则为 null(或空);
    • value 字段用于当某个键恰好在该节点结束(即键完全耗尽)时保存对应值。
  • Extension 节点(extension) — 用于把一段共享前缀聚合成一条边:

    ExtensionNode:
    +----------------------+    指向下一级节点
    | path: [nibble数组]   |  --->  子节点
    +----------------------+      (Branch/Leaf/Extension)
    • path 是一段 nibble(十六进制半字节)序列;extension 不包含值,只是压缩中间相同前缀。
  • Leaf 节点(leaf) — 存储键的剩余部分(从分支到末尾)和对应值:

    LeafNode:
    +---------------------------+
    | path: [剩余 nibble数组]   |
    | value: bytes              |
    +---------------------------+
    • 当一个键在 trie 中一个分支走到底时,用 leaf 存储剩下的 nibble 和最终值。

比如我们把把三个键插入到空的MPT中:

  • Key A:[a, b, c, d] -> 值 V1
  • Key B:[a, b, c, e] -> 值 V2
  • Key C:[a, b, f] -> 值 V3
root = Extension([a,b]) -> Branch
                         /   |   ...
                        c    f
                        |      \
                 Extension([c])  Leaf([ ] -> V3)
                    |
                   Branch
                   /   \
                 d      e
                 |      |
               Leaf   Leaf
               (V1)   (V2)

路径 [a,b] 是共有前缀,[c](成为 extension/直接连接到 branch 继续分叉指向 leaf v1 和 leaf V2,子槽 f 指向 leaf 值是 V3。

Block Header 四颗状态树

在 Block Header 里面有四棵树,状态树 (State Trie)、交易树 (Transactions Trie)、收据树 (Receipts Trie)、提款树 (Withdrawals Trie) 都是用 MPT 来构建的。

  • 状态树 State Trie它记录了所有账户的全局状态(余额、nonce、合约代码、合约存储)。需要注意的是 这是唯一一棵持久化的树。它不只是记录这个区块发生的事,而是记录了在执行完这个区块的所有交易之后,以太坊全世界所有账户(包括智能合约)的最终状态。每个新区块都会在旧状态树的基础上进行“更新”,产生一个新的 stateRoot,其他不变的账号状态还是用原来的节点。

    image-20251107213815983

    主要包含:

    • 每个人的 ETH 余额。
    • 每个账户的 nonce(交易计数)。
    • 每个智能合约的 codeHash(代码)。
    • 每个智能合约的 storageRoot(指向它自己的存储树)。
  • 交易树 (Transactions Trie)里面包含当前区块中的所有交易。它的唯一目的就是按顺序存储仅属于这个区块的所有交易。

    主要包含:

    • 交易 0, 交易 1, 交易 2…
  • 收据树 (Receipts Trie)包含当前区块中所有交易的执行回执(Receipts)。 这棵树对于 DApp 和钱包至关重要。当你想知道“我的交易成功了吗?”或者“某个智能合约是否触发了某个事件(Event)?”,你就是在这棵树里查找(或验证)这个“收据”。

    主要包含:

    • 交易 0 的结果:status: success, gasUsed: 21000, logs: [...]

    • 交易 1 的结果:status: failure, gasUsed: 50000, logs: []

  • 提款树 (Withdrawals Trie)这是“上海/Shapella”升级后新增的,专门用于处理从信标链(共识层)提款到执行层的操作,它为质押提款提供了可验证的记录。

    主要包含:

    • 提款 0:验证者 A 提取 X ETH 到地址 B。

    • 提款 1:验证者 C 提取 Y ETH 到地址 D。

logsBloom 日志布隆过滤器

在 ETH 的区块头还有一个logsBloom 字段,它使用布隆过滤器(Bloom Filter)来实现的,目的是为了做“快速索引”“摘要”。

在以太坊上,智能合约通过触发“事件”(Events/Logs)来与外界(DApp 前端、钱包)通信。例如,一个 ERC-20 代币合约在转账时会触发一个 Transfer 事件。假设你的钱包想显示你所有的 ERC-20 代币转账记录。它该如何找到这些记录?

如果没有 logsBloom ,那么钱包必须下载整条链(几 TB 的数据),然后遍历每一个区块里的每一笔交易每一条收据(Receipt),逐一检查其 logs 字段,看看是不是你想要的 Transfer 事件。这对于轻客户端(如手机钱包)或 DApp 前端来说是绝对不可能的。

logsBloom 巧妙的使用布隆过滤器(Bloom Filter)来构建区块中的交易触发的所有事件索引 log,当一个合约触发一个事件时,例如 Transfer(address indexed from, address indexed to, uint256 value),以太坊会把触发事件的合约地址所有被 indexed (索引) 标记的参数(比如 fromto 的地址)“添加”到这个布隆过滤器中。

布隆过滤器(Bloom Filter)是一种概率型数据结构,它非常节省空间,专门用来回答一个问题:“某个东西可能 在这个集合里吗?

它的回答只有两种:

  1. “绝对没有” (False): 如果它说“没有”,那这个东西 100% 不在集合里。
  2. “可能有” (True): 如果它说“有”,那这个东西有很大概率在集合里。(注意:它有很低的概率是“假阳性”,即它以为有,但其实没有)。

对 布隆过滤器(Bloom Filter)感兴趣的,可以去看我这篇文章:Go语言实现布谷鸟过滤器

那么有了这个 Log 我们就可以:

  1. 你的钱包想查找所有“发送到你地址 0xABC...”的 Transfer 事件。
  2. 不需要下载整个区块。它只需要下载区块头(非常小)。
  3. 它检查区块头里的 logsBloom 字段。
  4. 它向这个 logsBloom 提问:“你这里面可能包含 0xABC... 这个地址吗?”

logsBloom 回答 “绝对没有”那么钱包100% 确定这个区块里没有任何一笔交易触发了与 0xABC... 相关的事件。logsBloom 回答 “可能有”钱包才会去下载这个区块的完整数据,来精确找到它要的 Transfer 事件。

工作量证明 & 权益证明

为什么要有共识机制

我们先来说一下为什么要有PoW (工作量证明)PoS (权益证明) 这种共识机制,它们都是为了解决一个在计算机科学中极其古老且棘手的问题,尤其是在一个“去中心化”和“无需信任”的环境中,用来确保即使网络中充满了互不信任的陌生人(甚至有坏人),整个系统也能安全、一致地运行。

它们具体解决了以下三个关键问题

  1. 防止"女巫攻击" (Sybil Attack) —— 谁有资格记账?

    在一个开放的网络中,一个坏人几乎可以零成本地创建一百万个“假身份”(节点)。如果“记账权”是靠“一人一票”来决定的,那么这个坏人就能轻易地用他的“百万大军”投票控制整个网络。

    因为在 web2 中,是有一个去中心化的节点来控制的,所以一般是通过认证与授权 (Authentication & Authorization)来实现的,但是 web3 中,是去中心化的,所以需要设计这样的共识机制,用它增加记账的门槛,防止坏人可以低成本的记账,对网络产生影响。

  2. 防止"双花" (Double-Spending) —— 如何确保账本不可篡改?

    "双花"是数字货币的“原罪”。坏人张三有 10 ETH,他先发一笔交易给李四,同时(或之后)又发一笔交易把同样的 10 ETH 发给王五。网络必须决定哪一笔交易是“唯一真实”的。

    在 web2 中,是通过 一个中心化的数据库 (Single Source of Truth) 来实现的,在web3中是一个分布式的账本,我如何确保所有人都同意“张三的 10 ETH 是先给了李四,而不是先给了王五”?

    那么web3中就可以共识机制就可以设计一些有成本的操作,让“作恶成本”提高,来确保账本的唯一性和不可篡改性。

  3. 激励机制 (Incentives) —— 为什么有人愿意来记账?

    既然保护网络这么昂贵(要买矿机或锁定 ETH),为什么会有人愿意做这件事?

    在 Web2 中,不需要通过激励“陌生人”来进行记账,银行只需要商业模式 (Business Model) 和 雇佣 (Salary)机制来保障,不需要别人来记账。

    但在web3中需要奖励机制来鼓励诚实者来进行记账,诚实节点地遵循规则、打包区块、验证交易,系统就会奖励你新发行的代币(例如 ETH)和用户支付的交易费。这样ETH 越有价值,你作为奖励收到的 ETH 就越值钱,你也就越有动力去保护它。

所以这也是为什么在 web3 中需要PoW (工作量证明)PoS (权益证明) 这种共识机制。下面我们来看看这两种共识机制有什么区别。

PoW (工作量证明)其实就是需要矿工投入巨额的硬件成本电费(物理工作)来解题。第一个解出题的就拥有的记账权。但是 PoW 有个极大的问题就是它不环保,浪费了大量的电来做这个事情。

那么就有人提议,其实PoW为了能去挖矿是需要投入巨量的硬件成本电费,最后就是谁投入的钱多,谁就拥有这个记账权,既然如此,可以不可以直接点,直接用金钱来做抵押,那么这就是PoS (权益证明)基本理念,在 PoS 机制下,网络的安全不再依赖于消耗能源,而是依赖于经济激励惩罚

我们下面来详细看看 PoS 是怎么做的。

PoS (权益证明)

在 PoS 中验证者取代了 PoW 中的“矿工”。他们是运行特定软件的节点,负责处理数据、执行交易,并将它们打包成新的区块添加到链上。要成为一个验证者,你需要向一个特殊的智能合约中质押 32 个 ETH

质押就是将 32 ETH 锁定,这部分的资金会在惩罚的时候用到,一旦发现作恶,作恶者的一部分质押金(最多 32 ETH)将被销毁(永久消失),并且该验证者将被强制踢出网络。比如在同一个时隙提议两个不同的区块(试图分叉),抑或是提交自相矛盾的投票(例如试图支持两条不同的链),这些都是作恶的行为。

在 ETH 中,是按时间来组织打包区块的,每个 固定的 12 秒时间段被称为 Slot,理论上每个 Slot 都会产生一个新区块。

每个 Slot,系统会随机选择一个验证者作为“区块提议者”(Proposer), 32 个Slot 组成一个Epoch(约 6.4 分钟)。

ETH 的 PoS 架构分为两层:

  1. 共识层 (Consensus Layer, CL):这是 PoS 的“大脑”。它不处理交易,只负责协调所有验证者、随机抽签、分发选票、统计投票,并就区块的顺序和有效性达成共识。
  2. 执行层 (Execution Layer, EL):这是“引擎”。它负责执行智能合约、处理交易、更新我们之前讨论的“状态树”(State Trie) 和其他三棵树。

具体步骤:

  1. 在每个 12 秒的 Slot 开始时,共识层会从所有验证者中随机抽选一个验证者,作为这个 Slot 的“提议者”(Proposer)。

  2. 提议者打包区块,被选中的 proposer:

    1. 从“执行层”的交易池 (Mempool) 中抓取一批交易。
    2. 打包这些交易,创建一个新的区块
    3. 对这个新区块签名,并将其广播到整个网络。
  3. 共识层会为同一个 Slot 随机抽选一组(一个“委员会”/Committee),大约 100~几百个验证者组成的 Attestation 委员会(Committee)

    委员会的工作:

    1. 它们会收到“提议者”广播的新区块。
    2. 它们验证这个区块的有效性(签名是否正确?交易是否合法?)。
    3. 如果有效,它们会投出“赞成票”(Attestation)。
    4. 广播 attestation 给全网

    这些“赞成票”会汇集到共识层。

上面的讲述中,为了防止节点作恶,PoS 设计了一套安全机制。

如果诚实地参与提议和投票,验证者会获得两种奖励:

  1. 共识层奖励: 少量新发行的 ETH,作为维护网络安全的“工资”。
  2. 执行层奖励: 用户支付的“小费”(Priority Fees)。

如果节点作恶,那么就会执行相应的惩罚,这里的惩罚分为几种:

  1. 轻微惩罚 (Inactivity Leak):节点掉线了(例如停电、断网),没能及时投票,这样会损失掉本应获得的小额奖励
  2. 严厉惩罚(Slashing):比如双重提议 (Double Proposing),在同一个 Slot 提议了两个不同的区块;双重投票 (Double Voting),同一个 Epoch 里投票给了两个竞争的区块(试图制造分叉)。这样做会将质押的 ETH 进行一定的销毁,并强制踢出验证者队列,永久失去参与共识的资格。

LMD-GHOST机制

还有就是真的就是有作恶者进行了分叉选择 (Fork-Choice),提议了两个不同的区块,或者网络延迟导致出现了两个竞争的区块(分叉),怎么办?

在 ETH 中是通过 LMD-GHOST 机制来保障。节点根据“所有验证者最近一次投票(attestation)”来决定哪条分支最“重”,从而选出链头(head)。

当你的以太坊节点需要确定“主链的头部是哪个区块”时,它会执行以下操作:

  1. 从一个已“最终确定” (Finalized) 的区块开始作为 root,Finalized 的区块一般是上个 Epoch 锁定的。
  2. 查看它“观测到”的、来自所有验证者的“最新”投票。
  3. 使用“贪婪”算法,从 root 出发,在每一个分叉路口,都选择那条其子树累计获得了最多“质押权重”投票的路径。
  4. 一直走到这条“最重”路径的末端(叶子区块),这个叶子区块就是当前的主链头部(Head)。

Casper FFG机制(FFG)

Casper FFG 就是 ETH 的最终性 Finality 的机制,用来决定哪些区块被不可逆地锁定。

因为LMD-GHOST 选出的“头部”是可以改变的。如果网络延迟很严重,或者有攻击者在故意制造分叉,LMD-GHOST 选出的“最重链”可能会在 A 链和 B 链之间“摇摆不定”。这样用户没法确定这笔存款是不是100%到账了。

在 BTC 中我们知道它是通过“6 个区块确认”来确保交易的理论安全,而 FFG 是通过 Epoch 来确定。

ETH 在每个 Epoch 的第一个区块设立了Checkpoint (检查点),FFG 会通常跨越两个 Epoch(约 13 分钟)来实现最终确定。

举个例子,假设我们现在处于 Epoch 100

第一步:标记Justification (合理化)

  • 所有验证者(委员会)会一起投票,连接“上一个检查点”和“当前检查点”,即投票支持 C(99) -> C(100) 这条链;
  • 如果超过 2/3 的总质押 ETH 都投票给了这个连接;
  • C(100) 这个检查点区块被标记为 "Justified" (合理化),但是 Justified只是意味着“这个区块看起来非常棒,全网大部分人都同意它在主链上”,但它还不是最终的。

第二部:Finalization (最终确定)

  • 时间进入到了下一个 Epoch,Epoch 101
  • 验证者们再次投票,支持 C(100) -> C(101)
  • 如果 C(101) 也获得了超过 2/3 的投票,它自己也变成了 "Justified"
  • 此时,协议会回头看 C(101) 的“来源”——C(100),因为 C(100) 本身是 "Justified" 的,所以协议在这一刻C(100) 的状态升级为 "Finalized" (最终确定)

小结

所以我们可以从上面看的出来 ETH 的 PoS 机制依靠验证者(质押 32 ETH)而非矿工来保护网络。其安全不靠算力,靠押金:诚实有奖,作恶(如双重签名)则被罚没 (Slashing)

这套系统由两个协同工作的机制驱动:

  1. LMD-GHOST (选头部): 一个快速、灵活的规则,根据验证者的最新投票来选择“当前”的主链。
  2. Casper FFG (定最终): 一个缓慢、严谨的规则,通过“两步确认”将历史区块永久锁定(Finalized),使其不可逆转。

智能合约

简单来说:智能合约就是运行在以太坊区块链上的一段“自动执行的代码”。我们可以把它比喻成一台全自动贩卖机。

  • 输入:你投币(转账 ETH)并选择商品(调用函数)。
  • 逻辑:机器内部验证金额是否足够(代码逻辑判断)。
  • 输出:吐出饮料(分发 Token 或 NFT)并找零。
  • 特点:无需店员(去中心化),一旦设定无法随意更改价格(不可篡改)。

智能合约有这几个特点:

  1. 自动执行:一旦条件触发,就一定会执行,没人能阻止(连开发者本人也不行)。
  2. 不可篡改:部署到以太坊后,代码永远不能改(除非你一开始就写好升级机制)。
  3. 完全透明:全世界都能看到代码是什么(在Etherscan上点开任何合约都能看源码)。
  4. 去中心化:不靠任何公司或服务器,运行在全球几万台节点上,只要以太坊活着,它就活着。
  5. 用Gas付费:每次调用智能合约都要花一点ETH作为“燃料费”(Gas),防止有人写无限循环攻击网络。

一个最简单的“银行”合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract SimpleBank {
    // 1. 状态变量 (State Variable)
    // 这些数据会永久写入区块链,类似于数据库中的表
    mapping(address => uint256) public balances;

    // 2. 事件 (Event)
    // 类似于日志系统,用于前端监听
    event Deposit(address indexed user, uint256 amount);

    // 3. 函数:存款
    // payable 关键字表示该函数可以接收 ETH
    function deposit() public payable {
        require(msg.value > 0, "Deposit amount must be greater than 0");

        // msg.sender 是调用者的地址
        balances[msg.sender] += msg.value;

        emit Deposit(msg.sender, msg.value);
    }

    // 4. 函数:提款
    function withdraw(uint256 amount) public {
        require(balances[msg.sender] >= amount, "Insufficient balance");

        balances[msg.sender] -= amount;

        // 将 ETH 转回给用户
        payable(msg.sender).transfer(amount);
    }
}
  • mapping 就是 Key-Value 存储(类似于 Redis),address 是 Key,uint256 是 Value。

  • msg.sendermsg.value 是全局注入的上下文变量(Context),无法伪造。

  • require 类似于 Assert 或中间件校验,如果不通过,整个事务回滚。

原子性

需要注意的是,智能合约的每次执行具有原子性,比如像上面这个银行的例子,如果失败了,就整个操作进行回滚,不存在中间状态;如果所有步骤都成功,所有的状态变更(State Changes)会被一起写入区块,永久生效。其实这就有点像数据库的事务。

熟悉数据库的朋友我可以这么解释:

在 EVM(以太坊虚拟机)中,每一笔交易天然就是一个隐式的 START TRANSACTION ... COMMIT。你不需要显式地写 Commit,但任何未捕获的错误都会触发自动 Rollback。

跨合约组合(Composability)

原子性不单单局限于单个合约内部,而是可以跨越多个合约的调用链。

假设一笔交易的调用链是这样的:

用户 -> 合约 A -> 合约 B -> 合约 C

如果在 合约 C 的执行中出错了:

  1. 合约 C 的状态回滚。
  2. 合约 B 的状态回滚。
  3. 合约 A 的状态回滚。
  4. 用户发起的这笔交易被标记为“失败”

这里就有一个问题,执行失败会收 Gas 费吗?我们先看看什么是 Gas 费

Gas 费

Gas 费本质上等于:工作量(Gas Units)X 单价(Gas Price)。

Gas Units(工作量/计量单位)

EVM(以太坊虚拟机)执行的每一个操作码(Opcode)都有一个固定的 Gas 消耗值,操作越复杂,消耗越高。

比如下面这些操作:

计算操作(便宜)

  • ADD (加法): 3 Gas
  • MUL (乘法): 5 Gas
  • KECCAK256 (哈希计算): 30 Gas + 动态数据费用

存储操作(极贵)

  • SSTORE (写入/修改状态变量): 20,000 Gas (这是最贵的操作之一)
  • SLOAD (读取状态变量): 2,100 Gas
  • LOG (生成日志): 375 Gas

基础费用

  • 发起一笔最简单的转账交易(不调用合约):固定 21,000 Gas

Gas Price(单价/市场汇率)

这是由市场供需决定的变量。Gas Units 是“你需要多少升油”,Gas Price 就是“今天加油站一升油卖多少钱”。

Gas 的单位是 Gwei:

  • 1 ETH = 10^18 Wei
  • 1 Gwei = 10 ^9 Wei (即 0.000000001 ETH)

动态定价

  • 网络拥堵时:大家都在排队提交交易,为了插队,你必须出高价(比如 100 Gwei)。这就像滴滴打车的“高峰溢价”。
  • 网络空闲时:价格下降(比如 10 Gwei)。

最终计算公式

现在的以太坊(EIP-1559 升级后)计费变得稍微复杂了一点点,分为两部分:

Tx Fee = Gas Used X (Base Fee + Priority Fee)
  1. Base Fee (基础费)
    • 系统自动定。根据上一个区块满了没,自动调整。
    • 这部分钱会被“销毁”(Burned),也就是说这部分 ETH 直接从流通总量中消失了(通缩机制)。
  2. Priority Fee (小费/优先费)
    • 你给矿工/验证者的红包
    • 如果你想让交易快点确认,就多给点小费,验证者会优先打包小费高的交易。

举个具体的例子:

假设你要在这个拥堵的周五晚上,调用一个合约函数 buyItem()

A. 代码层面(Gas Units) EVM 跑完你的代码,发现你做了一次加法,写了一次数据库,总共消耗了 50,000 Gas

B. 市场层面(Gas Price) 当前的 Base Fee 是 50 Gwei,为了快点成交,你给了 2 Gwei 的小费。 总单价 = 52 Gwei。

C. 你的账单

花费 = 50000(Units)X 52(Gwei) = 2,600,000 Gwei

换算成 ETH 就是 0.0026 ETH。 假设 ETH 现价 $3000,这笔操作就要花你 $7.8 美金

操作失败会收 gas 费吗?

两种失败场景的区别:

在 EVM 中,交易失败主要分两种情况,扣费逻辑略有不同:

  • 场景 A:Gas 耗尽(Out of Gas)

    Gas Limit 是预算上限,假如设置了100,000Gas ,意思是“我这笔交易最多允许烧掉 100,000 Gas,再多我就不付了”。

    如果代码跑到 100,000 还没跑完(Out of Gas),EVM 强制停机,这 100,000 的钱全部扣掉不退,且交易回滚。

  • 场景 B:逻辑错误(Revert / Require 失败)

    比如转账余额不足、权限不够。比如代码运行到第 10 行,触发 require(false)。这样只扣除前 10 行代码消耗的 Gas。剩余未使用的 Gas(即 Gas Limit - Gas Used)会退回到你的钱包。

为什么要这么设计?

对于去中心化网络,这是为了防止 DDoS 攻击(拒绝服务攻击)。 如果失败不收费,黑客可以写一个无限循环的恶意合约:

while(true) { i++; }

然后向网络发送几百万笔交易来调用它。如果不收费,全网节点的 CPU 就会被免费占用,导致网络瘫痪。

小结

我们可以看出ETH的智能合约,把区块链从“账本”升级为了“通用计算平台”。没有智能合约,区块链就只能炒币(Store of Value);有了智能合约,区块链才有了应用层(Application Layer),多了更多的活力和玩法。

比如:

  • Uniswap(去中心化交易所 – AMM),它没有传统股票交易所的“订单薄”(Order Book),没有挂单和吃单的概念;
  • Aave / Compound (去中心化借贷),资金池模式。存款人把钱扔进池子拿利息,借款人抵押资产从池子借钱付利息;
  • MakerDAO (去中心化稳定币 – DAI),相当于是以太坊上的“美联储”,通过合约抵押的方式,生成一个永远锚定 1 美元的币(DAI);

还有很多有意思的玩法,我这里就不一一列举了,如果要深入学习智能合约的话,可以看这几个教程:

https://cryptozombies.io/ 通过建一个“僵尸养成”区块链游戏,一步步教你写Solidity智能合约。

https://docs.soliditylang.org/ Solidity官方英文文档

https://www.wtf.academy/zh/course/solidity101 WTF Solidity极简教程

如何在ETH链上发币?

ETH 社区定义了 ERC-20 标准的 API 接口规范,任何一个智能合约,只要实现了这套规定的 API(方法和事件),它就是一个 ERC-20 代币。

我们可以把代币想象成存储在一个巨大的 Map 里面,比如这样的一个结构:

// 这是一个简化的核心存储结构
contract ERC20 {
    // 1. 账本:记录 "地址 -> 余额"
    mapping(address => uint256) private _balances;

    // 2. 授权表:记录 "我 -> 授权给谁 -> 多少钱"
    mapping(address => mapping(address => uint256)) private _allowances;

    // 3. 代币总供应量
    uint256 private _totalSupply;
}

当你发起一笔转账(例如 A 转给 B 100个币)时,EVM(以太坊虚拟机)到底做了什么?

  1. 交易发起:用户 A 向该代币合约地址发起一笔调用 transfer(B, 100) 的交易。
  2. 余额检查:EVM 检查 _balances[A] 是否大于等于 100。
  3. 数据重写
    • _balances[A] = _balances[A] - 100
    • _balances[B] = _balances[B] + 100
  4. 持久化:这些修改后的数据被写入以太坊的状态树(Merkle Patricia Trie),并打包进新的区块中,永久不可篡改。

数据结构

image-20251125210438372

我这边再引用一下上面的图,合约其实在 ETH 里面也是一个账户对象,当你通过地址找到这个账户(比如那个 ERC-20 代币合约地址)时,你会得到一个包含四个字段的结构体:

Nonce: 交易计数器。

Balance: 这里存的是 ETH 的余额(比如 0.5 ETH),不是代币余额

CodeHash: 智能合约的代码哈希(如果是普通账户则为空)。

StorageRoot (存储根): 这是一个哈希值,它指向了另一棵 MPT 树的根节点。这棵树专门属于这个合约,用来存储它所有的变量数据。

StorageRoot (存储根)

这棵树本质上是一个巨大的、持久化的 Key-Value 映射,其实也是一颗 MPT 树。

  • Key: 32 字节(256位)的存储槽位置 (Slot Index)。
  • Value: 32 字节(256位)的数据内容。

比如这样一个代币合约:

contract MyToken {
    // Slot 0: 假如这里有个 owner 变量
    address owner; 

    // Slot 1: 假如这里是总供应量
    uint256 totalSupply; 

    // Slot 2: 这里就是代币余额的 Mapping
    mapping(address => uint256) private _balances;
}

当 EVM 运行到 _balances 时,它并不会把整个 Mapping 存在 Slot 2 里面(因为 Mapping 大小是不确定的)。 它是通过哈希算法计算出具体的存储位置。

如果你想查 0xUserA 这个人的余额,数据存储在 Storage Trie 中的 Key 是这样算出来的:

image-20251126145250636

  1. 0xUserA: 用户的钱包地址(补齐到 32 字节)。
  2. SlotIndexOfMapping: _balances 变量在代码中声明的位置(假设是 Slot 2)。
  3. keccak256: 对这两个拼接后的数据进行哈希运算。

运算得出的结果(一个乱码一样的哈希值),就是该用户余额在底层的物理存储地址。 而这个位置对应的 Value,就是 uint256 类型的余额数值。

所以如果要查询代币的值,整个查找链条是这样的:

  1. Block Header -> 拿到 StateRoot

  2. World State Trie -> 用 TokenContractAddress 查找 -> 拿到 Account Object

  3. Account Object -> 拿到 StorageRoot (进入该合约的私有数据库)。

  4. Storage Trie -> 计算 keccak256(UserAddress + SlotIndex) 作为 Key -> 拿到余额数据

北大《区块链技术与应用》——ETH篇最先出现在luozhiyun`s Blog

泡温泉&跑步(别府篇)

作者 luozhiyun
2025年11月2日 20:37

最近有点时间,想找个地方休息一下,本来只是想去熊本看看高达的,别府只是顺路去一下,没想到这个小地方还是挺惊艳到。

别府有着很多温泉资源,拥有近3,000个温泉源头,温泉涌出量位居日本第一,在全球也仅次于美国的黄石国家公园。

温泉水多到什么程度呢,别府站旁边就有个池子可以用温泉水洗手,没错,就是下面这个雕像的右边,顺带一提,这个雕像的衣服会经常换,各位如果也来别府,看看他会穿什么。

image-20251102201329540

走在路上你甚至可以在街上的下水道里面看到有冒着白雾,也就是说这些下水道的水都是温泉水哦。

image-20251102201710144

泡温泉&跑步

在别府有很多温泉,大都集中在铁轮温泉区,其中最出名的就是这个温泉,葫芦温泉,是一家百年的老店。

image-20251102201933253

这个温泉固然是很好的,但是我不只是想讲这个温泉体验怎样。在泡温泉的时候,我就想泡温泉的时候就和跑步好像:

  • 不能玩手机;
  • 身体在承受一定的痛苦;

不能玩手机代表你必须要聚焦于自我的思绪当中,不受外界的信息干扰,表示你有更多的精力让你的思想放空,可以想到不一样的事情。很多时候,我都是在跑步的时候,放下手机的时候突然想起要做某件事情,然后去做,这篇文章也是一样,在泡温泉的时候,想起可以去写一篇这样的文章。

身体在承受一定的痛苦意味着身体会时不时提醒你有多久没关注过当下的生活了,你在吃什么,身边在发生什么事,经过了什么样的景色,遇到了什么样的人,似乎我们都错过了好多。但是泡温泉和跑步的时候,身体的痛苦会把你的思绪拉回来,让你记住当下的生活。

比如今天我泡温泉就记住了在一个池子里面有个老人长的挺帅的,可能有六七十岁了,戴着眼镜,有点像电影里面的老人,相信他穿着打扮一下,肯定气质不凡。

在露天温泉里有几个年轻人在我左前方一直在讲话,让我想起,要是国内也能这么方便的可以泡温泉就好了,一般我们都喜欢边吃饭边聊天,其实我们也可以把场景换一下,边泡温泉,大家赤诚相见,边泡温泉边聊天,其实也是不错的。

泡温泉和跑步有一点是不一样的,跑步都是越快越好,大家都在追求速度,但是温泉不一样,强制大家慢下来。在日本泡温泉的时候,会有告示牌告诉你,别这么急着进去泡,先慢慢洗一下身子。洗完身子之后,要缓步走到池子里,因为都是水,急的话就容易摔倒。进到池子的时候,也是要慢慢进去,因为水温很烫,身体一下钻进去肯定会把你烫出来。

我在国内生活很多年,几乎所有人都在说快一点,快一点。要快点学,赶紧考个好大学;要快点工作,好赚多点钱养家;要快点结婚生子,好繁衍子嗣;要快点工作完,好完成当下的kpi。那么什么时候慢下来呢?是必须要这么快吗?

说会到葫芦温泉,这里的水煮蛋,应该是我在日本看过最便宜的水煮蛋,只要80日元。

image-20251102204852686

在别府这里还有一个温泉,别府鉄輪蒸し湯,也是一家百年的老店,体验也还不错。

image-20251102205206851

这个温泉的特点仪式感很重,首先会让你进去先用温泉水淋一下,对,就是单纯的淋一下,然后穿上浴衣之后进入到蒸汽房里面,躺在草堆上,用药草蒸10分钟,到8分钟的时候门外会有人问一下你是否要提前出去,出来之后是感觉确实不同,很舒服。蒸完之后把浴衣换下之后就清洗一下身子开始泡温泉了。

汗蒸这个东西,其实很多温泉店都会有,但是这家温泉会强制顾客一定要去汗蒸一下,确实不太一样。

日本其实是一个很重仪式感的地方,它深刻地根植于日本文化的各个层面。比如泡温泉的时候,进玄关的时候会让顾客脱鞋,让顾客穿他们的浴衣,礼物本身可能不贵重,但包装一定要精美,店里面的店员一定会对顾客鞠躬问好等等。

其实我是一个不怎么喜欢仪式感的人,比如我一般在逢年过节的时候不会特意的去买各种节日食品,月饼,汤圆之类的,过生日也不喜欢搞什么生日聚会,不会买蛋糕,也不会去买礼物。

但是我的观念最近在慢慢的改变,我觉得仪式感可能没什么不好。生活本质上是充满不确定性和混乱的。仪式感通过固定的程序和可预测的步骤,为我们创造了一个“可控”的心理空间,其实是一种对抗不确定性的方式。并且仪式感可以将平凡的日常行为转变为特殊且有意义的时刻,其实也蛮有意思。

比如同样是吃饭,用你最喜欢的餐具、摆放整齐、关掉电视专注地吃,这顿饭的体验和价值感就远高于边看手机边草草了事。我们大部分时间都处在“自动驾驶”模式,脑子里想着过去或未来,想着各种事情。而仪式迫使你将注意力拉回到“此时此刻”的身体和感官上,这也是仪式的意义。

喜欢在干净的路上逛街

在日本比起大城市,其实我更喜欢乡下,因为没什么人,并且很干净。有时候看着这种没人的干净街道,就想一直逛下去,即使没什么好看的,也可以安安静静地走一天。

image-20251102212001736

11月了草还挺绿。

image-20251102212522976

我还是很喜欢海边散步的。

image-20251102212535038

晚霞

image-20251102212650725

写于 2025年11月2日晚,别府三日游明早就要走咯。

泡温泉&跑步(别府篇)最先出现在luozhiyun`s Blog

北大《区块链技术与应用》——BTC篇

作者 luozhiyun
2025年10月30日 23:08

课程地址:https://www.bilibili.com/video/BV1Vt411X7JF?spm_id_from=333.788.videopod.episodes&vd_source=f482469b15d60c5c26eb4833c6698cd5&p=2

什么是加密货币

加密货币(Cryptocurrency)是一种运用密码学原理来确保交易安全并控制新单位创造的数字交易介质 。

根据 Jan Lansky 所述,加密货币是满足六个条件的系统:

  1. 去中心化:该系统无需中心机构,也就是不需要央行,靠共识维持。
  2. 所有权记录:系统能够清晰地记录每一单位加密货币及其当前的所有权归属。
  3. 发行机制:该系统定义能否产生新的加密货币。如果可以,则系统需定义新币的来源,并定义如何确定这些新币的所有者。
  4. 密码学所有权:对加密货币的所有权只能通过密码学手段(即私钥)来证明和行使。
  5. 所有权转移:该系统允许通过交易来改变加密货币的所有权。交易仅可从能证明加密货币当前所有权的实体发布。
  6. 双重支付防范:如果同一时间产生了两个改变相同加密货币所有权的指令,该系统最多只能执行其中一个。

这些条件共同定义了一种革命性的资产形式,其价值和安全性不依赖于任何单一机构的信用背书。有些人简单的将它归结为“数字化”,其实是不对的,因为支付宝、信用卡支付等都是数字话的,但是这些传统数字金融系统的核心是建立在对中心化中介机构(如银行、支付网关)的“信任”之上,由它们来维护账本、验证交易的合法性 。加密货币的根本性突破在于,它通过密码学、分布式共识等一系列技术手段,构建了一个“去信任化”(Trustless)的系统。

区块链的核心数据结构

所谓区块链,其实是由一系列按时间顺序连接的数据单元构成的,这些单元被称为“区块”(Block)。每一个区块包含以下内容:

  • 每个区块的交易数据;
  • 指向前一个区块的加密引用;

链条的起点是一个特殊的区块,被称为“创世区块”(Genesis Block),因此没有指向“前一个区块”的引用。

那么在实现上,区块是如何保存交易数据和引用的呢?数据结构是怎样的?每个区块在数据结构上,包含了区块头(Block Header)和区块体(Block Body):

  • 区块头:每个区块起始处的一个紧凑的、固定大小的部分。在btc协议中,其大小为80字节 。头中不含交易数据,仅包含了元数据(metadata)。元数据包括指向前一个区块的链接、时间戳以及对区块体内所有交易数据的加密摘要。
  • 区块体:这是一个可变大小的部分,其主要内容是该区块所包含的经过验证的、详细的交易记录列表 。

区块头

我们以btc为例,在btc协议中,区块头大小为80字节 。根据功能,这80字节可以被划分为六个独立的字段,共同构成了区块的元数据 。

  • 版本(Version) – 4字节

  • 前一区块哈希(Previous Block Hash) – 32字节。这是区块链数据结构中最关键的连接元素,它就是哈希指针。该字段存储的是其父区块(即链上的前一个区块)的区块头的SHA256(SHA256())双重哈希值 。通过这个字段,每个区块都牢固地指向其前驱,从而将独立的区块编织成一条不可分割的、按时间顺序排列的链。

    image-20250615162240044

  • Merkle Root – 32字节。btc 中使用的 merkle tree 的形式存储了区块体内所有交易,所以在头里面,还存储了区块中 merkle tree 的头节点,也就是 merkle root;

  • 时间戳(Timestamp) – 4字节,记录了该区块被矿工创建的大致时间。

  • 难度目标/比特(Difficulty Target / Bits) – 4字节。表示了当前区块挖矿的难度目标 ,也就是用来挖矿的。

  • 随机数(Nonce) – 4字节。这是一个由矿工在挖矿过程中不断改变的计数器 。矿工将版本、前一区块哈希、默克尔根、时间戳、难度目标和这个随机数拼接在一起,形成80字节的区块头,然后对其进行哈希计算。如果结果不满足难度目标,矿工就将随机数加一,然后再次尝试。这个暴力枚举的过程就是工作量证明的核心。

区块体

区块体则承载了该区块内所有经过验证的交易信息,所以可以把它理解为账本。以btc为例,它的区块体的结构由两部分构成:

  • 交易计数器 (Transaction Counter):记录该区块中包含的交易总数。
  • 交易列表 (Transactions):连续存放的、该区块内所有交易的原始数据。

那么如何记录一笔交易呢?比如,我们现实生活中进行转账,A 要给 B 转账,那么对于这笔记录首先我们需要知道这个币从哪里来的,这个叫做输入(Inputs);然后需要知道这个币是转给了谁,这个叫做输出(Outputs)。在转账的过程中,由于互联网上是基于互不信任的原则,所以这笔转账的过程还需要密钥加密,这叫数字签名(Digital Signatures),数字签名由资金所有者使用其私钥生成。

A转了5个币给B,给了 5个币给 C,这个过程中不停的交易,形成的这个链就是账本。比如下图,可以看成是交易账本的简化形式。

image-20250615171338943

那么交易账本的形式有了,那么如何构建安全的交易呢?首先,我们要了解一下什么是签名,上面我们也说了,在交易的过程中,A 转帐给 B,A 需要给这笔转账用 A 的私钥加密,这其实就是签名。

整个签名,其实就是非对称加密的过程,在btc钱包中,公钥相当于银行账号,私钥相当于银行密码。比如说A 要给 B 转 1 个 BTC,当 A 发起这笔交易时,他的钱包软件会做以下事情:

  1. 首先要回答 A 的“钱包”里有什么?

    假设 A 的钱包里并不是一个写着“我有 1.2 BTC”的数字。实际上,他的钱包知道他拥有两笔“未花费的钱”(Unspent Transaction Output,简称 UTXO),比如:

    • UTXO-1:价值 0.5 BTC(来自之前别人付给 A 的一笔钱)
    • UTXO-2:价值 0.7 BTC(来自更早之前别人付给 A 的另一笔钱)
  2. A 要给 B 转 1 个 BTC

    当 A 发起这笔交易时,他的钱包软件会做以下事情:

    • 输入 (Input):交易的“输入”必须明确指出它要花费哪几笔 UTXO。在这个例子里,钱包会选择 UTXO-1 (0.5 BTC) 和 UTXO-2 (0.7 BTC) 作为输入。总输入金额为 1.2 BTC。
    • 指定输出 (Output)
      • 输出1:向 B 的地址支付 1 BTC。这会为 B 创建一个价值 1 BTC 的新 UTXO。
      • 输出2 (找零):将多余的 0.2 BTC(1.2 – 1.0 = 0.2)转回给 A 自己的一个新地址。这会为 A 自己创建一个价值 0.2 BTC 的新 UTXO。
    • 签名:A 用自己的私钥对这笔完整的交易(包括输入和输出)进行数字签名。
  3. 最后就是验证这笔交易如何验证,在 BTC 中整笔交易要达成共识,入链才算完成。

    这笔交易被广播到btc网络后,每一个收到它的节点(矿工)都会进行严格的验证:

    • 验证签名:首先,用 A 的公钥验证交易签名是否有效。
    • 验证资金来源(最关键的一步!):节点会追溯整个区块链历史,去检查 A 在交易输入中声称要花费的 UTXO-1 和 UTXO-2 是否真的存在,并且是否真的是“未花费”的状态。
    • 检查结果
      • 如果节点在过去的区块里找到了这两笔 UTXO,确认它们属于 A,并且之前从未被花掉过,那么节点就确认 A 确实拥有这笔钱。
      • 如果 A 试图花费一个不存在的、或者已经被花掉的 UTXO(这就是“双花攻击”),全网的节点在查账时会立刻发现这个输入是无效的,从而拒绝这笔交易。
    • 一旦验证通过,矿工就会把这笔交易打包进一个新的区块。

好了,到这里,一笔交易内容有什么,以及如何保证安全已经说明了,那么如何在不下载全部数据的情况下,高效地验证某一个“小数据”是否属于这个“大数据集合”?就比如,我的手机钱包,如果验证 1 笔交易,而不用下载整个区块?

这就要提到 Merkle Tree,它是交易列表构成的一个树形结构,Merkle Tree 的叶子节点存的是每一笔交易的哈希值(Transaction Hash,也叫 TXID),可以看成是下图这样的结构:

image-20250615163517554

merkle tree 本质上是一棵哈希二叉树

  • 树的叶子节点是原始数据块(在区块链中就是一笔笔的交易)的哈希值。

  • 树的非叶子节点(树枝和树干)是它下面两个子节点哈希值拼接后再计算出的哈希值。

  • 这个过程不断重复,层层向上,直到只剩下一个最终的、位于最顶端的节点,这个节点被称为 merkle root。

merkle tree被发明出来主要有两个目的:

  1. 保证数据完整性(防篡改)

    merkle root 是对区块内所有交易的最终摘要。任何一笔交易哪怕被改动一个字节,其对应的叶子哈希就会改变,这个改变会像多米诺骨牌一样,层层向上传导,最终导致计算出的merkle root 完全不同。

  2. 极高的验证效率(轻量级验证)

    它允许在不下载整个区块数据的情况下,就能快速验证某笔交易是否存在于该区块中。

    比如手机钱包知道自己的交易哈希 H3,钱包从网络上下载了该区块的区块头(只有80字节,非常小),并从中获取了正确的merkle root ,那么对于 H3来说,它的验证路径是 H3 -> H34 -> Merkle Root。也就是只需要,它的直接兄弟 H4,它的上一层节点的兄弟 H12,然后就可以通过计算 hash 进行对比验证,这个过程就叫Merkle Proof。

    整个过程,手机钱包只需要下载几十字节的区块头和几十字节的 Merkle Proof,就能完成验证,而无需下载整个区块(可能好几MB)的所有交易数据,极大得节省了资源。

所以通过 merkle tree 就可以实现高效的“存在性证明”(Merkle Proof)以及保证数据完整性(防篡改)。

Hash 在区块链的作用?

其中在区块链中使用 Hash 算法有其关键的作用:

  1. collision resistance:在btc中使用的是SHA256(SHA256())双重哈希值,几乎不可能出现hash碰撞,因为如果可以轻易找到“碰撞”(两个不同输入得到相同输出),那么恶意攻击者就可能用一笔伪造的交易来替代合法的交易,从而破坏整个系统的信任;

  2. Hiding:由于算法的不可逆,所以无法由hash值推导出原值。这个特性对于保护数据隐私至关重要。在密码学中,这也被称为抗原像性 (Pre-image Resistance)。;

  3. puzzle friendly:如果想要找到某个特定的hash值对应的输入是什么,只能挨个去尝试,没有其他任何途径可以找到符合条件的hash值。

区块链中,由于下一个的指针是指向前一个,如果某个块的hash发生了改变,那么后续的也要改变,也就是牵一发而动全身,比如一个Merkle tree,叶子节点变了,其他节点也要变,因为其他节点是根据叶子节点算出来的。

                        [ Merkle Root ]
                              /     \
                             /       \
                    [ Hash_ABCD ]   [ Hash_EFGH ]
                       /     \         /     \
                      /       \       /       \
                [ Hash_AB ] [ Hash_CD ] [ Hash_EF ] [ Hash_GH ]
                   /   \       /   \       /   \       /   \
                 H(A) H(B)   H(C) H(D)   H(E) H(F)   H(G) H(H)

所以btc 中某个本地节点可以只保存最近的某些节点,如果需要前面的其他的节点可以问别人要,并且可以通过hash计算的方式来保证别人给的区块一定是正确的。就比如上图,只有几个节点,但是可以通过后面的节点计算别人给过来的前面的节点是否正确。

举例:

  1. 全节点发给你三样东西:
  • TX_C(交易 C 本身)
  • H(D) (你的“兄弟”哈希)
  • Hash_AB (你“叔叔”辈的哈希)
  • Hash_EFGH (你“伯父”辈的哈希)
  1. 轻钱包开始自己计算:
  • 第 1 步: 先把 TX_C 自己哈希一次,得到 H(C)
  • 第 2 步: 用自己算出的 H(C) 和全节点给你的 H(D) 组合起来哈希:
    • H( H(C) + H(D) ) 算出了 Hash_CD
  • 第 3 步: 用全节点给你的 Hash_AB 和上一步算出的 Hash_CD 组合起来哈希:
    • H( Hash_AB + Hash_CD ) 算出了 Hash_ABCD
  • 第 4 步: 用上一步算出的 Hash_ABCD 和全节点给你的 Hash_EFGH 组合起来哈希:
    • H( Hash_ABCD + Hash_EFGH ) 算出了一个“最终的 Root”
  1. 算出来的“最终的 Root”从区块头里拿到的那个“标准答案 Merkle Root”进行对比。

因为哈希的collision resistance特性。如果“别人”的 TX_C 是假的,或者 H(D)Hash_AB 中任何一个是假的,最终算出来的 Root 都不可能与“标准答案”一致。

分布式共识 distributed consensus

一个交易要被认可要取得分布式共识。那么什么什么是分布式共识?

想象一下,一群好朋友(比如 100 个人)共同记一本账本,记录着大家之间谁欠谁钱。他们没有一个中心记账员(比如银行),而是每个人手上都有一本一模一样的账本。

当其中一位朋友 A 要转 100 元给朋友 B 时,他会向所有人大喊:“我要从我的账上转 100 元给 B!”。

这时候问题来了:

  • 怎么确保 A 真的有这 100 元?
  • 怎么确保 A 没有同时跟别人说“我要把这 100 元转给 C”?(也就是所谓的“双花攻击”)
  • 最重要的是,在没有中心决策者的情况下,如何让这 100 个人全都同意在自己的账本上写下“A 转给 B 100 元”这同一笔记录,并且确保之后没有人可以反悔或篡改?

分布式共识 就是为了解决这个问题而设计的一套规则和方法。它的目标是:让一个分布式系统中的所有参与者(节点),在没有中央指挥的情况下,最终能够对某个状态或数值达成一致的协议。

如何达成分布式共识

上面我们也提到了,如果想要 A 转给 B 1 个 BTC,在 BTC 中整笔交易要达成共识,入链才算完成。这个共识算法就是工作量证明(Proof of Work, PoW)。

在 A 转 1 个 BTC 给 B 的时候,会用私钥对一笔交易进行数字签名,然后钱包会将这笔签好名的交易广播到整个区块链网络中,附近的节点会接收到这笔交易。

之后:

  1. 节点验证与传播

    • 初步验证:

      接收到交易的节点(我们称之为“矿工节点”)会立即进行验证,首先用A的公钥检查数字签名是否正确,再来就是追溯A的交易历史,确保您确实拥有足够的资金来支付这笔交易;

    • 验证通过后,这笔交易会被放入该节点的“内存池(Mempool)”中,这是一个等待被打包的交易的临时集合;

    • 该节点会将这笔验证过的交易继续传播给与它相连的其他节点,直到这笔交易遍布全网。

  2. 竞争记账权(挖矿)

    • 所有矿工节点会从自己的交易池中挑选一批交易,将选中的交易和上一个区块的哈希值等信息打包成一个“候选区块”
    • 矿工们开始进行疯狂的哈希计算,不断变换候选区块头中的一个随机数(Nonce),试图找到一个满足特定难度目标的哈希值(例如,开头有非常多个零);
  3. 达成共识与全网同步

    • 假设矿工 M 率先找到了正确的哈希值,他立刻向全网广播他创建的、包含您交易的新区块;
    • 网络上其他矿工收到这个新区块后,会停止自己的计算,并立即验证这个区块的有效性(包括验证其中的每一笔交易和那个“幸运哈希值”是否符合规则);
    • 验证非常快速。一旦确认无误,其他节点就会承认这个新区块是合法的,并将其添加到自己的区块链副本的末端。
    • 这就代表,全网对“这个新区块以及其中包含的所有交易是有效的”这件事达成了共识

由于网络问题产生分叉怎么办?比如即同时有两个矿工挖出区块,这个时候为了确保交易不可逆转,通常需要等待更多的区块在此基础上继续生成。

一般来说,在btc网络中,等待 6 个区块确认(大约 1 小时)后,该笔交易就被认为是完全被承认且不可篡改的了。

为什么矿工要帮忙做工作量证明

上面的工作量证明看起来实际上需要很大的计算量,需要很多计算机的算力,所以矿工做这些事情也会获得相应的报酬去激励他们继续保护和运行整个区块链网络。矿工主要有两部分收益:

  1. 区块奖励(Coinbase Reward)

    这是系统凭空创造出来、作为对矿工维护网络安全奖励的新币。这部分奖励是btc(或其他加密货币)通货膨胀的主要来源。对于btc来说这个奖励的数额是协议预先规定好的,并且会定期“减半”(Halving)。例如,btc最初每个区块奖励50个btc,现在(2024年减半后)是3.125个btc;

  2. 交易手续费(Transaction Fees)

    这是该矿工从他打包的那个区块中,所有交易的发起者支付的手续费的总和。用户为了让自己的交易能被矿工尽快打包,会附加一笔手续费。矿工自然会优先选择手续费高的交易来打包。这部分收益的数额是不固定的,取决于当时网络的拥堵情况和用户愿意支付的费用。

所以矿工的总收益 = 区块奖励 + 该区块内所有交易的手续费总和。

但是在 btc 中随着时间的推移,区块奖励会越来越少,直到最终变为零(预计在2140年左右)。到那时,矿工维护网络的唯一动力就将完全来自于交易手续费。这个设计确保了即使在所有币都发行完毕后,依然有经济激励促使矿工继续保护和运行整个区块链网络。

挖矿

上面我们提到了,挖矿的过程其实就是改变block header 里面的 Nonce 字段,计算出一个有效的“哈希值”,计算出的哈希值必须小于或等于当前网络设定的“目标值” (Target)。

举个例子,假设目标是: 00000000000000000005a3f6d8a4c1d8d3f6a8b3c5d1e7f9a2b4c6d8e。那么,任何计算出来的哈希值,只要在数值上比上面这个小,就是有效的。比如:00000000000000000001b8d3c5d1e7f9a2b4c6d8e4a3f6d8a4c1d8 (这个值更小,所以是有效的)。

但是Nonce 只是一个 32 位的字段,目前来说矿机每秒可以执行数百亿亿次哈希运算(TH/s)。一台高端ASIC矿机(例如140 TH/s)可以在微秒级别的时间内遍历整个Nonce空间。

所以仅靠Nonce 是找不到对应难度目标(Target)的值,当然Timestamp也可以有一定的调整空间,但是比较有限,后面就演变成使用 ExtraNonce 来扩展搜索空间。ExtraNonce 指的是矿工放置并修改在铸币交易(Coinbase Transaction)的scriptSig字段(也被称为coinbase data)中的任意数据,它可以进行修改 。

scriptSig 字段通常是用来数字签名来证明所有权用的,但是Coinbase交易是凭空创造新币的,它不消耗任何已存在的UTXO。因此,其输入中的scriptSig字段无需包含任何解锁脚本或数字签名。根据btc协议,这部分空间可以由矿工自定义填充,长度限制在2到100字节之间。

那么挖矿算法的大致流程就会变成:

  1. 构建区块模板:
    • 从内存池(mempool)中选择交易,通常按费率(fee rate)高低排序。
    • 构建铸币交易,包含区块奖励、交易费,并设置一个初始的ExtraNonce值(如0)。
    • 基于此交易集合计算出hashMerkleRoot
    • 组装候选区块头,填入VersionhashPrevBlockhashMerkleRoot、当前的TimeBits
  2. 内层循环(Nonce迭代):
    • FOR nonce FROM 0 TO 2^32:
      • 在区块头中设置当前的nonce值。
      • 计算 hash = SHA256(SHA256(header))
      • IF hash <= Target:
      • 找到解。广播完整的区块。
      • 返回第一步,开始构建下一个区块的模板。
  3. 外层循环(ExtraNonce迭代):
    • IF 内层循环完成仍未找到解:
      • 在铸币交易中递增ExtraNonce的值。
      • 重新计算hashMerkleRoot
      • 更新区块头中的hashMerkleRoot字段。
      • (可选)如果时间变化足够大,更新Time字段。
      • 返回第二步,使用新的区块头重新开始Nonce的迭代.

并且为了控制 btc 的产量,在btc网络诞生之初,矿工每成功打包一个区块,可以获得 50 BTC 的奖励。并且btc的协议规定,每产生210,000个区块,区块奖励就会减少一半(减半)。由于比特币网络的目标是平均每10分钟产生一个区块,210,000个区块大致相当于4年的时间(10分钟×210,000≈4年)。

我们可以用一个等比数列求和的公式来表示这个过程:

总供应量 = 210000×(50+25+12.5+6.25+…) = 210000×50×(1+0.5+0.25+0.125+…)=210000×50×2=2100w

image-20250705234838859

由于区块奖励不断减半,奖励金额会变得越来越小。大约在第33次减半(约2140年左右)之后,区块奖励将变得微不足道(小于1聪,即比特币的最小单位)。届时,可以说几乎所有的比特币都已被挖出,矿工的收入将完全依赖于交易手续费。

这种通缩模型的设计,使得比特币具有了稀缺性,从而避免了像传统法定货币那样因无限增发而导致的通货膨胀问题。

为什么早期仅遍历Nonce就可以,现在却不行?

主要是因为btc它会自动的调整难度,早期的时候参与者少,算力低在只有几 MH/s 的算力下,要遍历完这43亿种可能性需要很长时间(几百秒甚至更久)。当时网络的目标是大约10分钟产生一个区块,因此,仅仅通过不断尝试和改变Nonce,就有非常大的概率能在这10分钟内找到一个符合条件的哈希值。

现在对于一台算力为 100 TH/s(1014 次哈希/秒)的现代ASIC矿机来说,遍历完43亿的Nonce仅需要0.000043 秒,如果还是这个难度,估计用不了几秒就要被挖光了。

btc的难度调整是其协议中最优雅的设计之一,它确保了无论全网算力如何变化,新区块的产生速度都能稳定在平均10分钟一个。

btc的难度目标(Target)是个以一个256位的数字。挖矿的本质就是找到一个区块头的哈希值,使其小于或等于这个目标值。目标值越低,挖矿越难。因为一个更低的目标值意味着哈希结果的开头必须有更多的“0”,符合条件的哈希值就越少,找到它所需要的计算次数就越多。

所以为了实现大约10分钟产生一个区块这个目标,btc会动态的调整难度,难度调整的具体机制如下:

  1. 比特币网络中的每个全节点都会每2016个区块自动进行一次难度调整。节点会计算生成这最近的2016个区块所花费时间,即 2016区块 × 10分钟/区块 = 14天;
  2. 节点会根据实际时间与期望时间的偏差来调整下一个周期的目标值,公式New Target = Old Target * (Actual Time / Expected Time)
    • 挖得太快了。如果Actual Time小于20160分钟(比如只用了12天),说明全网算力增强了。此时 (Actual Time / Expected Time) 这个比率小于1,New Target就会变小,从而增加挖矿难度
    • 挖得太慢了。如果Actual Time大于20160分钟(比如用了16天),说明全网算力下降了。此时这个比率大于1,New Target就会变大,从而降低挖矿难度
  3. 为了防止网络因算力剧烈波动而产生过大的难度变化,单次调整的幅度被限制在一个4倍的范围内。即,调整系数(Actual Time / Expected Time)最大不会超过4,最小不会低于1/4。

我们也可以看到下面图的难度曲线的设置,是越来越陡峭的,跟价格几乎是成正比:

image-20250720180550478

并且比特币矿机也不再是以前的 GPU 时代了,而是专门的专用矿卡进行挖矿。比特币矿机的演变是一场追求极致算力和能效比的“军备竞赛”:最初人们用个人电脑(CPU) 就能挖矿,很快被游戏显卡(GPU) 的高并行算力所取代;接着,更省电的FPGA(半定制芯片) 短暂出现,但最终被ASIC(专用定制芯片) 以绝对的算力和能效优势彻底统治,使挖矿从此进入了专业的工业化时代。

Progress-Free性质

Progress-Free 指的是“无记忆性”(Memoryless),也就是在任何给定时刻,矿工找到下一个区块的概率与他们过去已经付出的努力无关。

在btc挖矿中,矿工们不断地进行哈希运算,尝试找到一个小于当前网络目标难度的哈希值。每一次哈希运算都是一个独立的、随机的尝试。也就是说包含两个特性:

  • 独立的尝试: 每一次哈希运算都使用一个略有不同的输入值(通过改变一个称为“nonce”的随机数)。因此,前一次哈希运算的结果对后一次完全没有影响。就像抛硬币一样,无论您已经连续掷出了多少次反面,下一次掷出正面的概率永远是50%,和过去的努力无关。
  • 成功的偶然性: 能否找到有效的哈希值,完全取决于运气。

由于这两个特性,表示过去的努力不会累积,每时每刻都是一个全新的开始,确保了挖矿的公平性,即便是算力较低的矿工,理论上也有机会在任何时刻找到区块。

挖矿攻击

Boycott Attack

指的是网络中掌握显著比例算力(或权益)的参与者(通常是大型矿池或PoS验证者)联合起来,故意“抵制”或“排斥”网络中的某些特定元素。

在这种攻击中最常见的就是一个或多个拥有大量算力的矿池故意拒绝将某些合法的交易打包到他们挖出的区块中。比如,某个矿池为了遵守其所在国的法规(如美国的 OFAC 制裁名单),宣布将“抵制”所有与黑名单地址相关的交易。

这种攻击的严重程度取决于“抵制联盟”掌握的算力。如果抵制联盟的算力低于 51%,被抵制的交易仍然可以被确认,但它们必须等待那些“不参与抵制”的矿池(例如只占 30% 算力)来挖到区块。

如果抵制联盟的算力超过 51%。他们不仅自己不打包这些交易,他们还会故意孤立(orphaning)任何包含了这些交易的诚实区块(因为他们总能挖出更长的链)。结果就是被抵制的交易将永远无法被打包上链。这等同于将某个用户或应用永久地“踢出”了网络

这种攻击并不像“双花”那样直接窃取资产,但它直接攻击了区块链最核心的价值主张之一:抗审查性(Censorship Resistance)和中立性(Neutrality)

51% Attack

当单个矿工或矿池掌握了全网超过 50% 的算力时,他们就有能力制造出一条比诚实网络更长的“分叉链”。攻击者可以在主链上将 BTC 发送给商家(例如,换取法币或商品),然后在自己的分叉链上构造一笔交易将同样的 BTC 发送回自己的地址。当他的分叉链长度超过主链并被网络接受时,之前给商家的交易就被“撤销”了,从而实现Double Spending。

但是这样的攻击需要天价的硬件和电力成本,而且成功攻击会摧毁人们对 BTC 的信心,导致币价暴跌,攻击者自身的收益也会大打折扣,,因此在经济上是不理智的。

虽然在BTC上不太可能这么做,但是这一点在那些算力较低的山寨币(altcoins)上体现得淋漓尽致。攻击者可以按小时租用强大的算力(OPEX)。对于一个小币种来说,攻击者可能只需租用比特币网络总算力的一小部分,就能轻松达到该小币种网络51%以上的算力。

Selfish Mining

攻击者,不需要 51% 的算力(理论上超过25%就有可能获利)。攻击者挖到区块后并不立即广播,而是选择不发布,并基于这个秘密区块继续挖下一个。

当诚实矿工挖到区块A时,自私矿工如果已经秘密挖到了区块A’和B’(比诚实链长),他就会立刻广播自己的A’和B’。网络会接受更长的链,导致诚实矿工的区块A作废,他们的算力被浪费。

为了防范这种攻击,在比特币中一笔交易通常需要等待6个区块的“确认”(Confirmations)后才被认为是最终、不可逆转的,这大约需要一个小时的时间。因为每增加一个确认,攻击者就需要付出更多的算力和时间来追赶并超越诚实的区块链。

中本聪的计算表明,如果一个攻击者掌握了10%的网络算力,那么在6个区块确认之后,他成功实现双花攻击的概率已经下降到了0.1%以下。这个概率被认为足够低,可以保障绝大多数商业交易的安全。“6个确认”因此成为了一个在安全性与用户体验之间取得平衡的行业“黄金标准”。

分叉

硬分叉

“硬”分叉 (Hard Fork) 是一种永久性的、不向后兼容的“规则升级”。当硬分叉发生时,旧版本的软件(节点)将不再接受新版本软件(节点)创建的区块,导致区块链永久性地分裂成两条不同的链

它强制要求所有参与者(矿工、节点、交易所、钱包)必须升级到新软件。如果你不升级,你就会被“留在”旧的、即将被淘汰的链上。

硬分叉在 BTC 最著名的例子就是 Bitcoin Cash (BCH),它在 2017 年 8 月从 BTC 硬分叉出去。

  • 分歧点: 如何解决比特币的“拥堵”(扩容)问题。们认为 1MB 的上限(以及 SegWit 的 4M WU)太小了,于是他们通过硬分叉,直接把区块大小上限改到了 8MB,后来又改到 32MB。
  • BTC 阵营(旧规则): 选择“隔离见证”(SegWit),这是一种复杂的“软分叉”升级(可以理解为优化道路,让每辆车坐更多人)。
  • BCH 阵营(新规则): 认为 SegWit 太复杂,主张简单粗暴的“硬分叉”——直接把区块大小上限(1MB)提高到 8MB(可以理解为直接把两车道公路扩建成八车道)。
  • 结果:
    1. BCH 修改了规则代码,并在特定区块高度激活。
    2. 坚持旧规则的节点继续留在 BTC 链上。
    3. 运行新规则的节点分裂出去,形成了 Bitcoin Cash 链。
    4. 新币诞生: 在分叉的那一刻,如果你持有 1 个 BTC,你现在会同时拥有 1 个 BTC(在原始链上)和 1 个 BCH(在新链上)。

这里有个有意思点,如果是在硬分叉之前拥有了某个加密货币,那么在分叉之后将同时拥有“原始货币”和所有“新分叉出来的货币”。

软分叉

软分叉 (Soft Fork) 是一种向后兼容的升级,可以认为是把旧规则变得更严格。

比如,以前的规则是“区块大小不能超过 1MB”。软分叉的新规则是“区块大小不能超过 1MB,并且里面必须包含 A 数据”。旧节点(只懂旧规则)看到新区块时,会觉得:“它没超过 1MB,合法。”(它看不懂 A 数据,但不影响)。新节点会严格执行新规则。

这样只要大多数矿工升级,网络就会被“拉”到新规则上,而不会像硬分叉那样导致区块链分裂

SegWit (Segregated Witness,隔离见证)就是btc链上一次最重大技术升级实现的软分叉。在 SegWit 之前,比特币网络面临两个主要问题:

  1. 在比特币交易被确认(打包进区块)之前,任何人都可以轻微地修改这笔交易的数字签名(scriptSig),而不会使交易失效,这种修改会导致交易ID(txid)发生变化。如果这个txid在交易确认前可以被篡改,就会引发严重问题,比如双花攻击的变种,或者让依赖 txid 的复杂合约(如闪电网络)变得极难实现。
  2. 比特币的区块大小被限制在 1MB,数字签名(见证数据)通常会占据一笔交易 60% 甚至更多的空间。这些数据都挤在 1MB 的空间里,大大限制了每个区块能容纳的交易数量。

SegWit 通过改变交易和区块的数据结构,重新定义了一笔交易的结构。它把交易分为两个部分:

  1. 核心交易数据 (Base Transaction Data)
    • 包含:发送方地址、接收方地址、金额等。
    • 不包含: 数字签名和解锁脚本。
    • 关键点: 交易ID(txid只根据这部分数据来计算。
  2. 见证数据 (Witness Data)
    • 包含:所有的数字签名和解锁脚本(即 scriptSigscriptWitness)。
    • 这部分数据被“隔离”出来,存放在交易的一个新字段中。

由于 txid 现在只根据核心交易数据计算,而签名(scriptSig)这个唯一可以被延展(篡改)的部分已经被移到了见证数据中,不再参与 txid 的计算。因此,一旦交易发出,其 txid 就被永久固定,交易延展性问题被彻底解决。

再来就是SegWit 并没有直接把 1MB 的区块大小限制(Block Size)改掉,而是引入了一个全新的概念:区块权重 (Block Weight)

  • 旧规则: 区块大小上限为 1MB
  • 新规则: 区块“权重”上限为 4,000,000 WU (Weight Units)

这个权重的计算方式是:

  • 1 字节核心交易数据 = 4 WU
  • 1 字节见证数据 = 1 WU

那么:

  1. 如果一个区块全是老式交易(数据和签名混在一起),所有数据都按 4 WU/字节 计算,那么 4,000,000 WU/ 4 = 1,000,000 字节,区块大小仍然是 1MB
  2. 如果一个区块全是SegWit交易(签名被隔离),大部分数据(签名)都按 1 WU/字节 计算。这使得区块可以塞下更多的交易数据。

在理想的(全是SegWit交易的)情况下,一个区块的实际物理大小可以达到接近 4MB,但其“权重”仍然是 400 万 WU。

北大《区块链技术与应用》——BTC篇最先出现在luozhiyun`s Blog

对腾讯5年职业生涯的总结

作者 luozhiyun
2025年10月24日 11:11

先摆出我的四个知识奖的奖牌镇楼,哈哈哈。该是时候总结一下自己这五年多来做了什么事情了,希望对各位能有所启发。

IMG_7331

其实我每年都有发自己的总结文章,附上以前的一些总结:

2024年总结:沉寂积蓄新的力量

2023年总结:保持心情愉悦&积极向上

2022年总结:保持&缓行

2021年总结

2020年总结

文章

对于我自己来说是比较喜欢研究技术,写文章,所以这五年确实沉淀了很多我以前研究的技术文章。发布的文章承蒙各位喜爱,在腾讯内网也上过很多次头条和推荐,这里我再总结一下,希望这些文章能带给大家一些启发或者帮助。

Frame 2

AI

合集

AI 相关的文章合集是当时因为图片生成爆火,然后自己顺着AI的名义给自己买了一张 4090 显卡,图片玩过几次除了生成一堆涩图以外想着是不是可以用来干点正事,于是就想着能不能学点AI 相关的知识,于是就有了这个系列的文章。

[长文]写给开发同学AI强化学习入门指南

这篇文章应该是我写的最满意的文章之一了,首先里面有真实的学习步骤是怎样的,里面讲了我是怎么学习的,以及弄了一些 demo 来跑我们的模型,总而言之入门很合适了。

如何用 PPO 算法让 AI 学会玩 FlappyBird

后面我还用强化学习,根据图片来学习怎么玩 FlappyBird 这个也很有意思。

合集里面剩下的几篇AI文章就是讲的我怎么在生活中使用 AI 的也希望能给大家一点启发。

Golang

合集:https://www.luozhiyun.com/archives/category/%E5%90%8E%E7%AB%AF/go

go相关的技术文章是写了真的不少,因为当时自己对这一块比较感兴趣,工作中也常用,所以写了很多源码研究之类的文章,很多文章即使现在来看都写的不错,诸如:

[长文]从《100 Go Mistakes》我总结了什么?

这篇文章有时候没事我会去看看的,因为细节太多,很多时候一不留神写的代码容易出错,里面的错误范例对于工作中还是很实用。

如何编译调试Go runtime源码

调试源码这个事情,一般是不会去做,但是如果对源码感兴趣是可以参考一下怎么去调试。

下面几篇我也觉得写的不错,就是比较底层,读起来最好自己跟着看看代码,否则会比较费劲。

Go语言GC实现原理及源码分析

从源码剖析Go语言基于信号抢占式调度

从栈上理解 Go语言函数调用

一文教你搞懂 Go 中栈操作

C++

合集:https://www.luozhiyun.com/archives/tag/cpp

C++ 相关的文章也是写了不少,写这些文章的时候基本上也是边写边学,写文章的同时顺带把我的疑惑点也给解答了。

计算机基础

合集:https://www.luozhiyun.com/archives/tag/%e8%ae%a1%e7%ae%97%e6%9c%ba%e5%9f%ba%e7%a1%80

这方面也是我自己慢慢想着要整理一下自己现有的知识体系,然后写的一些文章。

作为开发需要了解 SSD 的一切

这篇文章可能在生产中还是有点用,算是科普一下SSD原理,以及如何更高效的使用。

CPU 是如何与内存交互的?

这一篇文章也是我对知识体系的一个梳理,但是很多知识点也只是起了一个抛砖引玉的效果,更多的知识感兴趣的同学可以去看看 《深入理解计算机系统》 、《深入浅出计算机组成原理》 等书。

云原生网络

合集:https://www.luozhiyun.com/archives/category/%e7%bd%91%e7%bb%9c

这里面的文章还是有点意思的,写这些文章也是因为我当时在研究 k8s 相关的技术的时候,对书里面的网络感觉很多地方都不懂,然后就自己专门又找了点资料自己研究了一下。

其他

这里就是再列举一些不知道分成什么类的文章,但是我觉得写的也不错。

深入 RocksDB 高性能的技术关键

这篇文章对于 RocksDB 是怎么实现的做了很详尽的分析,我觉得写的还是不错的,里面的图也画的很精美(我其他文章图也画的很精美)。

Protobuf 编码&避坑指南

这篇文章里面讲了一下 protobuf编码原理 & 最佳实践,我觉得在工作中还是很实用的,里面的图也画的很精美。

构建属于自己的云游戏服务器

其实我是很喜欢玩游戏的,但是不能在公司流畅的玩我想玩的游戏这点让我很难受,所以我决定自己来搞。

Yolov5物体识别与应用

当时沉迷于 DNFM 无法自拔,但是我又不想每天花2个小时搬砖,所以就想有什么办法可以让电脑来自动化这件事情。

最后

虽然我的鹅厂生涯结束了,但是我对这个世界的探索不会结束,感兴趣的可以继续关注我的公众号 & 微信 & 博客(文章最后放出)。

这五年来的一些改变

更健康的饮食

最近一两年我开始自己做饭,然后中午带饭去公司吃。我一开始觉得做饭是一种浪费时间的行为,但是后面觉得自己做饭一方面可以更健康的生活,另一方面做饭的时候我喜欢自己一边做饭一边听点播客,我基本什么都听,其实也算是了解其他行业的渠道。

其实就目前来看,我们的饮食里面其实配比是很不正常的,所以我会在做饭的时候会有这几点要求:

  1. 蔬菜的量尽量多点;
  2. 不要吃太多的碳水;
  3. 少摄入油盐;
  4. 戒糖;

控糖

其实我们生活中很大的疲倦感除了是真正的劳累造成的以外,血糖的波动也会带来疲倦感。血糖不稳定,就像让我们的身体坐上一辆失控的“过山车”,时而冲上顶峰(高血糖),时而跌入谷底(低血糖):

吃完饭血糖飙升 → 感到困倦、没精神(高血糖疲劳) → 胰岛素过量分泌 → 血糖骤降 → 感到虚弱、无力、发慌(低血糖疲劳) → 赶紧吃东西(尤其是高糖食物) → 再次飙升……

由于我们传统的饮食结构的影响,我们食物中大多是米饭,以及面条为主食,他们都含有大量的淀粉,淀粉会消化分解为葡萄糖,这些葡萄糖分子被小肠吸收到血液中,“血糖”指的就是血液中的葡萄糖浓度。因此,大量葡萄糖涌入血液,血糖水平就会迅速飙升。

当然,我要控糖也和我的尿酸偏高有关系。果糖(Fructose),对尿酸的影响非常显著,甚至可以说是独立于“高嘌呤饮食”之外的另一个主要诱因。糖对尿酸的影响是:

  • 一方面(开源):果糖在肝脏代谢,直接加速了嘌呤的分解,导致尿酸产量增加
  • 另一方面(节流):高糖引起的胰岛素抵抗,导致肾脏对尿酸的排泄减少

我们日常吃的白砂糖(蔗糖)在体内会分解成一半葡萄糖和一半果糖。而奶茶、可乐等甜饮料中添加的“高果糖玉米糖浆”,其果糖含量更高。

所以,控糖基本上就包含了两种:

一种是少吃各种碳水,米饭,面条等等,之类的含有高淀粉的食物,用糙米、黑米、藜麦、燕麦米等全谷物来部分或全部替代白米饭;

另一方面是含有甜味的食物,如奶茶,可乐等含有果糖的食物尽量不要去吃,顺带一提,很多水果也是高果糖的,也不能多吃;

蔬菜

然后再来提一下蔬菜,其实我们平时的蔬菜摄入实在是太少了,根据《中国居民膳食指南(2022)》成年人每日应摄入300至500克的蔬菜,但是很多时候蔬菜摄入实在太少了,比如一份点外卖,里面可能就几根蔬菜。所以我现在即使是早上都是粗粮比较多,比如会早上吃玉米,一份蔬菜,三个鸡蛋,然后才是两个小笼包。

蔬菜的好处有很多啦,我就不重复了,我说一点我比较关注的,就是可以帮助我们控制和稳定血糖的强大“盟友”。

蔬菜中的膳食纤维,特别是可溶性膳食纤维,扮演着“血糖缓冲器”的角色。可溶性膳’便纤维在遇水后会形成一种凝胶状物质,包裹住食物。这会显著减慢胃排空的速度和食物在肠道中的消化过程,从而使碳水化合物(糖)的吸收变得更加平缓,避免餐后血糖像坐过山车一样急剧飙升。并且纤维能够提供强烈的饱腹感,有助于控制食量。

运动

健身

其实我到现在为止健身超过 8 年了,从我大学时候就开始了,至今为止也练的不错,以前锻炼是想要练成super man 这样的肌肉形状,后面自己尝试过之后发现没有上科技根本不可能,现在也就保持这样的体型已经很久了,也就是其实和我的职业生涯一样,进入了“平台期”很久了。

Frame 1

而且随着年纪的慢慢增大,继续激烈的力量训练是很容易受伤的,近3年我就分别受伤了3次,腰部、肩膀、手腕都受伤过。手腕是最近一次去年 11月受伤,直到现在都没怎么恢复好,所以各位运动的时候一定要注意安全。

开始跑步

我从今年年初开始跑步,跑步真的给我很大的帮助,每次都竭尽全力完成每次计划的跑量真的很爽。然后看着自己的跑速,心肺,各项指标都在稳定的提高真的很开心,要知道人到中年能稳定提升的能力不多了

image-20251019115853678

跑步和健身很不一样,一方便跑步的时候没有组间休息,另一方面就是一旦开始了就不能停。现在我一旦心情不舒畅就会大跑一场,跑完之后你会发现自己无比淡定,再糟心的事情也不过如此,没有什么是过不去的。

一般的情况下我会选一个播客来听,这样起码让我的跑步过程不会很无聊,我一般喜欢听下面几个播客:

  • 知行小酒馆:听的最多的应该是这个,讲如何生活,讲投资。但是有些嘉宾请的也不是很好,比如 Anker 那期,翻开评论你可以发现全是骂的;

  • 半拿铁:里面会讲很多商业漫谈,以及商业史,但是我最喜欢的还是最喜欢他们的西游篇,把西游记从头到尾讲了一遍;

  • 面基:这个博主主要讲投资相关的东西,有时候看到感兴趣的主题会看看;

  • 起朱楼宴宾客:也是主要讲经济,商业,投资相关的,还不错;

  • The Wanderers 流浪者:这个是我最近喜欢听的,他们会对最近的实事进行总结,讲讲他们的投资理念,主理人是三个专业的投资人,讲解的话题覆盖了A股,港股,美股,加密货币;

image-20251019155331023

投资

我其实前几年开始就在想存钱,投资,然后可以产生复利,这样就可以通过投资反哺我的生活,让我有一定的被动收入,所以我也一直没买房买车,奢侈类的消费基本不参与,看准自己的目标,并为之而努力。

我的收入其实现在是分为几个部分:

  1. 现金以应对不确定性,在我没工作的时候可以给我提供支撑,或者市场大跌的时候可以有资金可以入场;
  2. 低波动资产,如纳指ETF、标普ETF、恒生指数ETF、货币基金等;
  3. 股票资产,主要以美股为主,港股为辅;

1和2大概占了我总资产的三分之一,3占了三分之二。我至今为止,其实盈利还行,主要还是选股选择的都是一些可以拿的住的一些股票,大多持股超过了1年,即使在波动比较大或者出现黑天鹅的时候,我也没有选择卖出,反而进行了加仓,这也是我投资的理念,除非公司大的方向发生了转变,否则我应该还会继续持有。

我也不是交易大师,但是我觉得这些交易策略是可以通用的:

  1. 不要追高,这点很重要,当然这是肯定要承受踏空的失落感,但同时起码能让你不亏;
  2. 做好交易笔记,保证是经过充分思考而买入,而不是一拍脑袋,然后就投入了你的血汗钱,买个手机都会货比三家,更何况是股票;
  3. 不要看短期热点炒股,热点这个东西有点虚无缥缈,我们是很难抓住的,通常消息传到我们耳朵里的时候,已经快过时了;

杂谈

人生是旷野

就在昨天跑步的时候,我都在想什么是财富自由?财富自由是表示自己不工作也不会饿死吗?表示自己可以一直躺平吗?我觉得不是,我觉得应该是无需为了生计而出卖自己的时间,可以自由选择自己想要的生活方式。也就是说我可以选择我的时间应该花在哪里,我可以将时间和精力投入到自己真正热爱的事业、兴趣爱好、家庭或社会贡献中,而不用被动地接受工作的束缚。

如果接受了这条假设,那么就表示代表人生不是只有一条路,而是充满了各种各样的选择和不确定性,就如同旷野一样。我可以去探索、去尝试,去定义自己的成功和幸福,而不是遵循他人的标准。当然我也需要自己去开辟道路,面对困难和迷茫。这既是挑战,也是成长的过程。

关于 AI 带给我的思考

最近我用 Claude Code 越多,我越发现它在平时的 coding 中能发挥的影响力就越大,我用它不限于:

  1. 对于陌生的项目,我让它帮我先看一下这个项目是做什么的,核心逻辑是啥,如何使用;
  2. 对于一些重复的代码,我会整理好模版和需求之后让它帮我快速实现,几乎100%没有问题;
  3. 对于一些自己也拿不准的需求,可以让它帮忙先出一个方案其实是一个挺好的探索;
  4. 现在用 Claude Code 做页面效果已经很好了,我不会写页面,但是也用它弄了一些网页,效果还不错;
  5. 很多时候写代码的时候,都会想要用它来帮我优化一版;

基于这几年对 ai 工具的持续使用,感觉现在真的已经慢慢的渗透到我们的日常工作中了,虽然现在很多时候仍需要手动接管,但是已经可以节省很多时间了,可以遇见在不远的未来,coding 的工作被取代也是情理之中的事情。

所以在这之前我一直在想,我能做什么?

我能做什么

对于我来说,我一直在审视自己的能力边界,以及后面可以持续发力的点,我不是个可以随时躺平的人,也不喜欢躺平,能找到一份热爱的事情,并持之以恒的做下去可以说是我人生的一直以来的追求。

那么对于我来说,可以一直做下去的事情莫过于有以下几种:

  1. 真的找到了可以一直深耕的业务,并且这个业务是需要人来做的,AI无法取代人,比如涉及钱的金融类的核心工作,至少在 AI 的幻觉被解决之前,操作钱的事情,交给人来做稳定性要更高一点,并且它具有一定的业务复杂度,并且发展很快;
  2. 找到真正热爱的事情,就像巴菲特一样,每天可以跳着踢踏舞去上班,每天上班都是带着激情去,而不是疲倦,不过这个事情是可遇不可求的;
  3. 一人公司,现在 ai 能力越来越强,我是真的觉得可能会有这个趋势,以前是一个人扎的越深,可能越值钱,但是未来我觉得在这一行广度也同样重要。只要有个很好的想法,ai 就可以帮我实现不再是一句空话,我自己而言就落地了几个产品,其实还不错;

所以对于我来说,我现在年纪不是特别大,对生活也有激情,钱其实也不是那么缺,在我了解到我既不可能在这里一直呆到退休,也不可能直接呆到财富自由,那么我一直在对自己发出灵魂拷问:

为什么不把剩余的时间投资到更有意义的地方呢?难道一定要等自己老了,尝啥都没味道了,逛任何地方都没有意思了,眼睛里面没有光了,才能开始做自己的事情吗?

IMG_7266 1

扫码_搜索联合传播样式-白色版 1

对腾讯5年职业生涯的总结最先出现在luozhiyun`s Blog

2024年总结:沉寂积蓄新的力量

作者 luozhiyun
2025年1月26日 17:08

今年本来元旦的时候可以抽时间写一下这篇文章,但是临近元旦竟然生病了,然后在床上躺了两天。竟然已经过了,那就不急了,慢慢写了。

新学习了啥?

文章

毫无疑问,我觉得现在的生活节奏是越来越快了,特别是在AI的加持之下,掌握AI并利用它进行终生学习已经是必然趋势了,所以我今年没有像往年一样输出很多技术类的文章,因为我觉得没什么必要了,很多知识直接问一下 AI 再结合自己过往的经验很快就能掌握。话虽然这么说,但是还是写了一些文章:

作为开发需要了解 SSD 的一切

透过ClickHouse学习列式存储数据库

深入 RocksDB 高性能的技术关键

构建属于自己的云游戏服务器

Yolov5物体识别与应用

C++ 中到底什么是”&&“ ?

相比以往动则十几篇,量已经少了很多,不出意外的话明年会更少(拼多多财报式发言,笑~)。

英语

从去年开始要学习突破英语,已经过去了一年,我其实还是蛮想去考个雅思,告诉大家自己的英语成绩有哪些突破,自己的方法有多么牛逼。

但是很可惜的是,我没有这么做,因为去年一年因为兴趣的原因,不太想花很多时间在应付考试上面,做的更多的是口语练习以及泛听磨耳朵训练,所以到后面写作以及雅思的考试范式练习我是一样没做。

不过还是讲一下,过去一年我是怎么优化学习英语的:

口语

首先是随便找一篇文章,或者一个感兴趣的事件,比如我经常不知道找什么话题,就会去 https://engoo.com/app/daily-news 上面找一篇文章看完,然后用对着 AI 复述一遍,让 AI 帮我找出我说的句子或语法有什么问题,或者在复述的时候,就发现了很多内容不会表达,全部都记下来,句子我会手写出来,单词我会记到 flomo 里面作为卡片,方便背诵:

Frame 1

然后,每次在开始每天的英语学习之前,把自己的笔记拿出来,不熟的可以多背背。

到了周末,有比较多的时间,我会和外教上一节自由交流课,其实大多时候没有任何主题,随便聊50分钟。但即使这样,很多时候也可以检验自己的英语到底怎样,因为在说的时候,你会发现自己对有些主题很熟悉,对有些主题很陌生。比如我对工作学习相关的主题就比较熟悉,对美食相关的就不怎么会说,这是因为各种食材,酱料之类的名词记的比较少。

听力

我把听力分为泛听和精听两部份:

我现在洗澡做饭,包括早上健身的时候都会经常泛听一些英语材料,泛听就比较发散了,随便什么都听,下面推荐几个感觉还不错的:

Round Table China:里面有几个人应该是中国人,会用英语和老外聊一些中国的实事,非常好懂,我是比较强烈推荐的;

Think Fast, Talk Smart Podcast:这个播客是斯坦福大学运营的一档节目,优点是发音比较清晰,主题偏向商务,工作,学习等;

Vinh Giang:他的播客主要聚焦于教人怎么表达,随便听听还行,因为主题关系,听多了有点单一;

Marques Brownlee:油管最大的电子区up主,发音清晰,用词也比较简单,主要是讲数码,讲电子产品功能啥的,没事可以听听;

Ariannita la Gringa:她的节目比较简单,多以生活场景为主,人也很上镜,学起来会比较愉快;

EnglishAnyone:这个人的英语会让你听起来很有信心,因为他会用简单的句子和单词告诉你该用什么方式学英语,但是场景比较单一;

Long Beach City College的LBCC Study Skills 课程还挺好听的,可以精听和泛听,这个课程讲的一点都不枯燥,强烈推荐!

对于精听,我一般需要抽一整块时间来听,需要配合插件 《Netflix和YouTube-AFL语言学习》一句句听了:

Tom Bilyeu:专门会找一些领域的大佬来访谈,我蛮喜欢的,比如最近找了 Ray Dalio ,Michael Saylor,

Answer in Progress:我非常喜欢他们的节目,各种小东西的科普,探究其原理,社会现象,每个视频十几分钟,但是完全不枯燥,强烈推荐。

AI带给我的学习上的改变

以前我学习效率都是比较低的,很多时候看到一本书的知识点遇到不懂的地方,也只能网上搜一下,但是目前网上不是没有知识,而是太多了,运气好的话,可以很快得出结果,运气不好得看很多篇同样的的文章才找到自己想要的。

但是现在有了AI之后学习方式可以说完全发生了改变,有些章节可以先问问AI,有个大概的了解再读书这样可以加速理解。对于不懂的可以问AI,然后让AI step by step的告诉你这是为什么,甚至可以给你举一反三,可以说AI成为了世上最好的老师。

还有就是 AI 帮我们做了知识的收口,现在互联网的知识太多了,随便查一个东西不是苦于没有,而是苦于不知道该看哪个,但是 AI 可以只吐给我们有效的知识,帮我过滤无用东西。

所以可以看到,合理的使用AI,是可以加速我们掌握新的技能,从另一个角度上讲,AI可能会让人们在未来更方便的转型,从A跨越到B不会太困难,所以我觉得在未来依旧充满了各种机会。

健康 & 健身

过去一年,我还是一样保持了高频的健身习惯,和过往一样,基本上每周至少 4次力量训练和至少一次的有氧训练,天气不冷的时候有氧训练会去游泳,天气太冷了就会去跑步。

Frame 2

近些年来,我一直保持了良好的健身习惯和饮食,要说今年有什么变化,就是在10月体检报告出来之后发现尿酸超标了一些,虽然没有超标很多,但是还是引起了很我重视。其实我本人也不胖,也不抽烟不喝酒,每天差不多11点就睡了,睡眠时间大概在7.5小时以上,之所以会有高尿酸究其原因我觉得应该是我常年以来的力量训练加上高蛋白饮食导致的。因为无氧训练和高蛋白饮食是容易对肾脏产生不小的压力的,再加上慢慢的年纪也大了,肾脏功能衰退也是意料之中的事情。

为了应付高尿酸,我主要做的一件事情就是利用控制变量观察我的尿酸变化情况,以及平时在饮食中减少对高嘌呤食物,含果糖食物的摄入。后面经过为期两个月的观察,发现其实还是喝水最能解决我的问题,因为人体中的尿酸基本是通过体液排出,所以按理来说只要喝水就能解决。然后我通过控制变量观察到基本上只要一天喝够4000ml的水我的尿酸就能稳定在380左右,其实问题就还好。

最后就是在12月某天训练的时候竟然扭伤了手腕,这也导致了12月我基本上就是腿部训练以及跑步为主,运动量没少,应该还大了,毕竟练腿是很累的 ;)。所以截止到1月我的手腕还没好,连我这个训练老手都会受伤,各位在训练的时候一定要注意安全。

Frame 3

学会如何投资

上面讲的几part都是自我价值的投资,这一part主要是想要分享一下我在股市中学到什么。

为什么要进行投资

对于我来说,这也是尝试一个新方向的机会,我想着把我以前学习的能力看看能不能迁移到做投资上。再来就是现在消息面实在是太多了,什么牛鬼蛇神都跑出来教人做投资,如果自己在这方面没有认知,那很容易吃亏的,所以与其被人带着跑,不如自己领跑。

10月的教训

10月那时候,天真的以为中概股会在沉寂几年之后会迎来新的机会,那时候我也受了很多情绪上面的感染,然后入股了一些中概,结果肯定是在不断追高之下输的很惨。后面也让我意识到了,在下行市场上,基本面没有变化的情况下,怎么挣扎力度是有限的。

后面我也去看了《巨债危机》,学习了一些周期方面的知识,让我体会到什么是周期。里面提到一点让我很受用,就是在经济下行的时候,政府会通过一些政策进行经济刺激,每次的刺激都会带来一些上涨,但是总体上来说是跌的,《巨债危机》里面例举了2008年美国的大萧条中,股市有六次大幅上涨,但是总共下跌了 89%。

image-20250126163244520

所以不要跟风,不要抛开基本面进行投资,其实大多数亏损都是在牛市中产生的。

不要被资讯所干扰

现在我们所处的世界,是信息太多了,连巴菲特也这么认为,所以他没有把公司设立在华尔街,他认为远离华尔街的喧嚣能够帮助他避免受到短期市场情绪和集体思维的干扰。在华尔街,投资者很容易陷入信息过载和盲从心理。

举个例子就是,我记得当时,我有个朋友在tesla发布了robotaxi之后很激动,以为这就是future,结果特斯拉第二天股价直接跳水十个点,并且持续低迷了一段时间。后来加上他又看了一些新闻,认为川普可能会选不上,所以就把tesla割肉抛掉了。

image-20250126164518304

这个事情也告诉我,不要被资讯所要干扰,如果当时真是相信马斯克的故事,认为他真的能做成,那就一直持有,否则就不要买入。

其实不只是股市,其他事情也是,现在社交媒体这么发达,很多时候看了一条新闻义愤填膺,然后过几天发现是 fake news,如果没有独立思考的能力就会被玩弄在鼓掌之中。

相信长期的力量

这件事情不只是反映在我对生活的态度,比如坚持写文章,坚持健身,学英语,到了投资这里也是一样的,巴菲特有句话说的好,如果你不愿意持有一只股票十年,那就不要考虑持有它十分钟。

一方面我们都是普通人,做短期很容易亏,看不准形式,并且短期中掺杂了很多噪音会影响判断,另一方面我也没这么多时间盯盘,整天看盘比较影响心情。

所以在过去一年我几乎所有的收益都是由长期持有的股票贡献的,反倒是因为一些小道消息跟风买的一些股票让我亏了一些。

弄了哪些有趣的玩意

游戏

其实整个24年让我印象深刻,并值得说的游戏并不多,玩的很多,但是能回忆起来的没有几个。

《寂静岭2》重制版:玩完《寂静岭2》真的给我一种淡淡的伤感,又是一个艺术与游戏结合的游戏,上次玩到这样的游戏还是 《最后生还者 1》。这个作品真的做到了久久的留存在我的脑海里,无论是背景音乐还是叙事手法,都趋近完美。

《暗黑4》:这游戏首发的时候我就发了600买了豪华版,玩了一阵之后感觉不太行就放下了。今年在第四赛季的时候风评好了不少,于是我又回归玩了一阵,总体来说还是不错的,但是暴雪总归是暴雪,在失望的路上从来不会让人失望,后面整出个300的DLC,我直接就没有太大的兴趣了,直接和暴雪say goodbye。

《流放之路2》:这游戏其实是免费游戏,只是现在还在内测,可以充30刀开启内测资格,还挺良心的,说实话,这游戏是真的做的不错,精良程度,游戏深度都做的不错;

买过哪些玩意

一加ACE3 pro

买这个手机是因为我很久没有体验过安卓机了,像搞个来看看目前主流安卓机有啥可玩的。

优点:

  1. 硬盘价格是真的便宜,16+512+8gen3只要3k多;
  2. 沙盒系统真好用,真的完全隔离,赖皮应用全丢到沙盒里,什么信息都获取不到;
  3. 侧滑返回真的爽,任何应用都可以左右侧滑返回,反观ios的垃圾返回操作万年不改;
  4. 小窗是很方便的,挂机打游戏,看视频回信息;
  5. 分屏很爽,有时候上下分两个屏处理信息快不少;
  6. 屏下指纹比faceid好用一万倍;
  7. 可玩性很高,可以玩各种 galgame ,switch模拟器,王国之泪也能在安卓机上玩;
  8. 100w充电真的很快,早上洗漱的时间,充好就可以拿走了;

缺点:

  1. 广告太多,虽然可以关,但是什么类似快应用这样的app仍然在后台收集数据;
  2. AI应用虽然好,但是打开后台应用的权限管理,可以看到AI的数据收集工作一刻不停,一天收集几百次,并且各种权限都要;
  3. 相册权限管理很差,适配的应用可以只给部分照片,没适配只能全给或者不给。像小红书这种,给了权限,几乎一天要读几十次相册

iPhone16P:没错,最后我还是换到了苹果,其实也没有什么特别的原因,感觉能满足我的需求,不需要折腾,没广告,偶尔去旅旅游录个视频效果也还挺好,那就足够了。没有买pro max是因为觉得太重了,太撑手了,现在市面上手机一个比一个宽,一个比一个大,感觉都要双手捧着才能用了;

iPad mini7:这个平板应该是我使用频率最高的电子产品之一,可以用来看电子书,看视频,玩游戏,串流,因为小巧轻便有时候还可以用笔记一点东西,由于很便携,我基本走哪里都带上;

macbook air m3:这台电脑是我五一的时候去冲绳买的,那时候日元汇率很不错,然后就入手了。它目前来看基本上能满足所有的日常使用需求,并且还非常的轻薄,后面出去玩再也不用带着我16寸的MacBook pro了,瞬间解放我的背包;

xiaomi buds5:这个产品刚使用的时候确实很惊艳,因为一个半入耳式的耳机竟然可以有不错的降噪效果,并且音效我听起来也很棒,感觉比我原来的 airpods2 好太多了。

去过哪些地方

北海道之行

平时雪都很少见的我,去小樽滑的第一场雪,我觉得是非常好玩的,一开始的时候我甚至都不能起来,后来还是放弃了,找了个教练,教练竟然是台湾人,带我练了几个小时后我基本上就可以自己入门了。

Frame 4

我去小樽的那天,他们也在搞灯节,还挺漂亮的,旁边有工作人员,可以100日元买个小杯子,里面有个小蜡烛,我写的是:for tomorrow

Frame 5

漫步在小樽的街上你可以感受到,他们的生活过的很悠闲,下午3点就没什么人了,以至于想找个吃饭的地方都没几个店。

Frame 6

很有意思的一点是,悠闲的逛公园的时候,你会发现很多牌子,告诉你小心乌鸦,因为一不小心你买的饼就没了。。。

Frame 7 (3)

冲绳

然后就是五一的时候去了冲绳,那边的沙滩还挺美的。

Frame 8

冲绳的海水真的好清澈,第一次看到玻璃一样的海水。

Frame 9

冲绳还有必去海洋馆。

Frame 10

总体来说,我觉得冲绳的旅游体验很好的,有点不太好的是交通不是很方便,经常需要打车。

亚庇

亚庇这个小城市,还挺有意思的,好几面墙上有巨大的壁画,可惜我去的是十月,天气不是很好,总是在下雨。

Frame 11

亚庇的主要旅游景点也是去各种小岛上面玩,但是我感觉其实体验很差,比如我去的这个环滩岛,据说是最好的岛之一了,但是各种服务还是很差,吃的也不好,洗浴间排队很长时间。所谓的游玩项目也就是浮潜一个多小时,然后回岛上吃个饭,然后就回去了。

并且浮潜如果没有自己带装备的话,他们提供的装备是真的差,并且漏水,然后一个一次性嘴塞10马币。

Frame 12

亚庇的消费其实挺高的,随便点个菜也要几十,并且当地的菜都是类似糊糊一样的东西,我是吃不太习惯,最好吃的感觉还是当地的华人菜馆。

然后就是亚庇的榴莲是真的好吃,我基本上每天都会去开两个榴莲吃,不必吃什么猫山王,就本地的白榴莲就可以了,一个就十几马币。

Frame 13

总结

总体来说,旅游体验来说:北海道>冲绳>亚庇,但是价格来说,也是北海道>冲绳>亚庇。

新的展望

技术上,我希望能学点不一样的东西,最近接触到了一点 ue 相关的开发,希望能在新的一年往这方面点一下技能树。

然后就是希望能更加深度挖掘一下已经掌握的技能,不是说学了就能放着不管,时间久了很慢慢遗忘,没什么想学的时候多问问自己哪些掌握了,哪些没有,比如:c++是真的熟悉了吗?c++17的特性有哪些?等等。

对于英语,还是希望能坚持,语言性的东西要学起来其实是需要漫长的过程,不是一朝一夕就可以练成,不过现在很多 AI 工具已经大大的简化了学习流程了,我觉得掌握起来不算太难,还是继续每天花至少半个小时多多练习比较重要。

对于健身,除了身形以外,更加希望是朝着健康的方向发展,更加需要注意怎样不让自己受伤的情况下,承受最大的重量,然后把身体锻炼好,适当的加入有氧运动,而不是全都是无氧运动,这样更能激活自己身体机能。

2024年总结:沉寂积蓄新的力量最先出现在luozhiyun`s Blog

C++ 中到底什么是”&&“ ?

作者 luozhiyun
2024年12月26日 18:58

最近《Effective Modern C++ 》看到了第五章,感觉这章挺有趣的,所以单独拿出来总结一下,主要是想对通用引用和右值引用相关的东西总结补充一下,感兴趣的不妨看看。

区分通用引用和右值引用

T&&”有两种不同的意思。第一种,当然是右值引用。“T&&”的另一种意思是,它既可以是右值引用,也可以是左值引用,被称为通用引用universal references)。

比如下面的例子中

void f(Widget&& param);             //右值引用
Widget&& var1 = Widget();           //右值引用
auto&& var2 = var1;                 //通用引用,auto&& 是通用引用,它的类型根据 var1 的类型来推导,var1 是一个右值引用,var2 也是一个右值引用,绑定到 var1。

template<typename T>
void f(std::vector<T>&& param);     //右值引用

template<typename T>
void f(T&& param);                  //通用引用            

上面的例子中:

auto&& var2 = var1;这是一个 通用引用(完美转发引用)。

  • auto&&通用引用,它的类型根据 var1 的类型来推导。
  • 由于 var1 是一个右值引用,auto&& 会推导为 Widget&&。因此,var2 也是一个右值引用,绑定到 var1

template<typename T> void f(T&& param);这是一个 通用引用(完美转发引用)。

  • T&& param 是一个 通用引用,它的类型根据传递给模板函数的实际类型来推导。
  • 如果传入的是右值,param 会被推导为右值引用 (T&&)。
  • 如果传入的是左值,param 会被推导为左值引用 (T&),这是通过引用折叠规则完成的。

对一个通用引用而言,类型推导是必要的,它必须恰好为“T&&”。下面几种情况都可能使一个引用失去成为通用引用的资格。

template<typename T> void f(std::vector<T>&& param);这是一个 右值引用

  • std::vector<T>&& param 是一个 右值引用,它接受一个 std::vector<T> 类型的右值。
  • 函数 f 只能接受右值类型的参数。

除此之外,即使一个简单的const修饰符的出现,也足以使一个引用失去成为通用引用的资格:

template <typename T>
void f(const T&& param);        //param是一个右值引用

对右值引用使用std::move,对通用引用使用std::forward

std::move 是一个类型转换工具,它将左值转换为右值引用,允许通过移动语义来避免不必要的对象复制。比如有时候我们希望将其资源转移给另一个对象时,你应该使用 std::move。这样可以避免复制操作,提高效率:

std::vector<int> createVector() {
    std::vector<int> v = {1, 2, 3};
    return std::move(v);  // 使用 std::move 防止复制,转移资源
}

std::forward 它通常用于完美转发(perfect forwarding)的场景中,保留了传入参数的值类别(即,是否是左值或右值)。这使得它特别适用于在函数模板中转发参数,而不改变它们的值类别。

完美转发:指的是保持参数的左值或右值性质,以便在转发时能够选择正确的构造(复制或移动)。比如下面:

template <typename T>
void wrapper(T&& arg) {
    // 使用 std::forward 保留 T&& 参数的左值或右值性质
    someFunction(std::forward<T>(arg));
}

在这个例子中,std::forward<T>(arg) 将确保传入的参数 arg 被正确地转发到 someFunction,而不引入不必要的复制或移动。

所以 move 和 forward 使用场景是不一样的,如果在通用引用上使用std::move,这可能会意外改变左值:

class Widget {
public:
    template<typename T>
    void setName(T&& newName)       //通用引用可以编译,
    { name = std::move(newName); }  //但是代码太太太差了!
    …

private:
    std::string name;
    std::shared_ptr<SomeDataStructure> p;
};

std::string getWidgetName();        //工厂函数

Widget w;

auto n = getWidgetName();           //n是局部变量

w.setName(n);                       //把n移动进w!

…                                   //现在n的值未知

上面的例子,局部变量n被传递给w.setNamen的值被移动进w.name,调用setName返回时n最终变为未定义的值。

所以对于 move 和 forward 我们尽量不要用错,但是也不要不用,否则可能会有性能上的损失,比如下面:

Matrix                              //按值返回
operator+(Matrix&& lhs, const Matrix& rhs)
{
    lhs += rhs;
    return std::move(lhs);          //移动lhs到返回值中
}

如果在最后 return的时候直接返回 lhs,那么其实是拷贝lhs到返回值中,拷贝比移动效率低一些。

forward情况也类似,如下:

template<typename T>
Fraction                            //按值返回
reduceAndCopy(T&& frac)             //通用引用的形参
{
    frac.reduce();
    return std::forward<T>(frac);     //移动右值,或拷贝左值到返回值中
}

如果std::forward被忽略,frac就被无条件复制到reduceAndCopy的返回值内存空间。

最后提一下,由于 RVO 的存在,我们也不要以为使用 move 操作就可以提升性能,RVO 主要解决的是在函数返回一个局部对象时,编译器可能会为该对象创建一个临时副本,然后将其复制到返回值中,从而引入了不必要的复制开销。

所以当我们想要“优化”代码,把“拷贝”变为移动:

Widget makeWidget()                 //makeWidget的移动版本
{
    Widget w;
    …
    return std::move(w);            //移动w到返回值中(不要这样做!)
}

通过返回 std::move(w),你实际上告诉编译器“我想要移动这个对象”,从而避免了 RVO 的优化,可能会导致不必要的资源转移。

所以你看,在c++中,为了做性能优化其实是一件挺复杂的事情。

避免在通用引用上重载

为什么要避免使用

通用引用可以绑定到几乎所有类型的实参,包括左值和右值。这种特性使得编译器在选择重载时可能会优先选择通用引用版本,即使这并不是开发者所期望的行为。并且由于通用引用的匹配规则,编译器在解析重载时可能会产生意外的结果。举个例子,假设有以下两个函数:

template<typename T>
void foo(T&& t) { /* 通用引用 */ }

void foo(int& t) { /* 左值引用 */ }

当调用foo(5)时,编译器会选择第一个函数,因为5是一个右值,而对于左值int a; foo(a);则会选择第二个函数。

但是假如将short a; foo(a);则会选择第一个函数。有两个重载的foo。使用通用引用的那个推导出T的类型是short,因此可以精确匹配。对于int类型参数的重载也可以在short类型提升后匹配成功。根据正常的重载解决规则,精确匹配优先于类型提升的匹配,所以被调用的是通用引用的重载。

如果想要使用重载该怎么办

最简单的方式当然是放弃了,比如 go 里面就没有重载,比如我们重载了logAndAdd函数,那么可以换成logAndAddName和logAndAddNameIdx来避免重载。

第二种方法就是将通用引用改写成 const T&,因为通用引用带来的复杂推导、重载歧义等问题反而会增加维护成本。此时,直接使用const 的左值引用即可满足需求。

改写之后 const T& 可以绑定到左值和右值,并且所有实参都被看作不可修改的“左值”,所以无需区分值类别。

比如原始版本(使用通用引用):

#include <utility> // std::forward

template<typename T>
void process(T&& param) {
    // 这里可能会做完美转发
    someFunction(std::forward<T>(param));
}

改用 const T& 后:

template<typename T>
void process(const T& param) {
    // 现在 param 总是 const 引用,不再做完美转发
    // 如果只是读 param 或传入其他函数当 const 引用使用就可以
    someFunction(param); // 直接传参即可
}

这种转变缺点是效率不高,因为放弃了移动语义,但是为了避免通用引用重载歧义、易读性优先,所以放弃一些效率来确保行为正确简单可能也是一种不错的折中。

再来就是使用tag dispatch,比如下面我们有两个重载函数,但是由于通用引用的存在,会发生引用重载歧义,所以我们想要正确的让函数实现最优匹配,可以使用std::is_integral 是一个类型特征(type trait),用来在编译期判定某个类型是否是整型

template<typename T>
void logAndAdd(T&& name)
{
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");
    names.emplace(std::forward<T>(name));
}

void logAndAdd(int idx)             //新的重载
{
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");
    names.emplace(nameFromIdx(idx));
}

比如我们可以改写成这样:

template<typename T>
void logAndAdd(T&& name)
{
    logAndAddImpl(
        std::forward<T>(name),
        std::is_integral<typename std::remove_reference<T>::type>()
    );
}

template<typename T>                            //非整型实参:添加到全局数据结构中
void logAndAddImpl(T&& name, std::false_type)    
{
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");
    names.emplace(std::forward<T>(name));
}

std::string nameFromIdx(int idx);           //与条款26一样,整型实参:查找名字并用它调用logAndAdd
void logAndAddImpl(int idx, std::true_type) //译者注:高亮std::true_type
{
  logAndAdd(nameFromIdx(idx)); 
}

logAndAdd传递一个布尔值给logAndAddImpl表明是否传入了一个整型类型,通过 std::true_typestd::false_type 来判断应该调用哪个函数。上面的例子中还用到了std::remove_reference,它的作用是去掉类型中的引用修饰符得到正确的类型:若 TU&U&&,则 std::remove_reference<T>::type 得到 U;如果 T 本身不是引用类型,则结果还是 T 本身。

在这个设计中,类型std::true_typestd::false_type是“标签”(tag),在logAndAdd内部将重载实现函数的调用“分发”(dispatch)给正确的重载。

再来就是基于 std::enable_if 的重载,如下实现了两个版本的重载函数:

// ----------------------
// (A) 针对整型参数的重载
// ----------------------
template<
    typename T,
    // 使用 std::enable_if 使该重载仅在 T 是整型时有效
    typename = std::enable_if_t<std::is_integral_v<std::remove_reference_t<T>>>
>
void logAndAdd(T&& idx)
{
    cout << "for int" << endl;
}

// ------------------------
// (B) 针对非整型参数的重载
// ------------------------
template<
    typename T,
    // 使用 std::enable_if 使该重载仅在 T 不是整型时有效
    typename = std::enable_if_t<!std::is_integral_v<std::remove_reference_t<T>>>
>
void logAndAdd(T&& name)
{
    cout << "for universal reference" << endl;
}

int main()
{
    logAndAdd("Alice");             // 非整型,调用重载 (B)
    std::string bob = "Bob";
    logAndAdd(bob);                 // 非整型,调用重载 (B)

    logAndAdd(42);                   // 整型,调用重载 (A)  

    return 0;
}

通过 SFINAE 条件:

std::enable_if_t<std::is_integral_v<std::remove_reference_t<T>>>

如果形参是整型类型(如 int, long,无论左值还是右值),匹配这个重载。否则就调用另外一个。

关于引用折叠reference collapsing

在C++中引用的引用是非法的,比如下面的写法,编译器会报错:

int x;
…
auto& & rx = x; //error: 'rx' declared as a reference to a reference

但是我们上面用了很多这样的例子:

template<typename T>
void func(T&& param);       //同之前一样

Widget w;                   //一个变量(左值)
func(w);                                    //用左值调用func;T被推导为Widget&

它并没有因为被推导成了 Widget& && param编译器报错。因为编译器会通过引用折叠把T推导的类型带入模板变成Widget& param,也就是说这时候 func 里面传入的是一个左值引用。

存在引用折叠是为了适配“完美转发”这种灵活的泛型编程需求。在模板中使用如 T&&(或者 auto&&)这类通用引用的时候,我们把左值传给 T&& 时,需要推导出的类型为 T&(左值引用);把一个右值传给 T&& 时,需要推导出的类型为 T&&(右值引用)。

由于这个推导会自动在类型上再套一层引用,所以不可避免会产生 T& &T&& &T& &&T&& && 这类“引用的引用”。若没有引用折叠规则,这些“引用的引用”将无法在语言中被直接表示。

C++ 标准中定义的引用折叠规则可总结为以下四条(这里的 & 代表左值引用,&& 代表右值引用):

  1. T& & 折叠为 T&
  2. T& && 折叠为 T&
  3. T&& & 折叠为 T&
  4. T&& && 折叠为 T&&

其中可以看出,只要有一个左值引用(&)参与,就会最终折叠成左值引用;只有当纯右值引用(T&& &&)相叠时,才会保留为右值引用。

可以用简单的测试代码来查看引用折叠的结果:

#include <type_traits>
#include <iostream>
#include <string>

template <typename T>
void testReference(T&& x) {
    using XType = decltype(x); // 注意这里的 x 是函数形参
    // 现在 XType 才是真正的形参类型,比如 int&、int&&

    std::cout << "XType is "
              << (std::is_reference<XType>::value ? "reference " : "non-reference ")
              << (std::is_lvalue_reference<XType>::value ? "&" : "")
              << (std::is_rvalue_reference<XType>::value ? "&&" : "")
              << std::endl;
}

int main() {
    int i = 0;
    const int ci = 0;
    // 1) 左值 int
    testReference(i);       // T 推断为 int&,故 T&& 折叠为 int&
    // 2) 右值 int
    testReference(10);      // T 推断为 int,  故 T&& 折叠为 int&&
    // 3) 左值 const int
    testReference(ci);      // T 推断为 const int&, T&& -> const int&
    // 4) 右值 const int
    testReference(std::move(ci)); // T 推断为 const int, T&& -> const int&&
    return 0;
}

完美转发失效的情况

花括号

比如我们有 fwd 的函数,利用fwd模板,接受任何类型的实参,并转发得到的任何东西。

template<typename... Ts>
void fwd(Ts&&... params)            //接受任何实参
{
    f(std::forward<Ts>(params)...); //转发给f
}

假定f这样声明:

void f(const std::vector<int>& v);

在这个例子中,用花括号初始化调用f通过编译,

f({ 1, 2, 3 });         //可以,“{1, 2, 3}”隐式转换为std::vector<int>

但是传递相同的列表初始化给fwd不能编译

fwd({ 1, 2, 3 });       //错误!不能编译

因为对f的直接调用(例如f({ 1, 2, 3 })),编译器看看调用地传入的实参,看看f声明的形参类型。它们把调用地的实参和声明的实参进行比较,看看是否匹配,并且必要时执行隐式转换操作使得调用成功。但是 fwd 是个模版,所以不能这样调用。

0或者NULL作为空指针

传递0或者NULL作为空指针给模板时,类型推导会出错,会把传来的实参推导为一个整型类型(典型情况为int)而不是指针类型。结果就是不管是0还是NULL都不能作为空指针被完美转发。解决方法非常简单,传一个nullptr而不是0或者NULL

仅有声明的整型static const数据成员

当一个编译时常量被用作 纯值,如传递给函数、用于模板参数等,编译器可能不会为其分配实际的存储空间,只在需要时进行内联优化。这意味着这些常量没有独立的地址可供引用。

然而,当你尝试取这些常量的地址时,编译器需要一个实际的存储位置。如果该常量没有被定义在某个存储位置(例如,静态成员变量在类外未定义),链接器将找不到该符号,从而产生链接错误。

比如在类中定义整型static const数据成员:

class Widget {
public:
    static const std::size_t MinVals = 28;  //MinVal的声明
    …
};

想象下ffwd要转发实参给它的那个函数)这样声明:

void f(std::size_t val);

我们尝试通过fwd调用f会报错:

fwd(Widget::MinVals);       //ld: symbol(s) not found for architecture arm64
c++: error: linker command failed with exit code 1

要能够安全地取编译时常量的地址,需要确保这些常量有实际的存储空间。一种方式是提供类外定义:

#include <cstddef>

class Widget {
public:
    static const std::size_t MinVals = 28; // 类内声明和初始化
};

// 类外定义
const std::size_t Widget::MinVals;

void f(std::size_t val) {}

template<typename T>
void fwd(T&& param) {
    f(std::forward<T>(param));
}

int main() {
    fwd(Widget::MinVals);
    const std::size_t* ptr = &Widget::MinVals; // 现在可以安全取地址
}

还有就是从 C++11 开始,使用 constexpr 可以在一定条件下避免需要类外定义,因为 constexpr 变量默认具有内联性质,编译器会为其分配存储空间:

#include <cstddef>

class Widget {
public:
    static constexpr std::size_t MinVals = 28; // 使用 constexpr
};

再来就是从 C++17 开始,可以使用 inline 关键字:

class Widget {
public:
    inline static const std::size_t MinVals = 28; // 使用 inline
};

重载函数

如果我们试图使用函数模板转发一个有重载的函数,也是会报错的,译器不知道哪个函数应被传递。如下f被定义为可以传递函数指针:

void f(int (*pf)(int));

现在假设我们有了一个重载函数,processVal

int processVal(int value);
int processVal(int value, int priority);

报错:

fwd(processVal);                    //错误!那个processVal?

但是我们可以给函数重命名来解决这个问题:

using ProcessFuncType =                          
    int (*)(int);

ProcessFuncType processValPtr = processVal;     //指定所需的processVal签名

fwd(processValPtr);                             //可以
fwd(static_cast<ProcessFuncType>(workOnVal));   //也可以

Reference

《Effective Modern C++ 》

C++ 中到底什么是”&&“ ?最先出现在luozhiyun`s Blog

构建属于自己的云游戏服务器

作者 luozhiyun
2024年6月29日 18:03

最近沉迷于暗黑4第四赛季,所以就在倒腾,怎样才能随时随地玩到暗黑4,掌机steam deck 我试过了,太重并且性能很差,已经被我卖了,于是折腾起了云游戏。

先来看看我的折腾成果:https://www.bilibili.com/video/BV1Z93TeuEQ4/

其实效果我没想到有这么好,在远程串流的情况,可以 1080p 60hz 几乎无卡顿的玩暗黑4,延迟只有20ms左右,配上我的手柄简直就是一个强大的掌机。

各种平台云游戏怎样了?

有了上面的需求之后,我就去试了以下几个平台:GeForce Now、Xbox Game Cloud、Start云游戏、网易云游戏。

但是遗憾的是,几乎每个平台都有自己的问题。首先上面列举到的所有平台,IOS 都只有网页版,因为苹果不让上架。

Start云游戏:我检查了一下,只有少数的几个单机游戏,大多是网游手游,并没有暗黑4;

网易云游戏:无论是快速启动还是普通启动,都非常的慢,估计要3分钟左右才能启动。并且我充值了一下玩了一下,网络倒是很流畅,延迟只有15ms左右,但是非常的卡,并且经常进入游戏界面死机,估计是机器性能不行,说实话我有点心疼我充值的10块钱了。

所谓的高配,其实性能很低:

image-20240623180606792

Xbox Game Cloud:微软自家的平台,好处是有了XGPU之后可以畅玩所有云游戏,最大的问题是服务器不在国内,所以连接延迟其实很高,并且常有波动,如果要玩Xbox Game Cloud 那么还需要充值XGPU才行;

Xbox Game Cloud 强大的游戏阵容:

image-20240623180512545

GeForce Now:这个云游戏平台可以说只能用无敌两个字来形容,每次登录都可以免费半个小时,即使服务器在海外,但是开了加速器也可以很稳定,画面有720P,在手机上玩还可以,主机性能也很好,经常分配到2080以上的机器。但是唯一不爽得是,免费用户每次都要排队挺长时间的,并且如果要付费,其实挺贵的,基本要100元每个月了。

GeForce Now价格表:

image-20240623180446219

开源解决方案

所以尝试了这么的云游戏发现都不好用之后,为什么不可以自己弄个呢?其实所谓的云游戏,无非是用客户端连接到主机端而已。那么我们实际上也可以把自己家里运行的PC或者主机,变成了由云游戏服务商提供的云上服务,这种行为就叫做串流游戏。

所以我们要做的是怎样把我们的私有网络的PC或者主机做成云服务提供给外网访问,让我们可以随时随地,只要有网就可以使用。

memory12

那么我的要求主要有这么几点:

  1. 要有跨平台的客户端,保证mac、iphone、android、win 都能用;
  2. 延迟要足够低,支持的可配置项要足够多;

正好自己有台闲置的 4090 的机器,那我就可以用它来作为主机端,我的安卓机作为客户端进行串流云游戏。

服务端 & 客户端

目前服务端主要有以下几个实现方案:

  1. N卡GeForce Experience
  2. Sunshine

客户端主要有:Moonlight

N卡GeForce Experience

如果你使用N卡,并且是GTX960以上可以通过GeForce Experience进行串流。只需要打开GeForce Experience在设置里找到SHELD这个串流配置,并添加游戏或者应用程序即可。

Featured image of post Nvidia Gamestream + Moonlight 如何串流桌面畫面

但是我现在不是很推荐这个方案,因为NV说过他们要把这个功能去掉,只是现在没有去掉而已。

Sunshine

它是一个开源推流方案 https://github.com/LizardByte/Sunshine , 属于通用串流方案,支持Nvidia、AMD、Intel。尤其适合核显串流(如果你用的是P106这类显卡没有视频编码器,只能使用Sunshine串流方案)。

下载Sunshine后首先需要运行服务端安装脚本install-service.bat,然后再运行sunshie。sunshine没有UI界面,设置需要通过网页端。运行sunshine后访问https://127.0.0.1:47990进行设置。设置里最重要的是进行PIN码配对,设备之间PIN匹配之后就可以进行串流了。

image-20230314210726196

Moonlight

Moonlight 以方便的将Windows电脑画面传输到各主流操作系统的客户端软件上,甚至可以直接传输至谷歌浏览器。画面方面,移动端最高支持4K120帧,且支持HDR(需要显卡支持),而桌面端甚至可以直接自定义分辨率和帧数;交互方面支持键鼠/手柄/触摸屏/触控板/触控笔,就像用自己的电脑一样使用远程电脑。该方案无广告,完全免费。

手机端可以在各大商店下载,也可以去 Moonlight官网地址:https://moonlight-stream.org 下载。如果使用iphone作为客户端,直接在App store下载Moonlight即可。

保持主机和客户端在同一局域网内,打开客户端软件,应该能够看到主机的计算机名。点击会弹出4位PIN码,需要在Sunshine配置网页 https://localhost:47990/pin 中输入PIN码。建立连接后,点击桌面(DESKTOP)将启动桌面串流。

网络配置好之后,在局域网内串流延迟通常相当的底,我经常躺床上用 pad 串流我书房的 pc 玩游戏,延迟只有几毫秒。

远程串流

由于Geforce Experience和Sunshine默认只在本地网络监听端口,客户端和主机位于同一局域网内才能连接成功,如果要真正实现远程连接,最简单稳定的方法是公网直连。

独一无二的IP地址使得主机能够在互联网中被识别,但是由于IPv4地址匮乏,大多数家庭网络并不具备公网IPv4地址。

所以我这里采用内网穿透的方式来构建我们的云服务:

Frame 2

内网穿透的核心思想就是“映射”和“转发”,把私有网络的设备的端口映射到公网设备的端口上,来进行流量转发。思想其实很简单,由于内网设备没有ip,那么我们通过一台有公网ip的机器来代替把流量做一层转发。比如上图,

我们在外网设置的用手机访问云服务器的 7000 端口,实际上云服务器会接收到之后通过47900进行转发到我们私有网络的pc机器,然后pc机器处理完之后再通过46900 端口转发给云服务器,上面所提到的端口都是可以自定义的。

那么对于做内网穿透一般现在流行两种做法:

  1. 直接 p2p 点对点的进行传输,流行的方案有 zerotier;
  2. 基于服务器的流量转发,流行的方案有 frp;

为什么会有内网穿透?

其实在互联网的世界中,如果每个用户都有真实的IP情况下,那么我们可以通过源IP+源端口+目标IP+目标端口+协议类型很容易的找到对方,是根本不需要P2P的,因为本来任何对象都可以作为Server或者Client来提供服务,彼此之间是可以互联。

但是IP和端口,是有限的,最初设计者也是没想到发展如此迅速,整个IPv4的地址范围,完全不够互联网设备来分配,那为了解决地址不够用的问题,就引入了NAT。

Frame 3

NAT(Net Address Translate,网络地址转换)是一种IP复用的一种技术,将有限的IP扩展成无限,由于IPv4地址资源有限,而NAT将网络划分成了公有网络和私有网络,允许多个设备使用一个公共IP地址访问互联网。路由器会将内部网络中的私有IP地址转换为公共IP地址,从而节省了IPv4地址资源。

所以我们在用 WIFI 的时候可以看到我们手机或PC上的IP地址通常是:192.168.x.xxx,这其实就是由路由器分配的地址,并不是真的地址。

另外,在 IPv4 地址资源越来越紧张的今天,很多电信运营商,已经不再为用户分配公网 IP;而是直接在运营商自己的路由器上运营 NAT,所以会出现甚至一整个小区共用一个 IP 出口的情况。

通过NAT技术的公私网络隔离,可以实现IP复用,解决了IPv4不够用的问题,但是也同时带来了新问题,那就是直接导致通信困难,由于NAT导致IP成为虚拟IP,外网无法针对内网某台主机进行直连通信,因为没有真实地址可用。

所以为了将NAT设备内外通信打通,就有了内网穿透技术。

zerotier

zerotier 是一个开源的内网穿透软件 https://github.com/zerotier/ZeroTierOne ,有社区版本和商业版本,唯一的区别是社区版本有 25台连接数量的限制,但对普通用户足够了,用它可以虚拟出一组网络,让节点之间的连接就像是在局域网内连接一样。

zerotier 底层是通过一个加密的p2p网络来实现连接。由于节点之间通常存在NAT隔离,无法直接通信,所以 zerotier 存在一个根服务器来帮助通路建设,所谓通路建设俗称打洞(hole punching),也就是穿透NAT隔离实现两个节点的连接。打洞也是区分 UDP 和 TCP 的,由于 zerotier 用的是 UDP,所以这里以 UDP 讲解打洞原理。

假设clientA 想要直接与clientB 建立UDP会话,用S表示根服务器:

A最初不知道如何到达B,因此A请求S帮助与B建立UDP会话,S会记录下他们各自的内外网IP端口:

Frame 4

打洞中:

S用包含B的内外网IP端口的消息回复A。同时,S使用其与B的UDP会话发送B包含A的内外网IP端口的连接请求消息。一旦收到这些消息,A和B就知道彼此的内外网IP端口;

当A从S接收到B内外网IP端口信息后,A始向这两个端点发送UDP数据包,并且A会自动锁定第一个给出响应的B的IP和端口;

B开始向A的内外网地址二元组发送UDP数据包,并且B会自动锁定第一个给出相应的A的IP和端口;

Frame 4

打洞后:

A和B直接利用内网地址通信

Frame 4

zerotier 的根服务实际上是部署在海外的,如果我们直接使用,很可能连不上,并且延迟基本在200ms以上,我们可以通过 zerotier-cli listpeers 查看根服务器:

# ./zerotier-cli listpeers
200 listpeers <ztaddr> <path> <latency> <version> <role>
200 listpeers 62f865ae71 50.7.252.138/9993;24574;69283 341 - PLANET
200 listpeers 778cde7190 103.195.103.66/9993;24574;69408 213 - PLANET  
200 listpeers cafe9efeb9 104.194.8.134/9993;4552;69462 159 - PLANET

上面的 PLANET 节点就是是ZeroTier网络中的根服务器。它们负责在对等点之间中继初始流量,帮助对等点建立对等连接,并充当身份和相关公钥的缓存。

我们随便 ping一下它的延迟:

# ping 50.7.252.138
PING 50.7.252.138 (50.7.252.138) 56(84) bytes of data.
64 bytes from 50.7.252.138: icmp_seq=1 ttl=46 time=347 ms
64 bytes from 50.7.252.138: icmp_seq=2 ttl=46 time=347 ms
64 bytes from 50.7.252.138: icmp_seq=3 ttl=46 time=354 ms

这样的游戏明显是玩不了云游戏的,zerotier 也考虑到这种延迟的情况,所以可以让有需要的用户自建 MOON 服务器。

ZeroTier中的MOON节点是用户定义的根服务器,可以添加到ZeroTier网络中。它的行为类似于ZeroTier的默认根服务器(称为PLANET节点),但由用户控制,我们可以把 MOON 服务器部署在离自己更近的地方,比如我就部署在广州,可这样以通过提供更近或更快的根服务器来提高网络性能。

怎样部署我这里就不贴教程了,可以自己去search一下,很简单。 但是现实场景中的网络要复杂的多,远远不是部署一个 MOON 节点就可以解决延迟的问题。通常我们的网络会涉及到防火墙限制、运营商级NAT、路由器兼容问题,还有就是 ZeroTier 走的是 UDP, 在国内的网络环境下一些运营商会对UDP流量实施QoS(服务质量)策略,,丢包可能会比较严重。

所以总之ZeroTier这条路并不是这么好走,看起来 p2p 直连貌似可以很美好,理论上可以不受根服务器的影响,两端直连跑满所有带宽,但实际上当不能打洞成功的时候那么就会退化成根服务器转发,那么实际的速率就取决于你自建的 MOON 节点的转发带宽了。

并且还有一个问题是,ZeroTier 是需要客户端的,到目前为止移动端的 app 是不支持添加自建 MOON 节点信息的,也就是说只能在电脑上进行串流,这实用性还是下降了不少。

所以总结一下优缺点:

优点:

  1. 组网非常方便,可以像局域网一样连接ZeroTier组网内的节点;
  2. 连接以及数据传输都是加密,所以比较安全;

缺点:

  1. 根服务器在海外,需要自建MOON,否则延迟很高;
  2. 依赖服务端,并且移动端app功能不完善;
  3. 受制于网络环境,p2p 打洞成功率低;

frp

frp 也是一个开源软件 https://github.com/fatedier/frp ,实际上它没有这么多花哨的功能,就是帮我们做了一个流量的转发。它的客户端连接不需要app,所以用来串流的话直接用moonlight直接连接frp远程转发服务器即可,可以说很方便了。

它的架构如下:

Frame 5

在安装frp远程转发服务的时候,我这里给一下配置,因为现在网上找的教程都是老的 ini 配置,现在新版本用的是 toml配置。

服务端的配置:

#frps服务监听的本机端口
bindPort = 9200
bindAddr = "0.0.0.0"
# frpc客户端连接鉴权token,默认为token模式
auth.token="xxxx" 

#日志打印配置
log.to = "./log"
log.level = "debug"
log.maxDays = 7

allowPorts = [
    // 远程连接需要用的端口
  { start = 47000, end = 48010 } 
]

sunshine主要连接的端口是这几个:

TCP 47984, 47989, 48010
UDP 47998, 47999, 48000, 48002, 48010

所以我们需要给这几个端口都加上防火墙,服务器和pc都要开放相应的端口,在测试的时候可以先全打开,测试完了再挨个加上,免得莫名其妙的问题。

pc端的配置:

#token需要与服务端的token一致
auth.token = "xxxxx"
# 服务端的公网ip
serverAddr = "1xx.xxx.xx.xx"
# 服务端的监听端口
serverPort = xxx

[[proxies]]
name = "47984"
type = "tcp"
localIP = "127.0.0.1"
localPort = 47984
remotePort = 47984

[[proxies]]
name = "47989"
type = "tcp"
localIP = "127.0.0.1"
localPort = 47989
remotePort = 47989

[[proxies]]
name = "47990"
type = "tcp"
localIP = "127.0.0.1"
localPort = 47990
remotePort = 47990

[[proxies]]
name = "48010"
type = "tcp"
localIP = "127.0.0.1"
localPort = 48010
remotePort = 48010

[[proxies]]
name = "47998"
type = "udp"
localIP = "127.0.0.1"
localPort = 47998
remotePort = 47998

[[proxies]]
name = "47999"
type = "udp"
localIP = "127.0.0.1"
localPort = 47999
remotePort = 47999

[[proxies]]
name = "48000"
type = "udp"
localIP = "127.0.0.1"
localPort = 48000
remotePort = 48000

[[proxies]]
name = "48002"
type = "udp"
localIP = "127.0.0.1"
localPort = 48002
remotePort = 48002

[[proxies]]
name = "48010"
type = "udp"
localIP = "127.0.0.1"
localPort = 48010
remotePort = 48010

pc端frp自动启动

windows系统开机自启比较麻烦,不像linux简单,所以为了保证windows后台运行 frpc,创建脚本 frpc.vbs,将以下内容粘贴进去:

set ws=WScript.CreateObject(“WScript.Shell”)
ws.Run “[frpc执行文件] -c [frpc配置]”,0

注意可能需要修改路径(默认路径是放C盘目录下)

将 frpc.vbs 放入 C:\ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp 目录内,即可实现开机自启动。

远程唤醒(Wake On LAN)pc

家里的电脑如果经常开机的话很费电,所以按需开机是最佳办法,那么就需要远程登陆开机。远程唤醒需要主板的支持,现在的主板基本都支持。

首先我们要进入到主板的 BIOS 设置选项里面把 WOL 功能打开,具体方法视厂商而定,可以参考的关键词包括:

  • Automatic Power On
  • Wake on LAN/WLAN
  • Power Management
  • Power On by Onboard LAN
  • Power On by PCI-E Devices

然后在我们被唤醒的电脑里面找到网卡设置:

img

img

然后我们可以在内网尝试一下,是否可以唤醒成功,在应用市场随便找个WOL软件,填上内网被唤醒机器的IP地址和MAC地址即可:

img

外网唤醒,我们需要一个中间设备来中转我们的流量,因为我们需要被唤醒的机器已经被休眠了,是无法接收到请求的,所以我这里内网用我的软路由进行转发:

首先我们要做的就是 DHCP固定住自己内网PC的内网IP,要不然无法转发唤醒,通常可以在路由器里面设置:

image-20240629175131874

然后我是通过 OpenWrt 来和我远端服务器建立好 frp通信,监听转发端口,到时候外面的请求会先到 OpenWrt ,然后由它再转发给我的内网PC:

image-20240629175315251

最后如果觉得麻烦,其实可以用远程物理按键解决,一劳永逸:

image-20240626145854217

隐私屏 / 作为副屏

在用 sunlight 串流的时候由于显示的是桌面的,,因为串流软件会捕捉屏幕上的内容并编码成视频流。如果关闭屏幕,编码器将无法获取到需要的画面信息,导致串流中断。

那么如果我们想要关闭屏幕串流,那么可以用这个工具 https://github.com/VergilGao/vddswitcher ,通过 vdd 创建一个虚拟屏幕可以实现即使主屏关闭也能串流。

最后

游戏串流最后不仅满足了我在外网想要随时随地玩游戏的想法,并且还拯救了我的腰椎,在家里玩游戏现在基本是用平板串流到我的电脑上面,然后买个支架夹着我的平板,然后躺着玩,但愿各位游戏佬都能找到属于自己的游戏环境。

image-20240626152310201

Reference

https://github.com/VergilGao/vddswitcher

https://github.com/LizardByte/Sunshine

https://github.com/moonlight-stream/moonlight-qt

https://keenjin.github.io/2021/04/p2p/

https://sspai.com/post/68037

https://bford.info/pub/net/p2pnat/

扫码_搜索联合传播样式-白色版 1

构建属于自己的云游戏服务器最先出现在luozhiyun`s Blog

深入 RocksDB 高性能的技术关键

作者 luozhiyun
2024年6月1日 18:03

本文从 RocksDB 基本架构入手介绍它是怎么运作的,以及从它的操作方式解释为什么这么快,然后探讨RocksDB 所遇到的性能挑战,各种放大问题是如何解决的,最后讨论一些新的 LSM 树优化方法,希望能对大家有所启发。

什么是RocksDB?

RocksDB 是一个高性能的 KV 数据库,它是由 Facebook 基于 Google 的 LevelDB 1.5构建的。RocksDB 被设计为特别适用于在闪存驱动(如 SSD)和 RAM 上运行,主要用于处理海量数据检索,以及需要高速存取的场景。 Facebook在 Messenger 上使用RocksDB,用户可以在其中体验快速消息发送和接收功能,同时确保其消息数据的持久性。

RocksDB 是一款内嵌式数据库使用 C++ 编写而成,因此除了支持 C 和 C++ 之外,还能通过 С binding 的形式嵌入到使用其他语言编写的应用中,如 https://github.com/linxGnu/grocksdb 。由于它是内嵌的数据库,所以它是没有独立进程的,它需要被集成进应用,和应用共享内存等资源,也没有跨进程通信的开销,也无法网络通信,也不是分布式的。

RocksDB 的设计目标是主要有以下几点:

  • 性能:高性能是RocksDB的主要设计点,它能提供快速存储和服务器工作负载的高性能,支持高效的点查找和范围扫描;
  • 生产支持:它内置了对工具和实用程序的支持,这些工具和实用程序有助于在生产环境中进行部署和调试;
  • 兼容性:此软件的较新版本应该向下兼容,以便现有应用程序在升级到较新版本的RocksDB时不需要更改;

基于以上这几点,如今很多分布式存储用它来做内部存储组建之一,如Apache Flink流处理框架中用作状态存储,它为维护流应用程序的状态提供快速高效的存储;TiDB 用它来构建存储引擎 TiKV,来支持大量的数据读写。

RocksDB architecture

RocksDB 基础的组件是MemTable、SSTable和 预写日志(WAL)日志。每当数据被写入RocksDB时,它会被添加到一个内存中的写缓冲区称为MemTable,同时也支持配置是否同步记录在磁盘上的预写日志(WAL)中,WAL主要用来做数据持久性和系统故障时的崩溃恢复使用,MemTable 默认是用跳表实现的,因此能保持数据有序,插入和搜索开销为 O(log n)。

img

MemTable 会根据配置的大小和数量来决定什么时候 flush 到磁盘上。一旦 MemTable 达到配置的大小,旧的 MemTable 和 WAL 都会变成不可变的状态,称为 immutable MemTable,在任何时间点,都只有一个活跃的 MemTable 和零个或多个 immutable MemTable。然后会重新分配新的 MemTable 和 WAL 用来写入数据,旧的 MemTable 会被 flush 到SSTable文件中称为L0层的数据。

被 flush 到磁盘上的 SSTable 会按照层级一层层存放,如上图从 L0 到 Ln,在每层中(级别0除外),数据被范围分区为多个 SSTable 文件。

这些 SSTables 都是是不可变的和有序的,每一层SSTable被组织成固定大小的块存放,每个SSTable都包含一个数据段和一个索引,可以通过二分查找快速查找数据,并且还可以通过布隆过滤器过滤无效数据,这种不变性、有序和索引结构的组合有助于RocksDB的整体性能和可靠性。

RocksDB是通过 LSM Tress 的方式通过将所有的数据添加修改操作转换为追加写方式,对于 insert 直接写入新的kv,对于 update 则写入修改后的kv,对于 delete 则写入一条 tombstone 标记删除的记录。

所以数据的查找会从 MemTable 内存数据开始,如果不存在,然后再从L0层级的 SSTable 开始找起,直到找到或者遍历完所有的 SSTable。SSTable 查找的时候会根据二分法加上布隆过滤器进行查找,过滤掉 key 不存在的 SSTable 文件,提升查询效率。

所以通过上面的简介可以知道对于 RocksDB 来说内部主要有以下几个结构:

  • MemTable:一个内存结构,所有的写入操作会先写入到这里,MemTable有好几种实现方式,默认使用跳表实现;
  • WAL日志:为了保证数据的持久性和一致性,用户写入的键值对首先被插入到WAL中。这确保了即使在发生故障时,也能从WAL中恢复数据;
  • SSTable(Sorted String Table):它是RocksDB中存储数据的基本单位,每个文件内部都是有序的。当MemTable写满之后会从磁盘flush到磁盘变成SSTable成为LSM树的 L0层级,当L0中的SSTable数量到达一定之后会出发 compaction 写入到下一层;

压缩(Compaction)

为什么会有Compaction

上面我们概述了一下 RocksDB 写入修改的过程是怎样的,数据首先会写入到 MemTable 中,当 MemTable 满了之后就会 flush 到磁盘中,称为 L0 级的 SSTable,L0 级的 SSTable 满了之后就会被 Compaction 到下一层级,也就是 L1 级中,以此类推。

如果没有 Compaction 行不行?直接把 L0 级的文件放入到 L1 中,这样不就省去了磁盘 IO 的开销,不需要重写数据,但是答案当然是不行。

因为 LSM Tree 通过将所有的数据修改操作转换为追加写方式,insert会写入一条新的数据,update会写入一条修改过的数据,delete会写入一条tombstone标记的数据,因此读取数据时如果内存中没有的话,需要从L0层开始进行查找 SSTable 文件,如果数据重复的很多的话,就会造成读放大。因此通过 Compaction 操作将数据下层进行合并、清理已标记删除的数据降低放大因子(Amplification factors)的影响。

一般我们说放大因子包括一下几种:

空间放大(Space amplification) :指的是需要使用的空间和实际数据量的大小的比值,如果您将10MB放入数据库,而它在磁盘上使用100MB,则空间放大为10。

读放大(Read amplification) :指的是每个查询的磁盘读取次数。如果每次查询需要读取5页来查询,则读取放大为5。

写入放大(Write amplification):指的是写入磁盘的数据与写入数据库的字节数的比值。比如正在向数据库写入10 MB/s,但是观察到30 MB/s的磁盘写入速率,您的写入放大率为3。如果写入放大率很高,高工作负载可能会在磁盘吞吐量上遇到瓶颈。如果写入放大为50,最大磁盘吞吐量为500 MB/s,那么只能维持 10 MB/s 的写入速度。

虽然 Compaction 可以降低放大因子的影响,但是不同的 Compaction 策略是对不同放大因子有侧重点,需要在三者之间权衡,后面我们会聊到。

什么是 Compaction

RocksDB的 Compaction 包含两方面:一是MemTable写满后flush到磁盘;二是从L0 层开始往下层合并数据。

最顶层的 L0 层级的 SSTable 是通过 MemTable 生成的,RocksDB的所有写入都首先插入到一个名为 MemTable 的内存数据结构中,一旦 MemTable 达到配置的大小,旧的 MemTable 和 WAL 都会变成不可变的状态,称为 immutable MemTable,然后会重新分配新的 MemTable 和 WAL 用来写入数据,旧的 MemTable 会被写入到SSTable文件中。

在任何时间点,都只有一个活跃的 MemTable 和零个或多个 immutable MemTable。 因为 MemTable 是有序的,所以 SSTable 文件也是有序的,所以 SSTable 都有自己的索引文件,通过二分查找来索引数据。

除L0层级的 SSTable 都是后台进程Compaction操作产生的。所以 Compaction 实际上就是一个归并排序的过程,将Ln层写入Ln+1层,过滤掉已经delete的数据,实现数据物理删除。

所以 Compaction 之后可以降低低放大因子的影响,使数据更紧凑,查找速度更快,但是因为会有一个 merge 过程,所以会造成写放大。

Compaction策略

现在主要的compaction策略就两种:Size-Tiered Compaction 和 Leveled Compaction,Leveled Compaction 是 RocksDB 中的默认 Compaction 策略。

Size-Tiered Compaction 策略

Size-Tiered Compaction 策略的做法相当简单。当新的数据写入系统时,首先被写入到内存中的一个结构 MemTable 中,一旦 MemTable 达到一定大小,MemTable会定期刷新到新的SSTable。

系统会监视 SSTable 的大小并将大小相似的 SSTables 分组。当一组中 SSTable 的数量达到预设的阈值(如 Cassandra 默认是 4),系统就会将这些 SSTable 合并成一个更大的 SSTable。在合并过程中,相同键的数据行会被合并,最新的更新会覆盖旧的数据。

如下4个小SSTable会合并成一个中等的SSTable,当我们收集到足够多的中等SSTable文件时,再将它们压缩成一个大SSTable文件,以此类推,压缩后的SSTable 越来越大。

Frame 4

Size-Tiered Compaction 的优点是简单且易于实现,并且SST数目少,定位到文件的速度快。缺点是空间放大比较严重。

Size-Tiered Compaction的空间放大

空间放大指的是需要使用的空间和实际数据量的大小的比值,Size-Tiered Compaction造成空间放大主要有这几个原因:

  • 数据重复存储。新的数据写入会导致创建新的SSTable,这意味着更新或删除的数据可以同时存在于多个SSTable中,直到发生Compaction操作,将多个SSTable合并成一个更大的文件。并且SSTable越大,Compaction操作越难触发,因为需要集齐多个同样大小的SSTable文件,这样导致数据保存了多份;
  • 临时空间需求。在Compaction操作中,删除较小、较旧的SSTable之前需要创建一个新的、较大的SSTable,这需要额外的磁盘空间,通常高达原始数据大小的50%以容纳新的SSTable和现有的SSTable,直到压缩完成并且可以安全删除旧的SSTable;
  • 高磁盘空间预留。由于临时空间需求,所以需要有一部分磁盘空闲(通常约为50%),以确保有足够的空间用于Compaction操作。这一要求有效地使数据库所需的磁盘空间翻倍,因为并非所有预留空间都被积极用于存储有用数据。

可以使用 cassandra 的极端的例子,在这个例子中,400万数据连续写入15次,写完之后将所有数据Compaction到一个文件中,显示磁盘使用与时间的图表现在如下所示:

img

由于最后进行了Compaction操作,所以在这张图中,我们可以看到我们数据库中真正拥有的数据量是1.2 GB。但是磁盘使用量的峰值是9.3 GB,并且在运行的大部分时间里,空间放大都高于3倍。

Leveled Compaction策略

Leveled Compaction 的思路是将原本 Size-Tiered Compaction 中原本的大 SSTable 文件拆开,成为多个key互不相交的小SSTable的序列。L0层是从 MemTable flush过来的新 SSTable,该层各个 SSTable 的key是可以相交的,并且其数量由配置控制,除L0外都是不相交的 SSTable。

Frame 6

其他层级中的每一个,L1、L2、L3等,每一层都有最大的大小,超过了层级的限制的最大大小会倍compaction 到下一层中,每一层的最大的大小通常呈指数增长。

Frame 7

在 RocksDB 中,当L0文件数达到 level0_file_num_compaction_trigger 时触发 compaction,会将所有 L0 的文件合并入 L1 中。

Frame 8

在L0 compaction 之后,可能会使 L1 超过其规定的大小,在这种情况下,我们将从L1中选择至少一个文件并将其与L2的重叠范围合并。结果文件将放置在L2中:

Frame 9

如果下一级的大小继续超过目标,那么会像以前一样执行操作,挑选一个文件进行合并。

所以由 Leveled Compaction 的 compaction 规则可以看出,它通过两种方式来解决空间放大的问题:

  1. Leveled Compaction 把文件都拆小了,所以在进行压缩的时候不需要这么大的临时空间;
  2. Leveled Compaction 除 L0 以外的每一层级数据都是互不相交的小SSTable的序列,数据上没有重叠,即使层与层之间有数据重叠,空间放大也是比较小的,这点我们可以算一下。例如,如果最后一级是L3,它有1000个SSTable。在这种情况下,L2和L1总共只有110个SSTable,那么L3 占全部的SSTable 90%,即使L1和L2都和 L3 重复,那么也就最多可以有1.11倍(=1/0.9)的空间放大。

img

同样1.2 GB数据集被一遍又一遍地写入15次。通过上图我们可以看到Leveled Compaction需要的空间要小的多,空间放大实际上达到了预期的1.1-2。

Leveled Compaction 虽然没有空间放大问题,但是随之而来的是写入放大的问题

Leveled Compaction的写入放大

写入放大指的是写入磁盘的数据与写入数据库的字节数的比值。在写入数据的时候 RocksDB 会有多次写磁盘的操作,如下图所示显示的是 Size-Tiered Compaction 策略写入放大情况,每个字节的数据都必须写入4次,有多少层就会写入多少次,还会写入一次 WAL log,至少4的写入放大。

Frame 10

但是 Leveled Compaction 是需要挑选上一层的一个 SSTable 然后找到下一层的重叠的SSTable进行合并写入,Ln层SST在合并到Ln+1层时是一对多的,如果下一层是上一层的十倍,那么在选择一个大小为X的sstable进行压缩的时候,它在下一个更高级别中会找到与此sstable重叠的大约10个sstable,并将它们与一个输入sstable进行压缩,它将大小约为11*X的结果写入下一个级别。所以在最坏的情况下, Leveled Compaction 可能比Size-Tiered Compaction 多写11倍。

写入放大最大最大值我们也可以很简单计算出来。首先假设每一层的级别乘数为10,L1 大小为 512MB,数据大小为 500GB,那么L2大小为5GB,L3为51GB,L4为512GB,因为数据大小为 500GB,所以更高级别将为空。

那么我们可以简单的算出空间放大为(512 MB + 512 MB + 5GB + 51GB + 512GB) / (500GB) = 1.14

计算写放大的时候可以从顶层开始写入。每个字节写到L0,然后将其压缩到L1,由于L1大小与L0相同,因此L0->L1压缩的写放大为2。然而,当来自L1的字节被压缩到L2时,它将被压缩为来自L2的10个字节(因为2级大10倍)。L2->L3和L3->L4压缩也是如此。因此,总写入放大大约为 1 + 2 + 10 + 10 + 10 = 33

写放大会带来两个风险:一是更多的磁盘带宽耗费在了无意义的写操作上,会影响读操作的效率;二是对于闪存存储(SSD),会造成存储介质的寿命更快消耗,因为闪存颗粒的擦写次数是有限制的。

RocksDB 的优化目标与 Dynamic Leveled Compaction

RocksDB 的优化目标最初是减少写放大,之后过渡到减少空间放大。在 RocksDB 上 Leveled Compaction 压缩方式的写放大通常在 10~ 30之间,在许多情况下,与 MySQL 中使用的 InnoDB 引擎相比,RocksDB 的写数量仅为其的 5% 左右。但是这个量级的写放大在频繁写的应用场景下面还是太大了,所以 RocksDB 引入了 Tiered Compaction 压缩方式,它的写放大只有 4–10 。

Frame 2

在经过若干年开发后,RocksDB 的开发者们观察到对于绝大多数应用来说,空间使用率比写放大要重要得多,此时 SSD 的寿命和写入开销都不是系统的瓶颈所在。实际上由于 SSD 的性能越来越好,基本没有应用能用满本地 SSD,因此 RocksDB 开发者们将其优化重心迁移到了提高磁盘空间使用率上。

RocksDB 开发者们引入了 Dynamic Leveled Compaction 策略,此策略下,每一层的大小是根据最后一层的大小来动态调整的。

我们来看个例子,如果不是动态调整,我们假设我们设置了 RocksDB 有4个层级,它们的大小是1GB、10GB、100GB、1000GB,如果数据都放满的话,那么空间放大将会是 (1000GB+100GB+10GB+1GB)/1000GB=1.111 ,可以看到空间放大非常小。但是实际中,是很难恰好让最后一级的实际大小是1000GB,如果在生产中,数据只有200GB,那么空间放大将是(200GB+100GB+10GB+1GB)/200GB=1.555

Frame 11

所以动态级别大小目标是根据最后一级的大小动态改变的。假设级别大小乘数为10,DB大小为200GB。最后一级的目标大小自动设置为级别的实际大小,即200GB,倒数第二个级别的大小目标将自动设置为size_last_level/10=20GB,倒数第三个级别的size_last_level/100=2GB,倒数第四个级别是 200MB。这样,我们就可以实现1.111的空间放大,而不需要对级别大小目标进行微调。

此策略的效果如下所示,Dynamic Leveled Compaction 策略将空间开销限制在13%,而Leveled Compaction策略在空间开销上可以超过 25%,在Facebook实际应用中,使用RocksDB替换InnoDB作为UDB数据库的引擎,可以使空间占用减少50%。

memory5

所以 RocksDB 应用程序的所有者应该选择合适的 compation 方式,来实现压缩率、写入放大、读取性能之间的平衡。

那么究竟如何降低写入放大?!

通过上面的分析,我们知道在 RocksDB 中的 compation 策略总有一定的问题,Size-Tiered Compaction 会增加空间放大,因为 SSD 成本比较高,后面 RocksDB 转向减少空间放大使用 Leveled Compaction 以及后面推出的微调版本 Dynamic Leveled Compaction 。

但是貌似写入放大的问题并 RocksDB 没有解决,因为我们知道 SSD 的擦写次数是有限制的,如果频繁的擦写也会减少 SSD 的寿命,增加 SSD 的使用。那么在业界,是如何解决 LSM 树写入放大问题的呢?主要有两种方式:

  • Key-Value分离,如 WiscKey在 LSMs 结构中存储 Key 和 一个指向相应 value 位置的指针,而Value 存在另外一个结构中,降低 LSM 树大小;
  • 优化LSM树结构,降低compaction次数,如PebblesDB借鉴skiplist思路,通过层级的结构性优化减少不必要的数据overlap,从而在整体上减少数据参与compaction次数。

下面我们就WiscKey和PebblesDB仔细聊聊他们是怎么做的。

WiscKey

FAST16,WiscKey: Separating Keys from Values in SSD-Conscious Storage

WiscKey 主要思想就是将key 和 value 剥离,在LSM树中只保留key值,用一个指针指向 value 的位置。因为一般情况下,Key比 Value小的多,所以这样做可以缩小 LSM 树的大小,降低写入放大。举个例子,假设现在 key 大小 1B,Value 大小 1KB,在LSM 树中按照10倍的写入放大来说,那么根据公式,实际的写入放大应该是:

写入放大 = 写入数据的大小/数据实际的大小 = (10*16+1024)/(16+1024) = 1.14

不过这样做的弊端是查找的时候比传统LSM树查找多一次IO,找到 key 之后需要再取 value。但是相对来说更小的LSM树也会有更好的查找性能,在LSM树中,查找可能会搜索更少层级的SSTables文件,并且由于LSM树很小,所以它的大部分内容可以很容易地缓存在内存中,所以在缓存住 key 的情况下,只有一次随机IO查找 value 的开销,大多数情况还是比 LevelDB要快的。

image-20240516173122366

WiscKey 的架构其实很简单,像上面的图一样,把 key 和 value 分开存储,value 这部分的文件叫做 value-log file 简称 vLog。

在插入数据的时候会将 value 数据 append 到 vLog 里面,然后写入一条key数据到 LSM 树里面,key 数据里面还包含了 value 的偏移和大小,类似这样 (<vLog-offset, value-size>)。在删除的时候还是只和LevelDB一样,写入一条标记了删除的记录到 LSM 树,vLog 里面的 value 数据随后会由一个单独的线程进行垃圾收集。

image-20240517151807591

为了能够实现更轻量化的 vLog 垃圾收集,vLog 不单只保存了 value 还保存了 key 值,保存的格式如上图为 (key size, value size, key, value)。在 vLog 中还用了 tail 和 head 表示头和尾,新的数据都从 head append 进入到文件里面。

在垃圾回收的时候会直接从 tail 读取一批数据,然后通过查询 LSM 树找出其中哪些值是有效的,将有效的数据 append 进 vLog 的 head,然后垃圾收集器会将这些被重新 append 的数据新的地址值 append 进入 LSM树,并更新 tail 位置的地址。

PebblesDB

SOSP17,PebblesDB: Building Key-Value Stores using Fragmented Log-Structured Merge Trees

PebblesDB 主要使用了一种新的数据结构 Fragmented Log-Structured Merge Trees (FLSM),它借鉴了 skiplist + lsm 的结构。通过这种结构,PebblesDB 在论文中提到了,它的写的的吞吐量是 RocksDB的 6.7倍,读的吞吐量比 RocksDB 高 27%,写入IO比RocksDB减少了2.4-3倍。

在 skiplist 中,其实是通过类似下面这样建立多层级的索引,上面的层级索引的节点实际上代表的一个范围内的数据,并且每一层的索引都是有序的列表,我们可以通过下图简明的看一下怎么查找 u 节点。

Frame 12

FLSM 就借用了上面索引的概念,定义了 Guards 结构来组织数据。Guards 就是 skiplist 里面的索引的概念,它会在插入数据的时候随机选择插入的key作为 Guard,L0 没有 Guard。

Guards 数量会随着层级的增加而增加,并且上层被的 Guard 也会带入到下一层,如下图 L1 中 5 被作为 Guard,那么 L2 和 L3 中它依然是 Guard。Guards 里面由一个个 SSTable组成,在同一层之间 Guards 是有序排列的,没有重叠,但是在单个 Guard 里面的 SSTable 是有可能重叠的。

Guard 和 skiplist 索引里面的作用一样,用来限定数据范围,如下图L1中,SSTable key 值超过 5 的都被放入到 Guard5 中,小于 5 的 SSTable 被放入到 Sentinel 中存放。L2 中 key 超过 375 的值都被放入到 Guard375 中,依次类推。所以 L1 Guard5 代表的其实是 [5,∞), L2 Guard5 代表 [5,375),Guard375代表 [375,∞)。这是一个左开右闭的合集。

image-20240514150432978

在大多数情况下,FLSM 的 compaction 不会重写 SSTables。在 PebblesDB 中,数据是通过 Guards 来组织的,这些 Guards 用于指示给定键范围在某一层级上的位置。每个 Guard 可以包含多个重叠的 SSTables。当一个层级上的 Guards 数量达到一个预设的阈值时,这些 Guards 和相应的键会被移动到下一个层级,这一过程通常不需要重写 SSTables,这是 FLSM 减少写入放大的主要方法。

PebblesDB 相比其他几个引擎,写放大是有显著的优势。下图显示了在插入或更新5亿个键值对(总计45 GB)时,不同键值存储引擎的总写入IO量(以GB为单位)。

Frame 3

总结

我们从 RocksDB 的 LSM Tree结构入手解释了 RocksDB 通过将所有的数据修改操作转化为追加写方式从而提高了数据操作的性能,解释了为什么这种结构支持高效的数据读写操作,然后说明了这种结构所引发写放大、读放大和空间放大等问题,以及对于 RocksDB 是如何通过 Compaction 策略去解决相应的放大问题。

此外,文章还探讨了如 WiscKey 和 PebblesDB 等新的 LSM 树优化方法,这些方法通过结构和操作的改进,旨在降低写放大,从而提高数据库的整体效率和性能。

Refercence

《Evolution of Development Priorities in Key-value Stores Serving Large-scale Applications: The RocksDB Experience》

《PebblesDB: Building Key-Value Stores using Fragmented Log-Structured Merge Trees》

《WiscKey: Separating Keys from Values in SSD-Conscious Storage》

https://artem.krylysov.com/blog/2023/04/19/how-rocksdb-works/

https://github.com/facebook/rocksdb/wiki/RocksDB-Tuning-Guide

https://bu-disc.github.io/CS591A1-Spring2020/slides_projects/RocksDB-Exploring-Compaction-Algorithms.pdf

https://github.com/facebook/rocksdb/wiki/Leveled-Compaction

https://github.com/facebook/rocksdb/wiki/Compaction

https://tidb.net/blog/eedf77ff#2%20%E4%B8%BA%E4%BB%80%E4%B9%88%E9%9C%80%E8%A6%81%20compaction%20?

https://engineering.fb.com/2018/06/26/core-infra/migrating-messenger-storage-to-optimize-performance/

https://github.com/facebook/rocksdb/wiki/RocksDB-Overview

https://www.scylladb.com/2018/01/17/compaction-series-space-amplification/

https://www.scylladb.com/2018/01/31/compaction-series-leveled-compaction/

https://rocksdb.org/blog/2015/07/23/dynamic-level.html

https://zhuanlan.zhihu.com/p/490963897

扫码_搜索联合传播样式-白色版 1

深入 RocksDB 高性能的技术关键最先出现在luozhiyun`s Blog

作为开发需要了解 SSD 的一切

作者 luozhiyun
2024年2月25日 20:42

这篇文章主要来探讨一下SSD相关的问题,以及我们在写代码的时候如何更高效的利用好 SSD 的特性。

SSD 基本介绍

其实现在 SSD 已经很普及了,SSD 被称之为固态硬盘是相对于普通的机械硬盘 HDD 而言,因为它没有机械结构。普通的机械硬盘 HDD 一般都需要将执行器臂将读写头定位在驱动器的正确区域上以读取或写入信息。

A diagram of a hard drive

因为驱动器磁头必须在磁盘的某个区域对齐才能读取或写入数据,并且磁盘不断旋转,所以在访问数据之前会有延迟。HDD 的延迟通常以毫秒为单位,硬盘驱动器通常需要10-15ms才能在驱动器上找到数据并开始读取,即使是现在 15000 转的 HDD 延迟最低也要 4ms,IOPS(每秒的输入输出量) 在 200 左右,顺序读写速度在300MB/s 左右 。

反观我们看现在 SSD,以三星的 990 PRO为例,读写速度最高达到了 7000+ MB/s ,IOPS 高达 1,400K,延迟在 41us 左右,这么一对比,大家可以知道其实 SSD 比 HDD 快了好几个数量级。

SSD 内部结构

SSD 的内部结构一般是由三部分构成:1. Controller 控制器 ;2.DRAM缓存;3.NAND闪存;

image-20240207100920222 1

控制器是用来控制 SSD 的所有操作的,从实际读取和写入数据到执行垃圾回收和耗损均衡算法等,以保证SSD 的速度及整洁度;

DRAM缓存不是所有的硬盘都有,对于我们上面提到的 990 PRO 来说会有 2GB 左右的缓存,主要是用来存放的是逻辑物理映射表,控制器会通过缓存的映射表来查找数据使用。还承载了一部分待写入的数据,等够一页的时候一次性写入到NAND颗粒里面,用来缓解写入放大;

顺带提一下无缓的固态硬盘也不用过于担心性能问题,一般来说会使用 HMB 技术通过PCIe通道找内存借用一部分内存空间来存储映射表,在每次开机的时候往内存写入部分常用的FTL表,这个空间大小通常是64MB。像我们国产的致钛TiPlus7100就是采用的无缓+HMB机制,其速度也不差。

NAND 闪存是最终用来存放数据的地方,它不仅决定了SSD的使用寿命,而且对SSD的性能影响也非常大。比如大家所熟知的 SLC, MLC, TLC 闪存。NAND本身由所谓的浮栅晶体管组成,它是一种非易失性存储器,即使不通电也能保持状态,NAND 由一个个浮栅晶体管堆叠而成。根据每个浮栅晶体管可以保存1bit, 2bit, 3bit数据量可以分为SLC, MLC, TLC。

Frame 3

1个Page 一般是4KB 或者 8 KB ,上面我们提到的 990 PRO 是 16KB, 不同 SSD 大小也不同。因为浮栅晶体管被分组并以非常特定的属性访问,所以我们只能按页来进行数据的读写,如下简化图中红色的每一行都代表一个 Page ,写数据的时候会给行列两端加上不同的电压,加电压只能加到行和列上,所以没法控制到单个浮栅晶体管;

image-20240207163929198

Block 是擦除的基本单位,每个 Block 会包含 128 到 256 个上面这样的 Page。如下图由于一块 Block 上面的所有浮栅晶体管其实是共用一个衬底,只要给这个衬底施加高压,浮栅晶体管里面存储的数据就会被清空,所以 Block 是擦除数据的基本单位。

image-20240207165403915

为啥 SSD寿命是有限的?

其实这取决于浮栅晶体管的结构,它的结构下图所示,我不打算概述它的原理,但是我们可以知道在浮栅晶体管中电子是存储在浮栅层的,读写数据的时候电子需要穿过隧穿层,由隧穿层来锁住电子,隧穿层穿越次数是有限制的,进出次数多了,就锁不住电子了,那么该 Block 就没法擦写数据了。

Frame 4

所以如果一直在同一个 Block 擦写数据,这个 Block 的寿命很快就会消耗完,为了有效的延迟 SSD 的使用寿命就很考验 Block 的管理技术了,后面我们可以看看现在的 SSD 有哪些管理抓手。

读、写、擦除

当首次向SSD写入数据时,因为数据都处于已擦除的状态,所以数据可以直接写入,至少一次性写入一个 Page。因为在写入的时候会按照 Page 为单位进行写入,所以如果要写入的数据小于一个 Page,那么其余写入超过必要的数据称为写入放大

只有空闲的Page才能被写入,并且是不能覆盖的,所以在修改数据的时候数据会被写入到一个空闲页面中,一旦被持久化到页面上,原来的旧页面会被标记成 stale 状态,表示它可以被擦除。

所以一个 Page 变成 stale 状态之后唯一能让它们再次使用的方式就是擦除它们,但是擦除的维度我们上面也讲过了,是以 Block 为维度进行擦除的,并且擦除命令不由用户控制,擦除命令是由SSD控制器中的垃圾回收机制进程在需要回收陈旧页面以腾出空闲空间时自动触发的。

除了上面提到的写入放大以外,如果在写入数据的时候不是以 Page 为单位进行写入,会导致Page 被修改并写回驱动器之前被读入缓存,这种操作被称为读-修改-写,比直接操作页面要慢。

所以我们写入数据的时候应该要对齐写入,也就是按 Page 整倍大小写入。并且为了最大限度地提高吞吐量,尽可能将小写入保留到 RAM 中的缓冲区中,当缓冲区已满时,执行一次大写入以批处理所有小写入。

垃圾回收

数据以 Page 为单位写入到存储中。然而,存储器只能以较大的单位 Block 擦除。如果不再需要一个Block 中某些Page内的数据,仅会读取该块中含有有效数据的Page,并重新写入到另一个先前擦除的空 Block 中,然后将原来的 Block 进行擦除,擦除之后的 Block 又可以重新使用,这个过程叫做垃圾回收。

所有的SSD都包含不同程度的垃圾回收机制,但在执行的频率和速度上有所不同,垃圾回收占了SSD上写入放大的很大一部分。

ssd-writing-data

比如上面这个例子中,在 Block 1000 中写入 x,y,z 三个数据,然后想要修改 PPN 为 0 的这块数据时由于不能覆盖,所以只能在 PPN 为 3 的 Page 上面写入 x` 。当后台的垃圾回收进程启动的时候会将有效的数据拷入到另一个空闲的 BLock 中,然后将原来的 Block 1000 中的数据擦除。

磨损平衡

SSD 擦除写入的次数也叫做 P/E 周期( program/erase cycle),由于 P/E 周期是有限的,所以假设SSD其中数据总是从同一个精确的块中读取和写入,那么这块 Block 很快就达到了使用寿命的上限,SSD控制器会将其标记为不可用。然后磁盘的整体容量会下降。

所以实现磨损均衡(wear leveling)是控制器的主要目标之一,即在块之间尽可能均匀地分配P/E周期,理想情况下,所有块都将达到其P/E周期限制并同时磨损。

我这里用长江存储致钛的图来解释一下,上面三张是没有磨损均衡的,那么反复写就会出现坏页,下面三张是有磨损均衡,那么控制器会根据不同的算法挑选出擦写次数较低的 Page 进行写入。

image 3

当然我们也知道,理论和现实总是有区别的,具体实现起来还是很难的。因为想要磨损均衡,那必须要写入的时候将一些Block的数据进行移动,这一过程本身会导致写入放大的增加。因此,块管理是最大化磨损均衡和最小化写入放大之间的权衡,也就是它是一个 trade-off 的艺术。

很蓝的啦是什么梗

磨损均衡分为静态磨损均衡与动态磨损均衡。动态磨损平衡是指当需要更改某个Page中的数据时,将新的数据写入擦除次数较少的物理页上,同时将原页标为无效页,动态磨损平衡算法的缺点在于,如果刚刚写入的数据很快又被更新,那么,刚刚更新过的数据块很快又变成无效页,如果频繁更新,无疑会让保存冷数据的Block极少得到擦除,对闪存整体寿命产生不利影响。早期的SSD主控多用动态磨损平衡算法。

静态磨损均衡是在每次写入时从空白Page中挑选擦写次数最少的进行写入,并且为了防止冷数据的Block极少得到擦除,静态磨损均衡会在条件具备的情况下搬走长期占用Block的不变数据,将其释放出来用于新数据写入,从而避免过度消耗其他闪存单元的寿命。

预留空间

预留空间 Over-provisioning,也叫OP,指的是 SSD 一般会保留一部分的空间对用户不可见,大多数的厂商都会预留7%到25%左右的空间,额外的预留空间有助于降低控制器写入闪存时的写入放大。用户可以通过将磁盘分区为低于其最大物理容量的逻辑容量来创建更多的过度配置。例如,可以在100 GB驱动器中创建一个90 GB分区,并将剩余的10 GB预留不使用。

比如下面的这种图中, 是我找到金士顿官网的预留空间图:

image-20240208170543909

预留空间主要是给 SSD 控制器使用,一方面是用来做是用来做磨损平衡,替换可见空间中被磨损的 Block,另一方面是用来提升性能使用;

因为上面也提到了,SSD 的写入是无法覆盖的,只能在空白页面上写入,所以在这个过程中预留空间足够大则从空白的页中进行写入,可以减少擦除次数也能够减少有效数据的再次写入,降低 Block 的磨损,从而提升硬盘的耐用性。

预留空间在读写工作负载很重的时候可以充当NAND闪存块的缓冲区,帮助垃圾回收机制处理吸收写入高峰。因为垃圾回收擦除数据比写入要花费更多的时间,垃圾回收一般在空闲的时候做,如果在负载很重的时候垃圾回收来不及擦除,那么可能会和写入操作同时进行,而预留空间可以作为缓冲区给垃圾回收机制留出足够的时间来赶上并再次擦除块。

另一方面预留空间也可以减少垃圾回收次数,空白页面多了自然就不用频繁的做垃圾回收了,这同时也减少了写入放大。

Trim

简单来说,TRIM主要是优化固态硬盘,解决SSD使用后的降速与寿命的问题。

原本在机械硬盘上,写入数据时,Windows会通知硬盘先将以前的擦除,再将新的数据写入到磁盘中。而在删除数据时,Windows只会在此处做个标记,说明这里应该是没有东西了,等到真正要写入数据时再来真正删除,由于这个标记只是在操作系统层面,SSD 并不知道哪些数据被删了,所以我们日常的文件删除,都只是一个操作系统层面的逻辑删除。这也是为什么,很多时候我们不小心删除了对应的文件,我们可以通过各种恢复软件,把数据找回来。

SSD 只有当操作系统在同一个被删了的 Block 里面写入数据的时候才知道这个数据已经被删了,那么才会把它标记成废弃,等待垃圾回收。。这就导致,我们为了磨损均衡,很多时候在都在搬运很多已经删除了的数据。这就会产生很多不必要的数据读写和擦除,既消耗了 SSD 的性能,也缩短了 SSD 的使用寿命。

Frame 5

比如上面这个图中,我们在系统层面删除了程序3,但是在SSD 的逻辑块层面,其实并不知道这个事情。这个时候,如果我们需要对 SSD 进行垃圾回收操作,程序3占用的物理页仍然要在这个过程中,被搬运到其他的 Block 里面去。只有当操作系统在里面重新写入数据的时候SSD才会把它标记成废弃掉。

TRIM指令让操作系统可以告诉固态驱动器哪些数据块是不会再使用的;否则SSD控制器不知道可以回收这些闲置数据块。TRIM的简约性将极大减少写入负担,同时允许SSD更好地在后台预删除闲置的数据块,以便让这些数据块可以更快地预备新的写入。

TRIM命令只有在SSD控制器、操作系统和文件系统支持它的情况下才有效。

并发性

来看并发性之前,先来看看 SSD 具体的层级结构,SSD 使用层级的方式管理NAND Flash。Controller会使用多Channel的方式与NAND Flash Chip连接。每个Channel可以被视为一条数据传输路线,多个通道可以让控制器同时读写更多的数据,提高数据传输的速度和效率。每个NAND Flash Chip内部封装(Packaging)了多个Die,每个Die上排列了多个Plane,每个Plane中包含多个Block,每个Block中有多个Page。

Frame 6

  • Channel级别:Controller可以同时使用多个Channel在不同的NAND Flash Chip上执行不同的操作
  • Chip级别:连接在相同Channel上的不同的不同Chip,也可以使用流水线(Interleaving)的方式同时执行不同的命令
  • Die级别:一个Chip封装多个Die,Chip可以把不同的命令发送到多个Die上并行执行
  • Plane级别:一个Die包含多个Plane,相同的命令(读、写、擦除)可以在一个Die的多个Plane上同时执行

像我们上面提到的 990 Pro 这款 SSD,有 8条 Channel,每条Channel可以传输2,000 MT/s,MT/s代表每秒百万次传输(MegaTransfers per second)。每个 Chip 里面有 16 个 Die,每个 Die 有 4 个 Plane。

写的并发性

所以通过上面信息可以知道,SSD 是可以并行写入多个 Block 的,所以利用 SSD 多级并行机制,可以被并行访问的最大数量的 Block 集合称为一组 Cluster Blocks,它的可以从几十MB到几百MB,具体取决于控制器策略、SSD 容量、NAND 闪存类型等,不过我查过了,一般厂商没有给出这个具体的大小。

利用Cluster Blocks的并行性能,SSD可以把一次大块写打散到多个Cluster Blocks上并行执行,有效降低时延,提高吞吐量。

如果写操作每次写入的数据量小于 Cluster Blocks 大小时则顺序写的吞吐量显著高于随机写,所以如果随机写入既是Cluster Blocks 的倍数,那么它们的性能与顺序写入一样好。两者的区别主要体现在以下两个方面:

  • 小块写入会导致更多的映射表更新,而顺序写可以合并映射表的更新;
  • 小块随机写更容易造成某个Block中少量的Page被设置为无效,需要进行擦除,加剧垃圾回收过程的写放大;

最后,关于并发,其实用一个线程写入一大块数据和用许多并发线程写入许多较小块数据一样快。事实上,一个大写入保证了SSD的所有内部并行性都被使用。因此,尝试并行执行多个写入不会提高吞吐量。然而,与单线程访问相比,许多并行写入会导致延迟增加。所以一次大写入比多次小并发写入要好。

再来就是当写入很小并且无法合并的时候,多线程是有益的,许多并发的小写请求将提供比单个小写请求更好的吞吐量。所以如果I/O很小并且无法批处理,最好使用多个线程。

总结就是这样:

大块写 小块写
单线程 最快 最慢
多线程并发 无助于吞吐量,提高时延 有助于提高多级并发机制的利用,提高吞吐量

读的并发性

同样的为了提高读取性能,最好将相关数据一起写入。因为读取性能是写入模式的结果。当一次写入一大块数据时,它会分布在单独的NAND闪存芯片上。因此,您应该在同一页、块或集群块中写入相关数据,这样以后就可以利用内部并行性,通过单个I/O请求更快地读取这些数据。

Frame 7

因为写入的时候SSD会尽量的把所写的Page打散到多个Clustered Block上进行并行,那么读操作如果同时读取这些Page将会获得最多的并行。相反如果读操作总是读取不同写操作写入的Page,那么这些Page很可能处于相同的Plane上,必须串行读取。如上图所示。

对于多线程并发小块数据读取来说,性能是不如单线程大块数据读取的,因为这不能用到预读机制,这种机制通过预先读取可能接下来会被请求的数据到缓存中,减少了数据访问的等待时间,从而提升了读取性能。

在编程时如何更好的利用SSD

更好的利用SSD可以获得一些免费的好处,如:提升应用程序性能、延长SSD的寿命、提高SSD的 IO 效率等等。

区分冷热数据

假设我们将冷热数据混合排布在同一个区块,对于 SSD 来说,如果要修改其中的一小块内容(小于 1 页),SSD 仍然会读取整页的数据,这样会导致写入放大。所以对于一些改动非常频繁的热数据应该尽可能的 cache 住在内存里,然后批量的进行更新,这样不仅更快,而且还能提升SSD寿命。

采用紧凑的数据结构

这其实也和 SSD 的结构相关,更紧凑的数据结构可以尽量让数据都聚拢在同一个 Block 中,减少 SSD 的读取操作,同时也能更好的利用缓存。同样的,由于SSD 写入方式的特殊性,紧凑数据结构将关联数据放置到相邻区域,减少可能的垃圾回收的同时,还能够降低写入放大带来的问题。

写的数据最好是页大小的倍数

避免写入小于NAND闪存页面大小的数据块,以最大限度地减少写入放大并防止读取-修改-写入操作。目前页面的最大大小为16 KB,因此默认情况下应使用该值,此大小取决于SSD型号。

当数据不足一页时为了最大限度地提高吞吐量,尽可能将小写入保留到RAM中的缓冲区中,当缓冲区已满时,执行一次大写入以批处理所有小写入。

将相关数据一起写入

读取性能是写入模式的结果。当一次写入一大块数据时,它会分布在单独的NAND闪存芯片上。因此,您应该在同一 Page、Block或 clustered block 中写入相关数据,这样以后就可以利用内部并行性,通过单个I/O请求更快地读取这些数据。

尽量避免读写混合

由小交错读写混合组成的工作负载将阻止内部缓存和预读机制正常工作,并将导致吞吐量下降。最好避免同时读取和写入,并在大块中一个接一个地执行它们,最好是clustered block的大小。例如,如果必须更新1000个文件,您可以迭代文件,对文件进行读写,然后移动到下一个文件,但那会很慢。最好一次读取所有1000个文件,然后一次写回这1000个文件。

不要总以为随机写入比顺序写入慢

如果写入很小(即低于clustered block的大小),则随机写入比顺序写入慢。如果写入既是clustered block的倍数,又与clustered block的大小对齐,随机写入将使用所有可用的内部并行级别,并且执行与顺序写入一样好。对于大多数驱动器,集群块的大小为16 MB或32 MB,因此使用32 MB是安全的。

大型单线程读取优于许多小型并发读取

上面我们也提到了,并发随机读取不能充分利用预读机制。此外,多个逻辑块地址可能最终在同一芯片上,没有利用或内部并行性。大型读取操作将访问顺序地址,因此能够使用预读缓冲区(如果存在)并使用内部并行性。因此,如果用例允许,最好发出大型读取请求。

大型单线程写入优于许多小型并发写入

大型单线程写入请求提供与许多小型并发写入相同的吞吐量,但是就延迟而言,大型单线程写入比并发写入具有更好的响应时间。因此,只要有可能,最好执行单线程大型写入。

当写入量很小并且无法分组或缓冲时,才使用多线程写

许多并发的小写请求将提供比单个小写请求更好的吞吐量。所以如果I/O很小并且无法批处理,最好使用多个线程。

对于读写负载很高的工作,应该配置更大的预留空间

预留空间有助于磨损均衡机制应对NAND闪存单元固有的有限生命周期。对于写入不那么重的工作负载,10%到15%的过度配置就足够了。对于持续随机写入的工作负载,保持高达25%的过度配置将提高性能。过度配置将充当NAND闪存块的缓冲区,帮助垃圾回收机制处理吸收写入高峰。

Reference

https://codecapsule.com/2014/02/12/coding-for-ssds-part-1-introduction-and-table-of-contents/

https://arstechnica.com/information-technology/2012/06/inside-the-ssd-revolution-how-solid-state-disks-really-work/

https://www.bilibili.com/video/BV1aF411u7Ct/?vd_source=f482469b15d60c5c26eb4833c6698cd5

https://www.extremetech.com/gaming/210492-extremetech-explains-how-do-ssds-work

https://www.bilibili.com/video/BV1644y157mB/?vd_source=f482469b15d60c5c26eb4833c6698cd5

https://zh.wikipedia.org/wiki/%E5%86%99%E5%85%A5%E6%94%BE%E5%A4%A7

https://time.geekbang.org/column/article/118191

https://aerospike.com/docs/

https://www.techpowerup.com/ssd-specs/samsung-990-pro-2-tb.d862

扫码_搜索联合传播样式-白色版 1

作为开发需要了解 SSD 的一切最先出现在luozhiyun`s Blog

2023年总结:保持心情愉悦&积极向上

作者 luozhiyun
2024年1月1日 20:59

hi,大家好呀,这是我第五年做年度总结了。

2022年总结

2021年总结

2020年总结

2019年年度总结(哈哈,19年我还将博客写在博客园)

我们永远不知道明天会发生什么,但是我们永远有保持开心的权利,这是这一期的主题。

有关学习

AI & 强化学习

在今年上半年,由于 AI 画图的兴起,让我感觉这个东西也挺有意思的,先后看了一些资料去学习 AI,这是我以前没有尝试过的领域,然后也写了两篇有关强化学习的文章:

写给开发同学AI强化学习入门指南 https://mp.weixin.qq.com/s/sTBFnSLS-WBjMWDokH2o9A

如何用 PPO 算法让 AI 学会玩 FlappyBird https://mp.weixin.qq.com/s/5DYBCCU3xsmTHtN5Ciz0WA

下面是我当时的学习计划:

  1. 如果和我一样一点基础也没有,并且概率论和线性代数的知识差不多都忘完了,那么可以去看一下相关课程学习一下,如果不关注公式啥的,这一步可以先忽略,大约周末一天时间就可以搞定;
  2. 然后如果对机器学习也一点基础都没有的话,可以先看吴恩达的课程,有个大致的理解,然后去看李宏毅的课程作为补充,如果单纯的想入门学习强化学习,那么只需要看前几节讲完神经网络那里就差不多了,这个视频课程估计要看 25 小时左右;
  3. 学完之后可以跟着《动手学深度学习 https://zh.d2l.ai/ 》一起动手学习一下我们上面学到的概念,写写代码,如果只是入门的话看前五章就好了,本篇文章的很多资料也是整理自这本书,大约 10 小时左右;
  4. 接下来可以看看 B 站王树森的深度学习的课程,可以先看前几节学习一下强化学习的基础知识点,大约 5 小时左右;
  5. 到这个阶段估计还是懵的,需要去上手做点项目,那么可以看《动手学强化学习》这本书,已经开源了 https://hrl.boyuai.com/ ,只看到DQN的部分,大约十几小时。

我是差不多花了小半年的时间学习并入门的,上面的学习步骤大家感兴趣的可以参考一下。

英语

今年十一去了趟日本旅行,旅行过程中发现自己的英语口语水平仅限于蹦单词,学了这么多年的英语有种白学的感觉,感觉我应该可以更好点,然后就开始各种练习口语以及听力。

我总结了一下我以前之所以没有学好英语是因为没有确定个目标,没有目标那就没有动力,所以这次我给自己定一个小目标,那就是先初步确定自己想要达到的雅思成绩。

定好了目标之后就想着怎么去达成,规划一下。首先要正视自己的问题所在,雅思不像以前的英语考试,考的是应试能力,它考的是全面的英语水平,所以我觉得首先是要提升自身的认知水平,应该要怎么学习才是有效的,可以看这两个视频:

【高能干货】这个视频将会颠覆你对英语学习的认知——听说篇 https://www.bilibili.com/video/BV1tf4y1s7NN

【高能干货】这个视频将会颠覆你对英语学习的认知——总述·阅读篇 https://www.bilibili.com/video/BV1aD4y127GE

听力

一开始我是找一下雅思的听力材料来听,对着材料反复听,反复默写,直到听明白为止,后面发现实在是有点无聊,这么枯燥的训练不是说坚持不下去,而是可以想办法让学习的过程更加有趣点。后面我就想起了最近上线的游戏《重返未来:1999》。

《重返未来:1999》是全英伦配音,每节的篇幅也不长,制作的也很用心,配乐也很好听,所以很适合用来做听力,并且可以一遍玩游戏一遍过剧情。

说实话我以前从来没有对一款游戏的剧情如此上心,因为需要练习听力的原因,需要反复听写,所以看了很多遍。需要注意的是,用这个素材来做听力,不需要到游戏里面去听,去b站听就好了,我听的是这个合集:https://www.bilibili.com/video/BV1dh4y1s7gg ,共100多 P 还在持续更新中。

里面我还是很喜欢斯奈德这个角色的,糯糯的声音,并且很有能力,独自一人支撑起自己的家族,可惜死了。

Frame11

口语

口语部分其实是不太好处理的,我以前也试过很多方法,包括请老师和我对话,但是效果都不怎么好,究其原因是因为自身的输入不够,背的单词和短语并没有内化到自己的语言体系中,导致一些你在阅读的时候觉得很简单的句子,但是你就是说不出来。

首先我觉得应该是要提升自己的句法量,短语量,我选择的是这个材料,简单,并且很有趣,里面会覆盖各种各样的场景:

EnglishPod全新版【1-365期全文本+讲解】英语听力口语学习 https://www.bilibili.com/video/BV1w7411g7jn

然后还有这个课程,通过拆解电影或电视剧里面的句子,简单易懂的告诉你如何连续,如何弱读。很多时候我们听不懂是因为没理解弱读和连读,很多时候正式交流中并不会读出整个单词。

Learn American Pronunciation through English Conversation https://www.youtube.com/playlist?list=PLrqHrGoMJdTQjpAE9LLYcpGqFOVQOyT7n

Frame12

AI 学习

chatgpt 练习口语

其实我也写过一篇文章,讲怎么使用 chatgpt 来练习口语以及写作了:

How to become a prompt engineer https://www.luozhiyun.com/archives/814

GPT其实也可以语音对话,那么是否可以用这个功能练口语呢?并且可以控制语音语速,你可以让它说快点,说慢点,等等。

Frame13

不过 chatgpt 就是有个问题,需要调教,如果不使用 prompt 直接和它聊,那么可能只是个夸夸机器人,或者无头苍蝇瞎聊。幸好的是 GPTs 开放出来了,大家可以直接使用它就行了,不需要再学习 prompt,比如这里我就搞了个 GPTs 的口语老师:

https://chat.openai.com/g/g-JnDEDhPgo-english-instructor

然后我还去找了一些雅思口语素材话题,每次我就翻一下书看看我要和它聊什么:

Frame14

然后我会和它说一段话关于这个主题,然后 GPT 会帮我分析我的句法问题,并告诉我怎么说才是正确的,这一点其实是比我的外教要好的,因为我的外教并不会每次都指正我的问题:

Frame15

然后我会让它结合我的观点,给我一个完整的例子,这样我可以自己维护起来背诵一些句型,这点尤其重要:

Frame17

chatgpt + notion 自动记单词

我在使用的是 python 调用 notion api + chatgpt 生成的表格,每当我有一个生词不认识,然后就输入一个单词,然后帮我填充上面的表格,全自动的,如下:

Frame16

并不是说记单词软件不好用,而是有时候我们在很多地方遇到的生词,想要维护自己的生词表,可以采用这种方式,并且可以自定义你想要啥都可以让 gpt 帮你生成,更加灵活。

关于用 AI 学习这方面我真的可以写好多技巧,比如我的强化学习也是有一部分是AI教我的,这里先这样,有机会再聊聊别的学习技巧。

其他

除了上面两点,我也看了一本让我感觉很厉害的书《designing-data-intensive-applications》,也就是 DDIA,如果能全部搞懂,相信你对数据库的了解会变得非常深入。当时这本书我才看到第二章就发现太牛逼了,以一种数据库总览的视角介绍了一遍一些数据库查询语言使用场景。当时读完之后我也是写了一篇总结文章:

数据库的数据复制与分区 https://www.luozhiyun.com/archives/805

但是也只有这一篇,因为原书功底实在深厚,我担心我总结不到位,并且网上搜了一下,总结的人实在太多,所以就没继续写了。

除此之外就是写了一些 C++ 相关的文章:

写给[C++ ]新人智能指针避坑指南 https://www.luozhiyun.com/archives/762

C++ 中让人头晕的const & constexpr https://www.luozhiyun.com/archives/756

C++ 中复杂却很有意思的SFINAE技术 https://www.luozhiyun.com/archives/744

C++ 中让人头晕的 typedef & typename https://www.luozhiyun.com/archives/742

学了这么久的 C++ 感觉还是很难用好它的,C++ 发展了30多年,历史包袱实在是太重了,太灵活了,导致各种特性,各种语法糖,让人感觉犹如天书,感觉如果真的有兴趣的话,这门语言是可以研究一辈子的。

分享点好物

apple watch ultra2

上一个我用的手表是 apple watch4,不知不觉已经用了5年的它,迎来了使命的终结,我决定给自己换一个新的手表:apple watch ultra2。

虽然我知道它又重,又大,还没有啥新功能,缺点很多,但是我买了它之后真的开心了很久,我几乎每次健身,每次游泳都会使用它的健身功能,每晚都会带着它睡觉帮我检测睡眠。

202311

大家可以感受一下,它真的大了不少:

2023

不过大也有大的好处,能看的文字更多了,很多长信息可以直接一屏就看完,不需要滑动。还有就是手表真好看啊,纯钛的合金戴着也很舒服。

老的apple watch4手表在完成它的使命之后现在在我桌面上当一个钟:

Frame 2

健身腰带

我原来的腰带已经用了很多年了,今年就想着自己已经练了七八年了,没有拥有过一条好的腰带,就想着给自己买一个好的腰带。一开始其实是看上了吕小军的店里面的腰带 CT800,买来后试了一下就发现实在是太硬了,并且还卡腰,不太适合我,就退了换了国外的卡迪罗。

这条腰带吸引我的点是因为它宣传纯手工牛皮打造,并且很多奥赛选手都在用它,但是买了用了一段时间后发现,质量实在是堪忧,没多久就开始开胶:

20231 2

后面健身群里有一个大佬,说自己很久没练了,想要出 SBD 腰带,我就收了,这一条也就是我现在最常用的腰带,这条腰带其实不是健身腰带,是一条力量举腰带,它和普通腰带不同的是它是等宽的。使用它的感觉就是它的包裹性真的比卡迪罗强太多了,如果想要稳定的支撑做硬拉和深蹲,这个腰带绝对是不二之选。

刚买了的适合前主人太久没用了,上面甚至有灰,感觉这位兄弟让我有机会体验这款腰带:

Frame 3

4090显卡

在年初的时候,我以学 AI 为由,给自己买了一张 4090显卡,当时这个卡买来的时候还怕亏很多,反复问自己真的需要一张这样的显卡吗?

后面的事情大家都知道,AI 的热度不断升温,曾经一度让这张卡一卡难求,后面甚至有禁令的加持,让这张卡即使我用了快10个月了,还能以高出买入价格的30%卖出,不愧第一神卡 (。A。)

Frame5

你要说用它值不值嘛,我觉得肯定是值的,用它玩过一些画图模型,视频模型,搞了一些蛮有意思的图片,后面还用它通关了《最后生还者》,不禁感慨,有生之年能玩到款游戏真好。后面还用它学习了强化学习,自己也写了一些文章: 《写给开发同学AI强化学习入门指南 https://mp.weixin.qq.com/s/sTBFnSLS-WBjMWDokH2o9A

kobo libra 2

买这款阅读器,是因为我的kindle实在是太老了,想要个有实体按键的阅读器但是 kindle oasis 又太久没更新了,正好10月去日本旅行的时候正好去试用了一下这款阅读器就以差不多1050的价格入手了(国内要1400左右)。

img

这款阅读器给我的第一感觉首先是颜值真的很难打,文字阅读的清晰度是真的很高,我在商场里和 kindle oasis 对比了一下,应该不相上下。然后就是它的按键翻页配合上它的人体工学的造型,握持起来真的很舒服,喜欢阅读的朋友不妨试一下。

Frame6

尝试点不一样的

多关注一下自己的心理健康

我发现自己很多时候内心充满了 chaos,以前不认为它是个问题所以长期得不到解决,但是今年开始我开始正视他们,不面对就永远得不到解决,我分解了一下我目前存在的问题,主要拆解为两部分:睡眠不好带来的疲倦感和内心的焦虑带来的不安感。

睡眠

首先来看看睡眠,在偶然间发现一本书:《why we sleep》,读了之后才发现原来睡眠和心理健康是相辅相成的,没有好的睡眠,一般心理健康就不会好,感觉这本书要是早20年被我看到就好了,不过这也是不可能的,《why we sleep》2017年才出版。

然后再说说我是怎么做的,总结起来我觉得有这几点建议是有用的:

  1. 冥想正念,这个下面再说;

  2. 睡前一小时调暗灯光,避免看手机,这样做主要是让身体有一个环境可以自己释放褪黑素,可以看看 kindle 或纸质书,我最近在看《禅与摩托车维修艺术》,这本书很厚,没什么情节,没什么逻辑,不需要理解,很好睡;

  3. 保证卧室不透光,这样不至于太早自然醒,因为我们的身体会在早上有光的情况下开始慢慢的降低褪黑素的水平,即使你睡眠不够,也会把你弄醒,所以保证卧室不透光可以让我们在睡够的时候醒来;

  4. 保持适量运动,但是不要在晚上运动,很多人白天没时间运动所以只能拖到晚上运动,这样我们会长时间处于亢奋状态下,晚上很难入睡,最好的运动时间其实是下午4点左右,其次是早上;

  5. 可以适当的在晚上睡前吃点锌镁片,可以促进睡眠,并且比褪黑素更加较健康安全;

  6. 空调温度开低一点,低一点真的会更加容易入睡,《why we sleep》里面有实验研究,想了解可以看看,但是注意不要感冒了;

  7. 不要看到时间,避免因看到时间导致的减缓焦虑;

  8. 躺床上长时间睡不着的时候,不要硬睡。可以慢慢起身,坐在床边,做做正念练习,缓缓的喝点点温水,调整一下思绪,尝试和自己说:“白天我已经想的足够多了,现在我应该安静下来”,再尝试入睡;

  9. 从下午开始不要喝任何有咖啡因的饮品,包括:奶茶,可乐,红牛等等。咖啡因的半衰期大约是3-4个小时,如果下午喝那么在睡前实际上还残留了蛮多咖啡因在体内;

实践中放弃使用的方法:

  1. 泡脚,这个实在麻烦,并且天气很热也不想泡,每次泡完之后还要倒水,清理泡脚桶,感觉是个体力活;

  2. 睡觉时听轻音乐,因为不可能整晚都在耳边放音乐,所以只能定时放,这让我很焦虑,总是在想:“我咋还没睡着,这个音乐又要停了”。

冥想

我试过很多正念课程,付费的有暂停实验室,还有b站各种免费课程,综合下来觉得李冉的这个课最好:https://www.bilibili.com/video/BV1NM4y1d7aC/ ,当然他也有付费课程,也不错的。每天睡前一小时正念15分钟左右,可以让自己在睡觉的时候保持一个纯净的思想环境,更容易入睡。

看了一下阿B给我的总结,确实是反复刷了蛮多次的:

Frame4

平时我们的大脑总是被各种信息打扰已经开始慢慢的变得受不了半刻的无聊了,连做饭,上厕所,也一定要找个机会拿起手机填补我们的大脑,但是这样是很不好的,有想过多久没和自己对话过了么,有关心过内心自己吗?

总的来说,正念是给自己一个机会,让自己有空和自己对话,舒缓自己的心情,让自己多和自己和解,有空多多拍拍自己的肩膀,对自己说你已经很棒了!

image-20231230224008005

让自己动起来

游泳

自从我在去年学会游泳之后,现在基本都会每周至少周末去游泳一次,今年游了多少次我就懒得统计了,即使是在这个冬天,我也游泳基本没有偷懒。

游泳的好处我就不说了,最近我一些同事朋友也因为进行户外运动而导致膝盖损伤的案例比较多,而游泳是最好的锻炼方式,在正确的泳姿下运动可以做到锻炼的同时,而不损伤膝盖。

对于游泳的装备来说,有几点需要注意的是:

  1. 眼镜可以的话,最好买防止水汽的,要不然游着游着就会视线模糊,有时候下午的阳光照进游泳池里,光线在水里的折射真的很美;
  2. 泳裤和泳帽不要买太便宜的了,泳帽最好买橡胶的,泳裤买贴身一点的;
  3. 因为游泳池一般是用含氯的消毒剂的,所以最好游完之后用去氯的沐浴露清洗一下。

上面讲的这些迪卡侬都有卖,而且很便宜,买百元左右的价位就足够用了。

健身

其实近一年,我的健身时间很多时候就没怎么把控好,因为我一般是早上训练,一方面我需要在早上9点前赶紧训练完之后去上班,另一方早上真的有点困,不过这也怪我,晚上早点睡就好了 ( ´・ω)

健身其实就和学习是一样的,只要坚持下去就一定会有进步,我过去一年也是按每周4次训练的方式训练,每周胸两次,背两次,深蹲和硬拉各一次。

这里要表扬一下我自己,我的身材真的已经很棒了!

Frame24

因为腰伤的原因,三大项中的深蹲和硬拉有段时间就一直没练,10月重启训练之后一直练的很保守,会在反复确认自己动作没有问题,自己身体能承受的范围内之后才会加重量,所以这两项成绩让大家见笑了。

健身就是这样掺不得一点假,一旦你霸蛮上了你本不该承受的重量,第二天可能就医院见,大家运动时一定要慎重。

卧推的成绩我一直都有跟踪,慢慢的还是有点点涨的,不过我也加的很慢,现在做组可以在77.5kg的水平。

Frame25

瑜伽

有空的话,我也推荐大家练一下瑜伽,这个很重要。因为平时我们久坐人群很容易有各种毛病,例如腰椎和肩颈,适当的花个十几分钟放松一下是很有必要的,我一般练下面的这个视频:

https://www.bilibili.com/video/BV15V411a7cV/

听一下现场的音乐会

这场《宫崎骏&久石让》的演奏会听着还真挺舒服的,让我想起小时候看过的宫崎骏动画。

这场还是蛮惊喜的,小提琴的轻盈,大提琴的悠扬,然后一直是主旋律的钢琴,还有后面总是会带来惊喜的敲击乐,都挺好。

3

我想起来了一个同事在网上的流言,有人问:最近越发不想和人接触,觉得人生无趣,觉得一切都是灰色的,应该怎么排解?

有个同事的回答让我觉得很值得和大家分享:

翻了下网上爬虫的统计(忽略统计时间偏差),豆瓣上评分在8分以上的书有112513 本,8分以上的电影有2094部。

毕加索有一句话:The purpose of art is washing the dust of daily life off our souls.

艺术是当下少有能够跨越时间和空间让一个灵魂与另一个灵魂交流的载体,而经过时间长河沉淀下来的艺术作品,我们有理由相信它来自一个充满色彩的灵魂,那就大可以把时间花在与这些有趣灵魂的交流上,而避免陷在如齿轮般机械运转的生活中自哀。

所以,好好坐下来翻开一本书、看一部电影、听一个歌单,都可以让你觉得世界并不只有灰度值,它还有RGB。如果这样坐下来还是感觉无聊,那就走出去看看几十亿年的地球历史留下来鬼斧神工的自然,抬头看看星空感叹下自己的生命放到宇宙的尺度有多么渺小,自嘲下自己当下的烦恼原来有多微不足道。所以吧,其实你可以做的事情真的不少:)

所以,这场音乐会是真的好听。

出行

2月的香港出行

在香港通关之后的第一个月我就去了香港,大约是2月上旬,其实香港对我来是以前是经常去的,应该没这么陌生,但是封了3年,当时就很想过去看看自己熟悉的地方,是否变得陌生。

这是我当初2月上旬写下的三年没去香港感受到的变化:

  1. 我是在福田口岸通关的,游客通关人数少了很多,开给游客的通关线只有几条,几乎不怎么排队,估计不久游客人数会多起来吧;

    b3bcb695ea01a8f9d0419a9fcac2e914.png

  2. 早上 11 点了,才陆陆续续有店铺开门,还有80%的店没有开门,我记得我以前早上去的时候也不至于这么晚才开门,但是下午在旺角,尖沙咀等地方人还是比较多的;

  3. 在小店买东西的时候,和店员说普通话,他还有些慌乱,不知道该怎么回我,估计是太久没见到大陆游客了,普通话有点生疏了;

  4. 坐地铁可以直接用支付宝了,我全天都是用支付宝坐地铁,但是有些地铁口能用支付宝的闸机比较少;

  5. 如果要使用八达通,需要去香港机器上花15块重新激活一下,并且现在八达通可以转入苹果钱包,可以直接用旅客八达通充值,不需要准备现钞充值了;

  6. 经过国内疫情几年的涨价,感觉香港的物价相比之前几乎没怎么变化,我60 RMB可以在回转寿司饱餐一顿,突然感觉竟然香港吃饭也有了性价比;

  7. 去到港口那里,几乎看不到什么游人,天气即使不怎么好我依然在那里坐了好久好久,吹着丝丝冷风,感觉有一丝丝放松;

    3bf39460195807e90a2a153f7c2e7d36.png

  8. 依然可以找到我喜欢吃的雪糕车,但是这一次吃感觉也没这么好吃,果然还是回忆里的东西比较好;

    Frame9

  9. 这几年国内电车炒的飞起,但是在香港路上电车还是比较少的,依然很多这种老式的出租车,我在想这些车尾气排放怎么样呢?

    Frame10

10月的日本旅行

今年我就只尝试去了日本旅行,虽然时间很赶,但是依然去了蛮多地方,还挺有趣的。我虽然很喜欢看动漫,但是以前从来没去过日本,所以在准备的时候,实际上各种期待,朋友们推荐了各种地方,导致这也想去,那也想去,加上来回路程行程只有7天,但是却规划了四五个地方,导致有点赶,很多地方都没有逛够。

地铁线路是真的复杂

去到日本坐地铁真的很容易迷路,各种线路异常繁杂,并且很多地铁线路都没有连在一起,有时候发现同一个站台进去之后发现不是这条线,要重新刷卡出来再到另一个地方坐车。即使有 Google map 很多次还是搞错,甚至我买了 jrpass 后面也没有怎么用,原因就是太过于麻烦不想每次都花很长时间去找 jr 站台。

img

很清净晚上七八点路上几乎看不到行人

即使在东京也是这样,晚上住在东京的秋叶原附近,晚上7点左右的样子出去觅食,除了商业街外几乎没有行人,写字楼也没有灯,感觉大家下班很早,休息的也很早,很好。

街道真的很干净

这里一整条街都是可以逛的,让我感觉惊讶的是,道路非常干净,在这么多人的情况路上几乎看不到垃圾。

Frame7

即使是心斋桥这条美食街地方也看不到垃圾。Frame8

到处都是扭蛋和自动贩卖机

扭蛋机我也是随便扭了几个,300(15RMB)~500(25RMB)日元一次,这东西随处可见,真的很流行的样子。我也扭了几个,蛮有意思的,但是切勿上头。

Frame26

左右自动贩卖机真的是个好东西,走到哪里都是,并且价格都是一样的,即使再景区,水也是180日元(8RMB)一瓶,而且还是冰的,真的很爽。

面很好吃

虽然被我拍的很没食欲的样子,但是日本的面做的真的有一手的,并且很多面价格都不贵,在800日元(40RMB)左右。

Frame28

富士山很值得一去

虽然这次没有去拍到富士山的雪山,但是它其他景点是真的很棒,我们在富士山那里遇到了几个国人,他们带我们一起去了附近的一个公园,那里很清净,没什么人,大片的草地很平整,我们去的那天还见到了上空有老鹰在盘旋。公园旁边的小河也是很清澈,偶尔有微风吹过,真的很舒服,我们在那里呆了好几个小时。

Frame29

Hep five

这是我第一次坐摩天轮,其实深圳和香港也有一些摩天轮,但是每次路过我都觉得要么太小,要么就太贵,没有去坐,但是这次的Hep five确实是很值。首先是价格只要600日元(30RMB),再来就是它真的很高,有100多米,并且当时我去的时候是下午太阳下山的时候,摩天轮下面就是地铁线路,映衬这个夕阳

Frame30

明日香竟然是个地名

坐地铁无意中发现的,原来看了这么多年的 EVA 里面的明日香是个地名:

Frame31

总结

好了,到这里,这一篇差不多该结束了,感谢你看完了我絮絮叨叨写了这么多,明年总结再见~

扫码_搜索联合传播样式-白色版 1

2023年总结:保持心情愉悦&积极向上最先出现在luozhiyun`s Blog

Snowflake有什么问题及相关解决方案

作者 luozhiyun
2023年12月10日 18:00

转载请声明出处哦~,本篇文章发布于luozhiyun的博客:https://www.luozhiyun.com/archives/820

有什么问题

以前写了一篇文章是关于 Snowflake 如何实现的文章,具体可以看这里:https://www.luozhiyun.com/archives/527 。它最早是twitter内部使用的分布式环境下的唯一ID生成算法。在2014年开源。开源的版本由scala编写,大家可以再找个地址找到这版本:https://github.com/twitter-archive/snowflake/tags

它有以下几个特点:

  1. 能满足高并发分布式系统环境下ID不重复;
  2. 基于时间戳,可以保证基本有序递增;
  3. 不依赖于第三方的库或者中间件;

其实它的实现原理也非常的简单,Snowflake 结构是一个 64bit 的 int64 类型的数据。如下:

image-20231126194538631

位置 大小 作用
0~11bit 12bits 序列号,用来对同一个毫秒之内产生不同的ID,可记录4095个
12~21bit 10bits 10bit用来记录机器ID,总共可以记录1024台机器
22~62bit 41bits 用来记录时间戳,这里可以记录69年
63bit 1bit 符号位,不做处理

如果直接使用它的话,也是有不少的缺点:

  1. 时间回拨问题,它是指系统在运行过程中,可能由于网络时间校准或者人工设置,导致系统时间主动或被动地跳回到过去的某个时间,那么会导致系统不可用,生成的时间戳重复;
  2. workerId(机器id)的分配和回收,原生算法中,该workerId的分配并没有特殊的处理,更多是人工添加处理;

有什么解决方案呢?

一般的情况,我们可以用当前时间和上一次的时间相对比,如果当前时间小于上一次的时间那么肯定是发生了回拨,发生回拨我们一般也有两种应对策略:

  1. 如果时间回拨时间较短,比如配置5ms以内,那么可以直接等待一定的时间,让机器的时间追上来;
  2. 如果时间的回拨时间较长可以添加扩展位,比如当这个时间回拨比较长的时候,我们可以不需要等待,直接在扩展位加1。4位的扩展位允许我们有15次大的时钟回拨,一般来说就够了;

下面举个例子,比如可以从序列号和机器ID里面分4个bit过来,组成 1 个 4 位的时钟 ID:

snowflake

在具体的实现逻辑上,主要是在每次发现时间回拨(即之前最后一次生成 ID 的时间戳大于等于当前时间戳)的时候,便将时钟 ID 加 1,相当于创建一个新的序列号。

那么这样就解决了吗?其实不尽然,仍有一些无法避免的问题:

  • 当时间回拨时,ID 递增性会被破坏,对于需要严格递增的场景,需要考虑其他解决方案;
  • 如何保证获得全局唯一的机器 ID,也是一个复杂的问题,另外时钟 ID 的引入,会占用额外的比特位,需要综合考虑从哪些比特片段中腾出这些需要留给时钟 ID 的比特位;
  • 多时钟雪花算法只是缓解了时钟回拨问题,端情况下的容错方案应该怎么解决;

除了上面这两种方案,还有一种方案就是 butterfly 方案,号称是绝对没有时间回拨问题的方案。主要改进点就是放弃使用机器的当前时间戳来做递增,而是采用历史时间,这是核心所在,具体做法是:

  • 在进程启动后,我们会将当前时间(实际处理采用了延迟10ms启动)作为该业务这台机器进程的时间戳中的起始时间字段;
  • 每次有数据请求,直接对序列号增加即可,序列号从0增加到最大,到达最大时,时间戳字段增加1,其实是时间增加1毫秒,序列号重0计算;

但是这样做也有个问题,就是分布式ID中的时间信息可能并不是这个ID真正产生的时间点,丢失了这部分信息,如果业务中有需要用到的话,该方法是不合适的。

再来就是 workId 分配的问题,一般有如下几种方案:

  1. zookeeper分配:一般是在ZK上寻找一个节点注册,然后获取到相应的 workid,然后每间隔一定时间定时上报一下,如果节点已过期表示可重用;
  2. db分配:db分配其实也差不多,建立一个表workerId分配表,每次分配节点的时候从最小的id开始查找,找到则获取workid,然后定时上报;

下面我还找了两个优秀的实现分别是美团的 Leaf-snowflake 、百度 UidGenerator 来看看它们是怎么实现的。

美团 Leaf-snowflake

Leaf-snowflake方案完全沿用snowflake方案的bit位设计,没有对snowflake做什么改动,但是Leaf-snowflake 依赖 Zookeeper 持久顺序节点的特性自动对snowflake节点配置wokerID:

snowflake2

  1. 启动Leaf-snowflake服务,连接Zookeeper,在leaf_forever父节点下检查自己是否已经注册过;
  2. 如果有注册过直接取回自己的workerID(zk顺序节点生成的int类型ID号),启动服务;
  3. 如果没有注册过,就在该父节点下面创建一个持久顺序节点,创建成功后取回顺序号当做自己的workerID号,启动服务,拿取workerID后,会在本机文件系统上缓存一个workerID文件,即使ZooKeeper出现问题也能保证服务正常运行;

那么 Leaf-snowflake 是解决保证时钟回拨的问题呢,答案是不解决,直接抛错,这样可以避免递增性被破坏,做多也只是重新去获得一次 ID 而已。具体情况如下:

  • Leaf-snowflake 服务若写过 ZK ,那么会去和 ZK 自己上报过的 leaf_forever/${self}节点记录时间做比较,若小于leaf_forever/${self}时间则认为机器时间发生了大步长回拨,服务启动失败并报警;

  • Leaf-snowflake 服务若没写过 ZK ,证明是新服务节点,直接创建持久节点leaf_forever/${self}并写入自身系统时间。

    • 接下来综合对比其余Leaf节点的系统时间来判断自身系统时间是否准确;
    • 取leaf_temporary下的所有临时节点,然后RPC请求获取所有节点的系统时间,计算sum(time)/nodeSize
    • 若abs( 系统时间-sum(time)/nodeSize ) < 阈值,认为当前系统时间准确,正常启动并进行续约;
    • 否则认为本机系统时间发生大步长偏移,启动失败并报警;
    • 每隔3s会上报自身系统时间并写入ZK;

其实深度思考一下,美团的 Leaf-snowflake 问题还蛮多的,我随便说两个点:

  1. 这个问题是我朋友 @NianniaN 提出来的:3s上报一次ZK可能会有问题的。比如一个节点正在运行,它不知不觉地快了1s。由于某种原因,它突然挂起,在1s内重新启动。重新启动后,它的时间恢复到正常状态,因为上一个报告周期是3s前,因此系统有可能成功启动。是否有可能会重复的ID,leaf重启的时间校验也是非常粗狂的,直接判断一下时间就结束了:

       private boolean checkInitTimeStamp(CuratorFramework curator, String zk_AddressNode) throws Exception {
           byte[] bytes = curator.getData().forPath(zk_AddressNode);
           Endpoint endPoint = deBuildData(new String(bytes));
           //该节点的时间不能小于最后一次上报的时间
           return !(endPoint.getTimestamp() > System.currentTimeMillis());
       }
  2. 这个问题是网友 @NotFound9 提出来的,依赖ZK可能也会有问题。如果启动时连接zookeeper失败,会去本机缓存中读取workerID.properties文件,读取workId进行使用,但是由于workerID.properties中只存了workId信息,没有存储上次上报的最大时间戳,所以没有进行时间戳判断,所以如果机器的当前时间被修改到之前,就可能会导致生成的ID重复;

    百度 UidGenerator

百度的Snowflake算法的结构上做了一些改变:

Snowflake

  • sign(1bit)
    固定1bit符号标识,即生成的UID为正数。
  • delta seconds (28 bits)
    当前时间,相对于时间基点"2016-05-20"的增量值,单位:秒,最多可支持约8.7年
  • worker id (22 bits)
    机器id,最多可支持约420w次机器启动。内置实现为在启动时由数据库分配,默认分配策略为用后即弃,后续可提供复用策略。
  • sequence (13 bits)
    每秒下的并发序列,13 bits可支持每秒8192个并发。

它通过采取如下一些措施和方案规避了时钟回拨问题和增强唯一性:

  1. workerId在实例每次重启时初始化,它的ID是数据表的自增ID,保证了唯一性。由于workerId默认22位,那么所有实例重启次数是不允许超过4194303次(即2^22-1),否则会抛出异常;
  2. 内部利用了一个 RingBuffer 数据结构预先生成若干个分布式ID并保存,不需要每次都实时计算从而提升性能;
  3. UidGenerator 根据时间递增其实也是和 butterfly 方案一样,用的是历史时间来做的,UidGenerator的时间类型是 AtomicLong,且通过incrementAndGet()方法获取下一次的时间,从而脱离了对服务器时间的依赖;

总结

总体上看了几种实现可以说解决时间回拨的方案是比较固定的,一般来说就是:

  1. 如果时间回拨时间较短,比如配置5ms以内,那么可以直接等待一定的时间,让机器的时间追上来;
  2. 如果时间的回拨时间较长可以添加扩展位,比如当这个时间回拨比较长的时候,我们可以不需要等待,直接在扩展位加1;
  3. 或者出现时间回拨直接拒绝报错,类似Leaf-snowflake的实现;
  4. 再来就是可以像 butterfly 一样,使用历史时间,每次 sequence 用完之后直接在时间序列上自增1就好了;

再来就是 workId 分配的问题,一般有如下几种方案:

  1. zookeeper分配:一般是在ZK上寻找一个节点注册,然后获取到相应的 workid,然后每间隔一定时间定时上报一下,如果节点已过期表示可重用;
  2. db分配:db分配其实也差不多,建立一个表workerId分配表,每次分配节点的时候从最小的id开始查找,找到则获取workid,然后定时上报;

Reference

https://github.com/SimonAlong/Butterfly

https://tech.meituan.com/2017/04/21/mt-leaf.html

https://tech.meituan.com/2019/03/07/open-source-project-leaf.html

https://blog.hackerpie.com/posts/algorithms/snowflake/multiple-clocks-snowflake/

https://www.jianshu.com/p/b1124283fc43

https://github.com/baidu/uid-generator/blob/master/README.zh_cn.md

扫码_搜索联合传播样式-白色版 1

Snowflake有什么问题及相关解决方案最先出现在luozhiyun`s Blog

❌
❌