[{"content":"作为后端程序员，架构知识通常是一点一点在工作中捡的：数据一致性踩坑了，知道了事务的重要性；服务改用 K8s 部署，真切体会到了声明式 API 给运维带来的便利性；性能遇到瓶颈了，加上缓存系统。每块都懂一点，但这些知识之间是什么关系，为什么要这么设计，一直没有形成体系化的思考，直到读到周志明老师的《凤凰架构》。这本书完整地梳理了服务端的知识地图，将 why 和 what 的问题讲得非常清楚，给了非常清晰的宏观视角。\n本文是第一章的总结和读后感：每一代架构风格的诞生，都是因为上一代遇到了它解决不了的具体问题。这些问题的性质各不相同，但贯穿始终的暗线是复杂度 —— 复杂度不会消失，只会转移。\n原文出处：微信公众号「张煜中」《架构演进的六个时代：从原始分布式到无服务》，作者张煜中。本文基于该文整理，作为个人读书笔记。\n原文内容 原始的分布式 通常我们会认为，服务架构是从单体开始的，为了逃离单体大泥球架构的地狱，才搞分布式。但历史上恰恰相反。对分布式架构的探索，从 20 世纪 70 年代就开始了。那时单机算力极其有限，16 位处理器、不到 5MHz 的主频，单机直接卡住了软件能做到的规模上限。人们不得不寻找多台计算机协作支持一套软件系统的方案。\nUNIX 设计风格强调：保持接口与实现的简单性，比系统的任何其他属性，包括准确性、一致性和完整性，都来得更加重要。\n理想很美 —— 远程调用应该尽可能透明，开发者无需关心自己调的是本地方法还是远程服务。但一旦触碰到\u0026quot;远程\u0026quot;二字，网络的不确定性便会带来相当的复杂度。远程的服务在哪里（服务发现），有多少个（负载均衡），网络出现分区、超时或者服务出错了怎么办（熔断、隔离、降级），方法的参数与返回结果如何表示（序列化协议），信息如何传输（传输协议），服务权限如何管理（认证、授权），如何保证通信安全（网络安全层），如何令调用不同机器的服务返回相同的结果（分布式数据一致性）—— 每一个都需要设计者耗费大量精力。\n这些探索催生了 RPC、DFS 等概念，人们也得到了一个价值千金的教训：某个功能能够进行分布式，并不意味着它就应该进行分布式，强行追求透明的分布式操作，只会自寻苦果。\n原始分布式时代的故事，是一次发现复杂度的过程。探索者试图用\u0026quot;透明调用\u0026quot;把分布式的复杂度屏蔽掉，让开发者像写本地程序一样写分布式程序。但现实证明，网络带来的不确定性是无法假装不存在的 —— 服务发现、一致性、网络分区，这些问题不会因为你不看它就消失。这次失败的意义不在于产出了什么可用的系统，而在于让整个行业认清了分布式复杂度的真实面貌。\n于是当硬件性能随摩尔定律起飞后，人们做了一个务实的选择：既然分布式的复杂度屏蔽不了，那就别分布式了。\n单体系统时代 软件退回到单体 —— 所有代码跑在同一个进程里，不用想网络，不用想一致性。\n大型的单体系统，经常是微服务书籍批判的对象。但一定要注意这里的定语 —— \u0026ldquo;大型的\u0026rdquo;。小型单体系统，不仅易于开发、测试、部署，且由于系统中各个功能、模块、方法的调用过程都是进程内调用，没有进程间通信，运行效率也很高。三个人的团队、一台机器撑得住的系统，搞微服务纯粹是给自己找麻烦。\n单体系统的缺陷在于，缺乏自治和隔离能力。进程内调用虽然简单高效，但故障也难以隔离，某个模块的 bug 能导致整个系统崩溃。而在大型系统中，出错几乎是必然的 —— 大型系统意味着多人协作、频繁变更，缺乏隔离就意味着一个模块的内存泄漏能拖垮整个进程，一次局部的代码升级需要整体停机重启。\n单体系统的设计哲学是「让每一部分都尽量不出错」，靠高质量来保证高可靠。但系统越大，出错越是必然。从「追求不出错」到正视「出错是必然」的观念转变，才是微服务架构得以挑战单体的底气所在。\n单体时代面对的不是分布式复杂度 —— 它根本没有分布式。它面对的是规模带来的复杂度，而这种复杂度体现在多个维度上：可维护性（一次局部改动需要整体停机重启）、团队协作（多人改同一个代码库，互相踩脚）、可靠性（单点故障拖垮全局）。系统小的时候，这些问题都不存在；但规模一旦上去，它们会同时爆发，而且在单体架构下无解 —— 因为缺乏隔离和自治能力。\n为了获得这种能力，人们再次走向分布式。\nSOA 时代 但在微服务之前，业界走过一段弯路 —— SOA。\nSOA 的野心极大，它不仅要解决技术问题，还想建立一套自上而下的软件研发方法论：如何挖掘需求、如何分解业务、如何编排服务，一揽子全包。它有 IBM、Oracle 等巨头撑腰，有 SOAP 协议族做底座，有企业服务总线（ESB）做通信管道，从技术可行性上看确实解决了分布式的主要问题。\n但问题恰恰出在「太完美」上。过于精密的规范带来过度的复杂性，SOAP 之上层层叠加的 ESB、BPM、SCA、SDO，让整个技术栈变成了只有少数专业人员才能驾驭的奢侈品。\nSOA 与 EJB 的失败如出一辙，一旦脱离人民群众，终究会淹没在群众的海洋之中。\nSOA 面对的问题其实和原始分布式一样 —— 分布式环境下的服务通信、发现、编排。不同的是，它试图用一套大一统的方案把所有问题一揽子解决。但解决方案本身成了新的复杂度来源：协议过重，对开发者强加了大量不需要的东西。你可能只想让两个服务通个信，却要先搞懂 WSDL、UDDI、SOAP Envelope 这一整套仪式。技术上可行，实践中过载。\n回过头看，经历了从原始分布式到 SOA 这漫长的三十年，应用受架构复杂度的牵绊越来越大，距离当年「透明调用」的初心也越来越远。微服务带着对这段历史的自省而来。\n微服务时代 Martin Fowler 与 James Lewis 给出了微服务的现代定义，其中的关键词是强终端弱管道 —— 从这里开始，微服务逐渐与 SOA 划清界限，反对 SOAP 和 ESB 这样复杂的通信机制。服务的通信应该回到 UNIX 风格，简单直接，如果需要额外的通信能力，应该在 Endpoint 上自己解决。\n同时，微服务还强调容错性设计，这是凤凰特性的关键。不再幻想服务永远稳定，而是接受出错的现实，用熔断、隔离、降级来应对。可靠系统完全可以由会出错的服务组成，这才是微服务最大的价值，也是「凤凰架构」这个名字的含义 —— 每个部分都能死掉并重生，系统整体却永远在线。\n微服务丢掉了 SOA 沉重的包袱，但分布式环境下的复杂度并没有消失。SOA 试图用统一规范解决的那些问题 —— 服务发现、负载均衡、通信协议、容错 —— 一下子全回来了，而且因为\u0026quot;自由选择\u0026quot;，每个问题都有一长串备选方案：远程调用可选 Dubbo、gRPC、Thrift；服务发现可选 Eureka、Consul、Nacos、ZooKeeper……\n这时候微服务面对的复杂度，是分布式基础设施的选型与集成复杂度。Spring Cloud 全家桶帮普通开发者屏蔽了大部分细节，但把选型决策集中到了架构师的桌上。自由是双刃剑：一刃指向 SOA 的复杂规范，夺回了选择权；另一刃朝向自己，选择太多本身就是负担。如果有下一个时代，能不能既有微服务的自由，又不必在应用层自行解决这些基础设施问题？\n后微服务时代 我们换位思考一下：分布式的那些老问题，比如服务发现、负载均衡、传输安全等，一定要在软件层面解决吗？这些问题其实早就有对应的硬件方案：负载均衡器、DNS、TLS 链路。之所以微服务选择在应用层用代码解决，是因为硬件基础设施跟不上软件的灵活性。\n而容器化和虚拟化让硬件也能通过敲键盘变出来了。当 Kubernetes 出现，虚拟化的基础设施终于能像软件一样灵活调度时，容器技术就不仅仅是解决程序的分发部署问题了，还开始解决分布式系统的难题。\n2017 年 Kubernetes 赢得容器战争，标志着后微服务时代的开端（也称云原生时代，因为 K8s 和微服务都在解决分布式问题，所以这里作者称为后微服务时代）。以前在 Spring Cloud 里用代码实现的服务发现、配置中心、负载均衡，现在可以下沉到基础设施层面，由 CoreDNS、ConfigMap、Ingress Controller 来承担。\n但 Kubernetes 也不完美。基础设施是容器粒度的，对单个远程服务的精细管控力不足 —— 微服务 A 调用 B 的两个接口，B1 正常 B2 持续 500，Kubernetes 切不切网络？切了影响 B1，不切继续被 B2 拖垮。为了解决这个问题，服务网格（Service Mesh）应运而生 —— 通过 Sidecar 代理，在应用毫无感知的情况下接管所有通信，实现熔断、认证、监控等功能，由此完成了业务代码和技术关注点的分离。\n后微服务时代解决的问题很明确：把分布式基础设施的复杂度从应用代码中剥离出去，下沉到基础设施层。 微服务时代架构师头疼的那些选型问题 —— 用哪个注册中心、哪个负载均衡器、怎么做熔断 —— 现在由基础设施统一提供，应用代码不再需要感知。复杂度从应用代码搬到了控制平面和 YAML 文件里，由专职的平台团队操持。原始分布式时代追求的「透明的分布式调用」，在三十多年后以另一种形态重新成为可能。\n无服务时代 如果说微服务是分布式这条路的极致，那无服务就是另一条路的起点 —— 不分布式的云端系统。\n无服务的愿景很纯粹：开发者只写业务逻辑（FaaS），后端组件直接取用（BaaS），部署、算力、运维全交给云。UC Berkeley 的论文将其类比为从汇编到高级语言的跨越 —— 不用关心寄存器，只关心业务表达。\n与此同时，它的局限性也很明显：按使用量计费决定了函数不会常驻，冷启动延迟不可避免；无状态的特性也限制了它只适合短链接、事件驱动类的应用。对于业务逻辑复杂、需要长连接的系统，目前并不适用。\n无服务面对的复杂度和前面几个时代都不太一样。它不再纠结于分布式通信怎么做、服务怎么发现这些问题 —— 这些在它的视角里根本不存在，因为开发者连\u0026quot;服务器\u0026quot;这个概念都不用想了。它面对的是运维和资源管理的复杂度：部署、扩缩容、容量规划、运维监控，这些全部交给云厂商。代价是你交出了控制权 —— 冷启动延迟你管不了，底层运行时你选不了，成本模型你只能接受。\n微服务和无服务并不存在替代关系，而是可以组合使用，将无服务作为技术层面的架构，将微服务作为应用层面的架构。\n总结 把整条线拉出来看，每一代架构面对的核心问题其实各不相同：\n原始分布式：发现了分布式复杂度 —— 试图用\u0026quot;透明调用\u0026quot;屏蔽它，失败了 单体：没有分布式复杂度，但规模带来了可维护性、团队协作、可靠性的多重困境 SOA：正面回答分布式问题，但解决方案本身太重，协议过载压垮了普通开发者 微服务：轻装上阵获得了自由，但分布式基础设施的选型与集成成了新的负担 后微服务：把基础设施复杂度从应用代码下沉到平台层，开发者终于可以不感知 无服务：连运维和资源管理的复杂度都交了出去，代价是交出控制权 每一代架构都在做同一件事：把当前最痛的那种复杂度，想办法从开发者的日常视野中移走。有时候是下沉到基础设施，有时候是交给云厂商。复杂度没有被消灭，只是不断地转移。\n个人读后感 读完整章，最大的感受是：架构演进不是技术升级，而是复杂度的转移游戏。\n原始分布式时代想屏蔽网络复杂度，失败了；单体时代回避了网络复杂度，却被规模复杂度反噬；SOA 试图用统一规范解决所有分布式问题，结果规范本身成了新的复杂度；微服务把选择权还给开发者，但选型的负担又压了上来；后微服务时代用基础设施接管了这些选择，无服务则更进一步，把运维复杂度也交了出去。\n每一代架构都在解决上一代最痛的问题，同时把痛点转移到新的层面。这不是螺旋上升，更像是复杂度在不同层级之间的流动。作为开发者，我们不需要记住每个协议的细节，但需要理解这个流动的方向 —— 当下一代架构出现时，才能判断它是在真正解决问题，还是只是把问题藏到了别处。\n周志明老师在书中反复强调「凤凰特性」：系统要能在部分组件故障时继续存活。这不仅是技术设计，更是一种工程哲学 —— 接受不完美，设计可恢复。微服务的熔断、K8s 的自愈、Service Mesh 的透明重试，本质上都是在实践这一理念。理解了这一点，再看那些纷繁的技术选型，就能抓住主线：不是选最牛的，而是选最能容错的。\n最后，这本书的价值不在于告诉你该用 Dubbo 还是 gRPC，而是帮你建立一张地图，知道每个技术点在整个服务端知识体系中的位置。对于像我这样在工作中零散积累架构知识的后端程序员，这种宏观视角比任何具体框架都重要。\n","permalink":"https://cloudside.icyyan.com/posts/architecture-evolution-six-eras/","summary":"\u003cp\u003e作为后端程序员，架构知识通常是一点一点在工作中捡的：数据一致性踩坑了，知道了事务的重要性；服务改用 K8s 部署，真切体会到了声明式 API 给运维带来的便利性；性能遇到瓶颈了，加上缓存系统。每块都懂一点，但这些知识之间是什么关系，为什么要这么设计，一直没有形成体系化的思考，直到读到周志明老师的《凤凰架构》。这本书完整地梳理了服务端的知识地图，将 why 和 what 的问题讲得非常清楚，给了非常清晰的宏观视角。\u003c/p\u003e\n\u003cp\u003e本文是第一章的总结和读后感：每一代架构风格的诞生，都是因为上一代遇到了它解决不了的具体问题。这些问题的性质各不相同，但贯穿始终的暗线是复杂度 —— 复杂度不会消失，只会转移。\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e原文出处：微信公众号「张煜中」《架构演进的六个时代：从原始分布式到无服务》，作者张煜中。本文基于该文整理，作为个人读书笔记。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"原文内容\"\u003e原文内容\u003c/h2\u003e\n\u003ch3 id=\"原始的分布式\"\u003e原始的分布式\u003c/h3\u003e\n\u003cp\u003e通常我们会认为，服务架构是从单体开始的，为了逃离单体大泥球架构的地狱，才搞分布式。但历史上恰恰相反。对分布式架构的探索，从 20 世纪 70 年代就开始了。那时单机算力极其有限，16 位处理器、不到 5MHz 的主频，单机直接卡住了软件能做到的规模上限。人们不得不寻找多台计算机协作支持一套软件系统的方案。\u003c/p\u003e\n\u003cp\u003eUNIX 设计风格强调：保持接口与实现的简单性，比系统的任何其他属性，包括准确性、一致性和完整性，都来得更加重要。\u003c/p\u003e\n\u003cp\u003e理想很美 —— 远程调用应该尽可能透明，开发者无需关心自己调的是本地方法还是远程服务。但一旦触碰到\u0026quot;远程\u0026quot;二字，网络的不确定性便会带来相当的复杂度。远程的服务在哪里（服务发现），有多少个（负载均衡），网络出现分区、超时或者服务出错了怎么办（熔断、隔离、降级），方法的参数与返回结果如何表示（序列化协议），信息如何传输（传输协议），服务权限如何管理（认证、授权），如何保证通信安全（网络安全层），如何令调用不同机器的服务返回相同的结果（分布式数据一致性）—— 每一个都需要设计者耗费大量精力。\u003c/p\u003e\n\u003cp\u003e这些探索催生了 RPC、DFS 等概念，人们也得到了一个价值千金的教训：某个功能能够进行分布式，并不意味着它就应该进行分布式，强行追求透明的分布式操作，只会自寻苦果。\u003c/p\u003e\n\u003cp\u003e原始分布式时代的故事，是一次发现复杂度的过程。探索者试图用\u0026quot;透明调用\u0026quot;把分布式的复杂度屏蔽掉，让开发者像写本地程序一样写分布式程序。但现实证明，网络带来的不确定性是无法假装不存在的 —— 服务发现、一致性、网络分区，这些问题不会因为你不看它就消失。这次失败的意义不在于产出了什么可用的系统，而在于让整个行业认清了分布式复杂度的真实面貌。\u003c/p\u003e\n\u003cp\u003e于是当硬件性能随摩尔定律起飞后，人们做了一个务实的选择：既然分布式的复杂度屏蔽不了，那就别分布式了。\u003c/p\u003e\n\u003ch3 id=\"单体系统时代\"\u003e单体系统时代\u003c/h3\u003e\n\u003cp\u003e软件退回到单体 —— 所有代码跑在同一个进程里，不用想网络，不用想一致性。\u003c/p\u003e\n\u003cp\u003e大型的单体系统，经常是微服务书籍批判的对象。但一定要注意这里的定语 —— \u0026ldquo;大型的\u0026rdquo;。小型单体系统，不仅易于开发、测试、部署，且由于系统中各个功能、模块、方法的调用过程都是进程内调用，没有进程间通信，运行效率也很高。三个人的团队、一台机器撑得住的系统，搞微服务纯粹是给自己找麻烦。\u003c/p\u003e\n\u003cp\u003e单体系统的缺陷在于，缺乏自治和隔离能力。进程内调用虽然简单高效，但故障也难以隔离，某个模块的 bug 能导致整个系统崩溃。而在大型系统中，出错几乎是必然的 —— 大型系统意味着多人协作、频繁变更，缺乏隔离就意味着一个模块的内存泄漏能拖垮整个进程，一次局部的代码升级需要整体停机重启。\u003c/p\u003e\n\u003cp\u003e单体系统的设计哲学是「让每一部分都尽量不出错」，靠高质量来保证高可靠。但系统越大，出错越是必然。从「追求不出错」到正视「出错是必然」的观念转变，才是微服务架构得以挑战单体的底气所在。\u003c/p\u003e\n\u003cp\u003e单体时代面对的不是分布式复杂度 —— 它根本没有分布式。它面对的是规模带来的复杂度，而这种复杂度体现在多个维度上：可维护性（一次局部改动需要整体停机重启）、团队协作（多人改同一个代码库，互相踩脚）、可靠性（单点故障拖垮全局）。系统小的时候，这些问题都不存在；但规模一旦上去，它们会同时爆发，而且在单体架构下无解 —— 因为缺乏隔离和自治能力。\u003c/p\u003e\n\u003cp\u003e为了获得这种能力，人们再次走向分布式。\u003c/p\u003e\n\u003ch3 id=\"soa-时代\"\u003eSOA 时代\u003c/h3\u003e\n\u003cp\u003e但在微服务之前，业界走过一段弯路 —— SOA。\u003c/p\u003e\n\u003cp\u003eSOA 的野心极大，它不仅要解决技术问题，还想建立一套自上而下的软件研发方法论：如何挖掘需求、如何分解业务、如何编排服务，一揽子全包。它有 IBM、Oracle 等巨头撑腰，有 SOAP 协议族做底座，有企业服务总线（ESB）做通信管道，从技术可行性上看确实解决了分布式的主要问题。\u003c/p\u003e\n\u003cp\u003e但问题恰恰出在「太完美」上。过于精密的规范带来过度的复杂性，SOAP 之上层层叠加的 ESB、BPM、SCA、SDO，让整个技术栈变成了只有少数专业人员才能驾驭的奢侈品。\u003c/p\u003e\n\u003cp\u003eSOA 与 EJB 的失败如出一辙，一旦脱离人民群众，终究会淹没在群众的海洋之中。\u003c/p\u003e","title":"架构演进的六个时代：从原始分布式到无服务"},{"content":"Marinara Engine 的官方介绍里把它称为一个本地 AI 聊天、角色扮演和游戏引擎。听起来很容易让人以为：既然是“本地”，是不是服务器上还要准备 GPU、下载模型、跑 llama.cpp 或者 embedding 服务？\n如果只是想把 Marinara 当成一个 Web 应用来用，答案是不需要。我的阿里云 ECS 现在跑的就是这种最小化部署：Docker 里只跑 Marinara Engine 本体，模型能力走外部 API，容器端口只绑定到 127.0.0.1，再由反向代理对外提供访问。\n本文记录的是这个部署方式。版本核对时间是 2026-06-29：官方最新稳定版是 v2.0.6，我当前服务器上实际运行的是 ghcr.nju.edu.cn/pasta-devs/marinara-engine:2.0.5-lite。新部署可以优先用最新稳定版的 *-lite tag；如果你想完全复刻本文环境，就固定到 2.0.5-lite。\n先理解这个“最小化”是什么意思 最小化不是功能阉割到不能用，而是把不适合小云服务器承担的部分拿掉。\nMarinara Engine 官方提供 lite 镜像。根据官方容器安装文档，lite 镜像去掉了三类较重的离线能力：\n本地 sidecar 模型，也就是容器内直接跑本地 LLM 的那部分。 本地 embedding 模型。 依赖本地 embedding 的语义记忆检索。 保留下来的能力仍然包括聊天、角色、游戏模式、agent、lorebook、角色卡、远程 LLM API 连接等。也就是说，只要你本来就打算用 OpenAI、OpenRouter、Gemini、Anthropic 或者其他远程 OpenAI-compatible API，这种部署方式就够了。\n我这里的结构是：\n浏览器 -\u0026gt; 阿里云安全组开放 80/443 -\u0026gt; Pingap / Nginx / Caddy 这类反向代理 -\u0026gt; 127.0.0.1:7860 -\u0026gt; Docker 容器内的 Marinara Engine -\u0026gt; 远程 LLM / 图片 / TTS API 看到这里，问题就变成了：服务器上到底需要跑什么？答案很少：Docker、一个 compose 文件、一个 .env 文件、一个反向代理入口。\n准备服务器 下面假设你已经能 SSH 到 ECS，并且服务器上已经安装 Docker 和 Docker Compose 插件。\n可以先确认一下：\ndocker version docker compose version 然后创建部署目录：\nmkdir -p /opt/marinara cd /opt/marinara 我建议这个目录只给 root 读写，因为 .env 里会有 Basic Auth 密码、ADMIN_SECRET 和 ENCRYPTION_KEY：\nchmod 700 /opt/marinara docker-compose.yml 这是我当前服务器上的 compose 文件整理版：\nservices: marinara: image: ghcr.nju.edu.cn/pasta-devs/marinara-engine:2.0.5-lite container_name: marinara restart: unless-stopped init: true env_file: - .env ports: - \u0026#34;127.0.0.1:7860:7860\u0026#34; volumes: - marinara-data:/app/data environment: NODE_ENV: production DATA_DIR: /app/data FILE_STORAGE_DIR: /app/data/storage MARINARA_DOCKER: \u0026#34;true\u0026#34; volumes: marinara-data: 这里有几个关键点。\n第一，镜像用的是 *-lite。如果你的服务器可以正常访问 GitHub Container Registry，可以把镜像地址换成官方地址：\nimage: ghcr.io/pasta-devs/marinara-engine:2.0.6-lite 如果在国内服务器上拉 GHCR 不稳定，也可以像我一样用镜像地址。版本最好固定，不要在生产环境里长期写 latest，否则升级时很难判断数据结构和配置行为发生了什么变化。\n第二，端口绑定是 127.0.0.1:7860:7860，不是 0.0.0.0:7860:7860。这表示 Marinara 只在服务器本机可访问，公网用户必须经过反向代理。这样即使阿里云安全组不小心开放了别的端口，7860 也不会直接暴露到公网。\n第三，数据放在 Docker named volume marinara-data 里。官方镜像会把运行时配置和文件存储放到 /app/data，所以容器删除重建不等于数据丢失。\n.env 最小化部署的重点其实在 .env。下面是一个可以直接照着改的模板：\nPORT=7860 HOST=0.0.0.0 AUTO_OPEN_BROWSER=false AUTO_CREATE_DEFAULT_CONNECTION=false LOG_LEVEL=warn BASIC_AUTH_USER=replace-with-your-user BASIC_AUTH_PASS=replace-with-a-long-password ADMIN_SECRET=replace-with-a-long-random-secret ENCRYPTION_KEY=replace-with-64-hex-chars CSRF_TRUSTED_ORIGINS=https://marinara.example.com CORS_ORIGINS=https://marinara.example.com BYPASS_AUTH_TAILSCALE=false BYPASS_AUTH_DOCKER=false REQUIRE_AUTH_FOR_DOCKER_PROXY=true ALLOW_UNAUTHENTICATED_PRIVATE_NETWORK=false ALLOW_UNAUTHENTICATED_REMOTE=false PROVIDER_LOCAL_URLS_ENABLED=false IMAGE_LOCAL_URLS_ENABLED=false TTS_LOCAL_URLS_ENABLED=false DEEPLX_LOCAL_URLS_ENABLED=false WEBHOOK_LOCAL_URLS_ENABLED=false CUSTOM_TOOL_SCRIPT_ENABLED=false SIDECAR_RUNTIME_INSTALL_ENABLED=false BACKGROUNDREMOVER_AUTO_INSTALL=false SPRITE_BACKGROUND_REMOVAL_ENGINE=builtin ENCRYPTION_KEY 可以这样生成：\nopenssl rand -hex 32 这里分三组理解就够了。\n第一组是访问控制。BASIC_AUTH_USER 和 BASIC_AUTH_PASS 会让浏览器进入 Marinara 前先弹出 HTTP Basic Auth。只要你把它放到公网，就应该配上；如果走 HTTPS 反代，Basic Auth 的账号密码才不会在明文 HTTP 里裸奔。\nADMIN_SECRET 用于高权限 API，比如备份、批量导入、更新、sidecar 安装、删除模型、自定义工具修改等。官方文档里也强调这些高风险操作需要这个 secret。它和登录密码不是一回事，建议单独生成一个长随机值。\n第二组是反向代理来源。CSRF_TRUSTED_ORIGINS 和 CORS_ORIGINS 要填浏览器实际访问的 origin。比如你用域名和 HTTPS：\nCSRF_TRUSTED_ORIGINS=https://marinara.example.com CORS_ORIGINS=https://marinara.example.com 如果只是临时用公网 IP 的 HTTP 验证，就写实际 origin，例如：\nCSRF_TRUSTED_ORIGINS=http://120.26.71.189 CORS_ORIGINS=http://120.26.71.189 有些浏览器请求或反代日志里会带上显式端口，比如 http://120.26.71.189:80。如果 Marinara 日志提示 not in CORS_ORIGINS，就把日志里出现的完整 origin 加进去，多个值用英文逗号分隔。\n第三组是“不要在这台服务器上跑本地东西”。这些开关全部关掉以后，Marinara 不会为了 provider、图片、TTS、DeepLX、webhook 或脚本工具去访问内网地址，也不会自动安装 sidecar runtime。这样更适合公开服务器：它只是一个 UI 和编排服务，真正的模型调用走你配置的外部 API。\n因为这里的 .env 是通过 Docker Compose 的 env_file 注入容器的，修改后建议重启一次容器：\ndocker compose up -d 启动和检查 在 /opt/marinara 目录执行：\ndocker compose up -d 查看容器：\ndocker compose ps 我当前服务器上的状态大概是这样：\nNAME IMAGE STATUS PORTS marinara ghcr.nju.edu.cn/pasta-devs/marinara-engine:2.0.5-lite Up 4 days 127.0.0.1:7860-\u0026gt;7860/tcp 再从服务器本机请求一下：\ncurl -I http://127.0.0.1:7860/ 如果你已经配置了 Basic Auth，看到 401 Unauthorized 不是坏事，反而说明访问控制生效了。响应头里会有类似：\nwww-authenticate: Basic realm=\u0026#34;Marinara Engine\u0026#34; 这一步能说明两件事：容器活着，端口也只在本机监听。\n反向代理 我的服务器入口用的是 Pingap。核心配置只有两段。\nupstreams.toml：\n[upstreams.marinara] addrs = [\u0026#34;127.0.0.1:7860\u0026#34;] read_timeout = \u0026#34;86400s\u0026#34; write_timeout = \u0026#34;86400s\u0026#34; idle_timeout = \u0026#34;86400s\u0026#34; locations.toml：\n[locations.marinaraIpHttp] host = \u0026#34;120.26.71.189\u0026#34; upstream = \u0026#34;marinara\u0026#34; client_max_body_size = \u0026#34;100mb\u0026#34; enable_reverse_proxy_headers = true 如果你用域名和 HTTPS，host 换成自己的域名即可。enable_reverse_proxy_headers = true 很重要，它会把原始客户端和协议相关信息传给后端，后端才能更正确地判断外部访问来源。\n如果你不用 Pingap，Nginx 写法也很直接：\nserver { listen 80; server_name marinara.example.com; client_max_body_size 100m; location / { proxy_pass http://127.0.0.1:7860; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection \u0026#34;upgrade\u0026#34;; } } 正式使用建议上 HTTPS。Basic Auth 只是编码，不是加密；真正让它安全传输的是 TLS。你可以用 Caddy 自动签证书，也可以继续用现有的 Nginx、Pingap 或其他网关统一处理证书。\n阿里云安全组怎么放 安全组只需要开放入口代理用的端口：\n使用 HTTP 验证时开放 80。 使用 HTTPS 时开放 443。 SSH 端口按你自己的运维习惯限制来源。 不要开放 7860。 容器端口本身已经绑定到 127.0.0.1，安全组再不开放 7860，就形成了两层限制。外部用户只能经过反向代理访问，后端容器不会被直接扫到。\n进入 Marinara 后怎么接模型 部署完成后，Marinara 只是一个可访问的 Web 应用。你还需要在界面里配置连接，也就是远程模型 provider。\n如果目标是“不在本机跑任何模型”，就优先选外部 API：\nOpenAI OpenRouter Google Gemini Anthropic Mistral 其他 OpenAI-compatible endpoint lite 镜像不会在容器里给你起本地模型。你也不要在同一台小 ECS 上额外跑 Ollama、llama.cpp 或 Stable Diffusion。小服务器适合做入口、存配置、存角色卡和聊天数据；模型推理交给专门的 API 或另一台有资源的机器。\n我这台 2GB 左右内存的 ECS 上，Marinara 容器空闲时大约占 200MB 内存。这个量级对小云服务器比较友好，和动辄几个 GB 起步的本地模型不是一类负担。\n更新和备份 容器部署没有自动原地更新，升级就是拉镜像再重建容器：\ncd /opt/marinara docker compose pull docker compose up -d 如果 compose 里固定了版本号，先把 image 的 tag 改到目标版本，例如从 2.0.5-lite 改到 2.0.6-lite，再执行上面的命令。\n但 Marinara 目前仍然是 alpha 软件，版本升级可能带来数据结构或行为变化。我更建议每次升级前先备份 named volume：\ndocker run --rm \\ -v marinara_marinara-data:/data \\ -v \u0026#34;$PWD\u0026#34;:/backup \\ busybox \\ tar czf /backup/marinara-data-backup.tgz -C /data . 如果你把 compose project 名改了，volume 名也会变。可以先查一下：\ndocker volume ls 我当前这台服务器上的 volume 名是：\nmarinara_marinara-data 常见问题 访问公网地址打不开，先不要急着改 Marinara。按顺序查：\ndocker compose -f /opt/marinara/docker-compose.yml ps curl -I http://127.0.0.1:7860/ systemctl status nginx systemctl status pingap 如果本机 curl 通，问题通常在反向代理或安全组；如果本机都不通，再看容器日志：\ndocker logs --tail 100 marinara 如果浏览器页面能开，但接口报 CORS 或 CSRF 错误，看日志里提示的 origin，然后补到 .env：\nCSRF_TRUSTED_ORIGINS=https://marinara.example.com CORS_ORIGINS=https://marinara.example.com 补完以后重启容器：\ndocker compose -f /opt/marinara/docker-compose.yml up -d 如果 curl -I http://127.0.0.1:7860/ 返回 401 Unauthorized，这是 Basic Auth 生效，不是服务挂了。\n如果日志里出现外部服务连接超时，例如访问翻译、图片或某个模型 API 超时，那通常不是容器部署问题，而是服务器到目标服务的网络不可达、DNS 问题、代理问题，或者目标服务本身限流。最小化部署只保证 Marinara 本体跑起来，不保证所有外部 provider 都能从你的 ECS 网络里顺畅访问。\n最后 这套部署的核心思路很简单：把 Marinara Engine 当成一个 Web 应用和数据入口，而不是把它当成推理服务器。\n小 ECS 负责：\n跑 Marinara 的 Web 服务。 保存配置、角色、聊天和上传文件。 通过反向代理提供统一入口。 用 Basic Auth、ADMIN_SECRET、CORS/CSRF origin 和安全组把边界收住。 模型服务负责：\nLLM 推理。 图片生成。 TTS 或其他重计算能力。 这样部署的好处是很直接的：服务器成本低，维护面小，升级回滚也清楚。等以后真的需要本地模型、私有 embedding 或语义记忆，再换 full 镜像或者单独加推理机器也不迟。\n参考资料：\nMarinara Engine GitHub 仓库 Container Installation Guide Configuration Reference ","permalink":"https://cloudside.icyyan.com/posts/marinara-engine-minimal-deployment/","summary":"\u003cp\u003eMarinara Engine 的官方介绍里把它称为一个本地 AI 聊天、角色扮演和游戏引擎。听起来很容易让人以为：既然是“本地”，是不是服务器上还要准备 GPU、下载模型、跑 llama.cpp 或者 embedding 服务？\u003c/p\u003e\n\u003cp\u003e如果只是想把 Marinara 当成一个 Web 应用来用，答案是不需要。我的阿里云 ECS 现在跑的就是这种最小化部署：Docker 里只跑 Marinara Engine 本体，模型能力走外部 API，容器端口只绑定到 \u003ccode\u003e127.0.0.1\u003c/code\u003e，再由反向代理对外提供访问。\u003c/p\u003e\n\u003cp\u003e本文记录的是这个部署方式。版本核对时间是 \u003cstrong\u003e2026-06-29\u003c/strong\u003e：官方最新稳定版是 \u003ccode\u003ev2.0.6\u003c/code\u003e，我当前服务器上实际运行的是 \u003ccode\u003eghcr.nju.edu.cn/pasta-devs/marinara-engine:2.0.5-lite\u003c/code\u003e。新部署可以优先用最新稳定版的 \u003ccode\u003e*-lite\u003c/code\u003e tag；如果你想完全复刻本文环境，就固定到 \u003ccode\u003e2.0.5-lite\u003c/code\u003e。\u003c/p\u003e\n\u003ch2 id=\"先理解这个最小化是什么意思\"\u003e先理解这个“最小化”是什么意思\u003c/h2\u003e\n\u003cp\u003e最小化不是功能阉割到不能用，而是把不适合小云服务器承担的部分拿掉。\u003c/p\u003e\n\u003cp\u003eMarinara Engine 官方提供 lite 镜像。根据官方容器安装文档，lite 镜像去掉了三类较重的离线能力：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e本地 sidecar 模型，也就是容器内直接跑本地 LLM 的那部分。\u003c/li\u003e\n\u003cli\u003e本地 embedding 模型。\u003c/li\u003e\n\u003cli\u003e依赖本地 embedding 的语义记忆检索。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e保留下来的能力仍然包括聊天、角色、游戏模式、agent、lorebook、角色卡、远程 LLM API 连接等。也就是说，只要你本来就打算用 OpenAI、OpenRouter、Gemini、Anthropic 或者其他远程 OpenAI-compatible API，这种部署方式就够了。\u003c/p\u003e\n\u003cp\u003e我这里的结构是：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e浏览器\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  -\u0026gt; 阿里云安全组开放 80/443\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  -\u0026gt; Pingap / Nginx / Caddy 这类反向代理\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  -\u0026gt; 127.0.0.1:7860\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  -\u0026gt; Docker 容器内的 Marinara Engine\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  -\u0026gt; 远程 LLM / 图片 / TTS API\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e看到这里，问题就变成了：服务器上到底需要跑什么？答案很少：Docker、一个 compose 文件、一个 \u003ccode\u003e.env\u003c/code\u003e 文件、一个反向代理入口。\u003c/p\u003e","title":"在阿里云 ECS 上最小化部署 Marinara Engine：不在服务器上跑本地模型"},{"content":"很多人第一次关心 Python 的垃圾回收，不是因为写了多复杂的代码，而是因为遇到了几个很实际的问题：\ndel obj 之后，内存为什么没有立刻降下来？ 明明没有全局变量引用某个对象，它为什么还活着？ Python 不是有垃圾回收吗，为什么还会出现内存泄漏？ gc.collect() 到底该不该在业务代码里手动调用？ 这些问题背后其实是同一个机制：Python 的内存回收不是单一算法，而是几层机制一起工作。平时我们说的“Python 垃圾回收”，在最常见的 CPython 解释器里，主要由两部分组成：\n引用计数：对象引用数变成 0 时，通常立刻释放。 循环垃圾回收器：专门处理引用计数解决不了的循环引用。 这篇文章主要讨论 CPython，因为日常用 python 命令启动的解释器，大多数情况下就是 CPython。PyPy、Jython、IronPython 等实现可以采用不同策略，所以不要把本文所有细节直接套到每一种 Python 实现上。\n先从变量和对象的关系说起 在 Python 里，变量不是装对象的盒子，更像是贴在对象上的名字。\na = [1, 2, 3] b = a 这段代码里没有创建两个列表。它创建了一个列表对象，然后让 a 和 b 都指向它：\na ─┐ ├──\u0026gt; [1, 2, 3] b ─┘ 执行：\ndel a 删除的也不是列表对象本身，而是删除名字 a 到列表对象的那条引用。因为 b 还指向这个列表，所以对象仍然活着：\nb ───\u0026gt; [1, 2, 3] 这就是理解 Python 垃圾回收的入口：对象什么时候能被回收，取决于还有没有地方能继续访问它。\ndel 的含义也要顺手纠正一下：del name 删除的是名字绑定；del obj.attr 删除的是属性引用；del some_list[i] 删除的是容器里的一个引用。它们都不等于“立刻把某块内存还给操作系统”。\n第一层：引用计数让多数对象及时释放 CPython 里，每个普通对象都会维护一个引用计数。可以把它理解成：\n这个对象现在被多少个地方指着？ 当有新的引用指向对象时，引用计数增加；当一个引用消失时，引用计数减少。引用计数减到 0，CPython 就知道这个对象已经无法再被程序访问，于是可以释放它。\n看一个简单例子：\nclass Watch: def __del__(self): print(\u0026#34;Watch object is being destroyed\u0026#34;) x = Watch() y = x del x print(\u0026#34;after del x\u0026#34;) del y print(\u0026#34;after del y\u0026#34;) 在 CPython 中，常见输出是：\nafter del x Watch object is being destroyed after del y del x 之后对象没有销毁，因为 y 还引用着它。del y 之后最后一个引用也没了，对象才被释放。\n引用计数的优点很明显：大多数对象的释放非常及时。函数返回时，局部变量引用消失；临时列表用完后引用消失；旧值被新值覆盖后，旧对象的引用也会减少。\n比如：\ndef build(): data = [1, 2, 3] return sum(data) build() 返回后，局部变量 data 消失。如果没有别的对象引用那个列表，列表的引用计数会归零，CPython 可以马上释放它。\n这也是很多 Python 程序“看起来不怎么需要管内存”的原因：大量短命对象靠引用计数已经被处理掉了。\nsys.getrefcount 可以观察，但不要迷信 Python 提供了 sys.getrefcount()，可以粗略观察某个对象的引用计数：\nimport sys items = [] print(sys.getrefcount(items)) other = items print(sys.getrefcount(items)) del other print(sys.getrefcount(items)) 不过它有两个常见坑。\n第一个坑是：把对象传给 sys.getrefcount() 本身，也会临时多一个引用。所以你看到的数字通常比直觉多 1。\n第二个坑是：新版本 CPython 对一些对象做了特殊优化。例如小整数、常用常量、部分内部对象可能是“近似永久存在”的对象，引用计数值不适合拿来当精确业务指标。\n所以 sys.getrefcount() 更适合用来帮助理解引用关系，不适合在生产代码里拿来判断对象生命周期。\n引用计数解决不了循环引用 引用计数最大的问题是：它只会数引用数量，不知道这些引用是不是已经和外部世界断开了。\n看一个最小的循环引用：\nitems = [] items.append(items) 现在这个列表里面装着它自己：\nitems ───\u0026gt; list │ └──\u0026gt; self 如果执行：\ndel items 外部变量 items 没了，但列表内部仍然有一个指向自己的引用。引用计数不会变成 0。单靠引用计数，这个列表就永远不会被释放。\n再看一个更接近业务代码的例子：\nclass Node: def __init__(self, name): self.name = name self.parent = None self.children = [] root = Node(\u0026#34;root\u0026#34;) child = Node(\u0026#34;child\u0026#34;) root.children.append(child) child.parent = root 这里 root 指向 child，child 又指回 root：\nroot ──children──\u0026gt; child ^ │ └──── parent ──────┘ 树、图、双向链表、缓存索引、观察者模式，都很容易写出这样的结构。如果外部不再引用 root 和 child，它们其实已经没用了，但引用计数仍然可能不为 0。\n这就是循环垃圾回收器要解决的问题。\n第二层：循环 GC 只处理可能成环的容器 CPython 的循环垃圾回收器不会扫描所有对象。整数、字符串、字节串这类原子对象不会自己引用别的 Python 对象，单独看它们不可能形成循环。\n真正需要关注的是容器对象，例如：\nlist dict set tuple 自定义类实例 一些 C 扩展类型对象 这些对象可能保存对其他对象的引用，也就可能组成环。\n可以用 gc.is_tracked() 看一个对象当前是否被循环 GC 跟踪：\nimport gc print(gc.is_tracked(123)) print(gc.is_tracked(\u0026#34;python\u0026#34;)) print(gc.is_tracked([])) print(gc.is_tracked(object())) 不同 Python 版本和对象内部状态会影响具体结果，尤其是元组、字典这类容器会有额外优化。这里要抓住的是大方向：循环 GC 主要盯着能引用别人的对象，而不是每个整数、每个字符串都扫一遍。\n看到这里，问题就变成了：循环 GC 怎么判断一个环是真的垃圾，而不是仍然能从外部访问？\n循环引用检测的核心直觉 假设现在有两组对象：\n外部变量 alive │ v A ───\u0026gt; B ───\u0026gt; C ^ │ └───────────┘ X ───\u0026gt; Y ^ │ └─────┘ A-B-C 是一个环，但它能从外部变量 alive 访问到，所以它不能被回收。\nX-Y 也是一个环，但没有任何外部引用能访问到它，所以它应该被回收。\n循环 GC 要做的事情，就是从一批候选容器对象里判断：\n哪些对象只是彼此引用，但已经没有外部入口？ CPython 的算法可以简化成几个步骤理解。\n第一步，选出一批候选对象。默认构建里，这批对象通常来自某一代或几代 GC 跟踪对象。\n第二步，给候选对象准备一个临时引用计数。这个临时计数来自对象真实的引用计数，但 GC 可以安全地修改它，不会破坏对象本身。\n第三步，遍历候选对象之间的引用。每看到一个“候选对象引用另一个候选对象”，就把被引用对象的临时计数减 1。\n这一步的含义是：先把候选集合内部互相撑起来的引用扣掉。\n扣完之后，如果某个对象的临时计数仍然大于 0，说明它至少还有来自候选集合外部的引用。它是活的。从它继续能到达的对象，也都是活的。\n如果一组对象扣完之后没有外部引用能到达，就会被放进“暂时不可达”的集合。等 GC 再确认没有活对象能连到它们，就可以把它们当作真正的循环垃圾处理。\n可以把这个过程想成：\n真实引用计数 - 候选集合内部引用 = 外部入口贡献的引用 如果外部入口贡献为 0，并且没有活对象能再走到它，这批对象就已经和程序断开了。\n这个算法的关键不在于“有没有环”，而在于“有没有外部入口”。有环但可达，不能回收；无外部入口的环，才是循环垃圾。\nGC 找到循环垃圾后怎么销毁 找到不可达对象只是第一步，真正销毁还要小心处理几个问题。\n假设两个对象互相引用：\nclass Node: def __init__(self): self.other = None a = Node() b = Node() a.other = b b.other = a del a del b 这两个对象已经没有外部变量引用，但它们互相指着。循环 GC 确认它们不可达后，会打断它们内部的引用关系。内部引用一断，引用计数就会继续下降，最后对象被释放。\n销毁阶段还要处理弱引用和 finalizer。\n弱引用不会增加目标对象的引用计数，常用于缓存、反向引用和观察者关系：\nimport weakref class User: pass u = User() ref = weakref.ref(u) print(ref() is u) del u print(ref()) 当目标对象销毁后，ref() 会返回 None。\n如果对象定义了 __del__，事情会更复杂。现代 CPython 已经能处理大多数带 __del__ 的循环引用，但 __del__ 仍然有几个风险：\nfinalizer 的调用顺序不适合承载复杂业务逻辑。 finalizer 里可能让对象重新变得可达，也就是“复活”对象。 解释器退出阶段，全局变量可能已经被清理，__del__ 里访问模块状态容易遇到奇怪结果。 所以文件、锁、socket、数据库连接这类资源，不要依赖垃圾回收来释放。更稳妥的写法是上下文管理器：\nwith open(\u0026#34;data.txt\u0026#34;, \u0026#34;w\u0026#34;, encoding=\u0026#34;utf-8\u0026#34;) as f: f.write(\u0026#34;hello\u0026#34;) 或者显式使用 try/finally：\nresource = acquire_resource() try: use(resource) finally: resource.close() 垃圾回收负责内存对象的生命周期兜底，不应该承担关键外部资源的释放时机。\n分代 GC：为什么不每次都扫全部对象 如果每创建一些对象就全量扫描所有容器，程序会很慢。CPython 默认构建使用分代收集来降低成本。\n它利用的是一个很朴素的观察：大多数对象很快就会死掉。\n比如函数里的临时列表、一次请求里的临时字典、一次解析过程里的中间对象，通常活不过几毫秒。相反，配置对象、路由表、模块对象、长期缓存，一旦活下来，往往会活很久。\n所以 CPython 把被 GC 跟踪的对象按“年龄”分代：\n分代 直觉 收集频率 generation 0 年轻对象，新创建的容器通常先在这里 最频繁 generation 1 活过一轮的对象 较少 generation 2 活得更久的对象 最少 对象如果在一次 GC 后仍然活着，就可能被移动到更老的一代。年轻代收集便宜，可以经常做；老年代对象多、扫描贵，就要更克制。\n可以用下面的代码观察阈值和计数：\nimport gc print(gc.get_count()) print(gc.get_threshold()) print(gc.get_stats()) 触发逻辑可以这样理解：\nCPython 记录自上次收集以来的对象分配数和释放数。 当“分配数减释放数”超过 threshold0，就会触发年轻代收集。 年轻代被收集多次后，会进一步检查更老的一代。 最老一代的完整收集更谨慎，因为成本和长期存活对象数量相关。 具体阈值不要背死。不同 Python 版本、不同构建方式、不同启动环境都可能变化。需要调优时，应该在目标环境里看 gc.get_threshold()、gc.get_count() 和 gc.get_stats() 的真实数据。\n这里还要注意 Python 3.14 之后的一个版本细节：截至 Python 3.14.6 官方文档，普通 CPython 构建的 gc 接口仍按三代统计；threshold2 在 3.14.5 起恢复为匹配 Python 3.13 的行为。读旧资料时，如果看到“3.14 移除了 generation 1”或“threshold2 被忽略”，要核对具体小版本。\nfree-threaded build 有什么不同 Python 3.13 开始引入了可选的 free-threaded build，也就是去掉全局解释器锁的构建方向。它的 GC 实现和普通 GIL 构建不完全一样。\n普通构建依赖 GIL 保证 GC 期间对象图不会被其他 Python 线程同时改乱。free-threaded build 没有这个前提，所以 GC 需要暂停其他正在执行的线程，才能安全地检查引用关系。\nCPython 内部文档也说明，free-threaded build 使用不同的数据结构来追踪对象，循环 GC 的扫描策略和普通分代 GC 不完全相同。官方 gc 模块文档还提到，free-threaded build 在运行收集前会额外参考进程内存增长情况，避免只因为分配计数达到阈值就过早触发。\n对普通 Python 使用者来说，这部分通常不用每天关心。但如果你在测试无 GIL Python、写 C 扩展、或者排查多线程程序里的 GC 停顿，就不能只拿传统 GIL 构建的经验直接判断。\n为什么对象释放了，内存还是没降 这是 Python 内存问题里最常见的误解之一。\n对象被回收，表示这个 Python 对象不能再被访问，它占用的那块内存可以被解释器复用。但这不等于进程 RSS 一定马上下降。\n原因有几类。\n第一，CPython 有自己的内存分配器。很多小对象由 pymalloc 管理，内存会按 arena、pool、block 这样的层次复用。对象释放后，空间可能回到 Python 的分配器里，留给后续对象使用，而不是立刻还给操作系统。\n第二，一些内置类型有 free list。某些对象释放后，会被放进内部空闲列表，方便下次快速复用。完整 GC 会清理部分内置类型的 free list，但不是所有内存都会因此还给系统。\n第三，RSS 包含的不只是 Python 对象。C 扩展、图片处理库、NumPy、Pandas、PyTorch、数据库客户端、压缩库，都可能在 Python GC 管理范围之外分配内存。\n第四，内存碎片也会影响观感。即使很多小块已经空出来，只要所在的大块内存还夹着活对象，操作系统也未必能整块回收。\n所以看到“对象已经释放，但进程内存没降”，不一定是泄漏。更实际的问题应该是：\n这部分内存后续能不能被复用？ 长期观察是否持续增长？ 增长来自 Python 对象，还是来自 C 扩展和外部分配器？ 这就是下一节要解释的地方：该怎么观察 GC 和内存。\ngc 模块能帮我们看什么 gc 模块提供了一组调试和调优接口。日常最常用的是这几个：\nimport gc print(gc.isenabled()) print(gc.get_count()) print(gc.get_threshold()) print(gc.get_stats()) gc.get_count() 返回当前三代计数。它不是对象数量，而是 GC 用来判断触发时机的计数。\ngc.get_threshold() 返回触发阈值。\ngc.get_stats() 返回每代从解释器启动以来的统计信息，比如收集次数、收集到的对象数量、不可回收对象数量。\n如果想手动触发一次完整收集，可以调用：\nimport gc collected = gc.collect() print(collected) gc.collect() 返回收集到的对象数加上不可收集对象数。它适合在测试、调试、基准测试边界、批处理阶段结束后使用，不适合在每个请求、每次循环里随手调用。手动全量 GC 可能制造明显停顿。\n调试疑似循环泄漏时，可以这样做：\nimport gc gc.set_debug(gc.DEBUG_SAVEALL) # 运行一段可疑代码 gc.collect() print(len(gc.garbage)) DEBUG_SAVEALL 会让不可达对象先留在 gc.garbage 里，方便检查。但它也会阻止这些对象真正释放，所以只应该在调试环境使用，查完要关闭调试标志并清理状态。\n还可以用：\ngc.get_referrers(obj) gc.get_referents(obj) 它们分别查看“谁引用了这个对象”和“这个对象引用了谁”。这两个接口对排查引用链很有用，但返回结果里可能包含解释器内部对象、临时栈帧、调试器对象。不要在业务逻辑里依赖它们。\n如果要排查“哪里分配了内存”，tracemalloc 往往比直接看 GC 更有帮助：\nimport tracemalloc tracemalloc.start() # 运行可疑代码 snapshot = tracemalloc.take_snapshot() for stat in snapshot.statistics(\u0026#34;lineno\u0026#34;)[:10]: print(stat) tracemalloc 关注 Python 层内存分配位置。它和 gc 解决的问题不一样：gc 帮你理解对象能不能被回收，tracemalloc 帮你定位内存是从哪行代码分配出来的。\n一个可运行的循环回收实验 下面这个实验能直观看到循环引用为什么需要 GC：\nimport gc import weakref class Node: pass gc.disable() node = Node() node.self = node ref = weakref.ref(node) del node print(ref() is None) gc.collect() print(ref() is None) gc.enable() 第一行输出通常是：\nFalse 因为 node 变量虽然没了，但对象还通过 self 属性引用自己。引用计数没有归零。\n手动 gc.collect() 后，输出变成：\nTrue 循环 GC 找到这个不可达的自环对象，打断内部引用，对象被释放，弱引用也失效了。\n这个例子也说明了一件事：关闭循环 GC 不等于关闭所有内存回收。引用计数仍然会工作。被影响的是那些引用计数无法处理的循环引用。\n什么情况下需要调 GC 参数 大多数程序不需要调 GC。默认参数已经能覆盖大量普通场景。\n需要考虑调优的情况通常更具体：\n程序频繁创建大量短命容器，GC 触发太频繁，带来明显停顿。 程序保留了大量长期存活对象，完整收集成本很高。 批处理任务在某个阶段制造大量循环对象，希望阶段结束后集中回收。 prefork 服务希望减少 fork() 后的 copy-on-write 内存损失。 调阈值可以用：\nimport gc old = gc.get_threshold() gc.set_threshold(3000, 10, 10) 但这类修改一定要用指标验证。观察至少应该包括：\nGC 每代收集次数。 每次收集耗时。 请求延迟或任务耗时。 进程内存长期曲线。 gc.get_stats() 里的 collected 和 uncollectable。 如果只是凭感觉把阈值调大，可能短期减少停顿，长期积累更多循环垃圾；把阈值调小，又可能让程序把更多时间花在 GC 上。\n对于 prefork 场景，gc.freeze() 是一个更特殊的工具。官方文档建议的思路是：父进程尽早 gc.disable()，在 fork() 前 gc.freeze()，子进程启动后再 gc.enable()。目的不是“让 GC 更强”，而是尽量减少父进程里长期对象的内存页在子进程中被写脏，从而保留 copy-on-write 共享。\n这类技巧适合 Gunicorn、uWSGI 这类预加载应用后再 fork worker 的服务。普通脚本通常没必要使用。\n常见误区 误区一：Python 只有标记清除 GC。\n不准确。CPython 首先依赖引用计数；循环 GC 是补充机制。循环 GC 的检测过程会分析对象图里的可达性，但不能把 CPython 简化成“只有标记清除”。\n误区二：del x 就是释放内存。\ndel x 只是删除一个引用。如果还有别的引用，对象继续活着。即使对象释放，内存也可能先回到 CPython 分配器，而不是立刻回到操作系统。\n误区三：手动 gc.collect() 能解决内存泄漏。\n它只能回收已经不可达的循环垃圾。如果对象仍然被全局缓存、闭包、日志上下文、任务队列、线程局部变量引用着，gc.collect() 不会把它变成垃圾。\n误区四：禁用 GC 可以提升性能，所以线上应该关掉。\n禁用循环 GC 只适合非常明确的场景，比如确认程序不会制造循环引用，或者在某个短时间关键阶段临时关闭。长期关闭可能让循环垃圾越积越多。\n误区五：看到 RSS 不降就是 GC 没工作。\nRSS 不降可能来自 Python 分配器复用、内存碎片、C 扩展分配、系统分配器策略。判断泄漏要看长期趋势和对象引用链，不能只看某一刻的进程内存。\n实战排查思路 如果线上 Python 服务内存持续增长，可以按这个顺序排查。\n第一，看增长是不是稳定可复现。偶发高峰后保持平台，不一定是泄漏；每小时持续抬升，更值得追。\n第二，用 tracemalloc 或采样工具找 Python 层分配热点。先回答“内存从哪里分配出来”。\n第三，用对象统计工具看哪些类型数量持续增长。可以结合 gc.get_objects() 做粗略统计，但要注意它只覆盖 GC 跟踪对象。\n第四，针对可疑对象查引用链。gc.get_referrers()、objgraph、调试器都可以帮忙，但要过滤掉调试工具自身制造的引用。\n第五，区分 Python 堆和 native 内存。如果 Python 对象数量稳定，但 RSS 持续增长，重点看 C 扩展、缓存库、模型推理框架、内存映射文件和系统分配器。\n第六，确认资源释放策略。文件、socket、连接池、线程、进程、GPU 显存都不应该只靠对象析构来管理。\n这套顺序比上来就调 gc.set_threshold() 更可靠。GC 参数调优只能优化回收节奏，不能替你修掉仍然被引用的对象。\n最后总结 理解 Python 垃圾回收，可以记住这条主线：\n普通对象生命周期： 引用计数归零 -\u0026gt; 立刻释放 循环引用对象： 引用计数无法归零 -\u0026gt; 等循环 GC 找到不可达环 -\u0026gt; 打断引用 -\u0026gt; 释放 回收到 Python 分配器： 对象不可访问 -\u0026gt; 内存可复用 但进程 RSS 不一定马上下降 再压缩成几句话：\nPython 变量是引用，不是对象本身。 CPython 的第一层回收是引用计数，所以多数对象释放很及时。 引用计数处理不了循环引用，循环 GC 专门补这个缺口。 循环 GC 主要扫描容器对象，不会无脑扫描所有值。 分代 GC 是为了减少扫描成本，利用“大多数对象死得早”这个事实。 gc.collect() 是调试和边界控制工具，不是内存泄漏万能药。 外部资源要用上下文管理器，不能指望 GC 按你想要的时间释放。 把这几层分开后，很多 Python 内存问题就不再神秘：对象为什么还活着，要看引用链；内存为什么不降，要看分配器和 RSS；程序为什么偶尔停顿，要看循环 GC 触发和扫描成本。\n参考资料 Python gc 模块文档 CPython Garbage collector design Python/C API Reference Counting Python weakref 模块文档 ","permalink":"https://cloudside.icyyan.com/posts/python-garbage-collection-guide/","summary":"\u003cp\u003e很多人第一次关心 Python 的垃圾回收，不是因为写了多复杂的代码，而是因为遇到了几个很实际的问题：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003edel obj\u003c/code\u003e 之后，内存为什么没有立刻降下来？\u003c/li\u003e\n\u003cli\u003e明明没有全局变量引用某个对象，它为什么还活着？\u003c/li\u003e\n\u003cli\u003ePython 不是有垃圾回收吗，为什么还会出现内存泄漏？\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003egc.collect()\u003c/code\u003e 到底该不该在业务代码里手动调用？\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e这些问题背后其实是同一个机制：Python 的内存回收不是单一算法，而是几层机制一起工作。平时我们说的“Python 垃圾回收”，在最常见的 CPython 解释器里，主要由两部分组成：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e引用计数：对象引用数变成 0 时，通常立刻释放。\u003c/li\u003e\n\u003cli\u003e循环垃圾回收器：专门处理引用计数解决不了的循环引用。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e这篇文章主要讨论 CPython，因为日常用 \u003ccode\u003epython\u003c/code\u003e 命令启动的解释器，大多数情况下就是 CPython。PyPy、Jython、IronPython 等实现可以采用不同策略，所以不要把本文所有细节直接套到每一种 Python 实现上。\u003c/p\u003e\n\u003ch2 id=\"先从变量和对象的关系说起\"\u003e先从变量和对象的关系说起\u003c/h2\u003e\n\u003cp\u003e在 Python 里，变量不是装对象的盒子，更像是贴在对象上的名字。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-python\" data-lang=\"python\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ea \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e [\u003cspan style=\"color:#ae81ff\"\u003e1\u003c/span\u003e, \u003cspan style=\"color:#ae81ff\"\u003e2\u003c/span\u003e, \u003cspan style=\"color:#ae81ff\"\u003e3\u003c/span\u003e]\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eb \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e a\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e这段代码里没有创建两个列表。它创建了一个列表对象，然后让 \u003ccode\u003ea\u003c/code\u003e 和 \u003ccode\u003eb\u003c/code\u003e 都指向它：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ea ─┐\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e   ├──\u0026gt; [1, 2, 3]\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eb ─┘\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e执行：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-python\" data-lang=\"python\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003edel\u003c/span\u003e a\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e删除的也不是列表对象本身，而是删除名字 \u003ccode\u003ea\u003c/code\u003e 到列表对象的那条引用。因为 \u003ccode\u003eb\u003c/code\u003e 还指向这个列表，所以对象仍然活着：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eb ───\u0026gt; [1, 2, 3]\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e这就是理解 Python 垃圾回收的入口：\u003cstrong\u003e对象什么时候能被回收，取决于还有没有地方能继续访问它。\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003edel\u003c/code\u003e 的含义也要顺手纠正一下：\u003ccode\u003edel name\u003c/code\u003e 删除的是名字绑定；\u003ccode\u003edel obj.attr\u003c/code\u003e 删除的是属性引用；\u003ccode\u003edel some_list[i]\u003c/code\u003e 删除的是容器里的一个引用。它们都不等于“立刻把某块内存还给操作系统”。\u003c/p\u003e","title":"Python 垃圾回收机制：引用计数、循环引用和分代 GC"},{"content":"学 Rust 时，Box\u0026lt;T\u0026gt; 很容易被一句话带过：它可以把数据放到堆上。\n这句话没错，但如果只记住这一句，后面看到 Box\u0026lt;dyn Trait\u0026gt;、Box\u0026lt;List\u0026gt;、Pin\u0026lt;Box\u0026lt;T\u0026gt;\u0026gt;、Box::leak 时，还是会觉得它像一个突然冒出来的语法补丁。\n更好的理解方式是：Box\u0026lt;T\u0026gt; 不是“逃离所有权系统”的工具，而是 Rust 所有权系统里最基础的一种拥有型指针。它让一个值住在堆上，同时让所有权仍然清清楚楚地归某个变量管理。\n先看 Box 解决了什么问题 普通变量通常可以这样理解：\nlet n = 42; n 这个值本身就放在当前栈帧里。栈很快，进入函数时分配，函数返回时回收，生命周期也很清楚。\n但有些场景光靠栈不够舒服：\n一个类型递归地包含自己，编译器算不出它的大小。 你想把不同具体类型放进同一个集合，只要求它们实现同一个 trait。 一个值很大，你希望移动时只移动一个指针。 某些 API 需要一个固定地址的拥有型对象，比如配合 Pin 使用。 这时 Box\u0026lt;T\u0026gt; 就出现了。\nlet n = Box::new(42); 可以把它粗略想成这样：\n栈上变量 n -\u0026gt; 保存一个指针 -\u0026gt; 指向堆上的 42 Box\u0026lt;T\u0026gt; 本身在栈上，里面保存指向堆数据的指针。真正的 T 在堆上。变量离开作用域时，Box\u0026lt;T\u0026gt; 会自动释放堆上的 T。\n所以 Box\u0026lt;T\u0026gt; 同时有两个特点：\n它是指针，可以间接访问堆上的值。 它拥有这个值，离开作用域时负责释放它。 这和 C 里的裸指针很不一样。你不用手写 free，也不能随便复制出多个拥有者。Rust 仍然会检查所有权、移动和借用。\n最基本的用法：Box::new 创建一个 Box\u0026lt;T\u0026gt; 最常见的方式是 Box::new：\nfn main() { let name = Box::new(String::from(\u0026#34;cloudside\u0026#34;)); println!(\u0026#34;{name}\u0026#34;); } 这里 String 这个值由 Box 放到堆上管理，name 是一个拥有它的 Box\u0026lt;String\u0026gt;。注意，String 自己内部还会管理一块字符串缓冲区；这里说的是 String 这个三字段结构本身的位置。\n访问里面的值时，很多时候你甚至感觉不到 Box 的存在，因为 Box\u0026lt;T\u0026gt; 实现了 Deref：\nfn print_len(s: \u0026amp;String) { println!(\u0026#34;{}\u0026#34;, s.len()); } fn main() { let boxed = Box::new(String::from(\u0026#34;rust\u0026#34;)); print_len(\u0026amp;boxed); } print_len 需要的是 \u0026amp;String，我们传进去的是 \u0026amp;Box\u0026lt;String\u0026gt;。Rust 会通过 deref coercion 自动把它转成 \u0026amp;String。\n如果确实要取出里面的值，可以解引用：\nfn main() { let boxed = Box::new(String::from(\u0026#34;hello\u0026#34;)); let value: String = *boxed; println!(\u0026#34;{value}\u0026#34;); } 这一步会把 String 从 Box 里移动出来。移动之后，原来的 boxed 就不能再用了。\nBox 不是引用，也不是 Rc 看到 Box\u0026lt;T\u0026gt; 里有个指针，容易把它和引用混在一起。\n区别其实很关键：\nlet value = String::from(\u0026#34;rust\u0026#34;); let borrowed = \u0026amp;value; let boxed = Box::new(value); borrowed 是借用。它不拥有 String，只是临时看一眼。\nboxed 是拥有。执行 Box::new(value) 之后，value 的所有权被移动进 Box，后面由 boxed 负责释放。\n再看 Rc\u0026lt;T\u0026gt;：\nuse std::rc::Rc; let a = Rc::new(String::from(\u0026#34;rust\u0026#34;)); let b = Rc::clone(\u0026amp;a); Rc\u0026lt;T\u0026gt; 是引用计数，可以有多个拥有者。Box\u0026lt;T\u0026gt; 只有一个拥有者。\n所以大致可以这样分：\n类型 所有权含义 常见用途 \u0026amp;T 借用，不拥有 临时读取 \u0026amp;mut T 可变借用，不拥有 临时修改 Box\u0026lt;T\u0026gt; 单一拥有者 堆分配、递归类型、trait object Rc\u0026lt;T\u0026gt; 单线程多个拥有者 共享不可变数据 Arc\u0026lt;T\u0026gt; 多线程多个拥有者 跨线程共享数据 看到这里，Box\u0026lt;T\u0026gt; 的定位就比较清楚了：它不是共享工具，而是“我拥有一个堆上的值”。\n场景一：递归类型必须用 Box 打断无限大小 Box\u0026lt;T\u0026gt; 最经典的用途，是定义递归类型。\n比如我们想写一个最简单的链表：\nenum List { Cons(i32, List), Nil, } 这段代码不能通过编译。原因是 List 里面又直接包含一个 List，编译器没法算出它到底有多大。\n可以展开想一下：\nList = i32 + List = i32 + i32 + List = i32 + i32 + i32 + List = ... 这个类型的大小没有尽头。\n用 Box 之后就不一样了：\nenum List { Cons(i32, Box\u0026lt;List\u0026gt;), Nil, } fn main() { let list = List::Cons( 1, Box::new(List::Cons( 2, Box::new(List::Cons(3, Box::new(List::Nil))), )), ); } 现在 Cons 里放的不是完整的下一个 List，而是一个指向下一个 List 的 Box\u0026lt;List\u0026gt;。\n编译器看到的是：\nList = i32 + 一个固定大小的指针 指针大小是固定的，所以整个 enum 的大小也能确定。\n这就是 Box 在递归类型里的作用：不是让递归消失，而是用一层指针把“无限嵌套的值”变成“固定大小的指针”。\n场景二：Box 用来做动态分发 另一个常见场景是 trait object。\n假设我们有一组组件，它们都能 draw：\ntrait Draw { fn draw(\u0026amp;self); } struct Button; struct Text; impl Draw for Button { fn draw(\u0026amp;self) { println!(\u0026#34;draw button\u0026#34;); } } impl Draw for Text { fn draw(\u0026amp;self) { println!(\u0026#34;draw text\u0026#34;); } } 如果想把 Button 和 Text 放进同一个 Vec，不能直接写：\n// 这不是可行写法 // let components = vec![Button, Text]; 因为 Vec\u0026lt;T\u0026gt; 要求所有元素是同一个具体类型。Button 和 Text 都实现了 Draw，但它们不是同一个类型。\n这时可以用 Box\u0026lt;dyn Draw\u0026gt;：\ntrait Draw { fn draw(\u0026amp;self); } struct Button; struct Text; impl Draw for Button { fn draw(\u0026amp;self) { println!(\u0026#34;draw button\u0026#34;); } } impl Draw for Text { fn draw(\u0026amp;self) { println!(\u0026#34;draw text\u0026#34;); } } fn main() { let components: Vec\u0026lt;Box\u0026lt;dyn Draw\u0026gt;\u0026gt; = vec![ Box::new(Button), Box::new(Text), ]; for component in components { component.draw(); } } 这里 dyn Draw 表示“某个实现了 Draw 的具体类型，但编译期不知道它到底是哪一个”。\n为什么还要套一层 Box？\n因为 dyn Draw 的大小在编译期不确定。不同实现类型可能有不同大小，不能直接放进 Vec。但 Box\u0026lt;dyn Draw\u0026gt; 是固定大小的指针，所以可以放进 Vec。\n这时调用 draw 会走动态分发：运行时根据具体类型找到对应的方法实现。\n泛型和 Box 怎么选 如果具体类型在编译期就能确定，优先考虑泛型：\ntrait Draw { fn draw(\u0026amp;self); } fn render\u0026lt;T: Draw\u0026gt;(component: T) { component.draw(); } 泛型会让编译器为具体类型生成代码，通常没有动态分发开销，也能保留更强的类型信息。\n如果你需要“不同类型放在同一个集合里”，或者 API 的调用方只关心 trait 行为，不关心具体类型，就可以考虑 Box\u0026lt;dyn Trait\u0026gt;：\ntrait Draw { fn draw(\u0026amp;self); } fn build_component(kind: \u0026amp;str) -\u0026gt; Box\u0026lt;dyn Draw\u0026gt; { match kind { \u0026#34;button\u0026#34; =\u0026gt; Box::new(Button), \u0026#34;text\u0026#34; =\u0026gt; Box::new(Text), _ =\u0026gt; Box::new(Text), } } struct Button; struct Text; impl Draw for Button { fn draw(\u0026amp;self) {} } impl Draw for Text { fn draw(\u0026amp;self) {} } 一个简单判断是：\n类型固定、追求静态分发：用泛型。 类型不固定、需要统一装进容器或返回统一接口：用 Box\u0026lt;dyn Trait\u0026gt;。 场景三：移动大对象时只移动指针 Rust 的 move 语义经常会让新手担心：一个很大的结构体被 move，是不是会复制很多内存？\n先看普通结构体：\nstruct BigData { bytes: [u8; 1024 * 1024], } fn consume(data: BigData) { println!(\u0026#34;{}\u0026#34;, data.bytes.len()); } fn main() { let data = BigData { bytes: [0; 1024 * 1024], }; consume(data); } 这里 BigData 的值很大。把它作为参数传递时，语义上是 move。编译器可能会优化实际拷贝，但从设计上说，这个类型本身确实很大。\n如果改成 Box\u0026lt;BigData\u0026gt;：\nstruct BigData { bytes: [u8; 1024 * 1024], } fn consume(data: Box\u0026lt;BigData\u0026gt;) { println!(\u0026#34;{}\u0026#34;, data.bytes.len()); } fn main() { let data = Box::new(BigData { bytes: [0; 1024 * 1024], }); consume(data); } 移动 Box\u0026lt;BigData\u0026gt; 时，移动的是指针这类固定大小的元信息，而不是把整个大对象在栈上搬来搬去。\n不过这不是说“大对象都应该 Box”。堆分配也有成本，间接访问也可能影响缓存局部性。Box 是一个工具，不是性能万能药。真的关心性能时，应该结合数据大小、调用路径和基准测试判断。\n场景四：Box 和闭包、回调 闭包也经常和 Box\u0026lt;dyn Trait\u0026gt; 一起出现。\n比如我们想保存一组回调：\nfn main() { let handlers: Vec\u0026lt;Box\u0026lt;dyn Fn(i32) -\u0026gt; i32\u0026gt;\u0026gt; = vec![ Box::new(|x| x + 1), Box::new(|x| x * 2), ]; let mut value = 10; for handler in handlers { value = handler(value); } println!(\u0026#34;{value}\u0026#34;); } 每个闭包都有自己独立的匿名类型。就算参数和返回值一样，两个闭包的具体类型也不一样。\nBox\u0026lt;dyn Fn(i32) -\u0026gt; i32\u0026gt; 把它们统一成“实现了 Fn(i32) -\u0026gt; i32 的对象”，于是可以放进同一个 Vec。\n这类写法在插件系统、事件处理、路由表、策略表里都很常见。\nBox 会自动释放，但释放时机仍然按所有权来 Box\u0026lt;T\u0026gt; 离开作用域时会自动释放里面的值。\nstruct Resource; impl Drop for Resource { fn drop(\u0026amp;mut self) { println!(\u0026#34;drop resource\u0026#34;); } } fn main() { { let _resource = Box::new(Resource); println!(\u0026#34;inside scope\u0026#34;); } println!(\u0026#34;outside scope\u0026#34;); } 输出顺序大致是：\ninside scope drop resource outside scope 这说明 Box 不是垃圾回收。它不会等到某个后台线程来清理，而是在所有权离开作用域时确定地执行 drop。\n如果你把 Box move 到另一个函数，释放时机也跟着所有权走：\nfn take(value: Box\u0026lt;String\u0026gt;) { println!(\u0026#34;{value}\u0026#34;); } fn main() { let value = Box::new(String::from(\u0026#34;rust\u0026#34;)); take(value); // 这里不能再使用 value } value 的所有权进入 take，函数结束时释放。\nBox::leak：故意让值活到程序结束 偶尔你会看到 Box::leak：\nfn main() { let config: \u0026amp;\u0026#39;static str = Box::leak(String::from(\u0026#34;debug\u0026#34;).into_boxed_str()); println!(\u0026#34;{config}\u0026#34;); } Box::leak 会把 Box\u0026lt;T\u0026gt; 变成一个引用，并且不再自动释放那块堆内存。这个引用可以拥有 'static 生命周期，因为内存被故意泄漏了。\n这个名字已经把风险写出来了：leak 就是泄漏。\n它适合少数场景，比如程序启动时构造一次全局配置，之后整个进程生命周期都要用。它不适合在请求处理、循环、频繁调用的路径里随手使用。\n如果你只是想共享全局只读数据，通常应该先看看 OnceLock、LazyLock、Arc 这些选择。\nPin\u0026lt;Box\u0026gt;：让堆上的值不要再被移动 还有一个常见组合是 Pin\u0026lt;Box\u0026lt;T\u0026gt;\u0026gt;。\nBox\u0026lt;T\u0026gt; 负责把值放到堆上并拥有它。Pin 负责表达另一个约束：这个值被固定住了，不能再被移动。\n在普通业务代码里，你不一定经常手写 Pin\u0026lt;Box\u0026lt;T\u0026gt;\u0026gt;，但在 async、Future、自引用结构、底层库里会经常遇到它。\n可以先记住这个分工：\nBox\u0026lt;T\u0026gt; 负责堆分配和所有权 Pin\u0026lt;Box\u0026lt;T\u0026gt;\u0026gt; 负责堆分配、所有权，以及“不要移动这个值”的承诺 不要把 Pin 理解成“更高级的 Box”。它解决的是移动语义的问题，不是普通堆分配问题。\n不要为了“看起来高级”到处 Box Box\u0026lt;T\u0026gt; 很基础，但不应该到处用。\n这些情况通常不需要 Box：\n一个普通的小整数、小结构体。 只是为了把参数传给函数。 只是为了绕开借用检查。 只是觉得堆上更“稳定”。 比如这样就没必要：\nfn add(a: Box\u0026lt;i32\u0026gt;, b: Box\u0026lt;i32\u0026gt;) -\u0026gt; i32 { *a + *b } 直接传值更简单：\nfn add(a: i32, b: i32) -\u0026gt; i32 { a + b } 如果你发现自己为了让代码编译，开始把很多东西都塞进 Box，那通常说明还没有想清楚真正的问题：\n是需要共享所有权？那可能是 Rc 或 Arc。 是需要内部可变性？那可能是 RefCell、Mutex 或 RwLock。 是需要 trait object？那 Box\u0026lt;dyn Trait\u0026gt; 才合理。 是递归类型大小无法确定？那 Box 很合适。 是生命周期借用关系没理清？那 Box 可能只是把问题换了个地方。 Box 能改变值放在哪里，但不能让所有权问题消失。\n一个实用判断 写代码时可以按这个顺序问：\n这个值需要在堆上拥有吗？ 如果答案是否，先别用 Box。\n如果答案是，再继续问：\n为什么必须在堆上？ 常见合理答案有三类：\n类型大小编译期无法确定，比如递归类型或 trait object。 需要稳定的间接拥有，比如和 Pin、FFI、某些库 API 配合。 值很大，移动拥有者时只想移动指针。 再问最后一个问题：\n这个值需要多个拥有者吗？ 如果只需要一个拥有者，Box\u0026lt;T\u0026gt; 很合适。\n如果需要多个拥有者，Box\u0026lt;T\u0026gt; 就不够了，应该考虑 Rc\u0026lt;T\u0026gt; 或 Arc\u0026lt;T\u0026gt;。\n最后再压缩成一句话 Box\u0026lt;T\u0026gt; 是 Rust 里最基础的拥有型智能指针。\n它做的事很具体：\n把 T 放到堆上 让 Box 拥有 T 通过所有权自动释放 T 它常用在这些地方：\n递归类型：用固定大小的指针打断无限大小。 trait object：用 Box\u0026lt;dyn Trait\u0026gt; 承载编译期大小未知的具体类型。 大对象：移动拥有者时只移动指针。 回调和闭包：把不同闭包统一成同一种 trait object。 Pin\u0026lt;Box\u0026lt;T\u0026gt;\u0026gt;：配合需要固定地址的类型。 所以不要把 Box 理解成“Rust 里的堆内存开关”这么简单。\n更准确地说，Box\u0026lt;T\u0026gt; 是 Rust 在不放弃所有权检查的前提下，提供给你的最小堆分配工具。它让值住到堆上，但仍然让谁拥有、谁释放、什么时候失效这些问题保持清楚。\n参考资料 The Rust Programming Language: Using Box to Point to Data on the Heap The Rust Programming Language: Using Trait Objects Rust std::boxed::Box ","permalink":"https://cloudside.icyyan.com/posts/rust-box-guide/","summary":"\u003cp\u003e学 Rust 时，\u003ccode\u003eBox\u0026lt;T\u0026gt;\u003c/code\u003e 很容易被一句话带过：它可以把数据放到堆上。\u003c/p\u003e\n\u003cp\u003e这句话没错，但如果只记住这一句，后面看到 \u003ccode\u003eBox\u0026lt;dyn Trait\u0026gt;\u003c/code\u003e、\u003ccode\u003eBox\u0026lt;List\u0026gt;\u003c/code\u003e、\u003ccode\u003ePin\u0026lt;Box\u0026lt;T\u0026gt;\u0026gt;\u003c/code\u003e、\u003ccode\u003eBox::leak\u003c/code\u003e 时，还是会觉得它像一个突然冒出来的语法补丁。\u003c/p\u003e\n\u003cp\u003e更好的理解方式是：\u003ccode\u003eBox\u0026lt;T\u0026gt;\u003c/code\u003e 不是“逃离所有权系统”的工具，而是 Rust 所有权系统里最基础的一种拥有型指针。它让一个值住在堆上，同时让所有权仍然清清楚楚地归某个变量管理。\u003c/p\u003e\n\u003ch2 id=\"先看-box-解决了什么问题\"\u003e先看 Box 解决了什么问题\u003c/h2\u003e\n\u003cp\u003e普通变量通常可以这样理解：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-rust\" data-lang=\"rust\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003elet\u003c/span\u003e n \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e42\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003ccode\u003en\u003c/code\u003e 这个值本身就放在当前栈帧里。栈很快，进入函数时分配，函数返回时回收，生命周期也很清楚。\u003c/p\u003e\n\u003cp\u003e但有些场景光靠栈不够舒服：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e一个类型递归地包含自己，编译器算不出它的大小。\u003c/li\u003e\n\u003cli\u003e你想把不同具体类型放进同一个集合，只要求它们实现同一个 trait。\u003c/li\u003e\n\u003cli\u003e一个值很大，你希望移动时只移动一个指针。\u003c/li\u003e\n\u003cli\u003e某些 API 需要一个固定地址的拥有型对象，比如配合 \u003ccode\u003ePin\u003c/code\u003e 使用。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e这时 \u003ccode\u003eBox\u0026lt;T\u0026gt;\u003c/code\u003e 就出现了。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-rust\" data-lang=\"rust\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003elet\u003c/span\u003e n \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e Box::new(\u003cspan style=\"color:#ae81ff\"\u003e42\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e可以把它粗略想成这样：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e栈上变量 n\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  -\u0026gt; 保存一个指针\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  -\u0026gt; 指向堆上的 42\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003ccode\u003eBox\u0026lt;T\u0026gt;\u003c/code\u003e 本身在栈上，里面保存指向堆数据的指针。真正的 \u003ccode\u003eT\u003c/code\u003e 在堆上。变量离开作用域时，\u003ccode\u003eBox\u0026lt;T\u0026gt;\u003c/code\u003e 会自动释放堆上的 \u003ccode\u003eT\u003c/code\u003e。\u003c/p\u003e\n\u003cp\u003e所以 \u003ccode\u003eBox\u0026lt;T\u0026gt;\u003c/code\u003e 同时有两个特点：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e它是指针，可以间接访问堆上的值。\u003c/li\u003e\n\u003cli\u003e它拥有这个值，离开作用域时负责释放它。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e这和 C 里的裸指针很不一样。你不用手写 \u003ccode\u003efree\u003c/code\u003e，也不能随便复制出多个拥有者。Rust 仍然会检查所有权、移动和借用。\u003c/p\u003e\n\u003ch2 id=\"最基本的用法boxnew\"\u003e最基本的用法：Box::new\u003c/h2\u003e\n\u003cp\u003e创建一个 \u003ccode\u003eBox\u0026lt;T\u0026gt;\u003c/code\u003e 最常见的方式是 \u003ccode\u003eBox::new\u003c/code\u003e：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-rust\" data-lang=\"rust\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003efn\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003emain\u003c/span\u003e() {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003elet\u003c/span\u003e name \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e Box::new(String::from(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;cloudside\u0026#34;\u003c/span\u003e));\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003eprintln!\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e{name}\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e这里 \u003ccode\u003eString\u003c/code\u003e 这个值由 \u003ccode\u003eBox\u003c/code\u003e 放到堆上管理，\u003ccode\u003ename\u003c/code\u003e 是一个拥有它的 \u003ccode\u003eBox\u0026lt;String\u0026gt;\u003c/code\u003e。注意，\u003ccode\u003eString\u003c/code\u003e 自己内部还会管理一块字符串缓冲区；这里说的是 \u003ccode\u003eString\u003c/code\u003e 这个三字段结构本身的位置。\u003c/p\u003e","title":"Rust 里的 Box 到底是什么"},{"content":"Redis 是程序员工具箱里很容易被“过度信任”的东西。\n它太顺手了。\n想存个值，SET 一下。想自动过期，EXPIRE 一下。想做计数器，INCR 一下。想搞队列，LPUSH / BRPOP 一下。想搞锁，SET NX PX 一下。再看一眼 Stream，好像连消息系统也能顺手安排。\n于是很多系统就这么一路滑坡：\n第一天：Redis 只是缓存。 第二天：这个状态先放 Redis 吧。 第三天：队列也先放 Redis 吧。 第四天：库存扣减也先放 Redis 吧。 第五天：Redis 挂了，大家开始翻日志考古。 这篇文章想讲的观点很简单：\nRedis 能做很多事，但不要让它单独拥有真相。\n如果一份数据不能丢、不能错、不能重复处理、不能悄悄过期、不能被内存策略淘汰，那它就不应该只待在 Redis 里。\nRedis 最舒服的位置是缓存。它可以让系统跑得更快，但不应该决定系统还活不活得明白。\n先看边界：缓存可以重建，事实不能只靠 Redis 先把话说在前面：这不是 Redis 不行。\nRedis 很强，而且越来越强。它有 RDB，有 AOF，有复制，有 Sentinel，有 Cluster，有 Streams，还有很多高级数据结构。Redis 8 之后，JSON、Search、Time Series、概率数据结构等能力也被合进 Redis Open Source。\n所以问题不是“Redis 能不能干更多活”。\n问题是：你把活交给 Redis 之后，能不能接受它的失败方式。\n缓存的失败方式很朴素：\nRedis 没了 -\u0026gt; 缓存 miss -\u0026gt; 回源数据库 -\u0026gt; 重新写缓存 这很烦，但不至于让业务当场失忆。\n如果 Redis 保存的是唯一订单状态、唯一库存、唯一任务队列、唯一支付回调记录，失败路径就完全不一样：\nRedis 没了 -\u0026gt; 事实没了 -\u0026gt; 业务开始沉默 -\u0026gt; 人开始翻日志 -\u0026gt; 会议开始变长 缓存丢了，可以重建。事实丢了，只能考古。\n这就是 Redis 的边界：它适合做性能层，不适合做户籍科。\n下面不按 Redis 命令分类，而按业务里最常见的“强行实现的功能”分类。这样更容易看清楚：问题往往不是某个命令危险，而是你让 Redis 承担了它不该单独承担的责任。\n强行当数据库：持久化不等于事务和账本 很多人会说：Redis 也有持久化，开 AOF 不就行了吗？\n这句话听起来像“我买了保险，所以可以不开安全带”。方向对了一半，风险还在。\nRedis 常见持久化有两类：\nRDB：定期生成内存快照。 AOF：把写命令追加到日志里，再用日志恢复。 它们都很有价值，但它们解决的是“Redis 重启后尽量恢复”，不是“Redis 从此拥有数据库级语义”。\nRDB 很像定期拍照。拍完照之后发生的事，如果还没来得及拍下一张，故障时就可能不在照片里。\nAOF 更耐用一些，但也不是魔法。Redis 官方文档说明，AOF 可以配置不同的 fsync 策略：不主动 fsync、每秒 fsync、每次写入都 fsync。默认每秒 fsync 性能很好，但理论上可能丢最近一秒的数据。\n如果 Redis 是缓存，这通常没什么。\n最近一秒缓存丢了 -\u0026gt; 再查数据库 -\u0026gt; 再写缓存 -\u0026gt; 继续上班 如果 Redis 是唯一事实来源，这一秒可能就很贵：\n一秒内的订单状态丢了。 一秒内的库存扣减丢了。 一秒内的任务入队丢了。 一秒内的积分变更丢了。 当然，你可以把 AOF 调成每次写入都 fsync。但这时 Redis 最香的低延迟会被磁盘同步拖住，而且你仍然没有传统数据库里的完整事务、约束、查询、审计和恢复工具。\n这就是第一个信号：\n如果你为了让 Redis 像数据库，开始把 Redis 调得不像 Redis，那多半是架构位置放错了。\n强行当状态仓库：淘汰策略会删掉业务事实 Redis 有一个很“缓存味”的能力：内存到上限后，可以按策略淘汰 key。\n对缓存来说，这很合理。缓存本来就是副本，内存不够了，删掉一部分，最多导致更多请求回源。\nRedis 官方文档讲 key eviction 时也是这个语境：Redis 常用于缓存，缓存项是持久数据的副本，所以内存不够时通常可以安全淘汰，之后再缓存回来。\n但如果 Redis 里放的是业务事实，淘汰策略就从“自动清理”变成“随机心跳加速”。\n假设你把用户会话、限流状态、订单中间态、任务队列都塞进同一个 Redis 实例，然后配置了 allkeys-lru：\n内存满了 -\u0026gt; Redis 开始淘汰 key -\u0026gt; 被淘汰的可能不是缓存 -\u0026gt; 而是你昨晚刚上线的业务状态 系统表现会非常有节目效果：\n用户突然掉登录。 任务凭空消失。 限流状态被清空。 幂等记录没了，重复请求又能进来了。 排查半天，最后发现“它只是被淘汰了”。 你也可以说：那我配置 noeviction，不让它删。\n可以。但内存满了以后，新的写入命令会报错。缓存写失败通常还能接受，业务状态写失败就必须有完整的错误处理、重试、告警和补偿。\n所以 Redis 的内存模型其实一直在提醒你：\n我适合保存可以丢的数据。 如果不能丢，请先给它找个更稳的家。 强行当消息队列：push/pop 只是入口 Redis List 做队列太顺手了：\nLPUSH jobs payload BRPOP jobs 0 看起来已经有内味了：生产者塞任务，消费者拿任务，跑起来还很快。\n但生产级队列不是“把东西排成一队”这么简单。真正麻烦的是：\n消费者拿到任务后崩了，消息去哪了？ 处理失败要不要重试？ 重试几次算死信？ 消费进度谁记录？ 重复消费怎么幂等？ 积压太大怎么削峰？ 消息要保留多久？ 以后想回放怎么办？ Redis List 不替你回答这些问题。它只是说：我能 push，我能 pop，剩下你自己想。\nRedis Streams 比 List 更完整。它有消息 ID、消费者组、待确认消息、XACK，也支持修剪。Redis 官方文档也把 Streams 描述成适合有序事件流、消费者组和可配置保留的场景。\n但这里仍然要看需求。\n如果只是轻量异步处理，Redis Streams 可以用。如果任务可以从数据库重新扫描出来，Redis 做加速也可以。如果丢一小段消息只是少算几次统计，问题也不大。\n但如果你的需求是：\n不能丢 要长期保留 要跨服务大规模消费 要清晰回放 要复杂订阅 要稳定分区扩展 那就别强行让 Redis 扮演 Kafka、RabbitMQ、NATS JetStream 或 Pulsar。\n专业消息系统把“消息的一生”当主线设计；Redis 的主线是内存数据访问。让 Redis 客串可以，让它长期主演就容易加班。\n强行当分布式锁：拿到锁不等于拿到修改权 Redis 锁的入门写法很漂亮：\nSET lock:order:1 token NX PX 30000 key 不存在才设置，还带过期时间。第一眼看上去，很有安全感。\n但分布式锁难的从来不是“抢锁”，而是“锁失效之后，世界是不是还正常”。\n比如：\n线程 A 拿到锁，准备处理订单。 A 卡住了，可能是 GC、网络抖动、机器负载飙高。 锁过期了，线程 B 又拿到锁。 A 恢复执行，继续写数据库。 B 也在写数据库。 这时 Redis 没有做错。锁确实过期了，B 也确实拿到了锁。\n错的是业务把“我拿到了 Redis 锁”理解成“我拥有宇宙唯一修改权”。\n如果一段逻辑真的不能并发，最后的保护通常应该落在事实数据所在的地方：\n数据库唯一约束。 乐观锁版本号。 状态机条件更新。 幂等表。 fencing token。 Redis 锁可以减少冲突，可以让系统少打架，但它不应该替数据库约束站最后一班岗。\n一句话：Redis 锁适合协调，不适合当法律。\n强行当库存和余额系统：计数器不是流水账 Redis 的 INCR 是快乐源泉：\nINCR article:1:view_count 浏览量、点赞数、曝光次数、临时统计，这些很适合 Redis。偶尔丢一点、晚点落库、事后修正，业务通常能接受。\n但如果这个数字代表库存、余额、积分、抽奖名额，事情立刻从“快乐源泉”变成“凌晨告警源泉”。\n库存不是一个数字。库存是一串问题：\n谁扣的？ 对应哪笔订单？ 支付超时怎么释放？ 订单取消怎么回滚？ 重复请求怎么防？ 数据库里的库存怎么对账？ 故障恢复后哪个值才是真的？ 如果 Redis 是唯一库存来源，故障恢复时很容易进入这种场景：\nRedis 里库存是 0 数据库里库存是 5 AOF 恢复后库存是 3 订单系统说卖完了 支付系统说还有未完成订单 运营说你们先别说话 这时你缺的不是更快的 DECR，而是能解释每一次变化来源的事实系统。\n比较稳的姿势是：\nRedis 保存可丢弃的库存缓存或预热计数。 数据库保存最终库存和订单状态。 扣减要有幂等记录和状态流转。 Redis 异常时可以回到数据库路径，最多变慢，不应该变错。 Redis 可以帮你扛峰值，但不要让它独自背锅。\n强行当流程状态机：Session 不是业务抽屉 很多系统把 session 放 Redis，这是常见做法。\n如果 session 的含义是“登录态缓存”，丢了最多重新登录，那问题不大。用户不开心，但系统还能解释。\n问题是，有些 session 会被越塞越胖，最后变成业务垃圾抽屉：\n未提交表单。 临时订单。 风控决策。 OAuth 授权中间态。 支付跳转状态。 多步骤流程进度。 这些东西一旦丢失，用户可能无法继续流程，业务也可能不知道下一步该怎么走。\n判断方法很简单：\nRedis 清空后，用户重新登录或重新操作就能恢复吗？ 如果能，它像缓存。\n如果不能，它只是伪装成 session 的业务事实。\n强行当大对象存储：一个 key 不该包办所有业务关系 Redis 快的前提，是命令尽量短小，key 尽量克制。\n把 Redis 当数据库用，最后很容易长出大 key：\n一个 Hash 存几百万个用户状态。 一个 ZSet 存全站排行榜和所有历史分数。 一个 Stream 从不修剪。 一个 Set 保存所有活跃用户且长期不拆。 一个 JSON 文档越塞越大。 刚开始你会觉得很方便：\n一个 key 搞定，查询也简单，完美。 过一阵子它会开始还账：\n删除可能阻塞。 迁移变慢。 RDB/AOF 变重。 复制压力变大。 网络返回太大。 Cluster 下单个 slot 过热。 大 key 最烦的地方是，它通常不是一天长大的。它像需求堆出来的历史包袱，刚发现时已经不太敢动。\n缓存里的大 key 还好办，能拆、能过期、能重建。\n数据库化的 Redis 大 key 就尴尬了：你想删它，它说里面有业务；你想迁它，它说我很大；你想拆它，它说先开个排期。\n强行当搜索和审计系统：能查不等于能追责 还有一类常见滑坡，是把 Redis 当成搜索、复杂查询或审计日志系统。\n这类需求一开始也很容易被 Redis 的数据结构吸引：\nSet 能做集合关系。 ZSet 能按分数排序。 Hash 能挂很多字段。 Stream 能保存事件。 Redis 8 之后还合进了 Search、JSON、Time Series 等能力。 所以很多方案会从一句很自然的话开始：\n先用 Redis 存一下，查起来快。 但搜索和审计的核心问题通常不是“能不能查到”，而是：\n查询条件会不会越来越复杂？ 索引如何构建、更新和回滚？ 数据需要保留多久？ 谁改过，什么时候改的，为什么改的？ 故障恢复后能不能重放和校验？ 出问题时能不能给业务、财务或风控解释清楚？ 如果只是做搜索结果缓存、热榜缓存、短期查询加速，Redis 很合适。它负责快，事实来源仍然在数据库、搜索引擎、对象存储或日志系统里。\n如果你想让 Redis 单独承担“可查询、可追溯、可恢复、可审计”的完整责任，就已经不是缓存问题了。看到这里，问题就变成了：你到底需要一个快的副本，还是一个能长期解释事实的系统？\n按功能判断：Redis 能参与，但别单独负责 不要问“Redis 能不能做这个”。\nRedis 的答案经常是：能。\n更应该问的是：\nRedis 出问题时，我能不能接受后果？ 功能分类 Redis 适合承担什么 更稳的做法 读取缓存 适合做商品详情、热点配置等读取加速 数据库或配置中心为准，Redis miss 后回源 临时计数 适合做浏览量、曝光、临时统计的聚合缓冲 异步落库，可容忍延迟和修正 登录态 只适合可重新登录的会话缓存 丢失后能让用户重新认证，而不是丢业务流程 订单状态机 不适合做唯一事实来源 数据库状态机 + Redis 缓存 库存、余额、积分账本 不适合做唯一账本 数据库事务、幂等记录和流水为准，Redis 做预热或限流 可靠任务队列 适合轻量异步，谨慎做主链路 Kafka/RabbitMQ/NATS，或数据库任务表 分布式锁正确性 适合协调，不适合兜底 数据库约束、版本号、状态条件更新、fencing token 搜索和复杂查询 适合做结果缓存或专门索引的一部分 搜索引擎、数据库索引，或明确维护 Redis Search 的索引生命周期 审计日志 不适合做唯一记录 追加日志、对象存储、数据库、消息系统 这张表背后的原则只有一句：\nRedis 可以参与流程，但不要让它单独拥有真相。\n正确分工：Redis 坐性能层这桌 一个更稳的架构通常长这样：\n读请求 -\u0026gt; 先读 Redis -\u0026gt; miss 后读数据库 -\u0026gt; 写回 Redis 写请求 -\u0026gt; 先写数据库或消息系统 -\u0026gt; 再删除/更新 Redis 这里 Redis 的价值很明确：\n降低数据库读压力。 扛住热点访问。 保存短期状态。 做可丢弃的聚合。 做限流、去重、短期幂等等辅助能力。 但系统真相在别处：\n订单在数据库。 消息在消息系统。 搜索索引可以重建。 审计日志在持久日志系统。 库存变化有流水。 这样设计的好处是：Redis 出问题时，系统最多变慢、缓存命中率下降、部分临时状态失效，但不会直接失去解释业务事实的能力。\n这就是 Redis 最舒服的位置：跑得快，但不背最终责任。\n可以越界的情况：前提是有兜底 工程里没有绝对规则。Redis 也不是只能做 GET / SET 缓存。\n下面这些场景可以考虑越界：\n数据本来就是临时的，比如验证码、短期 token、限流窗口。 数据可以从别处重建，比如排行榜缓存、搜索结果缓存。 业务能接受丢失，比如实时在线人数、曝光计数。 Redis 只是第一层，后面还有数据库、日志或消息系统兜底。 你非常清楚 Redis 的持久化、复制、故障转移和内存策略，并且写过恢复预案。 真正危险的是这些熟悉的话：\n“这个数据暂时先只放 Redis。” “AOF 开着，应该不会丢。” “反正 Redis 很快，先把队列放进去。” “库存先扣 Redis，后面再同步数据库。” “这个 Hash 虽然大，但查起来挺方便。” 这些话一般不是方案成熟的标志，而是系统正在把风险打包塞进 Redis，然后给包裹贴上“以后再说”。\n结尾：Redis 是加速器，不是户口本 Redis 最好的状态，是让系统更快，而不是让系统更脆。\n当 Redis 是缓存时，它坏了，系统还能回源；它慢了，系统还能降级；它清空了，数据还能重建。\n当 Redis 是唯一数据源时，它坏了，系统就要开始解释为什么事实丢了。\n所以我更愿意把 Redis 放在这个位置：\nRedis 是性能层，不是真相层。 Redis 是副本，不是账本。 Redis 是加速器，不是最后的存储承诺。 只要守住这条线，Redis 就非常好用。\n一旦越过这条线，Redis 的每一个优点，都会在故障恢复时变成一句灵魂拷问：\n这么重要的数据，当初为什么只放 Redis？ 参考资料 Redis Docs: Redis persistence Redis Docs: Key eviction Redis Docs: Redis Streams Redis Docs: Redis streaming use cases ","permalink":"https://cloudside.icyyan.com/posts/redis-cache-only/","summary":"\u003cp\u003eRedis 是程序员工具箱里很容易被“过度信任”的东西。\u003c/p\u003e\n\u003cp\u003e它太顺手了。\u003c/p\u003e\n\u003cp\u003e想存个值，\u003ccode\u003eSET\u003c/code\u003e 一下。想自动过期，\u003ccode\u003eEXPIRE\u003c/code\u003e 一下。想做计数器，\u003ccode\u003eINCR\u003c/code\u003e 一下。想搞队列，\u003ccode\u003eLPUSH\u003c/code\u003e / \u003ccode\u003eBRPOP\u003c/code\u003e 一下。想搞锁，\u003ccode\u003eSET NX PX\u003c/code\u003e 一下。再看一眼 Stream，好像连消息系统也能顺手安排。\u003c/p\u003e\n\u003cp\u003e于是很多系统就这么一路滑坡：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e第一天：Redis 只是缓存。\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e第二天：这个状态先放 Redis 吧。\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e第三天：队列也先放 Redis 吧。\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e第四天：库存扣减也先放 Redis 吧。\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e第五天：Redis 挂了，大家开始翻日志考古。\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e这篇文章想讲的观点很简单：\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eRedis 能做很多事，但不要让它单独拥有真相。\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e如果一份数据不能丢、不能错、不能重复处理、不能悄悄过期、不能被内存策略淘汰，那它就不应该只待在 Redis 里。\u003c/p\u003e\n\u003cp\u003eRedis 最舒服的位置是缓存。它可以让系统跑得更快，但不应该决定系统还活不活得明白。\u003c/p\u003e\n\u003ch2 id=\"先看边界缓存可以重建事实不能只靠-redis\"\u003e先看边界：缓存可以重建，事实不能只靠 Redis\u003c/h2\u003e\n\u003cp\u003e先把话说在前面：这不是 Redis 不行。\u003c/p\u003e\n\u003cp\u003eRedis 很强，而且越来越强。它有 RDB，有 AOF，有复制，有 Sentinel，有 Cluster，有 Streams，还有很多高级数据结构。Redis 8 之后，JSON、Search、Time Series、概率数据结构等能力也被合进 Redis Open Source。\u003c/p\u003e\n\u003cp\u003e所以问题不是“Redis 能不能干更多活”。\u003c/p\u003e\n\u003cp\u003e问题是：\u003cstrong\u003e你把活交给 Redis 之后，能不能接受它的失败方式。\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e缓存的失败方式很朴素：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eRedis 没了\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  -\u0026gt; 缓存 miss\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  -\u0026gt; 回源数据库\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  -\u0026gt; 重新写缓存\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e这很烦，但不至于让业务当场失忆。\u003c/p\u003e","title":"请不要用 Redis 做任何缓存之外的事"},{"content":"很多人第一次学 Redis，记住的是五种常用类型：String、List、Hash、Set、ZSet。再往后背面试题，又会遇到 SDS、quicklist、listpack、intset、skiplist、hashtable。\n这些名字放在一起很容易让人迷糊：Redis 不是 key-value 数据库吗？为什么一个 Hash 后面还会有 listpack 和 hashtable 两种实现？为什么同样是 Set，有时是 intset，有时又变成哈希表？\n真正要理解的不是“Redis 有哪些数据结构”这张清单，而是一个更具体的问题：\nRedis 为什么要让同一种对外类型，在不同场景下切换不同的内部编码？\n顺着这个问题往下看，Redis 的底层数据结构会清楚很多。它不是为了把实现弄复杂，而是在内存、CPU、查询速度、扩容成本之间做了一组很工程化的取舍。\n本文写作和更新时的版本背景是：截至 2026 年 5 月 23 日，Redis Open Source 最新稳定版是 8.6.3，这是 2026 年 5 月发布的安全修复版本；Redis GitHub releases 里还能看到 8.8-RC1，但它是预发布版，官方说明不适合生产环境使用。\n从底层数据结构这条线看，Redis 8.6 最值得关注的不是“又多背几个结构名”，而是几类更贴近日常排查的问题：\n大 Hash 和大 ZSet 的内存占用继续被优化。 Stream 增加了 XADD IDMP / IDMPAUTO，用于幂等写入。 新增 HOTKEYS，能直接辅助定位热点 key。 新增 key 内存大小直方图相关能力，排查 List、Set、Hash、ZSet 的规模分布更方便。 新增 volatile-lrm 和 allkeys-lrm 淘汰策略，按“最近修改时间”参与淘汰。 旧版本里经常出现的 ziplist，在 Redis 7 之后大多已经被 listpack 替代，所以不要再只背“压缩列表”那套旧答案。\n对外类型和内部编码不是一回事 平时我们写 Redis 命令，面对的是对外类型：\nSET name bole LPUSH queue a b c HSET user:1 name bole age 18 SADD tags redis cache ZADD rank 100 alice 这些命令分别对应 String、List、Hash、Set、ZSet。但 Redis 在内存里不会只保存“这是一个 Hash”这么简单的信息。\n可以把一个 Redis value 粗略理解成三层：\nredisObject -\u0026gt; type: 对外类型，比如 string / list / hash / set / zset -\u0026gt; encoding: 内部编码，比如 raw / embstr / listpack / hashtable -\u0026gt; ptr: 指向真正的数据结构 type 决定这个 key 能执行哪些命令，encoding 决定 Redis 现在用哪种底层结构保存它。\n所以同样是 Hash，内部可能是：\n小 Hash -\u0026gt; listpack 大 Hash -\u0026gt; hashtable 同样是 ZSet，内部可能是：\n小 ZSet -\u0026gt; listpack 大 ZSet -\u0026gt; skiplist + hashtable 这就是理解 Redis 底层结构的入口：命令看到的是类型，性能和内存更多取决于编码。\n你可以用 OBJECT ENCODING 直接观察一个 key 当前的内部编码：\nredis-cli SET age 18 redis-cli OBJECT ENCODING age # 可能输出：int redis-cli HSET user:1 name bole age 18 redis-cli OBJECT ENCODING user:1 # 可能输出：listpack 具体输出会受 Redis 版本、配置阈值和数据内容影响，但这个命令很适合用来验证“我以为的数据结构”和 Redis 实际选择是否一致。\n最外层：整个数据库先是一张哈希表 先不看 value，Redis 的 key 本身也要被保存起来。\n一个 Redis 数据库大致可以理解成这样：\ndb.dict key -\u0026gt; value object db.expires key -\u0026gt; expire timestamp db.dict 是主字典，负责从 key 找到 value。db.expires 是过期字典，负责记录哪些 key 有过期时间。\n这也解释了为什么 Redis 按 key 查找通常很快：最外层就是哈希表，按 key 定位 value 平均是 O(1)。\n但这里的 O(1) 只表示“找到这个 key 对应的 Redis 对象”很快。找到之后，具体命令还要看 value 的内部编码。\n比如：\nGET name 找到 String 后，直接返回内容。 HGET user:1 age 找到 Hash 后，还要在 Hash 的内部结构里找 field。 ZRANGE rank 0 9 找到 ZSet 后，还要按分数或排名遍历有序结构。 所以不要只用“Redis 是哈希表，所以所有操作都是 O(1)”来理解 Redis。那只说对了最外层的一小段。\nString：很多结构的地基都是 SDS Redis 的 String 不是直接用 C 语言的 char * 当普通字符串。\nC 字符串有几个问题：\n计算长度需要从头扫到 \\0，复杂度是 O(n)。 不能自然保存包含 \\0 的二进制数据。 追加内容时，如果空间不够，需要重新分配内存。 Redis 自己实现了一套 SDS，也就是 Simple Dynamic String。可以粗略理解成：\nSDS header len -\u0026gt; 已使用长度 alloc -\u0026gt; 已分配空间 flags -\u0026gt; header 类型 buf 真正的字节内容 这样一来，Redis 至少拿到了三个好处。\n第一，取长度是 O(1)。因为长度直接存在 header 里，不需要每次扫描字符串。\n第二，SDS 是二进制安全的。它不依赖 \\0 判断字符串结束，所以可以保存图片片段、序列化后的对象、压缩数据。\n第三，追加内容时可以预留空间，减少频繁扩容带来的内存拷贝。\n不过 Redis String 的内部编码还会继续细分：\n编码 典型场景 直觉 int 字符串内容刚好能表示 64 位整数 直接存整数，省掉字符串对象 embstr 较短字符串 Redis 对象和 SDS 放在同一块内存里 raw 较长字符串 Redis 对象和 SDS 分开分配 比如：\nredis-cli SET counter 100 redis-cli OBJECT ENCODING counter # 可能输出：int redis-cli SET name bole redis-cli OBJECT ENCODING name # 可能输出：embstr 这里的重点不是记住某个具体阈值，而是理解 Redis 的思路：能用更紧凑的表示，就先用更紧凑的表示；当数据变复杂，再换成更通用的结构。\nHash：小的时候挤在一起，大了再换哈希表 Hash 是业务里非常常见的结构，适合保存一个对象的多个字段：\nHSET user:1 name bole age 18 city hangzhou 直觉上，Hash 底层应该就是哈希表。但 Redis 不会一上来就为一个很小的 Hash 分配完整哈希表。\n如果一个 Hash 只有几个字段，每个字段和值都很短，直接用哈希表会有不少额外开销：桶数组、指针、entry 结构、扩容状态。这些元数据可能比真正的业务数据还占空间。\n所以小 Hash 通常会用 listpack。\n可以把 listpack 想成一段连续内存，field 和 value 挨着放：\nname | bole | age | 18 | city | hangzhou 查找 age 时，Redis 在这段紧凑内存里从前往后扫。单次查找不是 O(1)，但因为元素很少，实际成本很低，而且非常省内存。\n当 Hash 变大，或者某个 field/value 超过配置阈值，Redis 就会把它转换成 hashtable：\nfield -\u0026gt; value field -\u0026gt; value field -\u0026gt; value Redis 默认配置里，Hash 的紧凑编码阈值大致由这两个参数控制：\nhash-max-listpack-entries 512 hash-max-listpack-value 64 也就是说，小对象优先省内存；对象变大后，再用哈希表换查询效率。\n看到这里，问题就变成了：如果我们把很多小对象拆成很多独立 key，和把它们放进一个 Hash，哪个更省？\n很多时候，小 Hash 会更省。因为多个 field 可以共享一个 Redis key 的元数据成本，还能用 listpack 挤在连续内存里。这也是很多“用户属性”“商品轻量信息”喜欢用 Hash 保存的原因。\n但别把这个经验用过头。一个超大的 Hash 会带来大 key 问题，影响删除、迁移、持久化和网络传输。Redis 的底层优化能帮你省内存，但不能替你消除大 key 的运维成本。\nRedis 8.6 里，Hash 还有一个和底层结构直接相关的变化：官方 release notes 提到，hashtable 编码的 Hash 做了明显的内存缩减，其中一个具体优化是把 field name 和 value 统一到一个结构里。\n这句话翻译成使用者能感知的结果是：大 Hash 的元数据成本更低了，但它仍然是大 key。 如果一个 Hash 已经大到影响迁移、删除或持久化，不能因为 Redis 8.6 更省内存就忽略拆分设计。\nList：它不是一条朴素的双向链表 如果从数据结构课本出发，List 很容易让人想到双向链表：\nnode \u0026lt;-\u0026gt; node \u0026lt;-\u0026gt; node \u0026lt;-\u0026gt; node 链表的好处是两端插入删除很快，坏处也明显：每个节点都要存前后指针，内存不连续，CPU cache 友好性差。\nRedis 早期确实使用过普通链表和压缩结构。现在理解 Redis List，重点看 quicklist。\nquicklist 可以理解成“链表 + 紧凑数组块”的组合：\nquicklist node -\u0026gt; listpack: [a, b, c] node -\u0026gt; listpack: [d, e, f] node -\u0026gt; listpack: [g, h, i] 外层是链表，方便从两端扩展；内层每个节点是一段 listpack，把多个元素连续放在一起。\n这比“每个元素一个链表节点”更省内存，也比“所有元素都塞进一整段连续内存”更容易在两端插入删除。\n所以 Redis List 的常见操作会呈现出这种特点：\nLPUSH / RPUSH：两端插入，适合队列。 LPOP / RPOP：两端弹出，也适合队列。 LLEN：长度信息直接维护，查询很快。 LINDEX / LRANGE：按下标走到中间位置时，仍然可能需要遍历。 这就是为什么 List 适合做简单队列、最近消息列表，不适合频繁随机访问中间元素。\n如果你真的需要按 ID 快速定位某条记录，List 通常不是最好的选择。可以考虑 Hash 存内容，再用 ZSet 或 List 保存顺序。\nSet：先看成员是不是整数 Set 对外提供的是无序去重集合：\nSADD online:users 1001 1002 1003 SISMEMBER online:users 1002 它的内部编码会先看一个很具体的条件：成员是不是都能表示成整数。\n如果一个 Set 很小，而且成员全是整数，Redis 可以用 intset 保存：\n[1001, 1002, 1003] intset 本质上是一段有序整数数组。它不需要为每个元素保存字符串对象，也不需要哈希表指针，所以很省内存。\n为了容纳不同大小的整数，intset 还会选择合适的整数宽度：\nint16 -\u0026gt; int32 -\u0026gt; int64 如果原来都是小整数，后来插入一个很大的整数，Redis 会把整个数组升级到更宽的编码。这个升级是单向的，因为已经变宽后，再缩回去通常不值得。\n如果 Set 里出现非整数成员，或者元素数量超过阈值，就会转成更通用的编码。Redis 7.2 之后，小的非整数 Set 也可以使用 listpack；更大的 Set 会使用 hashtable。\n所以 Set 的选择可以这样记：\n小整数集合 -\u0026gt; intset 小的非整数集合 -\u0026gt; listpack（Redis 7.2+） 普通大集合 -\u0026gt; hashtable 从使用者角度看，这些转换是透明的。你仍然执行 SADD、SREM、SISMEMBER。但如果你关心内存，就应该知道“全是整数”和“混入一个字符串”可能会让底层编码发生变化。\nZSet：排行榜为什么常常提到跳表 ZSet，也就是 sorted set，是 Redis 里最值得细看的结构之一。\n它既要支持按 member 查分数：\nZSCORE rank alice 又要支持按 score 范围查询：\nZRANGEBYSCORE rank 80 100 还要支持按排名取一段：\nZRANGE rank 0 9 WITHSCORES 一个结构很难同时把这些操作都做得舒服，所以 Redis 对大 ZSet 使用的是组合结构：\nhashtable: member -\u0026gt; score skiplist: 按 score 和 member 排序 哈希表负责快速通过 member 找到 score，跳表负责按分数范围和排名顺序遍历。\n跳表可以理解成带多级索引的有序链表：\nlevel 3: 10 ----------------\u0026gt; 80 level 2: 10 ------\u0026gt; 50 -----\u0026gt; 80 level 1: 10 -\u0026gt; 20 -\u0026gt; 50 -\u0026gt; 70 -\u0026gt; 80 查找时先从高层快速跳过一大段，再逐层下降。它不像平衡树那样需要复杂旋转，平均复杂度也能做到 O(log N)。对 Redis 来说，跳表实现简单，范围遍历也自然。\n小 ZSet 则没有必要一上来就维护哈希表和跳表。元素少时，Redis 会用 listpack 把 member 和 score 紧凑地放在一起：\nalice | 100 | bob | 95 | cindy | 90 当元素数量或成员长度超过阈值，再转换成 skiplist + hashtable。\n默认配置里，ZSet 的紧凑编码阈值通常是：\nzset-max-listpack-entries 128 zset-max-listpack-value 64 这也解释了一个常见现象：刚开始很小的排行榜，内存占用可能很低；用户数上来后，底层结构一转换，内存会明显上升。不是 Redis 突然变浪费了，而是它从“省内存模式”切到了“保证查询效率的通用模式”。\nRedis 8.6 对大 ZSet 也做了类似的内存优化。官方说明里提到，skiplist 编码的 sorted set 有明显内存下降，其中一个具体点是把 score 和 value 统一到一个结构里；同时 ZRANK 也有优化。\n所以，如果你的业务大量依赖排行榜、延迟队列、按分数范围查询，升级到 8.6 之后，大 ZSet 的内存和部分排序相关命令可能会有更好的表现。但底层仍然是 skiplist + hashtable 这套组合逻辑：一个负责顺序，一个负责按 member 快速定位。\nStream：radix tree 管索引，listpack 管内容 Redis Stream 是后来的数据类型，用来保存类似消息流的数据：\nXADD events * user bole action login XREAD COUNT 10 STREAMS events 0 Stream 的内部可以粗略理解成：\nrax stream id range -\u0026gt; listpack entries rax 是 radix tree，也叫基数树。它适合按前缀和有序 key 做查找。Stream 的消息 ID 天然有顺序，适合用树结构组织。\n真正的消息字段和值，则被打包进 listpack。这样一来，Stream 既能按 ID 范围定位，又能把多条小消息紧凑存储。\n如果只是使用 Stream 做轻量消息队列，你不一定需要记住每个结构细节。但知道它是“树索引 + 紧凑块存储”，就能理解为什么 Stream 适合追加、按范围读取，也能理解为什么特别大的单个 Stream 仍然需要关注修剪策略。\nRedis 8.6 还给 Stream 增加了一个更偏工程语义的能力：XADD 支持 IDMP 和 IDMPAUTO 参数，用来做幂等生产，目标是提供 at-most-once 语义。\n这和底层结构的关系在于：Stream 不只是“追加一条消息”那么简单，它还要维护消息 ID、消费者组、待确认消息、修剪策略，以及现在的幂等生产元数据。使用 Stream 做消息队列时，除了看写入速度，也要关注这些元数据会不会随着使用方式不断膨胀。\n自动转换：Redis 会帮你换结构，但不会替你设计数据模型 Redis 的很多内部编码转换都是自动发生的。\n比如一个 Hash 一开始很小：\nHSET profile:1 name bole age 18 OBJECT ENCODING profile:1 # 可能是 listpack 后来字段越来越多，或者某个 value 很长：\nHSET profile:1 bio \u0026#34;a very very long text ...\u0026#34; OBJECT ENCODING profile:1 # 可能变成 hashtable 这个转换对命令语义没有影响，但对内存和性能有影响。\n需要注意的是，很多转换是朝更通用的编码走，通常不要指望它自动变回紧凑编码。比如一个 Set 从 intset 升级成 hashtable 后，就算你删掉那个导致升级的元素，它也不会为了省一点内存立刻降回去。\n这背后还是同一个取舍：转换本身也有成本。Redis 更愿意避免频繁来回切换，让结构稳定在能覆盖更多操作的编码上。\n一张表把常见结构串起来 把前面的内容压成一张表，大概是这样：\n对外类型 常见内部编码 适合记住的重点 String int / embstr / raw 小整数、小字符串会特殊优化，底层核心是 SDS List quicklist / listpack 链表负责扩展，listpack 负责紧凑存储 Hash listpack / hashtable 小对象省内存，大对象换查询效率 Set intset / listpack / hashtable 小整数集合非常省，混入复杂成员会升级 ZSet listpack / skiplist + hashtable 小集合紧凑，大集合同时照顾 member 查找和范围遍历 Stream rax + listpack 树负责按 ID 定位，listpack 保存消息内容 这张表不是让你死记，而是让你看到 Redis 的统一思路：\n小数据先压紧，大数据再换成通用结构；读写语义保持不变，内部编码按数据规模和形态调整。\n这里还要补一句关于 Redis 8 之后的背景：Redis 8 Open Source 已经把 Redis Stack 的很多能力并进来，比如 JSON、Time Series、概率数据结构、Search/Query 和向量相关能力。它们也有自己的底层结构和索引实现，但这篇文章主要讲 Redis 常用核心类型，所以不展开模块级细节。\n如果你关注预发布版本，8.8-RC1 里已经出现了一个新数据结构 Array。但 RC 不是稳定版本，生产环境选型还是应该以当前稳定版和对应 release notes 为准。\n平时怎么用这些知识 理解底层结构，最后还是要回到日常开发和排查问题。\n第一，遇到内存异常时，不要只看 key 数量。可以配合使用：\nOBJECT ENCODING key MEMORY USAGE key 前者看内部编码，后者看大致内存占用。很多时候，同样数量的 key，因为 value 类型和编码不同，内存会差很多。\n在 Redis 8.6 之后，还可以关注 key 内存大小直方图相关能力。官方 release notes 里提到，Redis 增加了 keys memory size histograms，并提供类似 db0_distrib_lists_sizes、db0_distrib_sets_sizes、db0_distrib_hashes_sizes、db0_distrib_zsets_sizes 这样的指标。它们的价值不是替代 MEMORY USAGE key，而是帮你先看整体分布：到底是少数大 key 把内存撑起来，还是大量中小 key 累积出来的压力。\n第二，写 Redis 方案时，要考虑数据是否会跨过编码阈值。\n一个只有几十个字段的 Hash，和一个有几十万个字段的 Hash，不只是“大小不同”，它们在删除、迁移、持久化、阻塞风险上都不是一类东西。\n第三，不要再把旧版 ziplist 当成唯一答案。\n很多中文资料还停留在 Redis 3.x、4.x、5.x 的描述里，会说 Hash、ZSet 小对象使用 ziplist。这个说法在旧版本里有历史意义，但 Redis 7+ 已经主要使用 listpack。面试或写文章时最好带上版本背景。\n第四，配置阈值不要凭感觉调大。\n这些参数看起来很诱人：\nhash-max-listpack-entries hash-max-listpack-value zset-max-listpack-entries zset-max-listpack-value set-max-intset-entries set-max-listpack-entries 调大它们可能会省内存，但也可能让本该使用哈希表或跳表的数据继续停留在线性扫描的紧凑结构里。数据量、字段长度、访问模式不同，结果会差很多。真正要调，应该先压测。\n第五，排查性能问题时，不要只盯慢查询。\nRedis 8.6 新增了 HOTKEYS，用于热点 key 检测和报告。热点 key 往往不是内部编码错了，而是访问分布太偏：一个 key 被大量请求命中，单线程主循环、网络输出、复制传播都可能被它拖住。底层数据结构解释的是“这个 key 自己怎么存”，热点排查解释的是“流量怎么打到这个 key 上”。\n第六，淘汰策略也开始出现新的维度。\nRedis 8.6 新增了 volatile-lrm 和 allkeys-lrm，这里的 LRM 是 least recently modified，也就是按最近修改时间参与淘汰。传统 LRU 更关心最近访问，LFU 更关心访问频率，LRM 则更贴近“最近有没有被写过”。如果你把 Redis 当缓存和状态存储混用，这类策略差异会影响哪些 key 更容易被淘汰。\n最后再看 Redis 为什么快 Redis 快，不只是因为“它在内存里”。\n更准确地说，Redis 快是因为它把很多工程细节都压到了合适的位置：\nkey 空间用哈希表快速定位。 String 用 SDS 避免 C 字符串的长度和二进制问题。 小 Hash、小 ZSet、小 Set 用紧凑编码省内存。 大 Hash、大 Set 用哈希表保证查询效率，Redis 8.6 继续降低了大 Hash 的元数据成本。 ZSet 用跳表处理范围和排名，Redis 8.6 也继续优化了大 ZSet 的内存和 ZRANK 路径。 List 用 quicklist 在链表和连续内存之间折中。 Stream 用 radix tree 和 listpack 兼顾顺序索引与紧凑存储。 新版本还在可观测性上补强，比如 HOTKEYS 和 key 内存大小直方图，帮助你从“单个 key 怎么存”进一步看到“整库 key 怎么分布”。 所以学习 Redis 底层数据结构，最有价值的不是背出每个结构名字，而是理解它的取舍逻辑。\n当数据很小，Redis 选择省内存；当数据变大，Redis 选择更稳定的查询复杂度；当一个类型需要多种访问方式，Redis 就组合多种结构。\n这也是 Redis 设计里最值得借鉴的地方：接口保持简单，底层实现可以很灵活。\n参考资料 Redis Docs: OBJECT ENCODING Redis Docs: Memory optimization Redis Docs: Redis Open Source release notes Redis Docs: Redis Open Source 8.0 release notes Redis Docs: Redis Open Source 8.6 release notes Redis GitHub releases ","permalink":"https://cloudside.icyyan.com/posts/redis-underlying-data-structures/","summary":"\u003cp\u003e很多人第一次学 Redis，记住的是五种常用类型：String、List、Hash、Set、ZSet。再往后背面试题，又会遇到 SDS、quicklist、listpack、intset、skiplist、hashtable。\u003c/p\u003e\n\u003cp\u003e这些名字放在一起很容易让人迷糊：Redis 不是 key-value 数据库吗？为什么一个 \u003ccode\u003eHash\u003c/code\u003e 后面还会有 \u003ccode\u003elistpack\u003c/code\u003e 和 \u003ccode\u003ehashtable\u003c/code\u003e 两种实现？为什么同样是 \u003ccode\u003eSet\u003c/code\u003e，有时是 \u003ccode\u003eintset\u003c/code\u003e，有时又变成哈希表？\u003c/p\u003e\n\u003cp\u003e真正要理解的不是“Redis 有哪些数据结构”这张清单，而是一个更具体的问题：\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eRedis 为什么要让同一种对外类型，在不同场景下切换不同的内部编码？\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e顺着这个问题往下看，Redis 的底层数据结构会清楚很多。它不是为了把实现弄复杂，而是在内存、CPU、查询速度、扩容成本之间做了一组很工程化的取舍。\u003c/p\u003e\n\u003cp\u003e本文写作和更新时的版本背景是：截至 2026 年 5 月 23 日，Redis Open Source 最新稳定版是 \u003ccode\u003e8.6.3\u003c/code\u003e，这是 2026 年 5 月发布的安全修复版本；Redis GitHub releases 里还能看到 \u003ccode\u003e8.8-RC1\u003c/code\u003e，但它是预发布版，官方说明不适合生产环境使用。\u003c/p\u003e\n\u003cp\u003e从底层数据结构这条线看，Redis 8.6 最值得关注的不是“又多背几个结构名”，而是几类更贴近日常排查的问题：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e大 Hash 和大 ZSet 的内存占用继续被优化。\u003c/li\u003e\n\u003cli\u003eStream 增加了 \u003ccode\u003eXADD IDMP\u003c/code\u003e / \u003ccode\u003eIDMPAUTO\u003c/code\u003e，用于幂等写入。\u003c/li\u003e\n\u003cli\u003e新增 \u003ccode\u003eHOTKEYS\u003c/code\u003e，能直接辅助定位热点 key。\u003c/li\u003e\n\u003cli\u003e新增 key 内存大小直方图相关能力，排查 List、Set、Hash、ZSet 的规模分布更方便。\u003c/li\u003e\n\u003cli\u003e新增 \u003ccode\u003evolatile-lrm\u003c/code\u003e 和 \u003ccode\u003eallkeys-lrm\u003c/code\u003e 淘汰策略，按“最近修改时间”参与淘汰。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e旧版本里经常出现的 \u003ccode\u003eziplist\u003c/code\u003e，在 Redis 7 之后大多已经被 \u003ccode\u003elistpack\u003c/code\u003e 替代，所以不要再只背“压缩列表”那套旧答案。\u003c/p\u003e","title":"Redis 底层数据结构：一个 key 背后到底藏着什么"},{"content":"如果一个接口被大量查询不存在的数据，比如用户不断请求不存在的商品 ID、缓存里没有的用户 ID，系统很容易被拖进一个尴尬局面：缓存没命中，请求继续打到数据库；数据库查不到，下一次同样的请求又会重复发生。\n这类问题的关键不是“如何更快地查到数据”，而是先回答一个更便宜的问题：这个东西是不是一定不存在？\n布隆过滤器解决的正是这个问题。它不能像哈希集合那样给出完全精确的答案，但它能用很少的内存，在极快的时间内告诉你：\n如果结果是“不存在”，那它一定不存在。 如果结果是“存在”，那它只是可能存在。 这个“可能”就是布隆过滤器最重要的取舍。它用少量误判，换来了很高的空间效率。\n先从一个普通集合说起 假设我们要记录 1 亿个已经注册过的用户 ID，最直接的做法是把这些 ID 放进一个集合：\nregistered_users = set() registered_users.add(\u0026#34;user:10086\u0026#34;) if \u0026#34;user:10086\u0026#34; in registered_users: print(\u0026#34;exists\u0026#34;) 这个方案很好理解，也很精确。但问题是：集合为了支持快速查询，通常不只存储原始元素，还要维护哈希表、桶、指针、扩容状态等额外结构。数据量一大，内存会明显上去。\n如果我们的目标只是提前过滤掉明显不存在的请求，真的需要保存完整的字符串吗？\n布隆过滤器的答案是：不需要。它只保存一些 bit。\n布隆过滤器的直觉 可以把布隆过滤器想成一个很大的位图，也就是一串只包含 0 和 1 的数组：\n0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 普通位图通常要求元素能直接映射成整数位置，比如数字 13 就对应第 13 个 bit。但现实里的元素可能是字符串、URL、邮箱、订单号，不一定能直接当数组下标。\n布隆过滤器在中间加了一层哈希函数。\n对于一个元素，它不用一个位置表示，而是用多个哈希函数算出多个位置：\nelement = \u0026#34;user:10086\u0026#34; hash1(element) -\u0026gt; 3 hash2(element) -\u0026gt; 9 hash3(element) -\u0026gt; 14 插入这个元素时，就把这些位置都置为 1：\n0 0 0 1 0 0 0 0 0 1 0 0 0 0 1 0 ↑ ↑ ↑ 查询时也做同样的计算。如果这几个位置中只要有一个还是 0，就说明这个元素一定没插入过。因为只要插入过，当时就一定会把这些位置都置为 1。\n如果这几个位置全部都是 1，就只能说明它可能插入过。因为这些 1 也可能是其他元素碰巧设置出来的。\n看到这里，布隆过滤器的核心就出来了：\n用一个 bit 数组保存状态。 用多个哈希函数把一个元素映射到多个 bit。 插入时把这些 bit 置为 1。 查询时检查这些 bit 是否全为 1。 为什么会误判 假设我们已经插入了两个元素：\nA -\u0026gt; 2, 5, 9 B -\u0026gt; 1, 5, 12 位图里被置为 1 的位置大概是：\n0 1 1 0 0 1 0 0 0 1 0 0 1 0 0 0 ↑ ↑ ↑ ↑ ↑ 现在查询一个从来没有插入过的元素 C：\nC -\u0026gt; 1, 9, 12 这三个位置刚好都已经是 1。于是布隆过滤器会回答“可能存在”，但实际上 C 并不存在。\n这就是误判，也叫 false positive。\n但布隆过滤器不会误判成“不存在”。只要一个元素真的插入过，它对应的那些 bit 一定被置为 1；查询时不可能突然看到其中某个 bit 是 0，除非你的实现里发生了清空、并发写错、数据损坏，或者使用了不支持删除的布隆过滤器却强行删除。\n所以它的语义要记牢：\n查询结果 含义 不存在 一定不存在 存在 可能存在 这个语义决定了它适合做“提前拦截”，不适合单独做“最终判定”。\n插入和查询流程 假设布隆过滤器有三个哈希函数，底层 bit 数组长度是 m。\n插入元素 x：\nh1 = hash1(x) % m h2 = hash2(x) % m h3 = hash3(x) % m bits[h1] = 1 bits[h2] = 1 bits[h3] = 1 查询元素 x：\nh1 = hash1(x) % m h2 = hash2(x) % m h3 = hash3(x) % m if bits[h1] == 1 and bits[h2] == 1 and bits[h3] == 1: return \u0026#34;可能存在\u0026#34; else: return \u0026#34;一定不存在\u0026#34; 它的时间复杂度是 O(k)，其中 k 是哈希函数数量。实际工程里 k 通常是一个很小的常数，比如 4、7、10，所以查询速度非常稳定。\n空间复杂度是 O(m)，只和 bit 数组长度有关，不直接保存元素本身。\n用 Python 写一个最小布隆过滤器 下面实现一个最小可用版本。为了避免依赖 Python 内置 hash() 的随机化行为，这里用 hashlib.blake2b 生成稳定哈希值。\nfrom hashlib import blake2b class BloomFilter: def __init__(self, bit_size: int, hash_count: int): if bit_size \u0026lt;= 0: raise ValueError(\u0026#34;bit_size must be positive\u0026#34;) if hash_count \u0026lt;= 0: raise ValueError(\u0026#34;hash_count must be positive\u0026#34;) self.bit_size = bit_size self.hash_count = hash_count self.bits = bytearray((bit_size + 7) // 8) def _hashes(self, value: str): data = value.encode(\u0026#34;utf-8\u0026#34;) for seed in range(self.hash_count): digest = blake2b( data, digest_size=8, person=seed.to_bytes(4, \u0026#34;little\u0026#34;), ).digest() number = int.from_bytes(digest, \u0026#34;little\u0026#34;) yield number % self.bit_size def _set_bit(self, position: int) -\u0026gt; None: byte_index = position // 8 bit_offset = position % 8 self.bits[byte_index] |= 1 \u0026lt;\u0026lt; bit_offset def _get_bit(self, position: int) -\u0026gt; bool: byte_index = position // 8 bit_offset = position % 8 return (self.bits[byte_index] \u0026amp; (1 \u0026lt;\u0026lt; bit_offset)) != 0 def add(self, value: str) -\u0026gt; None: for position in self._hashes(value): self._set_bit(position) def contains(self, value: str) -\u0026gt; bool: return all(self._get_bit(position) for position in self._hashes(value)) 使用方式：\nbloom = BloomFilter(bit_size=1_000_000, hash_count=7) bloom.add(\u0026#34;user:10086\u0026#34;) bloom.add(\u0026#34;user:10087\u0026#34;) print(bloom.contains(\u0026#34;user:10086\u0026#34;)) # True，可能存在 print(bloom.contains(\u0026#34;user:99999\u0026#34;)) # False，一定不存在 这里的 True 不能直接翻译成“存在”，更准确的含义是“没有被过滤掉，需要继续查真正的数据源”。这也是工程里最常见的用法。\n哈希函数不一定真的要写 k 个 前面的代码为了直观，用不同的 seed 生成多个哈希结果。实际工程里经常会用“双重哈希”来模拟多个哈希函数。\n先算出两个基础哈希：\nh1 = hash_a(x) h2 = hash_b(x) 然后构造第 i 个位置：\nposition_i = (h1 + i * h2) % m 这样只需要两次真正的哈希计算，就能得到 k 个位置。很多成熟实现都会采用类似思路，因为哈希计算往往比 bit 操作贵得多。\n关键点不是“必须有 k 个完全独立的哈希函数”，而是这些位置要足够分散，不能大量挤在同一片 bit 上。哈希分布越差，误判率越容易偏离预期。\n为什么需要无偏哈希 布隆过滤器的误判率公式有一个隐含前提：哈希结果要尽量均匀，也就是每个 bit 位置被选中的概率大致相同。\n如果哈希函数有偏，问题会很快出现。比如 bit 数组长度是 1000，但某个哈希函数总是更容易落在前 100 个位置，那么这些位置会被很快打满，后面的 900 个位置却很少使用。这样即使 bit 数组整体看起来还没满，查询时也更容易碰到一组已经被置为 1 的位置，实际误判率会比公式估算高。\n所以这里说的“无偏”不是指哈希值不能冲突。冲突一定会发生。它指的是：对大量不同输入来说，哈希结果落到 0..m-1 每个位置的机会应该尽量接近。\n工程里通常要注意三点：\n使用分布质量可靠的哈希算法，不要随手拼接字符串长度、ID 后几位这类弱规则。 如果用 hash(value) % m 映射位置，要确认原始哈希值已经足够均匀。严格来说，取模可能带来一点 modulo bias；如果使用 64 位哈希且 m 远小于 2^64，这点偏差通常可以忽略。 不要只依赖低位 bit，除非你确定哈希算法的低位质量也足够好。有些哈希在低位上的分布比高位更差，直接用 hash \u0026amp; (m - 1) 可能会放大偏斜。 前面提到的双重哈希也建立在这个前提上：h1 和 h2 本身要有足够好的分布。如果基础哈希已经偏了，后面构造出来的多个位置也会跟着偏。\n误判率怎么估算 布隆过滤器最容易被问到的问题是：bit 数组开多大？哈希函数用几个？\n我们先定义几个变量：\n变量 含义 n 预计插入的元素数量 m bit 数组长度 k 哈希函数数量 p 目标误判率 插入 n 个元素，每个元素设置 k 个 bit。一个特定 bit 在一次哈希后仍然是 0 的概率是：\n1 - 1 / m 一共发生 k * n 次设置之后，它仍然是 0 的概率近似为：\n(1 - 1 / m)^(k * n) ≈ e^(-k * n / m) 所以一个 bit 为 1 的概率大约是：\n1 - e^(-k * n / m) 查询一个不存在的元素时，k 个位置都刚好为 1，就会误判。因此误判率近似为：\np ≈ (1 - e^(-k * n / m))^k 这个公式不用每次手算，但它能帮我们理解三个事实：\nm 越大，bit 越不容易被填满，误判率越低。 n 越大，写入越多，误判率越高。 k 不是越大越好，太少不够区分，太多会更快把 bit 数组打满。 在给定 m 和 n 时，比较合适的哈希函数数量是：\nk ≈ (m / n) * ln(2) 如果反过来，已经知道预计元素数量 n 和目标误判率 p，可以估算需要的 bit 数：\nm ≈ -(n * ln(p)) / (ln(2)^2) 再用上面的公式算出 k。\n一个容量估算例子 假设我们预计要放入 1000 万个用户 ID，希望误判率大约是 1%。\n代入公式：\nn = 10,000,000 p = 0.01 m ≈ -(n * ln(p)) / (ln(2)^2) ≈ 95,850,584 bit 换算成内存：\n95,850,584 bit ÷ 8 ÷ 1024 ÷ 1024 ≈ 11.43 MB 合适的哈希函数数量：\nk ≈ (m / n) * ln(2) ≈ 6.64 实际可以取 k = 7。\n也就是说，用大约 12 MB 内存，就能为 1000 万个元素提供约 1% 误判率的存在性过滤。换成普通哈希集合，内存通常会高很多，因为它要保存完整 key 和哈希表结构。\n它适合放在哪里 布隆过滤器最适合放在“昂贵查询”之前，先挡掉确定不存在的数据。\n缓存穿透保护 一个常见链路是：\n请求 -\u0026gt; 布隆过滤器 -\u0026gt; 缓存 -\u0026gt; 数据库 如果布隆过滤器判断 ID 一定不存在，直接返回空结果，不再访问缓存和数据库。\n如果判断可能存在，再继续查缓存；缓存没命中时，再查数据库。\n这能缓解大量无效 key 把数据库打穿的问题。注意它只是保护层，不是数据源。对于布隆过滤器判断“可能存在”的请求，后面仍然要查真正的数据。\n爬虫 URL 去重 爬虫抓取页面时，需要判断某个 URL 是否已经见过。URL 数量可能非常大，如果全部放进精确集合，内存压力会很高。\n布隆过滤器可以用来快速过滤“很可能见过”的 URL。代价是少量新 URL 可能被误判成已经见过，从而被跳过。\n这个代价是否能接受，要看业务目标。如果是搜索引擎核心抓取链路，可能需要更复杂的去重系统；如果是日志分析、监控采样、内容发现任务，少量漏抓通常可以接受。\n数据库和存储引擎 很多存储系统会在底层文件或数据块旁边放布隆过滤器。查询某个 key 时，先问布隆过滤器：\n如果它说一定不存在，就不用读磁盘文件。 如果它说可能存在，再去读对应的数据块。 这种场景下，布隆过滤器节省的不是业务内存，而是昂贵的磁盘 IO。\n黑名单和风控预过滤 如果有一批恶意 IP、设备 ID、账号 ID，可以用布隆过滤器做第一层快速判断。\n但风控场景要特别小心：布隆过滤器会误判。它适合用来触发后续精确检查，不适合单独作为封禁依据。否则正常用户可能因为误判被拦截。\n为什么它通常不支持删除 普通布隆过滤器没有办法安全删除一个元素。\n原因是多个元素可能共享同一个 bit。比如：\nA -\u0026gt; 2, 5, 9 B -\u0026gt; 5, 8, 13 位置 5 同时被 A 和 B 使用。如果删除 A 时直接把 2、5、9 都清零，就会影响 B。之后查询 B 时，发现位置 5 是 0，布隆过滤器会错误地回答“不存在”。\n这会破坏布隆过滤器最重要的保证：不存在就一定不存在。\n如果业务确实需要删除，可以使用 Counting Bloom Filter。它不再用一个 bit 表示位置，而是用一个小计数器：\n插入时，对应计数器加 1。 删除时，对应计数器减 1。 查询时，所有计数器都大于 0 才算可能存在。 代价是内存会增加，而且要处理计数器溢出、并发更新、重复删除等问题。很多场景会选择另一种更简单的办法：按时间窗口重建布隆过滤器。\n比如每天生成一个新的过滤器，旧过滤器保留几天后丢弃。这样不需要单条删除，系统也更容易保持可控。\n容量超过预期会发生什么 布隆过滤器创建时就隐含了一个前提：预计插入 n 个元素。\n如果实际写入远远超过这个数量，bit 数组会越来越满。满到一定程度后，大部分位置都是 1，查询任何不存在的元素都很容易得到“可能存在”。\n极端情况下，如果 bit 数组几乎全是 1，布隆过滤器就退化成了“永远放行”。它不会产生错误的“不存在”，但过滤效果基本没了。\n所以线上使用时最好监控两个指标：\n已插入元素数量的估计值。 bit 位被置为 1 的比例。 当填充率过高时，可以扩容、重建，或者切换到可伸缩布隆过滤器。可伸缩布隆过滤器的思路是：旧过滤器满了以后，再挂一个新的过滤器；查询时依次检查多个过滤器。\n工程实现里的几个坑 不要把 True 当成最终存在 这是最常见的错误。\nif bloom.contains(user_id): return user 这样写是有问题的。布隆过滤器返回 True 只表示“可能存在”，后面必须继续查缓存、数据库或精确集合。\n更合理的链路是：\nif not bloom.contains(user_id): return None return load_user_from_cache_or_db(user_id) 初始化数据要和真实数据对齐 如果用布隆过滤器保护数据库查询，过滤器里的数据必须来自可信数据源。常见做法是服务启动时从数据库或离线快照加载一遍，之后新增数据时同步写入过滤器。\n这里要考虑一个时间窗口：新数据刚写入数据库，但还没写入布隆过滤器。如果这时查询新 key，过滤器会说“不存在”，请求就被错误拦掉。\n解决方式可以是：\n写数据库成功后同步写布隆过滤器。 通过消息队列异步更新，但查询链路要能容忍短暂延迟。 对刚创建的数据走旁路策略，不完全依赖过滤器。 具体选哪种，取决于业务能不能接受短时间内查不到新数据。\n哈希函数要稳定 布隆过滤器经常需要持久化、跨进程共享，或者在服务重启后继续使用。这个时候哈希结果必须稳定。\n一些语言的默认哈希函数可能会为了安全原因在进程启动时加入随机种子。比如 Python 的内置 hash() 就不适合直接拿来做持久化布隆过滤器。\n更稳妥的做法是使用明确的哈希算法，比如 MurmurHash、xxHash、SipHash、BLAKE 系列，并固定编码方式和种子。\n分布式场景要先想清楚一致性 如果每个服务实例都有自己的本地布隆过滤器，就要考虑它们什么时候加载、什么时候更新、是否可能不一致。\n如果使用 Redis 这类集中式组件保存布隆过滤器，一致性更简单，但每次查询都要多一次网络访问。这个访问成本是否值得，要看它能替你省掉多少数据库或远程存储压力。\n布隆过滤器很小，不代表它在架构上没有状态。只要它影响请求是否继续往下走，就必须把更新、重建、回滚、热加载这些问题想清楚。\n误判率不是固定不变的魔法数字 “1% 误判率”只在容量和哈希分布符合预期时成立。\n如果元素数量超了、哈希分布差、bit 数组复用太久，实际误判率都会上升。线上最好把它当成一个需要维护的结构，而不是一次创建后永久正确的配置。\n和哈希集合怎么选 如果你需要精确判断，或者要遍历元素、删除元素、取出原始 key，哈希集合更合适。\n如果你只需要在昂贵查询前做一次快速过滤，并且能接受少量误判，布隆过滤器通常更划算。\n可以用下面这张表快速判断：\n问题 更适合 必须 100% 精确判断存在 哈希集合 查询“不存在”后要直接拦截 布隆过滤器 需要保存并取回原始元素 哈希集合 数据量很大，内存紧张 布隆过滤器 需要频繁删除单个元素 哈希集合或 Counting Bloom Filter 少量误判可以接受 布隆过滤器 布隆过滤器不是哈希集合的替代品，而是一个更靠前的过滤层。它最好的位置通常不是“数据结构的终点”，而是“昂贵操作的入口”。\n总结 布隆过滤器的设计非常克制：它不保存元素本身，只保存多个哈希位置对应的 bit。这个设计让它可以用很少的内存处理很大的数据量。\n它的核心价值也来自这个取舍：\n查询结果为不存在时，可以相信。 查询结果为存在时，只能继续验证。 误判率可以通过 m、n、k 估算和控制。 不适合直接删除，删除需求要换 Counting Bloom Filter 或按时间窗口重建。 如果你在系统里遇到的问题是“太多不存在的 key 正在消耗后端资源”，布隆过滤器往往值得考虑。它不能替你查到数据，但能用很便宜的方式告诉你：这个查询根本不该继续往下走。\n","permalink":"https://cloudside.icyyan.com/posts/bloom-filter-guide/","summary":"\u003cp\u003e如果一个接口被大量查询不存在的数据，比如用户不断请求不存在的商品 ID、缓存里没有的用户 ID，系统很容易被拖进一个尴尬局面：缓存没命中，请求继续打到数据库；数据库查不到，下一次同样的请求又会重复发生。\u003c/p\u003e\n\u003cp\u003e这类问题的关键不是“如何更快地查到数据”，而是先回答一个更便宜的问题：\u003cstrong\u003e这个东西是不是一定不存在？\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e布隆过滤器解决的正是这个问题。它不能像哈希集合那样给出完全精确的答案，但它能用很少的内存，在极快的时间内告诉你：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e如果结果是“不存在”，那它一定不存在。\u003c/li\u003e\n\u003cli\u003e如果结果是“存在”，那它只是可能存在。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e这个“可能”就是布隆过滤器最重要的取舍。它用少量误判，换来了很高的空间效率。\u003c/p\u003e\n\u003ch2 id=\"先从一个普通集合说起\"\u003e先从一个普通集合说起\u003c/h2\u003e\n\u003cp\u003e假设我们要记录 1 亿个已经注册过的用户 ID，最直接的做法是把这些 ID 放进一个集合：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-python\" data-lang=\"python\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eregistered_users \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e set()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eregistered_users\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003eadd(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;user:10086\u0026#34;\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;user:10086\u0026#34;\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003ein\u003c/span\u003e registered_users:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    print(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;exists\u0026#34;\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e这个方案很好理解，也很精确。但问题是：集合为了支持快速查询，通常不只存储原始元素，还要维护哈希表、桶、指针、扩容状态等额外结构。数据量一大，内存会明显上去。\u003c/p\u003e\n\u003cp\u003e如果我们的目标只是提前过滤掉明显不存在的请求，真的需要保存完整的字符串吗？\u003c/p\u003e\n\u003cp\u003e布隆过滤器的答案是：不需要。它只保存一些 bit。\u003c/p\u003e\n\u003ch2 id=\"布隆过滤器的直觉\"\u003e布隆过滤器的直觉\u003c/h2\u003e\n\u003cp\u003e可以把布隆过滤器想成一个很大的位图，也就是一串只包含 0 和 1 的数组：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e普通位图通常要求元素能直接映射成整数位置，比如数字 13 就对应第 13 个 bit。但现实里的元素可能是字符串、URL、邮箱、订单号，不一定能直接当数组下标。\u003c/p\u003e\n\u003cp\u003e布隆过滤器在中间加了一层哈希函数。\u003c/p\u003e\n\u003cp\u003e对于一个元素，它不用一个位置表示，而是用多个哈希函数算出多个位置：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eelement = \u0026#34;user:10086\u0026#34;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ehash1(element) -\u0026gt; 3\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ehash2(element) -\u0026gt; 9\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ehash3(element) -\u0026gt; 14\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e插入这个元素时，就把这些位置都置为 1：\u003c/p\u003e","title":"布隆过滤器：用少量内存判断一个元素是否可能存在"},{"content":"如果让你记录“今天哪些用户登录过”，最直接的想法可能是放进一个集合：\nlogged_in_users = set() logged_in_users.add(10086) 这样当然能用。但如果用户 ID 范围很大、查询又特别频繁，集合会带来不少额外开销。有没有一种更省空间的办法？\n位图，也就是 Bitmap，解决的就是这类问题：用一个 bit 表示一个状态。这个状态通常只有两种结果：有或没有、是或否、出现过或没出现过。\n位图到底是什么 先从一个字节说起。\n一个字节有 8 个 bit：\n0 0 0 0 0 0 0 0 每个 bit 都可以表示一个编号是否存在。比如我们用 bit 记录数字是否出现过：\nbit 0 表示数字 0 是否出现 bit 1 表示数字 1 是否出现 bit 2 表示数字 2 是否出现 ... bit 7 表示数字 7 是否出现 如果数字 3 出现过，就把第 3 个 bit 置为 1：\n0 0 0 0 1 0 0 0 ↑ 数字 3 当编号超过 7 时，就放到下一个字节里。比如数字 13：\n13 // 8 = 1 -\u0026gt; 放在第 1 个字节 13 % 8 = 5 -\u0026gt; 使用这个字节里的第 5 个 bit 所以位图的核心映射关系只有两行：\n字节下标 = 数字 // 8 bit 下标 = 数字 % 8 看到这里，位图就不神秘了。它本质上是一个数组，只不过数组里的每个 bit 都被拿来当成一个小开关。\n为什么位图省内存 假设我们要记录 1 亿个用户是否登录过。\n如果用普通布尔数组，哪怕一个用户只占 1 个字节，也需要：\n100,000,000 字节 ≈ 95 MB 如果用位图，一个用户只占 1 个 bit：\n100,000,000 bit ÷ 8 ≈ 12 MB 这还只是和“理想布尔数组”比。实际的 set、dict、对象数组通常还有哈希表、指针、扩容等额外开销，真实内存差距会更大。\n这就是位图最适合的地方：状态很简单，但数量很大。\n三个核心操作 位图常用操作只有三个：\n操作 含义 set 把某个数字标记为存在 get 查询某个数字是否存在 clear 清掉某个数字的标记 用位运算表达就是：\nset: bits[index] = bits[index] | (1 \u0026lt;\u0026lt; offset) get: bits[index] \u0026amp; (1 \u0026lt;\u0026lt; offset) != 0 clear: bits[index] = bits[index] \u0026amp; ~(1 \u0026lt;\u0026lt; offset) 如果你不熟悉位运算，可以先这样理解：\n1 \u0026lt;\u0026lt; offset 是做出一个只有目标位置为 1 的小掩码。 | 是把目标 bit 打开。 \u0026amp; 是检查目标 bit 是否已经打开。 \u0026amp; ~ 是把目标 bit 关掉。 用 Python 写一个最小 Bitmap 下面是一个最小可用的位图。为了让例子简单，它只支持 0 到 max_value 之间的非负整数。\nclass Bitmap: def __init__(self, max_value: int): if max_value \u0026lt; 0: raise ValueError(\u0026#34;max_value must be non-negative\u0026#34;) self.max_value = max_value byte_len = (max_value + 1 + 7) // 8 self.bits = bytearray(byte_len) def _position(self, value: int) -\u0026gt; tuple[int, int]: if value \u0026lt; 0 or value \u0026gt; self.max_value: raise ValueError(\u0026#34;value out of range\u0026#34;) byte_index = value // 8 bit_offset = value % 8 return byte_index, bit_offset def add(self, value: int) -\u0026gt; None: byte_index, bit_offset = self._position(value) self.bits[byte_index] |= 1 \u0026lt;\u0026lt; bit_offset def contains(self, value: int) -\u0026gt; bool: byte_index, bit_offset = self._position(value) return (self.bits[byte_index] \u0026amp; (1 \u0026lt;\u0026lt; bit_offset)) != 0 def remove(self, value: int) -\u0026gt; None: byte_index, bit_offset = self._position(value) self.bits[byte_index] \u0026amp;= ~(1 \u0026lt;\u0026lt; bit_offset) 使用方式：\nbitmap = Bitmap(100) bitmap.add(3) bitmap.add(13) bitmap.add(88) print(bitmap.contains(13)) # True print(bitmap.contains(14)) # False bitmap.remove(13) print(bitmap.contains(13)) # False 这个例子里的 bytearray 就是底层存储。每次 add()、contains()、remove() 都先算出目标数字落在哪个字节、哪个 bit，然后对那个 bit 做操作。\n一个更贴近日常业务的例子：用户签到 假设一个活动最多有 100 万个用户参与，我们想记录“今天谁签到了”。如果用户 ID 从 0 开始连续增长，就可以这样做：\ntoday_checkin = Bitmap(1_000_000) def checkin(user_id: int) -\u0026gt; None: today_checkin.add(user_id) def has_checked_in(user_id: int) -\u0026gt; bool: return today_checkin.contains(user_id) 查询某个用户是否签到，就是一次数组访问加一次位运算，速度非常快。\n如果还要统计“连续两天都签到的人”，位图也很好用。假设昨天和今天各有一个 bitmap：\nyesterday = Bitmap(1_000_000) today = Bitmap(1_000_000) # 两个位图按字节做 AND，就能得到两天都签到的用户集合 both = bytearray( a \u0026amp; b for a, b in zip(yesterday.bits, today.bits) ) 这就是位图另一个很强的地方：集合运算很便宜。\nAND：求交集，比如两天都签到。 OR：求并集，比如两天任意一天签到。 XOR：求差异，比如两天状态发生变化的人。 这些操作可以按字节、按 64 位整数，甚至借助 CPU 的 SIMD 指令批量执行，比逐个用户判断快得多。\n常见使用场景 位图适合很多“编号到状态”的问题。\n判断是否出现过 比如判断一个整数是否已经出现：\nseen = Bitmap(10_000_000) def add_number(n: int) -\u0026gt; None: seen.add(n) def exists(n: int) -\u0026gt; bool: return seen.contains(n) 如果数字范围可控，位图会比哈希集合更省内存。\n用户签到和活跃状态 每天一个 bitmap，用户 ID 对应 bit：\nuser_id = 10086 bit = 10086 今天登录了，就把对应 bit 置为 1。统计多天活跃、连续活跃、某几天都活跃，都可以用位运算组合。\n权限开关 如果一个系统里权限项数量不多，也可以用 bit 表示权限：\nbit 0 = 能查看 bit 1 = 能编辑 bit 2 = 能删除 bit 3 = 能发布 一个用户的权限可以压缩成一个整数：\nCAN_VIEW = 1 \u0026lt;\u0026lt; 0 CAN_EDIT = 1 \u0026lt;\u0026lt; 1 CAN_DELETE = 1 \u0026lt;\u0026lt; 2 permission = CAN_VIEW | CAN_EDIT can_edit = (permission \u0026amp; CAN_EDIT) != 0 这种方式常见于权限标记、状态位、功能开关。不过权限系统一旦涉及继承、资源范围、审计和动态策略，就不能只靠一个整数硬扛。\n位图不适合什么 位图虽然省内存，但不是所有场景都合适。\n编号范围很大但数据很少 如果只存 1000 个用户，但用户 ID 最大可能到 10 亿，直接建一个支持 10 亿范围的位图就不划算：\n1,000,000,000 bit ÷ 8 ≈ 119 MB 这时用 set 可能更合适。位图适合的是“范围可控，并且数据比较密集”的场景。\n需要记录复杂信息 位图只能表达“是或否”。它不能直接记录：\n用户登录了几次。 用户最后一次登录时间。 某个 ID 对应的业务对象。 如果需要这些信息，位图最多只能作为辅助索引，真正的数据还要放在别的结构里。\nID 不是整数或无法映射 位图天然喜欢整数 ID。如果你的 key 是邮箱、手机号、字符串 UUID，就需要先做映射。\n这一步要小心：如果只是把字符串 hash 到一个 bit 上，就可能发生冲突。冲突可接受时，可以考虑布隆过滤器；冲突不可接受时，就需要维护一个可靠的 ID 映射。\n和布隆过滤器有什么区别 很多人会把位图和布隆过滤器混在一起。它们都用 bit，但目标不一样。\n位图是精确的：\n数字 13 -\u0026gt; 固定对应第 13 个 bit 只要范围没越界，查询结果就是准确的。\n布隆过滤器是概率型的：\n一个 key -\u0026gt; 多个 hash -\u0026gt; 多个 bit 它能用更小空间判断“某个元素可能存在，或者一定不存在”。代价是会有误判：它可能说一个元素存在，但实际不存在。\n简单说：\n结构 查询结果 适合场景 位图 精确 整数范围可控，不能接受误判 布隆过滤器 可能误判 超大规模去重、缓存穿透防护、能接受少量误判 实际使用时的几个小坑 注意边界 如果支持 0 到 max_value，需要的 bit 数是 max_value + 1，不是 max_value。所以字节数最好这样算：\nbyte_len = (max_value + 1 + 7) // 8 +7 是为了向上取整。比如 9 个 bit 需要 2 个字节，不能只分配 1 个字节。\n注意并发写 如果多个线程同时修改同一个字节里的不同 bit，可能会互相覆盖。因为 bits[i] |= mask 看起来是一行，但底层通常是“读出、修改、写回”三个步骤。\n高并发场景要使用锁、原子位操作，或者按分片减少竞争。\n注意持久化格式 位图很适合持久化，因为它本质就是一段字节数组。但落盘前要想清楚：\n最大 ID 范围是多少。 bit 顺序是从低位到高位，还是从高位到低位。 是否需要版本号，方便以后扩展。 是否需要压缩，比如 Roaring Bitmap。 尤其是多个语言之间共享位图时，bit 顺序和字节序要写清楚，不然读出来很容易对不上。\n总结 位图最核心的思想很简单：用一个 bit 记录一个编号的状态。它适合整数范围可控、状态只有是或否、数据量又很大的场景。\n如果你只是记录少量离散数据，用 set 更简单；如果你要记录复杂对象，用数据库或哈希表更自然；但如果你面对的是几千万、几亿个“是否出现过”的状态，位图就是一个非常值得掌握的工具。\n判断是否适合用位图，可以记住三个问题：\n这个状态是不是只有是或否？ ID 能不能稳定映射成非负整数？ 最大 ID 范围是不是可控？ 三个答案都是“是”，位图通常就值得试一试。\n","permalink":"https://cloudside.icyyan.com/posts/bitmap-data-structure-guide/","summary":"\u003cp\u003e如果让你记录“今天哪些用户登录过”，最直接的想法可能是放进一个集合：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-python\" data-lang=\"python\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003elogged_in_users \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e set()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003elogged_in_users\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003eadd(\u003cspan style=\"color:#ae81ff\"\u003e10086\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e这样当然能用。但如果用户 ID 范围很大、查询又特别频繁，集合会带来不少额外开销。有没有一种更省空间的办法？\u003c/p\u003e\n\u003cp\u003e位图，也就是 Bitmap，解决的就是这类问题：\u003cstrong\u003e用一个 bit 表示一个状态\u003c/strong\u003e。这个状态通常只有两种结果：有或没有、是或否、出现过或没出现过。\u003c/p\u003e\n\u003ch2 id=\"位图到底是什么\"\u003e位图到底是什么\u003c/h2\u003e\n\u003cp\u003e先从一个字节说起。\u003c/p\u003e\n\u003cp\u003e一个字节有 8 个 bit：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e0 0 0 0 0 0 0 0\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e每个 bit 都可以表示一个编号是否存在。比如我们用 bit 记录数字是否出现过：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ebit 0 表示数字 0 是否出现\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ebit 1 表示数字 1 是否出现\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ebit 2 表示数字 2 是否出现\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e...\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ebit 7 表示数字 7 是否出现\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e如果数字 3 出现过，就把第 3 个 bit 置为 1：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e0 0 0 0 1 0 0 0\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        ↑\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      数字 3\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e当编号超过 7 时，就放到下一个字节里。比如数字 13：\u003c/p\u003e","title":"位图 Bitmap：用一个 bit 记录海量状态"},{"content":"如果你已经看过 Pingora 的最小反向代理示例，很容易产生一个后续问题：生产环境是不是也要自己继续写配置解析、证书加载、日志、热更新和管理界面？通常不需要。Pingap 就是基于 Pingora 做好的反向代理应用，它更接近一个可以直接部署的 Nginx 替代方案。\n这篇文章只讲 Pingap：怎么安装，怎么写一个最小反代配置，怎么加 HTTPS，怎么用 Docker 或 systemd 部署，以及从裸 Pingora 或 Nginx 迁移过来时有哪些容易忽略的坑。\n本文示例版本核对时间：2026-05-17。示例固定使用 Pingap v0.13.4。实际部署前建议再看一次官方 GitHub Releases 和文档，因为 Pingap 还在快速变化。\nPingap 和 Pingora 的关系 Pingora 是框架，你通过 Rust 代码实现代理逻辑。Pingap 是应用，它把常见反向代理能力包装成 TOML 配置和 Web 管理界面。\n一个最小 Pingap 配置大概长这样：\n[upstreams.app] addrs = [\u0026#34;127.0.0.1:3000\u0026#34;] [locations.app] host = \u0026#34;app.example.com\u0026#34; path = \u0026#34;/\u0026#34; upstream = \u0026#34;app\u0026#34; enable_reverse_proxy_headers = true [servers.http] addr = \u0026#34;0.0.0.0:6188\u0026#34; locations = [\u0026#34;app\u0026#34;] 这段配置和一个最小 Pingora 代理做的是同一件事：监听 6188，把匹配 app.example.com 的请求转发到 127.0.0.1:3000。差别在于，你不用实现 ProxyHttp，也不用自己处理常见代理应用需要的周边能力。\nPingap 更适合这些场景：\n替换 Nginx 做 HTTP/HTTPS 反向代理。 用 TOML 或 Web UI 管理 upstream、location、plugin、certificate。 需要热加载配置、访问日志、指标和常见插件。 想用 Pingora 的底层能力，但不想自己写完整代理应用。 安装 Pingap 官方 README 提供了一行安装脚本：\ncurl -sSL https://raw.githubusercontent.com/vicanso/pingap/main/install.sh | sh 生产环境更建议固定版本。下面以 v0.13.4 为例，x86_64 Linux 可以下载 musl 静态版本：\ncurl -L https://github.com/vicanso/pingap/releases/download/v0.13.4/pingap-linux-musl-x86.tar.gz \\ -o /tmp/pingap.tar.gz tar -xzf /tmp/pingap.tar.gz -C /tmp install -m 755 /tmp/pingap-linux-musl-x86 /usr/local/bin/pingap pingap --version ARM64 机器把资产名换成 pingap-linux-musl-aarch64.tar.gz；如果你的环境确实需要 glibc 版本，再选 linux-gnu-* 资产。\n准备 HTTP 反代配置 建议把配置拆成多个 TOML 文件，后续更容易维护。\nmkdir -p /opt/pingap/conf /opt/pingap/logs /opt/pingap/conf/basic.toml：\n[basic] name = \u0026#34;edge\u0026#34; threads = 0 work_stealing = true upstream_keepalive_pool_size = 512 pid_file = \u0026#34;/run/pingap.pid\u0026#34; upgrade_sock = \u0026#34;/run/pingap-upgrade.sock\u0026#34; log_level = \u0026#34;INFO\u0026#34; threads = 0 表示按 CPU 自动决定线程数。upstream_keepalive_pool_size 是 upstream keepalive 连接池大小，高并发反代可以按实际情况调大。\n/opt/pingap/conf/upstreams.toml：\n[upstreams.app] addrs = [\u0026#34;127.0.0.1:3000\u0026#34;] connection_timeout = \u0026#34;3s\u0026#34; read_timeout = \u0026#34;60s\u0026#34; write_timeout = \u0026#34;60s\u0026#34; idle_timeout = \u0026#34;90s\u0026#34; /opt/pingap/conf/locations.toml：\n[locations.app] host = \u0026#34;app.example.com\u0026#34; path = \u0026#34;/\u0026#34; upstream = \u0026#34;app\u0026#34; enable_reverse_proxy_headers = true proxy_set_headers = [\u0026#34;Host:$host\u0026#34;] client_max_body_size = \u0026#34;50mb\u0026#34; enable_reverse_proxy_headers = true 会自动添加常见反向代理头，比如 X-Forwarded-* 和 X-Real-IP。如果后端依赖原始域名，就保留 Host:$host；如果后端必须看到内部域名，可以改成固定值，比如 Host:backend.internal。\n/opt/pingap/conf/servers.toml：\n[servers.http] addr = \u0026#34;0.0.0.0:6188\u0026#34; locations = [\u0026#34;app\u0026#34;] access_log = \u0026#34;/opt/pingap/logs/access.log {remote_addr} {host} {method} {path} {status} {upstream_addr} {upstream_processing_time}\u0026#34; 验证配置：\npingap -c /opt/pingap/conf -t 前台启动：\nRUST_LOG=INFO pingap -c /opt/pingap/conf --autoreload 测试：\ncurl -H \u0026#39;Host: app.example.com\u0026#39; http://127.0.0.1:6188/ --autoreload 会定期检查配置变化。upstream、location、plugin、certificate 这类配置可以热加载，不需要重启进程。\n加 HTTPS 和 HTTP 跳转 如果已有证书，可以新增证书配置。\n/opt/pingap/conf/certificates.toml：\n[certificates.app] domains = \u0026#34;app.example.com\u0026#34; tls_cert = \u0026#34;/etc/letsencrypt/live/app.example.com/fullchain.pem\u0026#34; tls_key = \u0026#34;/etc/letsencrypt/live/app.example.com/privkey.pem\u0026#34; 添加 HTTPS server：\n[servers.https] addr = \u0026#34;0.0.0.0:443\u0026#34; locations = [\u0026#34;app\u0026#34;] global_certificates = true enabled_h2 = true access_log = \u0026#34;/opt/pingap/logs/access.log {remote_addr} {host} {method} {path} {status} {upstream_addr} {upstream_processing_time}\u0026#34; global_certificates = true 很关键。开启后，Pingap 才会根据客户端 TLS SNI 从全局证书池选择对应证书。\n再加 HTTP 到 HTTPS 跳转。\n/opt/pingap/conf/plugins.toml：\n[plugins.redirectToHttps] category = \u0026#34;redirect\u0026#34; http_to_https = true /opt/pingap/conf/locations.toml 增加：\n[locations.redirectToHttps] host = \u0026#34;app.example.com\u0026#34; path = \u0026#34;/\u0026#34; plugins = [\u0026#34;redirectToHttps\u0026#34;] HTTP server 改成：\n[servers.http] addr = \u0026#34;0.0.0.0:80\u0026#34; locations = [\u0026#34;redirectToHttps\u0026#34;] 验证：\npingap -c /opt/pingap/conf -t curl -I http://app.example.com/ curl -kI https://app.example.com/ 如果你先在旁路端口验证，可以把 80/443 临时改成 8080/8443：\ncurl -I --resolve app.example.com:8080:127.0.0.1 http://app.example.com:8080/ curl -kI --resolve app.example.com:8443:127.0.0.1 https://app.example.com:8443/ 用 Pingap 托管静态文件 Pingap 的 directory 插件可以把某个 location 变成静态文件服务，不需要 upstream。\n/opt/pingap/conf/plugins.toml：\n[plugins.staticSite] category = \u0026#34;directory\u0026#34; path = \u0026#34;/var/www/html\u0026#34; index = \u0026#34;index.html\u0026#34; max_age = \u0026#34;24h\u0026#34; charset = \u0026#34;utf-8\u0026#34; autoindex = false /opt/pingap/conf/locations.toml：\n[locations.staticSite] host = \u0026#34;www.example.com\u0026#34; path = \u0026#34;/\u0026#34; plugins = [\u0026#34;staticSite\u0026#34;] 对于 Hugo、Vue、React 这类静态站点，它可以替代一部分 Nginx 静态文件功能。如果你的站点强依赖复杂的 try_files、SPA fallback 或非常细粒度的静态缓存规则，需要实际验证插件行为；必要时也可以让 Caddy、Nginx 或简单静态服务器只在本机承载静态文件，Pingap 作为统一入口反代过去。\nDocker 部署 Docker 是 Pingap 官方推荐的快速部署方式。生产环境不要用会漂移的 latest，建议固定版本号：\nversion: \u0026#34;3.8\u0026#34; services: pingap: image: vicanso/pingap:0.13.4-full container_name: pingap restart: always ports: - \u0026#34;80:80\u0026#34; - \u0026#34;443:443\u0026#34; volumes: - ./pingap:/opt/pingap - /etc/letsencrypt:/etc/letsencrypt:ro environment: PINGAP_CONF: /opt/pingap/conf PINGAP_ADMIN_ADDR: 0.0.0.0:80/pingap PINGAP_ADMIN_USER: pingap PINGAP_ADMIN_PASSWORD: \u0026#34;change-this-password\u0026#34; command: - pingap - --autoreload 启动：\nmkdir -p pingap/conf docker compose up -d Docker 场景有一个特殊点：不要使用 --autorestart。--autorestart 依赖 daemon/upgrade 模式，会让主进程 fork 到后台，这和容器要求主进程在前台运行冲突。容器里应该用 --autoreload；如果改了监听端口这类无法热加载的基础配置，就执行：\ndocker restart pingap Web 管理界面按上面的配置会暴露在：\nhttp://\u0026lt;server-ip\u0026gt;/pingap/ 生产环境不要把管理界面裸露在公网。至少要使用强密码，并限制内网或 VPN 访问；也可以单独绑定到 127.0.0.1:3018，通过 SSH tunnel 管理。\n二进制加 systemd 部署 如果不用 Docker，推荐让 systemd 管前台进程，而不是让 Pingap 自己 daemonize。\n/etc/systemd/system/pingap.service：\n[Unit] Description=Pingap Reverse Proxy After=network-online.target Wants=network-online.target [Service] Type=simple ExecStart=/usr/local/bin/pingap -c /opt/pingap/conf --log=/opt/pingap/logs/pingap.log --autoreload Restart=always RestartSec=3 LimitNOFILE=1048576 [Install] WantedBy=multi-user.target 启动：\nsystemctl daemon-reload systemctl enable --now pingap systemctl status pingap journalctl -u pingap -f 如果你想使用 Pingap 的 -d、-u 和 upgrade_sock 做零停机二进制升级，可以按官方命令行参数走 daemon/upgrade 模式。普通个人服务器或中小型服务，用 systemd 重启通常已经足够简单可靠。\n从 Pingora 到 Pingap 的隐藏坑点 Pingora 和 Pingap 都和反向代理有关，但抽象层完全不同。Pingora 是框架，Pingap 是应用。真正迁移时，容易踩坑的地方就在这些抽象差异里。\nPingora 代码能力不能直接搬到 Pingap 配置里 Pingora 代码里的 upstream_peer() 可以在每个请求上做任意判断，比如按用户、Header、灰度规则、实时状态选择 upstream。Pingap 则主要通过 TOML 里的 server、location、upstream 和 plugin 组合能力。\n能配置出来的东西，用 Pingap 很快；需要把复杂业务逻辑写进请求生命周期的东西，还是 Pingora 更合适。\nserver 和 location 关系不是 Nginx 的嵌套块 Nginx 是：\nserver { server_name app.example.com; location /api { proxy_pass http://api; } } Pingap 是：\n[locations.api] host = \u0026#34;app.example.com\u0026#34; path = \u0026#34;/api\u0026#34; upstream = \u0026#34;api\u0026#34; [servers.http] addr = \u0026#34;0.0.0.0:80\u0026#34; locations = [\u0026#34;api\u0026#34;] 也就是说，请求先进入某个 server，再在这个 server 绑定的一组 location 里按 host/path/weight 匹配。迁移 Nginx 配置时，不要机械地把 server {} 和 location {} 一层层翻译。\n反向代理头不要重复加 在裸 Pingora 里，你要自己写 X-Forwarded-For、X-Real-IP、X-Forwarded-Proto。在 Pingap 里，如果已经设置了：\nenable_reverse_proxy_headers = true 就不要再用 proxy_set_headers 手动重复设置同一批头。重复设置会让后端看到奇怪的值，尤其是 X-Forwarded-For 链路。\nHost 头要按后端需求决定 很多后端框架会根据 Host 做路由、生成绝对 URL 或校验回调域名。迁移时先确认后端到底要看到公网域名还是内部域名。\n保留用户请求里的 Host：\nproxy_set_headers = [\u0026#34;Host:$host\u0026#34;] 改成固定内部 Host：\nproxy_set_headers = [\u0026#34;Host:backend.internal\u0026#34;] 这个选择没有统一答案，要按应用行为验证。\n热加载不是所有配置都能热加载 Pingap 的 --autoreload 适合 upstream、location、plugin、certificate 这类运行期配置。监听端口、基础线程模型、某些全局行为属于更底层配置，改完通常要重启或走 graceful restart。\nDocker 里尤其不要用 --autorestart，用容器重启表达基础配置变化更清楚。\nHTTPS 不只是证书路径 证书文件路径要能被 Pingap 进程读到，Docker 里还要确认 volume 挂载路径一致。多个域名证书场景下，HTTPS server 需要：\nglobal_certificates = true 否则你可能以为证书配置好了，但 TLS SNI 并没有按预期选择证书。\n不要假设 Pingap 覆盖所有 Nginx 指令 Pingap 能覆盖大部分常见反代场景，但 Nginx 的 try_files、复杂 rewrite、非常细的静态缓存规则、历史遗留 location 优先级，迁移时都要单独验证。\n看到这里，比较稳的迁移方式是：先让 Pingap 跑在旁路端口，用 curl --resolve 验证所有域名和路径，再切换 80/443，保留原来的 Nginx 配置用于回滚。\nNginx 到 Pingap 的常见映射 Nginx Pingap server { listen 80; } [servers.http] addr = \u0026quot;0.0.0.0:80\u0026quot; server_name app.example.com; [locations.app] host = \u0026quot;app.example.com\u0026quot; location /api { ... } [locations.api] path = \u0026quot;/api\u0026quot; proxy_pass http://backend; [locations.api] upstream = \u0026quot;backend\u0026quot; + [upstreams.backend] upstream backend { server ...; } [upstreams.backend] addrs = [...] proxy_set_header Host $host; proxy_set_headers = [\u0026quot;Host:$host\u0026quot;] X-Forwarded-* enable_reverse_proxy_headers = true client_max_body_size 50m; client_max_body_size = \u0026quot;50mb\u0026quot; proxy_read_timeout 60s; upstream read_timeout = \u0026quot;60s\u0026quot; root /var/www/html; directory 插件 return 301 https://... redirect 插件 HTTPS 证书 [certificates.X] + global_certificates = true 访问日志 server access_log 上线前检查清单 正式接管 80/443 前，建议按这个顺序验证：\n# 1. 配置语法 pingap -c /opt/pingap/conf -t # 2. 监听端口 ss -tlnp | grep pingap # 3. 后端健康 curl -I http://127.0.0.1:3000/ # 4. Host 路由 curl -H \u0026#39;Host: app.example.com\u0026#39; http://127.0.0.1:6188/ # 5. HTTPS + SNI curl -kI --resolve app.example.com:8443:127.0.0.1 https://app.example.com:8443/ # 6. HTTP -\u0026gt; HTTPS 跳转 curl -I --resolve app.example.com:8080:127.0.0.1 http://app.example.com:8080/ # 7. 日志 tail -f /opt/pingap/logs/pingap.log* tail -f /opt/pingap/logs/access.log* 切换时不要直接删 Nginx 配置。更稳的方式是：\nPingap 先跑在 8080/8443。 用 curl --resolve 验证所有域名和路径。 停掉 Nginx，或者先让 Nginx 旁路到 Pingap。 把 Pingap server 改到 80/443。 再执行 pingap -c /opt/pingap/conf -t。 启动或重启 Pingap。 保留 Nginx 配置和证书路径，方便回滚。 总结 Pingap 适合“部署代理”，Pingora 适合“开发代理”。如果你的目标是站点入口、普通反代、HTTPS、静态文件、插件和管理界面，直接用 Pingap 更省事；如果你的代理规则需要深度进入请求生命周期，或者你要做自己的网关产品，再回到 Pingora 写代码。\n参考资料 Pingap GitHub README Pingap Releases Pingap 官方介绍 Pingap Getting Started Pingap 配置项参考 Pingap 命令行参数与环境变量 Pingap Docker 部署文档 Pingap HTTPS 证书管理 Pingap Directory 插件 Pingap 插件总览 ","permalink":"https://cloudside.icyyan.com/posts/pingap-reverse-proxy-guide-and-pitfalls/","summary":"\u003cp\u003e如果你已经看过 Pingora 的最小反向代理示例，很容易产生一个后续问题：生产环境是不是也要自己继续写配置解析、证书加载、日志、热更新和管理界面？通常不需要。Pingap 就是基于 Pingora 做好的反向代理应用，它更接近一个可以直接部署的 Nginx 替代方案。\u003c/p\u003e\n\u003cp\u003e这篇文章只讲 Pingap：怎么安装，怎么写一个最小反代配置，怎么加 HTTPS，怎么用 Docker 或 systemd 部署，以及从裸 Pingora 或 Nginx 迁移过来时有哪些容易忽略的坑。\u003c/p\u003e\n\u003cp\u003e本文示例版本核对时间：\u003cstrong\u003e2026-05-17\u003c/strong\u003e。示例固定使用 Pingap \u003ccode\u003ev0.13.4\u003c/code\u003e。实际部署前建议再看一次官方 GitHub Releases 和文档，因为 Pingap 还在快速变化。\u003c/p\u003e\n\u003ch2 id=\"pingap-和-pingora-的关系\"\u003ePingap 和 Pingora 的关系\u003c/h2\u003e\n\u003cp\u003ePingora 是框架，你通过 Rust 代码实现代理逻辑。Pingap 是应用，它把常见反向代理能力包装成 TOML 配置和 Web 管理界面。\u003c/p\u003e\n\u003cp\u003e一个最小 Pingap 配置大概长这样：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-toml\" data-lang=\"toml\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e[\u003cspan style=\"color:#a6e22e\"\u003eupstreams\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eapp\u003c/span\u003e]\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003eaddrs\u003c/span\u003e = [\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;127.0.0.1:3000\u0026#34;\u003c/span\u003e]\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e[\u003cspan style=\"color:#a6e22e\"\u003elocations\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eapp\u003c/span\u003e]\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003ehost\u003c/span\u003e = \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;app.example.com\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003epath\u003c/span\u003e = \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;/\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003eupstream\u003c/span\u003e = \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;app\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003eenable_reverse_proxy_headers\u003c/span\u003e = \u003cspan style=\"color:#66d9ef\"\u003etrue\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e[\u003cspan style=\"color:#a6e22e\"\u003eservers\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ehttp\u003c/span\u003e]\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003eaddr\u003c/span\u003e = \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;0.0.0.0:6188\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003elocations\u003c/span\u003e = [\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;app\u0026#34;\u003c/span\u003e]\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e这段配置和一个最小 Pingora 代理做的是同一件事：监听 \u003ccode\u003e6188\u003c/code\u003e，把匹配 \u003ccode\u003eapp.example.com\u003c/code\u003e 的请求转发到 \u003ccode\u003e127.0.0.1:3000\u003c/code\u003e。差别在于，你不用实现 \u003ccode\u003eProxyHttp\u003c/code\u003e，也不用自己处理常见代理应用需要的周边能力。\u003c/p\u003e","title":"用 Pingap 部署反向代理，以及从 Pingora 迁移时容易踩的坑"},{"content":"Cloudflare 开源 Pingora 之后，很多人会问：它能不能替代 Nginx？如果问题只是“能不能把请求转发到后端服务”，答案是能，而且几十行 Rust 就够了。但 Pingora 不是一个读取 nginx.conf 的服务器，它更像一套用来写代理、网关和网络服务的 Rust 框架。\n这篇只做一件事：用 Pingora 写一个最小反向代理，让 http://127.0.0.1:6188/ 转发到本机的 127.0.0.1:3000，然后把这个二进制按服务方式部署起来。\n本文示例版本核对时间：2026-05-17。示例使用 pingora = \u0026quot;0.8\u0026quot;。\n先看等价的 Nginx 配置 如果用 Nginx，最小反代大概长这样：\nserver { listen 6188; location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto http; } } Pingora 的思路不一样。你不是写配置块，而是实现 ProxyHttp trait：请求进来以后，代码决定它要转发到哪里，转发前要改哪些请求头，最后日志怎么记。\n创建项目 cargo new mini-pingora-proxy cd mini-pingora-proxy Cargo.toml：\n[package] name = \u0026#34;mini-pingora-proxy\u0026#34; version = \u0026#34;0.1.0\u0026#34; edition = \u0026#34;2021\u0026#34; [dependencies] async-trait = \u0026#34;0.1\u0026#34; env_logger = \u0026#34;0.11\u0026#34; log = \u0026#34;0.4\u0026#34; pingora = { version = \u0026#34;0.8\u0026#34;, features = [\u0026#34;proxy\u0026#34;] } 这里用的是 pingora 聚合 crate，并启用 proxy feature。Pingora 没有默认打开所有能力；如果后面要做负载均衡，需要再启用 lb；如果要监听 HTTPS 或连接 HTTPS upstream，还要选择对应的 TLS feature，比如 openssl、boringssl、s2n 或实验性的 rustls。\n实现最小代理 src/main.rs：\nuse async_trait::async_trait; use log::info; use pingora::prelude::*; struct MiniProxy { upstream: \u0026amp;\u0026#39;static str, } #[async_trait] impl ProxyHttp for MiniProxy { type CTX = (); fn new_ctx(\u0026amp;self) -\u0026gt; Self::CTX {} async fn upstream_peer( \u0026amp;self, _session: \u0026amp;mut Session, _ctx: \u0026amp;mut Self::CTX, ) -\u0026gt; Result\u0026lt;Box\u0026lt;HttpPeer\u0026gt;\u0026gt; { let peer = HttpPeer::new(self.upstream, false, String::new()); Ok(Box::new(peer)) } async fn upstream_request_filter( \u0026amp;self, session: \u0026amp;mut Session, upstream_request: \u0026amp;mut RequestHeader, _ctx: \u0026amp;mut Self::CTX, ) -\u0026gt; Result\u0026lt;()\u0026gt; { if let Some(host) = session .req_header() .headers .get(\u0026#34;host\u0026#34;) .and_then(|value| value.to_str().ok()) { upstream_request.insert_header(\u0026#34;Host\u0026#34;, host).unwrap(); } if let Some(client_addr) = session.client_addr().and_then(|addr| addr.as_inet()) { let ip = client_addr.ip().to_string(); let forwarded_for = match session .req_header() .headers .get(\u0026#34;x-forwarded-for\u0026#34;) .and_then(|value| value.to_str().ok()) { Some(existing) if !existing.is_empty() =\u0026gt; format!(\u0026#34;{existing}, {ip}\u0026#34;), _ =\u0026gt; ip.clone(), }; upstream_request.insert_header(\u0026#34;X-Real-IP\u0026#34;, \u0026amp;ip).unwrap(); upstream_request .insert_header(\u0026#34;X-Forwarded-For\u0026#34;, forwarded_for) .unwrap(); } upstream_request .insert_header(\u0026#34;X-Forwarded-Proto\u0026#34;, \u0026#34;http\u0026#34;) .unwrap(); Ok(()) } async fn logging( \u0026amp;self, session: \u0026amp;mut Session, error: Option\u0026lt;\u0026amp;Error\u0026gt;, _ctx: \u0026amp;mut Self::CTX, ) { info!( \u0026#34;{} error={:?}\u0026#34;, session.request_summary(), error.map(|e| e.to_string()) ); } } fn main() { env_logger::init(); let mut server = Server::new(None).unwrap(); server.bootstrap(); let proxy = MiniProxy { upstream: \u0026#34;127.0.0.1:3000\u0026#34;, }; let mut service = http_proxy_service(\u0026amp;server.configuration, proxy); service.add_tcp(\u0026#34;0.0.0.0:6188\u0026#34;); server.add_service(service); server.run_forever(); } 看到这里，Pingora 的核心就出来了：\nupstream_peer() 决定请求要去哪个后端，类似 Nginx 的 proxy_pass。 upstream_request_filter() 在转发前补请求头，类似 proxy_set_header。 http_proxy_service() 把 MiniProxy 变成真正监听 TCP 端口的服务。 HTTP 解析、连接建立、连接复用、请求体和响应体转发这些通用代理工作，Pingora 已经处理了。你写的是“这个请求应该怎么被代理”的业务逻辑。\n本地运行 先启动一个后端服务：\npython3 -m http.server 3000 再启动 Pingora 代理：\nRUST_LOG=info cargo run 测试：\ncurl -v http://127.0.0.1:6188/ 如果能看到 Python 静态服务器返回的目录列表或页面，说明最小反代已经跑通。\n编译 release 版本 开发时用 cargo run 就够了，上服务器时应该编译 release 版本：\ncargo build --release 复制到服务器上的常见位置：\ninstall -m 755 target/release/mini-pingora-proxy /usr/local/bin/mini-pingora-proxy 如果后端服务和 Pingora 代理都在同一台机器上，确认本机能访问后端：\ncurl -I http://127.0.0.1:3000/ 再直接运行代理：\nRUST_LOG=info /usr/local/bin/mini-pingora-proxy 另开一个终端测试：\ncurl -I http://127.0.0.1:6188/ 用 systemd 部署 最简单的 systemd 单元可以这样写。\n/etc/systemd/system/mini-pingora-proxy.service：\n[Unit] Description=Mini Pingora Reverse Proxy After=network-online.target Wants=network-online.target [Service] Type=simple ExecStart=/usr/local/bin/mini-pingora-proxy Restart=always RestartSec=3 Environment=RUST_LOG=info LimitNOFILE=1048576 [Install] WantedBy=multi-user.target 启动：\nsystemctl daemon-reload systemctl enable --now mini-pingora-proxy systemctl status mini-pingora-proxy 看日志：\njournalctl -u mini-pingora-proxy -f 到这里，这个最小代理已经能作为一个系统服务运行。它还没有配置文件、没有 HTTPS、没有 Web 管理界面，也没有把 upstream 做成可热更新配置，但作为理解 Pingora 的起点已经足够。\n接入 Pingora 的运行配置 如果你想让这个二进制更像正式服务，可以让 Pingora 接管命令行参数：\nuse pingora::prelude::*; fn main() { env_logger::init(); let mut server = Server::new(Some(Opt::parse_args())).unwrap(); server.bootstrap(); // add service... server.run_forever(); } 这样二进制就可以读取 Pingora 的运行配置。一个典型 conf.yaml：\n--- version: 1 threads: 2 pid_file: /run/mini-pingora-proxy.pid error_log: /var/log/mini-pingora-proxy/error.log upgrade_sock: /run/mini-pingora-proxy-upgrade.sock upstream_keepalive_pool_size: 512 work_stealing: true 后台启动：\nRUST_LOG=INFO /usr/local/bin/mini-pingora-proxy -c conf.yaml -d 优雅停止：\npkill -SIGTERM mini-pingora-proxy 优雅升级：\npkill -SIGQUIT mini-pingora-proxy RUST_LOG=INFO /usr/local/bin/mini-pingora-proxy -c conf.yaml -d -u 这种模式更接近传统代理服务的运行方式。新进程可以接管监听 socket，旧进程继续处理已经在途的请求，避免粗暴停启造成连接中断。\n如果要多个后端 最小代码只转发到一个后端。真实服务通常还要多个 upstream 和健康检查。Pingora 可以做，但你要继续写代码。\n启用负载均衡 feature：\npingora = { version = \u0026#34;0.8\u0026#34;, features = [\u0026#34;proxy\u0026#34;, \u0026#34;lb\u0026#34;] } 核心逻辑类似这样：\nuse async_trait::async_trait; use pingora::prelude::*; use std::sync::Arc; struct LB(Arc\u0026lt;LoadBalancer\u0026lt;RoundRobin\u0026gt;\u0026gt;); #[async_trait] impl ProxyHttp for LB { type CTX = (); fn new_ctx(\u0026amp;self) -\u0026gt; Self::CTX {} async fn upstream_peer( \u0026amp;self, _session: \u0026amp;mut Session, _ctx: \u0026amp;mut Self::CTX, ) -\u0026gt; Result\u0026lt;Box\u0026lt;HttpPeer\u0026gt;\u0026gt; { let upstream = self.0.select(b\u0026#34;\u0026#34;, 256).unwrap(); let peer = HttpPeer::new(upstream, false, String::new()); Ok(Box::new(peer)) } } fn main() { let mut server = Server::new(None).unwrap(); server.bootstrap(); let upstreams = LoadBalancer::try_from_iter([\u0026#34;127.0.0.1:3000\u0026#34;, \u0026#34;127.0.0.1:3001\u0026#34;]).unwrap(); let mut service = http_proxy_service(\u0026amp;server.configuration, LB(Arc::new(upstreams))); service.add_tcp(\u0026#34;0.0.0.0:6188\u0026#34;); server.add_service(service); server.run_forever(); } 这对应 Nginx 的：\nupstream app { server 127.0.0.1:3000; server 127.0.0.1:3001; } server { listen 6188; location / { proxy_pass http://app; } } 如果再加主动健康检查，Pingora 官方示例会用 TcpHealthCheck，并把 LoadBalancer 放到 background service 里运行。看到这里，问题就变成了：你到底是想写一个自己的代理程序，还是想部署一个现成的反向代理？\n什么时候直接用 Pingora Pingora 适合下面这些场景：\n你要学习代理链路，理解请求从 downstream 到 upstream 的生命周期。 你要把鉴权、灰度、限流、审计、路由规则写成 Rust 代码。 你要做自己的 API Gateway、Ingress、CDN 代理或内部网关产品。 你能接受维护自己的配置格式、证书加载、管理接口、日志指标和升级策略。 如果目标只是“把几个域名反代到几个服务，顺便配 HTTPS”，直接写 Pingora 不是最短路径。Pingora 能做到，但 TLS、ACME、配置热更新、访问日志、Web 管理和插件系统都需要你自己补。\n参考资料 Cloudflare Pingora GitHub README Pingora docs.rs Pingora feature flags Pingora quick start: load balancer Pingora ProxyHttp API Pingora HttpPeer API Open sourcing Pingora: Cloudflare Blog ","permalink":"https://cloudside.icyyan.com/posts/pingora-minimal-reverse-proxy-guide/","summary":"\u003cp\u003eCloudflare 开源 Pingora 之后，很多人会问：它能不能替代 Nginx？如果问题只是“能不能把请求转发到后端服务”，答案是能，而且几十行 Rust 就够了。但 Pingora 不是一个读取 \u003ccode\u003enginx.conf\u003c/code\u003e 的服务器，它更像一套用来写代理、网关和网络服务的 Rust 框架。\u003c/p\u003e\n\u003cp\u003e这篇只做一件事：用 Pingora 写一个最小反向代理，让 \u003ccode\u003ehttp://127.0.0.1:6188/\u003c/code\u003e 转发到本机的 \u003ccode\u003e127.0.0.1:3000\u003c/code\u003e，然后把这个二进制按服务方式部署起来。\u003c/p\u003e\n\u003cp\u003e本文示例版本核对时间：\u003cstrong\u003e2026-05-17\u003c/strong\u003e。示例使用 \u003ccode\u003epingora = \u0026quot;0.8\u0026quot;\u003c/code\u003e。\u003c/p\u003e\n\u003ch2 id=\"先看等价的-nginx-配置\"\u003e先看等价的 Nginx 配置\u003c/h2\u003e\n\u003cp\u003e如果用 Nginx，最小反代大概长这样：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-nginx\" data-lang=\"nginx\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eserver\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003elisten\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e6188\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003elocation\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e/\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#f92672\"\u003eproxy_pass\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003ehttp://127.0.0.1:3000\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#f92672\"\u003eproxy_set_header\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003eHost\u003c/span\u003e $host;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#f92672\"\u003eproxy_set_header\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003eX-Real-IP\u003c/span\u003e $remote_addr;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#f92672\"\u003eproxy_set_header\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003eX-Forwarded-For\u003c/span\u003e $proxy_add_x_forwarded_for;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#f92672\"\u003eproxy_set_header\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003eX-Forwarded-Proto\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003ehttp\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003ePingora 的思路不一样。你不是写配置块，而是实现 \u003ccode\u003eProxyHttp\u003c/code\u003e trait：请求进来以后，代码决定它要转发到哪里，转发前要改哪些请求头，最后日志怎么记。\u003c/p\u003e\n\u003ch2 id=\"创建项目\"\u003e创建项目\u003c/h2\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ecargo new mini-pingora-proxy\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ecd mini-pingora-proxy\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003ccode\u003eCargo.toml\u003c/code\u003e：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-toml\" data-lang=\"toml\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e[\u003cspan style=\"color:#a6e22e\"\u003epackage\u003c/span\u003e]\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003ename\u003c/span\u003e = \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;mini-pingora-proxy\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003eversion\u003c/span\u003e = \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;0.1.0\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003eedition\u003c/span\u003e = \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;2021\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e[\u003cspan style=\"color:#a6e22e\"\u003edependencies\u003c/span\u003e]\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003easync-trait\u003c/span\u003e = \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;0.1\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003eenv_logger\u003c/span\u003e = \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;0.11\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003elog\u003c/span\u003e = \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;0.4\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003epingora\u003c/span\u003e = { \u003cspan style=\"color:#a6e22e\"\u003eversion\u003c/span\u003e = \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;0.8\u0026#34;\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003efeatures\u003c/span\u003e = [\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;proxy\u0026#34;\u003c/span\u003e] }\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e这里用的是 \u003ccode\u003epingora\u003c/code\u003e 聚合 crate，并启用 \u003ccode\u003eproxy\u003c/code\u003e feature。Pingora 没有默认打开所有能力；如果后面要做负载均衡，需要再启用 \u003ccode\u003elb\u003c/code\u003e；如果要监听 HTTPS 或连接 HTTPS upstream，还要选择对应的 TLS feature，比如 \u003ccode\u003eopenssl\u003c/code\u003e、\u003ccode\u003eboringssl\u003c/code\u003e、\u003ccode\u003es2n\u003c/code\u003e 或实验性的 \u003ccode\u003erustls\u003c/code\u003e。\u003c/p\u003e","title":"用 Pingora 写一个最小反向代理，并把它部署起来"},{"content":"Cloudflare 公开 Pingora 时，最抓眼球的数字是：在相同流量下，新的代理系统比旧系统少用了约 70% CPU 和 67% 内存。\n看到这个数字，很多人的第一反应可能是：是不是 Nginx 不行了？是不是 Rust 天生就比 C 快很多？\n都不是。\n我们真正要回答的是：Cloudflare 为什么会觉得原来的 NGINX/OpenResty 代理不够用了？Pingora 又改掉了哪些最贵的地方？\n后面就顺着这个问题往下走。先看 Pingora 是什么，再看 Nginx 在普通场景里为什么很好用，最后再看 Cloudflare 这种规模下，连接池、线程模型和业务逻辑会怎样把成本放大。\n可以先带着三句话往下读，后面会一层层展开：\nCloudflare 替换的是他们内部深度改造过的 NGINX/OpenResty 代理，不是说 Nginx 在所有场景下都慢。 Pingora 开源版更像“用 Rust 写代理服务的工具箱”，不是一个直接读取 nginx.conf 的 Nginx 替代品。 重点不是“Rust 打败 Nginx”，而是连接复用、线程共享和复杂代理逻辑这些具体问题。 1. Pingora 不是一个新的 Nginx 如果你平时用 Nginx，可能会自然地把 Pingora 想成“另一个服务器软件”。但这样理解会有点偏。\nPingora 更像一套用 Rust 写代理服务的工具箱。它帮你处理连接、TLS、HTTP 协议、负载均衡、连接池、优雅升级这些通用能力；你的业务逻辑则写在 Rust 回调里。\n它不是单个二进制 Web 服务器，而是一组 crate：\ncrate 作用 pingora-core 底层协议、服务运行、监听器、连接器、优雅升级 pingora-proxy HTTP 代理生命周期和 ProxyHttp 回调接口 pingora-pool 高并发连接池 pingora-runtime Tokio runtime 封装，支持 work-stealing 与 no-steal 模式 pingora-load-balancing 负载均衡、健康检查、选择算法 pingora-cache / pingora-memory-cache 缓存相关能力 这些名字不用急着都记住。先有一个印象就够了：Pingora 把“写代理服务”拆成了一组可以组合的 Rust 库。\nCloudflare 在 2024 年 2 月开源 Pingora，并说明它已经在 Cloudflare 全球网络中支撑了大量流量。GitHub README 里也提到，Pingora 已经长期服务超过 4000 万请求/秒的生产流量。到 2026 年 5 月 4 日，Cloudflare 又在 changelog 中说明，Cloudflare cache 也已经迁移到基于 Pingora 的新代理上。\n这个背景想说明的不是“大家都该换 Pingora”，而是：它不是实验性质的小项目，而是从 Cloudflare 自己的生产问题里长出来的。\n为了避免一上来就掉进 crate 名字里，可以先看它的编程方式。一个非常简化的代理大概长这样：\nstruct Gateway; #[async_trait] impl ProxyHttp for Gateway { type CTX = RequestContext; fn new_ctx(\u0026amp;self) -\u0026gt; Self::CTX { RequestContext::default() } async fn request_filter( \u0026amp;self, session: \u0026amp;mut Session, ctx: \u0026amp;mut Self::CTX, ) -\u0026gt; Result\u0026lt;bool\u0026gt; { // 鉴权、限流、路由预处理、直接返回响应 Ok(false) } async fn upstream_peer( \u0026amp;self, session: \u0026amp;mut Session, ctx: \u0026amp;mut Self::CTX, ) -\u0026gt; Result\u0026lt;Box\u0026lt;HttpPeer\u0026gt;\u0026gt; { // 为当前请求选择 upstream Ok(peer) } async fn response_filter( \u0026amp;self, session: \u0026amp;mut Session, response: \u0026amp;mut ResponseHeader, ctx: \u0026amp;mut Self::CTX, ) -\u0026gt; Result\u0026lt;()\u0026gt; { // 改响应头、打点、策略处理 Ok(()) } } 这套接口有点像 OpenResty 的各阶段回调：请求进来时做校验，选择 upstream，响应回来时改响应头，最后记录日志。\n区别是，业务逻辑不再在 Nginx C core 和 Lua VM 之间来回切换，而是直接写在 Rust 里。连接池、TLS、HTTP 解析、读写、超时、重试、优雅关闭这些通用代理能力，由框架帮你处理。\n2. 再看 Nginx：它为什么稳，也为什么会卡在连接复用上 Nginx 的经典架构是：一个 master 进程管理多个 worker 进程，每个 worker 是单线程事件循环。\n这个设计很稳。进程之间隔离，一个 worker 出问题，不容易把整个服务一起拖垮。对静态文件、普通反向代理、边缘入口这类场景来说，Nginx 仍然非常合适。\n问题出在 Cloudflare 这类场景：请求量巨大，后端 origin 很多，而且非常依赖 upstream 连接复用。\n这时，Nginx 多 worker 的设计会带来一个不太直观的代价：\n每个 worker 都有自己的地址空间。 每个 worker 都维护自己的 upstream keepalive 连接。 一个请求落到哪个 worker，通常就只能复用那个 worker 手里的连接。 这不是 Nginx “写得差”，而是多进程模型本来就是这样工作的。\nNginx 官方文档对 upstream keepalive 的描述很直接：connections 参数保存的是“每个 worker 进程”里的空闲 upstream keepalive 连接。\n换句话说，如果一台机器上有 16 个 worker，它不是拥有一个大家共享的连接池，而是有 16 份彼此看不见的小池子。\n源码上也能看到这个模型。ngx_http_upstream_keepalive_module.c 里，keepalive 模块维护的是当前 worker 内部的 cache / free 队列；请求释放 upstream 连接时，连接被挂回当前 worker 的队列；后续只有同一 worker 再处理到同类 upstream 请求时，才能直接复用它。\n这个细节在普通业务里通常没问题，但在 Cloudflare 这种规模下会被放大：\n请求量极大。 origin 数量极多。 TLS 握手成本高。 不同请求被内核和 Nginx 调度到不同 worker。 单个客户或单个 origin 的流量可能高度突发。 最后就会出现一种很浪费的情况：机器上明明已经有到某个 origin 的空闲 TLS 连接，但新请求落在另一个 worker，它还是可能重新建 TCP 连接、重新做 TLS 握手。\n3. 关键变化：连接池可以跨线程共享 上一节的问题，其实就是 Pingora 最重要的改动入口。\nCloudflare 官方文章里提到，Pingora 的 TTFB 改善不是因为旧代码单个请求处理得很慢。旧服务本身也能在亚毫秒级完成处理。真正的收益来自新架构能跨线程共享连接，从而大幅提高连接复用率。\n落到 Pingora 源码里，对应的就是 pingora-pool。\npingora-pool/src/connection.rs 里有三个关键概念：\n结构 核心含义 ConnectionMeta 描述连接的分组 key 和连接 id，同一个 key 下的连接可互换复用 PoolNode 某个 key 对应的一组连接 ConnectionPool\u0026lt;S\u0026gt; 全局连接池，按 group key 找到对应 PoolNode，并用 LRU 控制池大小 简化后的结构关系可以理解成：\nConnectionPool -\u0026gt; RwLock\u0026lt;HashMap\u0026lt;GroupKey, Arc\u0026lt;PoolNode\u0026gt;\u0026gt;\u0026gt; -\u0026gt; Lru\u0026lt;ConnectionId, ConnectionMeta\u0026gt; PoolNode -\u0026gt; hot_queue: lock-free ArrayQueue -\u0026gt; fallback: Mutex\u0026lt;HashMap\u0026lt;ConnectionId, Connection\u0026gt;\u0026gt; 这里不用急着记结构名，抓住几个点就够了。\n第一，连接按 GroupKey 分组。对 HTTP 代理来说，这个 key 可以代表“同一个 upstream 目标及其连接属性”。只要 key 相同，连接就可以被另一个请求拿走复用。\n第二，连接池被设计成线程安全结构。Pingora 是多线程单进程模型，业务逻辑、连接池和共享状态可以放在 Arc 后面被多个线程访问。线程 A 释放的 upstream 连接，可以被线程 B 取走。\n第三，PoolNode 有一个很小的 lock-free hot queue。高频复用的连接会优先走这个热队列，减少在高 RPS 下反复抢同一个互斥锁。只有 hot queue 满了，才落到带锁的 HashMap。\n第四，连接池有 LRU 淘汰。池子不是无限增长，超出容量时淘汰最久未使用的连接，避免“为了复用而把 origin 打爆”。\n再往 HTTP 层看，HttpSession::reuse() 的语义是：如果当前 HTTP/1 连接仍然可复用，就把底层 stream 取出来交还给连接池；否则关闭连接。TransportConnector 的文档也明确说明它负责通过 TCP/TLS 建连，并支持 connection reuse。\n把 Nginx 和 Pingora 的连接复用路径并排看，差异会更直观：\nNginx/OpenResty: request -\u0026gt; worker N -\u0026gt; worker N 的 upstream keepalive cache -\u0026gt; 命中：复用 -\u0026gt; 未命中：重新 TCP + TLS Pingora: request -\u0026gt; 任意线程 -\u0026gt; 共享连接池 -\u0026gt; 同 group key 命中：复用 -\u0026gt; 未命中：重新 TCP + TLS Cloudflare 给出的生产数据也正好对应这个机制：迁移到 Pingora 后，整体新建连接数降到旧服务的三分之一；某个大客户的连接复用率从 87.1% 提升到 99.92%，新建 origin 连接减少了 160 倍。TTFB 中位数降低 5ms，P95 降低 80ms。\n这就是为什么“共享连接池”很关键。TCP/TLS 握手比在已有连接上收发数据贵得多。少建连接，就意味着少烧 CPU、少等 RTT、少占内存。\n4. 线程模型：重点不是神奇调度，而是少一些 worker 隔离 聊完连接池，再看线程模型就顺了。\n很多文章会把 Pingora 的性能归因于 Tokio work-stealing。这个说法有一部分对，但如果只这么说，会让人误以为“换个调度器就快了”。\nNginx worker 是多进程单线程模型。某个连接、某个请求、某个 Lua 执行阶段落在哪个 worker，基本就由哪个 worker 处理。Cloudflare 官方文章提到，这种 request-process pinning 会带来 CPU 核心之间负载不均的问题：一个 worker 被 CPU-heavy 或阻塞 I/O 拖慢时，其他 worker 未必能帮它处理队列里的请求。\nPingora 的运行模型不同。pingora-runtime 把 Tokio runtime 封装成两类：\nSteal：普通 Tokio 多线程 runtime，也就是带 work-stealing 的调度器。 NoSteal：Pingora 自己封装的多个 current-thread Tokio runtime，用来减少 work-stealing 的调度开销，同时仍然利用多核。 这说明 Pingora 并不是简单地相信“work-stealing 一定最快”。它真正提供的是一个更灵活的多线程执行模型：业务可以在同一个进程内跨线程共享状态，运行时也可以根据场景选择是否启用 work stealing。ServerConf 里甚至直接暴露了 threads、work_stealing、upstream_keepalive_pool_size 等配置项。\n因此，这一层带来的性能改善可以概括为：\nNginx 的 worker 之间隔离，调度和状态共享都受限。 Pingora 的线程之间可共享连接池和业务状态。 Tokio runtime 能把大量异步 I/O 任务调度到多个核心。 对特定场景，Pingora 还能用 no-steal runtime 降低调度器自身开销。 所以这一节的重点不是某个调度算法有多神，而是 Pingora 少了很多 worker 之间互相看不见、帮不上忙的问题。\n5. 业务逻辑：少在 C 和 Lua 之间来回搬数据 如果只是普通反向代理，连接池可能已经解释了很大一部分收益。但 Cloudflare 的旧系统不是“只跑 Nginx 配置”。\n它在 NGINX/OpenResty 上承载了大量复杂逻辑：路由、鉴权、安全策略、缓存控制、产品特性等。\nOpenResty 的强大之处在于把 Lua 嵌进 Nginx 生命周期，让用户可以在不同阶段写脚本。但这也带来一个天然成本：底层 HTTP 对象在 C 结构体里，上层业务逻辑在 Lua VM 里。\nCloudflare 官方文章举了一个具体例子：Lua 代码访问 HTTP header 时，需要从 Nginx C 结构体读出、分配 Lua string、拷贝过去，之后还要交给 Lua GC 回收。共享内存也有类似限制：旧体系中共享内存访问需要 mutex，而且只能放字符串和数字。\nPingora 的 ProxyHttp 则把这些业务阶段直接变成 Rust trait 方法：\nrequest_filter()：请求头到达后做校验、限流、鉴权，或者直接返回。 upstream_peer()：选择 upstream。 upstream_request_filter()：发往 upstream 前改请求。 upstream_response_filter() / response_filter()：处理 upstream 响应和下游响应。 logging()：请求结束后的指标和日志。 也就是说，原来可能跨越 Nginx C core、Lua VM、共享字典、FFI glue 的逻辑，现在可以在一个 Rust 类型系统里完成。\n性能收益不只来自 Rust 编译成本地代码，还来自少分配、少拷贝、少 GC、少跨语言上下文切换。\n这也是 Pingora 对 Cloudflare 特别有价值的原因：它不是只代理几个固定 upstream，而是承载大量产品逻辑的可编程代理平台。\n6. Rust 的价值：让团队敢写更复杂的共享结构 讲到这里，再看 Rust 就比较合理了。\nRust 的内存安全经常被拿来解释 Pingora 的性能，但它不是直接让 CPU 指令变快。它更重要的作用，是让团队更敢于写复杂的共享结构。\n在 C 里写一个高并发连接池、共享缓存、跨线程状态系统，需要工程师长期和 use-after-free、double free、数据竞争、悬垂指针打交道。为了稳定性，很多设计会天然保守。\nRust 的所有权、借用检查和 Send / Sync 约束让 Pingora 可以把“连接能不能跨线程移动”“引用能不能跨线程共享”这类问题提前放到编译期检查。比如连接池里的对象要跨线程复用，就必须满足相应的 trait 约束；共享状态放进 Arc、锁、原子类型背后，数据竞争会被类型系统拦下。\n这不代表 Pingora 就不会有 bug。HTTP request smuggling、缓存 key 设计、协议兼容性这些仍然要靠严谨实现和测试。\n但 Rust 至少把一大类内存破坏和数据竞争问题从运行时事故变成了编译期约束。对一个需要高并发连接池、共享缓存、跨线程状态的代理系统来说，这一点很重要。\n7. Pingora 做了什么取舍 Nginx 是一个非常通用的系统：\n静态文件服务器。 反向代理。 负载均衡。 FastCGI / uwsgi / SCGI。 邮件代理。 大量第三方模块生态。 通用性是 Nginx 的优势。但 Cloudflare 的 origin-facing proxy 需要的是另一种东西：一个能被内部产品深度编程、能高效复用 origin 连接、能持续演进协议和缓存能力的代理平台。\nPingora 的取舍更聚焦：\n不追求成为 nginx.conf 的兼容替代品。 把核心抽象放在 Rust API 上，而不是配置 DSL 上。 把高并发连接复用、代理生命周期、TLS、HTTP/1/2、负载均衡、观测能力做成可组合库。 让 Cloudflare 可以快速加产品特性，比如 HTTP/2 upstream、gRPC、Cache Reserve，以及 2026 年迁移到 Pingora 的新 cache proxy。 所以 Pingora 的高性能不是某一个神奇算法，而是几个选择叠加后的结果：\n性能收益 = 更高连接复用率，减少 TCP/TLS 握手 + 多线程共享状态，减少 worker 孤岛 + Rust 原生业务逻辑，减少 C/Lua 边界和 GC 成本 + 更适合 Cloudflare 代理需求的协议与缓存架构 + 内存安全带来的并发优化空间 8. 你该不该用 Pingora？ 如果你的需求是：\n托管静态文件。 简单反向代理几个服务。 依赖成熟配置生态。 需要大量现成模块。 团队不想写 Rust 代码。 那 Nginx 仍然是非常合理的选择。它稳定、成熟、文档多、运维经验丰富。\n如果你的需求是：\n网关逻辑非常复杂。 需要把代理深度嵌入业务系统。 对 upstream 连接复用和延迟极度敏感。 希望用 Rust 写原生代理逻辑。 想构建自己的 API gateway、CDN proxy、service mesh sidecar、可编程负载均衡器。 那 Pingora 更值得考虑。\n换句话说，Nginx 是成熟的通用服务器，Pingora 是现代化的可编程代理底座。\nCloudflare 能得到巨大收益，是因为它的问题正好集中在几个地方：worker 之间连接不能共享，复杂逻辑要穿过 C/Lua 边界，代理系统还要长期承载大量产品能力。\n9. 总结 Pingora 比 Cloudflare 旧 NGINX/OpenResty 体系高效很多，核心不是“Rust 天生比 C 快”，更不是“Nginx 慢”。\n把整篇文章压缩成一句话就是：Cloudflare 原来的代理系统在连接复用、状态共享和复杂业务逻辑上付出了很高成本，而 Pingora 针对这些成本重新设计了一套 Rust 代理框架。\n具体来说：\nNginx upstream keepalive 是每 worker 独立缓存，连接复用率会被 worker 数量和流量分布稀释。 Pingora 通过多线程单进程模型和线程安全连接池，让 upstream 连接可以跨线程复用。 Pingora 的连接池源码针对高并发做了 hot queue、LRU、idle watcher 等设计。 Pingora 避免了 OpenResty 场景下 C/Lua 数据转换、字符串分配、GC、共享内存 mutex 等额外成本。 Rust 类型系统降低了复杂并发共享结构的实现风险，让团队可以更放心地做并发优化。 在中小规模、通用 Web 场景下，Nginx 仍然是非常优秀的选择。到了 Cloudflare 这种每秒数千万请求、海量 origin、复杂代理逻辑、强连接复用诉求都叠在一起的场景，Pingora 的价值才会被充分放大。\n参考资料 How we built Pingora, the proxy that connects Cloudflare to the Internet Open sourcing Pingora: our Rust framework for building programmable network services Cloudflare changelog: Pingora now powers Cloudflare\u0026rsquo;s cache cloudflare/pingora GitHub README pingora-pool 0.8.0 docs.rs pingora-runtime docs.rs pingora-proxy ProxyHttp docs.rs Pingora HttpSession::reuse() docs.rs Nginx upstream keepalive documentation Nginx upstream keepalive module source ","permalink":"https://cloudside.icyyan.com/posts/pingora-source-deep-dive/","summary":"\u003cp\u003eCloudflare 公开 Pingora 时，最抓眼球的数字是：在相同流量下，新的代理系统比旧系统少用了约 70% CPU 和 67% 内存。\u003c/p\u003e\n\u003cp\u003e看到这个数字，很多人的第一反应可能是：是不是 Nginx 不行了？是不是 Rust 天生就比 C 快很多？\u003c/p\u003e\n\u003cp\u003e都不是。\u003c/p\u003e\n\u003cp\u003e我们真正要回答的是：\u003cstrong\u003eCloudflare 为什么会觉得原来的 NGINX/OpenResty 代理不够用了？Pingora 又改掉了哪些最贵的地方？\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e后面就顺着这个问题往下走。先看 Pingora 是什么，再看 Nginx 在普通场景里为什么很好用，最后再看 Cloudflare 这种规模下，连接池、线程模型和业务逻辑会怎样把成本放大。\u003c/p\u003e\n\u003cp\u003e可以先带着三句话往下读，后面会一层层展开：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eCloudflare 替换的是他们内部深度改造过的 NGINX/OpenResty 代理，不是说 Nginx 在所有场景下都慢。\u003c/li\u003e\n\u003cli\u003ePingora 开源版更像“用 Rust 写代理服务的工具箱”，不是一个直接读取 \u003ccode\u003enginx.conf\u003c/code\u003e 的 Nginx 替代品。\u003c/li\u003e\n\u003cli\u003e重点不是“Rust 打败 Nginx”，而是连接复用、线程共享和复杂代理逻辑这些具体问题。\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"1-pingora-不是一个新的-nginx\"\u003e1. Pingora 不是一个新的 Nginx\u003c/h2\u003e\n\u003cp\u003e如果你平时用 Nginx，可能会自然地把 Pingora 想成“另一个服务器软件”。但这样理解会有点偏。\u003c/p\u003e\n\u003cp\u003ePingora 更像一套用 Rust 写代理服务的工具箱。它帮你处理连接、TLS、HTTP 协议、负载均衡、连接池、优雅升级这些通用能力；你的业务逻辑则写在 Rust 回调里。\u003c/p\u003e\n\u003cp\u003e它不是单个二进制 Web 服务器，而是一组 crate：\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003ecrate\u003c/th\u003e\n          \u003cth\u003e作用\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003epingora-core\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e底层协议、服务运行、监听器、连接器、优雅升级\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003epingora-proxy\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eHTTP 代理生命周期和 \u003ccode\u003eProxyHttp\u003c/code\u003e 回调接口\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003epingora-pool\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e高并发连接池\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003epingora-runtime\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eTokio runtime 封装，支持 work-stealing 与 no-steal 模式\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003epingora-load-balancing\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e负载均衡、健康检查、选择算法\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003epingora-cache\u003c/code\u003e / \u003ccode\u003epingora-memory-cache\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e缓存相关能力\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e这些名字不用急着都记住。先有一个印象就够了：Pingora 把“写代理服务”拆成了一组可以组合的 Rust 库。\u003c/p\u003e","title":"Pingora 深度解析：为什么 Cloudflare 用 Rust 重写了 Nginx 时代的代理架构"},{"content":"0. 背景与架构演进：为什么我们需要流水线？ Hugo 的运行机制 Hugo 是一个基于 Go 语言的静态网站生成器（Static Site Generator）。它的核心工作流是将我们编写的 Markdown 源文件，结合站点配置和主题模板，在编译阶段直接渲染成纯静态的 HTML、CSS 和 JS 文件（输出到 public/ 目录）。 由于是纯静态站点，线上的服务器不需要运行任何数据库或动态后端（如 PHP/Node.js），只需要一个 Web 服务器来托管这些文件即可对外提供访问。这种架构带来了极高的访问速度和安全性。\n自动化构建的必然性 正因为 Hugo 所有的页面都是预先生成的，如果采用手动发布，我们每次更新文章就会陷入一个繁琐的循环：本地写 Markdown -\u0026gt; 本地运行 hugo 构建出 public 目录 -\u0026gt; 手动打包或通过 scp/rsync 把文件传到服务器。 这三步里有两步是枯燥的机械劳动，不仅容易出错（比如忘了构建就上传），而且如果更换电脑写作，还得重新配置 Hugo 环境。因此，搭建一条自动化的 CI/CD 流水线，把编译和部署工作交给云端，是大幅提升博客写作体验的必选项。我们最终期望的体验是：只管写 Markdown，推代码即自动部署。\n为什么这样设计部署架构？ 基于上述诉求，我们设计了当前的流水线架构：\n构建层（GitHub Actions）：文章源码天然需要使用 Git 进行版本控制并托管在 GitHub 上。GitHub Actions 提供了与代码仓库深度绑定的免费 CI/CD 环境。它可以监听 git push 事件，自动在云端运行容器，拉取子模块（主题）并执行 hugo build。这样无论我们在哪里写代码，只要能 push，云端就能帮我们完成构建。 传输层（rsync）：在云端构建完成后，我们使用 rsync 经由 SSH 将生成的静态文件增量同步到阿里云 ECS。rsync 非常轻量高效，配合 --delete 参数能够确保服务器上的文件状态与最新编译结果完全一致（自动删除已移除的文件），不需要在服务器端部署任何复杂的 Agent。 网关与服务层（从 Nginx 到 Pingap）：静态文件传到服务器的指定目录后，需要对外暴露。本站点早期用 Nginx 直接托管静态文件，后来迁移到 Pingap 统一入口。为了让 Hugo 生成的目录页、文章页和标签页都保持稳定的 index.html 行为，当前采用 darkhttpd 在本机监听静态目录，Pingap 负责 HTTPS、SNI、HTTP 跳转和反代。无论入口网关怎么演进，“云端编译静态文件并推送到服务器目录” 始终是整套发布流程的核心。 1. 最终部署架构图 本地写 Markdown ──git push──→ GitHub ──Actions──→ Hugo build ──rsync──→ ECS /var/www/hugo-site/ ↑ SSH deploy key GitHub：源码仓库 + CI 触发器 + 版本历史 GitHub Actions：Ubuntu runner 上跑 hugo build，然后 rsync 阿里云 ECS：实际跑服务的地方，darkhttpd 承载静态文件，Pingap 对外反代 2. 准备：仓库初始化 假设你已经有一个空仓库克隆到本地：\ncd my_articles rm -rf main.py .idea # 清理旧文件 mkdir -p archetypes assets content/posts data layouts static themes 添加主题（这里用 PaperMod，和你现有站点保持一致）：\ngit submodule add --depth=1 https://github.com/adityatelange/hugo-PaperMod.git themes/PaperMod 写站点配置 hugo.toml：\nbaseURL = \u0026#39;https://cloudside.icyyan.com\u0026#39; languageCode = \u0026#39;en\u0026#39; title = \u0026#39;Cloudside\u0026#39; theme = \u0026#39;PaperMod\u0026#39; # 分页：每页 6 篇文章 paginate = 6 [params] author = \u0026#39;Icyyan\u0026#39; description = \u0026#39;这是一个用 Hugo + PaperMod 搭建的个人博客\u0026#39; ShowReadingTime = true ShowPostNavLinks = true ShowBreadCrumbs = true ShowCodeCopyButtons = true ShowToc = true Search = true [params.profileMode] enabled = true title = \u0026#39;Cloudside\u0026#39; subtitle = \u0026#39;记录、思考、分享\u0026#39; [params.homeInfoParams] Title = \u0026#39;Cloudside\u0026#39; Content = \u0026#39;记录、思考、分享\u0026#39; [[params.socialIcons]] name = \u0026#39;github\u0026#39; url = \u0026#39;https://github.com/Icyyan22\u0026#39; [outputs] home = [\u0026#34;HTML\u0026#34;, \u0026#34;RSS\u0026#34;, \u0026#34;JSON\u0026#34;] [menu] [[menu.main]] identifier = \u0026#39;posts\u0026#39; name = \u0026#39;文章\u0026#39; url = \u0026#39;/posts/\u0026#39; weight = 10 [[menu.main]] identifier = \u0026#39;tags\u0026#39; name = \u0026#39;标签\u0026#39; url = \u0026#39;/tags/\u0026#39; weight = 20 [[menu.main]] identifier = \u0026#39;search\u0026#39; name = \u0026#39;搜索\u0026#39; url = \u0026#39;/search/\u0026#39; weight = 30 创建 archetype 模板 archetypes/default.md：\n+++ date = \u0026#39;{{ .Date }}\u0026#39; draft = true title = \u0026#39;{{ replace .File.ContentBaseName \u0026#34;-\u0026#34; \u0026#34; \u0026#34; | title }}\u0026#39; +++ 创建第一篇测试文章 content/posts/hello.md：\n+++ date = \u0026#39;2025-05-12T00:00:00+08:00\u0026#39; title = \u0026#39;Hello\u0026#39; +++ 这是我在 Hugo 上发布的第一篇文章！ 3. GitHub Actions Workflow 创建 .github/workflows/deploy.yml：\nname: Deploy Hugo Site to ECS on: push: branches: [main] workflow_dispatch: permissions: contents: read jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 0 - name: Setup Hugo uses: peaceiris/actions-hugo@v3 with: hugo-version: \u0026#39;0.161.1\u0026#39; extended: true - name: Build run: hugo --minify - name: Verify build output run: | echo \u0026#34;=== public/ contents ===\u0026#34; ls -la public/ || echo \u0026#34;public/ NOT FOUND\u0026#34; echo \u0026#34;=== file count ===\u0026#34; find public/ -type f | wc -l - name: Setup SSH agent uses: webfactory/ssh-agent@v0.9.0 with: ssh-private-key: ${{ secrets.DEPLOY_KEY }} log-public-key: false - name: Test SSH + target dir env: DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_USER: ${{ secrets.DEPLOY_USER }} run: | echo \u0026#34;=== SSH test ===\u0026#34; ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \\ \u0026#34;$DEPLOY_USER@$DEPLOY_HOST\u0026#34; \u0026#34;echo \u0026#39;SSH OK\u0026#39;; ls -ld /var/www/hugo-site/\u0026#34; - name: Deploy via rsync env: DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_USER: ${{ secrets.DEPLOY_USER }} run: | rsync -avz --delete \\ -e \u0026#34;ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\u0026#34; \\ public/ \u0026#34;$DEPLOY_USER@$DEPLOY_HOST:/var/www/hugo-site/\u0026#34; 关键点：\nactions/checkout@v4 必须加 submodules: recursive，否则 PaperMod 主题拉不下来 peaceiris/actions-hugo@v3 装 hugo，比手动下载二进制稳 webfactory/ssh-agent 管理私钥，比手动 echo 到文件更不容易出格式问题 rsync -avz --delete 确保远端和本地完全一致，删掉的文件也会被清理 这个 workflow 是当前仓库正在使用的版本，已经能稳定部署。后续可以继续加固：用 ssh-keyscan 或固定 host key secret 替代 StrictHostKeyChecking=no，并给 rsync 加 --no-owner --no-group 避免远端文件所有者变成 GitHub runner 的 UID/GID。\n4. 服务器端：部署密钥 + rsync 4.1 准备部署目录 当前服务器实际是用 root 作为 DEPLOY_USER 部署，远端目录是 /var/www/hugo-site：\nmkdir -p /var/www/hugo-site 我后来 SSH 复核时看到这个目录的所有者是 1001:1001，这是 GitHub Actions runner 通过 rsync 同步后留下的 UID/GID。因为文件权限是 644、目录权限是 755，darkhttpd 仍然可以读取，所以当前站点访问正常。更稳的做法是创建专用 deploy 用户，并在 rsync 时加 --no-owner --no-group，但这属于后续加固，不是当前服务器现状。\n4.2 生成密钥对 在服务器上生成一对专门给 GitHub Actions 使用的 SSH key：\nssh-keygen -t ed25519 -C \u0026#34;github-actions-deploy\u0026#34; -f ~/.ssh/github_actions_deploy -N \u0026#34;\u0026#34; 4.3 公钥授权 cat ~/.ssh/github_actions_deploy.pub \u0026gt;\u0026gt; ~/.ssh/authorized_keys 4.4 私钥塞进 GitHub Secrets 打开仓库 Settings → Secrets and variables → Actions → New repository secret：\nSecret 值 DEPLOY_KEY ~/.ssh/github_actions_deploy 的完整私钥内容（含 -----BEGIN/END-----） DEPLOY_HOST 你的服务器公网 IP DEPLOY_USER 当前服务器实际使用 root；如果后续创建专用用户，就改成对应用户名 4.5 装 rsync 阿里云 Linux 3 默认不带 rsync：\nyum install -y rsync 没装 rsync 的话，workflow 跑到 deploy 步骤会报 rsync: connection unexpectedly closed (code 12)，这是本次踩的最大坑。\n5. 第一次触发 git add -A git commit -m \u0026#34;init hugo site + deploy pipeline\u0026#34; git push origin main push 后去 GitHub Actions 页面看执行日志。如果一切正常，30 秒内你会看到：\nCheckout 成功 Hugo build 成功，public/ 下生成了几十到几百个文件 SSH 测试通过，SSH OK + 目录信息 rsync 成功，sent xxx bytes received xxx bytes 6. 踩坑实录 6.1 当时踩到 directory 子目录 index.html 兼容问题 这是当时迁移环境里遇到的 Pingap 静态目录兼容问题，不是 CI 的问题，但会直接影响部署后的访问效果。见上一篇迁移文档的 10.5 节。当前建议是：新版本先重新验证 directory 插件；如果要稳妥上线，继续使用 darkhttpd（或类似轻量级 HTTP 服务器）在本机承载 Hugo 静态目录，Pingap 只负责对外反代。\n6.2 服务器上没有 rsync 报错：rsync: connection unexpectedly closed (code 12)，bash: rsync: command not found。\n解决：yum install -y rsync。\n6.3 DEPLOY_KEY 格式问题 GitHub Secrets 里粘贴私钥时，如果丢了换行符，webfactory/ssh-agent 会报 invalid format。\n解决：确保复制的是完整内容，包括 -----BEGIN OPENSSH PRIVATE KEY----- 和 -----END OPENSSH PRIVATE KEY-----，中间不要手动加空格或换行。\n6.4 Submodules 没拉下来 如果 workflow 里忘了 submodules: recursive，build 步骤会报 theme not found。\n解决：checkout 步骤必须加 submodules: recursive。\n6.5 文件 UID 变成 runner 用户 rsync 的 archive 模式会尝试保留源文件的 UID/GID。GitHub Actions runner 里的文件属于 1001:1001 这类临时用户，如果远端用 root 接收且保留 owner/group，部署后的 /var/www/hugo-site/ 可能出现奇怪的所有者。以后切回 nginx 或其他静态文件服务时，就可能遇到 403。\n当前服务器就处于这个状态：/var/www/hugo-site 的 owner 是 1001:1001。因为权限仍然允许读取，所以 darkhttpd 能正常提供页面；但这不是一个干净的长期状态。\n解决方法有两个：\nworkflow 里的 rsync 加 --no-owner --no-group，让远端文件保持接收用户或目录默认权限。 已经出现问题时，按实际运行用户修正一次所有者。 如果继续用 root 部署，可以临时修正为：\nchown -R root:root /var/www/hugo-site/ 如果后续改成专用 deploy 用户，再改成：\nchown -R deploy:deploy /var/www/hugo-site/ 7. 后续更新文章的工作流 现在已经完全不需要 SSH 上服务器了：\n# 1. 本地写新文章 vim content/posts/my-new-article.md # 2. 提交并推送 git add content/posts/my-new-article.md git commit -m \u0026#34;add: my new article\u0026#34; git push origin main # 3. 等 30 秒，刷新浏览器 如果某个文章还在草稿阶段，把 frontmatter 里的 draft = true 留着，hugo build 时不会生成它。想发布了就把 draft 改成 false 再 push。\n8. 还没做但值得做 图片/资源管理：content/posts/my-article/ 下建 images/ 目录，把配图和 Markdown 放一起， Hugo 会自动处理 缓存优化：在 Pingap 或前置 CDN 层配置静态资源缓存，重点处理 assets/、图片、字体这类长期不变文件 评论系统：PaperMod 支持 Giscus（GitHub Discussions 驱动），几行配置就能接 全文搜索：PaperMod 支持 Fuse.js 本地搜索，build 时自动生成索引 自定义域名 + CDN：境内直连要处理 ICP 备案；如果走 Cloudflare 橙云，需要单独验证境内外访问路径和缓存策略 ","permalink":"https://cloudside.icyyan.com/posts/hugo-github-actions-cicd/","summary":"\u003ch2 id=\"0-背景与架构演进为什么我们需要流水线\"\u003e0. 背景与架构演进：为什么我们需要流水线？\u003c/h2\u003e\n\u003ch3 id=\"hugo-的运行机制\"\u003eHugo 的运行机制\u003c/h3\u003e\n\u003cp\u003eHugo 是一个基于 Go 语言的静态网站生成器（Static Site Generator）。它的核心工作流是将我们编写的 Markdown 源文件，结合站点配置和主题模板，在编译阶段直接渲染成纯静态的 HTML、CSS 和 JS 文件（输出到 \u003ccode\u003epublic/\u003c/code\u003e 目录）。\n由于是纯静态站点，线上的服务器不需要运行任何数据库或动态后端（如 PHP/Node.js），只需要一个 Web 服务器来托管这些文件即可对外提供访问。这种架构带来了极高的访问速度和安全性。\u003c/p\u003e\n\u003ch3 id=\"自动化构建的必然性\"\u003e自动化构建的必然性\u003c/h3\u003e\n\u003cp\u003e正因为 Hugo 所有的页面都是预先生成的，如果采用手动发布，我们每次更新文章就会陷入一个繁琐的循环：\u003cstrong\u003e本地写 Markdown -\u0026gt; 本地运行 \u003ccode\u003ehugo\u003c/code\u003e 构建出 \u003ccode\u003epublic\u003c/code\u003e 目录 -\u0026gt; 手动打包或通过 \u003ccode\u003escp\u003c/code\u003e/\u003ccode\u003ersync\u003c/code\u003e 把文件传到服务器\u003c/strong\u003e。\n这三步里有两步是枯燥的机械劳动，不仅容易出错（比如忘了构建就上传），而且如果更换电脑写作，还得重新配置 Hugo 环境。因此，搭建一条自动化的 CI/CD 流水线，把编译和部署工作交给云端，是大幅提升博客写作体验的必选项。我们最终期望的体验是：\u003cstrong\u003e只管写 Markdown，推代码即自动部署。\u003c/strong\u003e\u003c/p\u003e\n\u003ch3 id=\"为什么这样设计部署架构\"\u003e为什么这样设计部署架构？\u003c/h3\u003e\n\u003cp\u003e基于上述诉求，我们设计了当前的流水线架构：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e构建层（GitHub Actions）\u003c/strong\u003e：文章源码天然需要使用 Git 进行版本控制并托管在 GitHub 上。GitHub Actions 提供了与代码仓库深度绑定的免费 CI/CD 环境。它可以监听 \u003ccode\u003egit push\u003c/code\u003e 事件，自动在云端运行容器，拉取子模块（主题）并执行 \u003ccode\u003ehugo build\u003c/code\u003e。这样无论我们在哪里写代码，只要能 push，云端就能帮我们完成构建。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e传输层（rsync）\u003c/strong\u003e：在云端构建完成后，我们使用 \u003ccode\u003ersync\u003c/code\u003e 经由 SSH 将生成的静态文件增量同步到阿里云 ECS。\u003ccode\u003ersync\u003c/code\u003e 非常轻量高效，配合 \u003ccode\u003e--delete\u003c/code\u003e 参数能够确保服务器上的文件状态与最新编译结果完全一致（自动删除已移除的文件），不需要在服务器端部署任何复杂的 Agent。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e网关与服务层（从 Nginx 到 Pingap）\u003c/strong\u003e：静态文件传到服务器的指定目录后，需要对外暴露。本站点早期用 Nginx 直接托管静态文件，后来迁移到 Pingap 统一入口。为了让 Hugo 生成的目录页、文章页和标签页都保持稳定的 \u003ccode\u003eindex.html\u003c/code\u003e 行为，当前采用 \u003ccode\u003edarkhttpd\u003c/code\u003e 在本机监听静态目录，Pingap 负责 HTTPS、SNI、HTTP 跳转和反代。无论入口网关怎么演进，\u003cstrong\u003e“云端编译静态文件并推送到服务器目录”\u003c/strong\u003e 始终是整套发布流程的核心。\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"1-最终部署架构图\"\u003e1. 最终部署架构图\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e本地写 Markdown ──git push──→ GitHub ──Actions──→ Hugo build ──rsync──→ ECS /var/www/hugo-site/\n                                                                  ↑\n                                                           SSH deploy key\n\u003c/code\u003e\u003c/pre\u003e\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eGitHub\u003c/strong\u003e：源码仓库 + CI 触发器 + 版本历史\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eGitHub Actions\u003c/strong\u003e：Ubuntu runner 上跑 hugo build，然后 rsync\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e阿里云 ECS\u003c/strong\u003e：实际跑服务的地方，\u003ccode\u003edarkhttpd\u003c/code\u003e 承载静态文件，Pingap 对外反代\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"2-准备仓库初始化\"\u003e2. 准备：仓库初始化\u003c/h2\u003e\n\u003cp\u003e假设你已经有一个空仓库克隆到本地：\u003c/p\u003e","title":"Hugo + GitHub Actions：从本地 Markdown 到线上站点的全自动流水线"},{"content":"0. 背景与动机：为什么要替换掉 Nginx？ 在过去很长一段时间里，本站点的静态文件托管（Hugo）以及动态服务反代（SillyTavern）一直由 Nginx 承担。Nginx 久经考验、极其稳定，堪称业界标杆。但随着系统架构的演进和统一网关管理的考量，我们开始寻求更现代化的反向代理方案，并最终将目光投向了 Pingap。\n为什么选择 Pingap？\n现代化的底层基石：Pingap 基于 Cloudflare 开源的 Pingora 框架构建，采用 Rust 编写。Pingora 在 Cloudflare 内部已经受了万亿级请求的考验。得益于 Rust 的内存安全特性，它从根本上规避了传统 C/C++ 网关常见的内存越界和崩溃风险。 更直观的配置模型：Nginx 的配置语法虽然强大，但指令的上下文和隐式执行顺序往往让人头疼。Pingap 采用了基于 TOML 的声明式配置，将 Server、Location、Upstream 和 Plugin 彻底解耦，配置的心智模型更加清晰，更利于后期的维护与扩展。 原生的高级特性支持：Pingap 对 HTTP/1.x、HTTP/2、WebSocket、gRPC-web、证书动态加载和插件化网关能力支持更直接。需要注意的是，HTTP/3/QUIC 这类能力应以当前官方 release 和文档为准，迁移生产入口时不要默认假设已经完整可用。 本次实战的核心目标：先在隔离端口上把新代理跑起来并完整验证，再用一次原子切换无缝替换 Nginx，全程保留 Nginx 配置以便出现问题时能够秒级回滚。\n通过这种“先验证后切换”的安全策略，我们最终实测的切换中断窗口仅为 174 ms，线上流量几乎无感。\n2026-05-17 复核：我重新 SSH 到服务器核对了一遍真实状态。当前服务器仍运行 pingap 0.13.2，pingap.service 使用 daemon 模式，Hugo 静态文件由 darkhttpd 承载。本文以下配置以服务器现状为准；新装环境可以再按最新 release 调整。\n1. 起点：现状盘点 服务器：阿里云 ECS（Alibaba Cloud Linux 3 / RHEL 兼容），跑 Nginx 1.20.1，承载两个站点：\n域名 性质 后端 关键能力 cloudside.icyyan.com Hugo 静态站点 本地文件 /var/www/hugo-site HTTPS、HTTP→HTTPS 重定向、静态资源 1 月缓存、Gzip silly.icyyan.com SillyTavern 反代 127.0.0.1:8000 HTTPS、WebSocket、client_max_body_size 100M、长连接 proxy_read_timeout 86400s、X-Forwarded-* 证书来自两个不同来源：\ncloudside：手动签发，放在 /etc/nginx/certs/cloudside.icyyan.com/ silly：Let\u0026rsquo;s Encrypt（certbot 自动续期），放在 /etc/letsencrypt/live/silly.icyyan.com/ 第一件事是 不要乱动证书路径——直接复用，迁移完出问题可以一键切回 Nginx，证书原地一致，省去后续同步的麻烦。\n2. Pingap 能不能替代 Nginx 做入口网关？ 逐项对照之后，作为 HTTPS 入口、反向代理和路由网关是可行的；静态文件服务则要按站点形态选择 directory 插件或本地静态文件服务器。对应关系如下：\nNginx 概念 Pingap 对应 server { listen 443 ssl; } [servers.https] + [certificates.X] proxy_pass http://upstream; [upstreams.X] + [locations.X] upstream = \u0026quot;X\u0026quot; root /var/www/... 静态文件 directory 插件，或本地静态文件服务器 + Pingap 反代 return 301 https://$host$request_uri; [plugins.X] category = \u0026quot;redirect\u0026quot; http_to_https = true（默认行为需按当前版本验证） proxy_set_header X-Real-IP ... enable_reverse_proxy_headers = true 自动注入 X-Real-IP / X-Forwarded-* client_max_body_size 100M; location 级 client_max_body_size = \u0026quot;100mb\u0026quot; proxy_read_timeout 86400s; upstream 级 read_timeout = \u0026quot;86400s\u0026quot; WebSocket Upgrade 原生支持，无需任何额外配置（pingora 底层透传） 多证书 SNI certificates.X.domains = \u0026quot;...\u0026quot; 自动匹配 唯一需要注意：Pingap 不是 Nginx 那种 server { location { ... } } 嵌套模型。Location 是全局定义的路由规则，但每个 [servers.X] 需要通过 locations = [...] 显式绑定自己要使用的规则；请求进入某个 server 后，再按 location 的 host、path 和权重匹配。\n3. 安装 二进制是 musl 静态链接，挑这个版本是因为不依赖系统 glibc，跨发行版兼容性最稳。本文记录的服务器当前版本是 0.13.2：\n# 下载并安装（musl 版本，静态链接） curl -L https://github.com/vicanso/pingap/releases/download/v0.13.2/pingap-linux-musl-x86.tar.gz \\ -o /tmp/pingap.tar.gz tar -xzf /tmp/pingap.tar.gz -C /tmp install -m 755 /tmp/pingap-linux-musl-x86 /usr/local/bin/pingap # 准备配置目录和日志目录 mkdir -p /etc/pingap /var/log/pingap pingap --version # pingap 0.13.2 4. 配置：六个文件搞定 Pingap 的配置可以一个大文件，也可以拆成多个文件放在一个目录里（启动时 -c /etc/pingap 自动扫描合并）。强烈建议拆分，方便后续单独 reload 某一类配置。\n最终方案里，Hugo 静态文件没有直接挂 directory 插件，而是由 darkhttpd 在服务器本机提供 9000 端口，Pingap 通过 127.0.0.1:9000 反代过去。实际 ss 看到 darkhttpd 监听的是 0.0.0.0:9000，所以应依赖云安全组/防火墙避免公网直接访问这个端口。选择这个方案，是因为当次迁移使用的 Pingap 版本在 Hugo 子目录 index.html 场景下有兼容问题，细节见 10.5。\n4.1 /etc/pingap/basic.toml — 全局 [basic] name = \u0026#34;pingap\u0026#34; log_level = \u0026#34;info\u0026#34; pid_file = \u0026#34;/run/pingap.pid\u0026#34; 4.2 /etc/pingap/certificates.toml — 证书 注意 domains 字段是 SNI 路由的关键，必须填上。\n[certificates.cloudside] domains = \u0026#34;cloudside.icyyan.com\u0026#34; tls_cert = \u0026#34;/etc/nginx/certs/cloudside.icyyan.com/cloudside.icyyan.com.pem\u0026#34; tls_key = \u0026#34;/etc/nginx/certs/cloudside.icyyan.com/cloudside.icyyan.com.key\u0026#34; [certificates.silly] domains = \u0026#34;silly.icyyan.com\u0026#34; tls_cert = \u0026#34;/etc/letsencrypt/live/silly.icyyan.com/fullchain.pem\u0026#34; tls_key = \u0026#34;/etc/letsencrypt/live/silly.icyyan.com/privkey.pem\u0026#34; 4.3 /etc/pingap/upstreams.toml — 后端 [upstreams.hugo_files] addrs = [\u0026#34;127.0.0.1:9000\u0026#34;] [upstreams.sillytavern] addrs = [\u0026#34;127.0.0.1:8000\u0026#34;] # WebSocket / 长连接：和 nginx 的 proxy_read_timeout / proxy_send_timeout 一一对应 read_timeout = \u0026#34;86400s\u0026#34; write_timeout = \u0026#34;86400s\u0026#34; idle_timeout = \u0026#34;86400s\u0026#34; 4.4 /etc/pingap/plugins.toml — 插件 # HTTP -\u0026gt; HTTPS 重定向 [plugins.httpToHttps] category = \u0026#34;redirect\u0026#34; http_to_https = true 4.5 /etc/pingap/locations.toml — 路由规则 # Hugo 静态站点，HTTPS [locations.cloudsideHttps] host = \u0026#34;cloudside.icyyan.com\u0026#34; upstream = \u0026#34;hugo_files\u0026#34; # SillyTavern 反代，HTTPS [locations.sillyHttps] host = \u0026#34;silly.icyyan.com\u0026#34; upstream = \u0026#34;sillytavern\u0026#34; client_max_body_size = \u0026#34;100mb\u0026#34; enable_reverse_proxy_headers = true # 自动注入 X-Real-IP / X-Forwarded-For / X-Forwarded-Proto / X-Forwarded-Host # 所有 HTTP 进来一律 301/307 跳到 HTTPS [locations.redirectHttp] plugins = [\u0026#34;httpToHttps\u0026#34;] 4.6 /etc/pingap/servers.toml — 监听端口 这是验证阶段和切换阶段唯一需要改动的文件。 验证时用非标端口，避免和 Nginx 抢端口。\n验证阶段（与 Nginx 共存）：\n[servers.http] addr = \u0026#34;0.0.0.0:8080\u0026#34; locations = [\u0026#34;redirectHttp\u0026#34;] [servers.https] addr = \u0026#34;0.0.0.0:8443\u0026#34; locations = [\u0026#34;cloudsideHttps\u0026#34;, \u0026#34;sillyHttps\u0026#34;] global_certificates = true # 启用全局证书池，按 SNI 自动选择 enabled_h2 = true # HTTP/2 切换阶段（接管标准端口）：\n[servers.http] addr = \u0026#34;0.0.0.0:80\u0026#34; locations = [\u0026#34;redirectHttp\u0026#34;] [servers.https] addr = \u0026#34;0.0.0.0:443\u0026#34; locations = [\u0026#34;cloudsideHttps\u0026#34;, \u0026#34;sillyHttps\u0026#34;] global_certificates = true enabled_h2 = true 4.7 启动本地 Hugo 静态文件服务 因为上面的 hugo_files upstream 指向 127.0.0.1:9000，所以在验证 Pingap 前，先确保本机静态服务已经跑起来：\n# /etc/systemd/system/hugo-static.service [Unit] Description=Hugo Static File Server After=network.target [Service] Type=simple ExecStart=/usr/sbin/darkhttpd /var/www/hugo-site --port 9000 --no-server-id Restart=on-failure RestartSec=2s [Install] WantedBy=multi-user.target systemctl daemon-reload systemctl enable --now hugo-static curl -I http://127.0.0.1:9000/ 5. 验证：在 8080/8443 上把所有路径全跑一遍 启动前先验证配置语法：\npingap -c /etc/pingap -t # 看到 \u0026#34;Validate config success\u0026#34; 才继续 后台启动：\npingap -c /etc/pingap -d --log=/var/log/pingap/pingap.log ss -tlnp | grep pingap # 确认监听了 8080 和 8443 跑 5 项验证（每一项都对应一个 nginx 时代的功能）：\n# 1. HTTPS + SNI -\u0026gt; Hugo 静态 200 curl -ksI --resolve cloudside.icyyan.com:8443:127.0.0.1 \\ https://cloudside.icyyan.com:8443/ # 2. HTTPS + SNI -\u0026gt; SillyTavern 反代（401 = 后端 Basic Auth，说明转发链路正常） curl -ksI --resolve silly.icyyan.com:8443:127.0.0.1 \\ https://silly.icyyan.com:8443/ # 3. HTTP -\u0026gt; HTTPS 重定向 307 curl -sI --resolve cloudside.icyyan.com:8080:127.0.0.1 \\ http://cloudside.icyyan.com:8080/ # 4. SNI 证书匹配 echo | openssl s_client -connect 127.0.0.1:8443 \\ -servername cloudside.icyyan.com 2\u0026gt;/dev/null \\ | openssl x509 -noout -subject -dates echo | openssl s_client -connect 127.0.0.1:8443 \\ -servername silly.icyyan.com 2\u0026gt;/dev/null \\ | openssl x509 -noout -subject -dates # 5. WebSocket Upgrade 握手 curl -sk --resolve silly.icyyan.com:8443:127.0.0.1 -i -N --max-time 3 \\ -H \u0026#34;Connection: Upgrade\u0026#34; \\ -H \u0026#34;Upgrade: websocket\u0026#34; \\ -H \u0026#34;Sec-WebSocket-Version: 13\u0026#34; \\ -H \u0026#34;Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\u0026#34; \\ https://silly.icyyan.com:8443/socket.io/?EIO=4\\\u0026amp;transport=websocket 这一步是这次迁移的安全网。 任何一项不通就回头改配置，绝不带着问题去做切换。\n6. systemd 服务化 # /etc/systemd/system/pingap.service [Unit] Description=Pingap Reverse Proxy After=network.target Wants=network.target [Service] Type=forking PIDFile=/run/pingap.pid ExecStartPre=/usr/local/bin/pingap -c /etc/pingap -t ExecStart=/usr/local/bin/pingap -c /etc/pingap -d --log=/var/log/pingap/pingap.log ExecReload=/bin/kill -HUP $MAINPID KillMode=mixed Restart=on-failure RestartSec=2s LimitNOFILE=65535 [Install] WantedBy=multi-user.target systemctl daemon-reload ExecStartPre 加了配置语法预检——任何错配会让 systemd 直接拒绝启动，比起 pingap 起来后才报错友好得多。当前服务器就是这个 daemon 模式：systemctl status pingap 里能看到主进程命令为 /usr/local/bin/pingap -c /etc/pingap -d --log=/var/log/pingap/pingap.log。\n7. 切换：让中断窗口尽可能短 切换之前先把 servers.toml 的端口从 8080/8443 改成 80/443，并把验证阶段启动的 pingap 进程清掉，然后一次性 stop nginx + start pingap：\n# 1. 清掉验证阶段的 pingap 进程 pkill -f /usr/local/bin/pingap # 2. 改 servers.toml（80/443 版本），再 systemctl daemon-reload 一次（unit 没变也无害） systemctl daemon-reload # 3. 切换（中断窗口就是这两条命令的间隔） systemctl stop nginx \u0026amp;\u0026amp; systemctl start pingap # 4. 等待 socket 出现 for i in 1 2 3 4 5; do ss -tln | grep -q \u0026#34;:443 \u0026#34; \u0026amp;\u0026amp; break sleep 0.2 done # 5. 立即验证 systemctl is-active nginx pingap ss -tlnp | grep -E \u0026#34;:(80|443)\\b\u0026#34; curl -ksI --resolve cloudside.icyyan.com:443:127.0.0.1 https://cloudside.icyyan.com/ 我这次实测从 nginx stop 到 pingap accept 总共 174 ms，外部观察基本无感。\n8. 收尾 systemctl enable pingap # 开机自启 systemctl disable nginx # 保留 nginx 但不开机自启（方便回滚） # 把验证阶段的旧目录、nginx 配置都打个备份保险 mv /root/pingap-old /root/pingap-old.bak.$(date +%Y%m%d) 2\u0026gt;/dev/null cp -a /etc/nginx /root/nginx-backup.$(date +%Y%m%d) 刻意 不 卸载 Nginx：\nnginx 的 conf 是已经验证过能 work 的\u0026quot;参考实现\u0026quot;，留着便于以后写新 location 时对照 一旦 pingap 出问题，回滚就是 systemctl stop pingap \u0026amp;\u0026amp; systemctl start nginx，秒级 9. 回滚预案 写在 ~/rollback-pingap.sh，应急时一行命令搞定：\n#!/bin/bash set -e systemctl stop pingap systemctl start nginx systemctl status nginx --no-pager 证书路径完全没改、nginx 配置原封不动，所以回滚是真正的\u0026quot;按下就回去\u0026quot;。\n10. 踩坑：本次迁移真正花时间的部分 10.1 SNI 拦截 / ICP 备案 迁移完成后从浏览器访问域名，出现 ERR_CONNECTION_CLOSED，第一反应是 pingap 有问题。但一通排查后定位到根因和 pingap 完全无关：\n症状对比：\n测试 SNI 结果 https://silly.icyyan.com/ silly.icyyan.com ❌ ERR_CONNECTION_CLOSED https://120.xx.xx.xx/（直接 IP） 无 / IP ✅ TLS 握手成功（证书 CN 不匹配但能握手） 带 SNI 就被切，不带 SNI 就能握手 —— 经典的 SNI 阻断 模式。\n根因：阿里云对未在阿里云 ICP 备案的域名会在境内入站方向做 SNI 检查，发现未备案的域名直接 TCP RST。境外 IP 出口因为路径不同绕过这层检查，所以从美国 IP 看 nginx access log 一切正常。这套机制和反代是 nginx 还是 pingap 毫无关系。\n走过的弯路：\n误以为是 pingap 启用 HTTP/2 / TLS 1.3 / ECH 不兼容 回滚到 nginx 后从我电脑测试发现同样的 Connection reset by peer，才意识到不是反代的问题 判别方法很简单：用 https://\u0026lt;IP\u0026gt;/ 直接访问，如果连证书警告页都打不开 → 端口 / 网络问题；如果能弹证书警告（CN 不匹配也行）→ 端口完全正常，问题在 SNI / 域名层面。\n解决方案二选一：\n补 ICP 备案 把域名挂 Cloudflare 真正代理（橙云），让阿里云那一段看到的 SNI 是 Cloudflare 的而不是你的 10.2 配置里 domains 字段不要忘 [certificates.X] 必须填 domains = \u0026quot;...\u0026quot;，否则 global_certificates = true 没法按 SNI 路由证书，会用默认证书或报握手错误。\n10.3 静态文件服务不在 [locations]，在 [plugins] Pingap 没有 nginx 的 root 指令。静态文件是通过 category = \u0026quot;directory\u0026quot; 插件提供，再在 location 的 plugins = [...] 数组里挂上。这一点和 nginx 的心智模型差别比较大，第一次看 pingap 文档时容易卡住。\n10.4 category = \u0026quot;redirect\u0026quot; 的 http_to_https 默认行为要单独验证 当次迁移时，http_to_https 的表现更接近“统一跳转 HTTPS”的网关插件，而不是 Nginx 里随手写一条 return 301。如果某些路径不希望强制跳 HTTPS（比如 .well-known/acme-challenge 给 Let\u0026rsquo;s Encrypt 验证用），需要为这个路径单独建一个 location，匹配权重高于带 httpToHttps 插件的那条。新版本如果需要固定 301/302/307，也应按当前 redirect 插件文档验证状态码配置。\n10.5 当时版本的 directory 插件对子目录 index.html 不稳定 这是本次迁移最大的功能陷阱。现象是：首页 / 正常，点进 /posts/、/tags/ 这类 Hugo 生成的目录页时返回 Not Found，即便对应目录下存在 index.html。\n这条结论严格限定在当次迁移环境和当时使用的 Pingap 版本。当前 Pingap 官方文档已经明确描述 directory 插件支持目录 index，所以新版本应重新实测，不能直接照搬这个坑。\n当时为了降低迁移风险，我没有继续把生产入口压在这个行为上，而是改成本地跑一个轻量静态文件服务器，Pingap 只做统一 TLS 终止、SNI、HTTP 跳转和反代。最终配置就是前面 4.7 的 darkhttpd + upstream 模式：\n[upstreams.hugo_files] addrs = [\u0026#34;127.0.0.1:9000\u0026#34;] [locations.cloudsideHttps] host = \u0026#34;cloudside.icyyan.com\u0026#34; upstream = \u0026#34;hugo_files\u0026#34; 这样 pingap 负责 TLS 终止、SNI、HTTP→HTTPS，darkhttpd 负责正确提供目录 index.html，各司其职。需要注意：当前 hugo-static.service 没有额外绑定 localhost，ss -tlnp 显示它监听在 0.0.0.0:9000；如果安全组放行了 9000，就会被公网直接访问。生产上最好只在安全组里放行 80/443/22，或改用支持绑定 127.0.0.1 的静态文件服务。\n11. 还没做但应该做 certbot 续期 hook：silly.icyyan.com 用 Let\u0026rsquo;s Encrypt，续期默认 reload nginx，现在 nginx 不在跑了。更稳的做法是在 certbot deploy hook 里执行 systemctl restart pingap，确保证书文件更新后新 TLS 配置一定被进程读到。 HTTP/3 / QUIC：不要在迁移文档里默认开启。Pingap 当前公开文档主要覆盖 HTTP/1.x、HTTP/2、gRPC-web、h2c；如果后续 release 明确支持 QUIC，再单独验证 UDP 443、安全组、回滚策略和客户端兼容性。 指标 / 监控：prometheus_metrics = \u0026quot;/metrics\u0026quot; 暴露给 Prometheus，或 webhook 接钉钉/企业微信告警 管理面板：[plugins.adminWebPage] 启用后可以在浏览器里改配置并热加载，但生产环境记得加 IP 白名单和强密码 一个总结 这次迁移的核心方法论是 \u0026ldquo;先建后切\u0026rdquo; —— 永远不要在生产端口上做实验。验证窗口越长越好，切换窗口越短越好；旧服务的配置和数据在切换后保留至少一个版本，回滚路径是肌肉记忆。\nPingap 的配置心智模型干净（upstream → location → server，配上 plugin 横切关注点），Rust 写的内存占用也确实小（~40MB resident，对比 nginx 多 worker 大约持平）。从这次站点入口迁移看，用 Pingap 替代 Nginx 承担 HTTPS 终止、反向代理和统一网关职责是可行的；静态文件这种细节能力则要结合版本和站点形态单独验证。\n","permalink":"https://cloudside.icyyan.com/posts/nginx-to-pingap-migration/","summary":"\u003ch2 id=\"0-背景与动机为什么要替换掉-nginx\"\u003e0. 背景与动机：为什么要替换掉 Nginx？\u003c/h2\u003e\n\u003cp\u003e在过去很长一段时间里，本站点的静态文件托管（Hugo）以及动态服务反代（SillyTavern）一直由 Nginx 承担。Nginx 久经考验、极其稳定，堪称业界标杆。但随着系统架构的演进和统一网关管理的考量，我们开始寻求更现代化的反向代理方案，并最终将目光投向了 \u003ca href=\"https://github.com/vicanso/pingap\"\u003ePingap\u003c/a\u003e。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e为什么选择 Pingap？\u003c/strong\u003e\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e现代化的底层基石\u003c/strong\u003e：Pingap 基于 Cloudflare 开源的 \u003cstrong\u003ePingora\u003c/strong\u003e 框架构建，采用 Rust 编写。Pingora 在 Cloudflare 内部已经受了万亿级请求的考验。得益于 Rust 的内存安全特性，它从根本上规避了传统 C/C++ 网关常见的内存越界和崩溃风险。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e更直观的配置模型\u003c/strong\u003e：Nginx 的配置语法虽然强大，但指令的上下文和隐式执行顺序往往让人头疼。Pingap 采用了基于 TOML 的声明式配置，将 \u003ccode\u003eServer\u003c/code\u003e、\u003ccode\u003eLocation\u003c/code\u003e、\u003ccode\u003eUpstream\u003c/code\u003e 和 \u003ccode\u003ePlugin\u003c/code\u003e 彻底解耦，配置的心智模型更加清晰，更利于后期的维护与扩展。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e原生的高级特性支持\u003c/strong\u003e：Pingap 对 HTTP/1.x、HTTP/2、WebSocket、gRPC-web、证书动态加载和插件化网关能力支持更直接。需要注意的是，HTTP/3/QUIC 这类能力应以当前官方 release 和文档为准，迁移生产入口时不要默认假设已经完整可用。\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e\u003cstrong\u003e本次实战的核心目标\u003c/strong\u003e：\u003cstrong\u003e先在隔离端口上把新代理跑起来并完整验证，再用一次原子切换无缝替换 Nginx，全程保留 Nginx 配置以便出现问题时能够秒级回滚。\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e通过这种“先验证后切换”的安全策略，我们最终实测的切换中断窗口仅为 \u003cstrong\u003e174 ms\u003c/strong\u003e，线上流量几乎无感。\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e2026-05-17 复核：我重新 SSH 到服务器核对了一遍真实状态。当前服务器仍运行 \u003ccode\u003epingap 0.13.2\u003c/code\u003e，\u003ccode\u003epingap.service\u003c/code\u003e 使用 daemon 模式，Hugo 静态文件由 \u003ccode\u003edarkhttpd\u003c/code\u003e 承载。本文以下配置以服务器现状为准；新装环境可以再按最新 release 调整。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"1-起点现状盘点\"\u003e1. 起点：现状盘点\u003c/h2\u003e\n\u003cp\u003e服务器：阿里云 ECS（Alibaba Cloud Linux 3 / RHEL 兼容），跑 Nginx 1.20.1，承载两个站点：\u003c/p\u003e","title":"从 Nginx 迁移到 Pingap：一次接近零停机的实战记录"}]