架构演进的六个时代:从原始分布式到无服务

作为后端程序员,架构知识通常是一点一点在工作中捡的:数据一致性踩坑了,知道了事务的重要性;服务改用 K8s 部署,真切体会到了声明式 API 给运维带来的便利性;性能遇到瓶颈了,加上缓存系统。每块都懂一点,但这些知识之间是什么关系,为什么要这么设计,一直没有形成体系化的思考,直到读到周志明老师的《凤凰架构》。这本书完整地梳理了服务端的知识地图,将 why 和 what 的问题讲得非常清楚,给了非常清晰的宏观视角。 本文是第一章的总结和读后感:每一代架构风格的诞生,都是因为上一代遇到了它解决不了的具体问题。这些问题的性质各不相同,但贯穿始终的暗线是复杂度 —— 复杂度不会消失,只会转移。 原文出处:微信公众号「张煜中」《架构演进的六个时代:从原始分布式到无服务》,作者张煜中。本文基于该文整理,作为个人读书笔记。 原文内容 原始的分布式 通常我们会认为,服务架构是从单体开始的,为了逃离单体大泥球架构的地狱,才搞分布式。但历史上恰恰相反。对分布式架构的探索,从 20 世纪 70 年代就开始了。那时单机算力极其有限,16 位处理器、不到 5MHz 的主频,单机直接卡住了软件能做到的规模上限。人们不得不寻找多台计算机协作支持一套软件系统的方案。 UNIX 设计风格强调:保持接口与实现的简单性,比系统的任何其他属性,包括准确性、一致性和完整性,都来得更加重要。 理想很美 —— 远程调用应该尽可能透明,开发者无需关心自己调的是本地方法还是远程服务。但一旦触碰到"远程"二字,网络的不确定性便会带来相当的复杂度。远程的服务在哪里(服务发现),有多少个(负载均衡),网络出现分区、超时或者服务出错了怎么办(熔断、隔离、降级),方法的参数与返回结果如何表示(序列化协议),信息如何传输(传输协议),服务权限如何管理(认证、授权),如何保证通信安全(网络安全层),如何令调用不同机器的服务返回相同的结果(分布式数据一致性)—— 每一个都需要设计者耗费大量精力。 这些探索催生了 RPC、DFS 等概念,人们也得到了一个价值千金的教训:某个功能能够进行分布式,并不意味着它就应该进行分布式,强行追求透明的分布式操作,只会自寻苦果。 原始分布式时代的故事,是一次发现复杂度的过程。探索者试图用"透明调用"把分布式的复杂度屏蔽掉,让开发者像写本地程序一样写分布式程序。但现实证明,网络带来的不确定性是无法假装不存在的 —— 服务发现、一致性、网络分区,这些问题不会因为你不看它就消失。这次失败的意义不在于产出了什么可用的系统,而在于让整个行业认清了分布式复杂度的真实面貌。 于是当硬件性能随摩尔定律起飞后,人们做了一个务实的选择:既然分布式的复杂度屏蔽不了,那就别分布式了。 单体系统时代 软件退回到单体 —— 所有代码跑在同一个进程里,不用想网络,不用想一致性。 大型的单体系统,经常是微服务书籍批判的对象。但一定要注意这里的定语 —— “大型的”。小型单体系统,不仅易于开发、测试、部署,且由于系统中各个功能、模块、方法的调用过程都是进程内调用,没有进程间通信,运行效率也很高。三个人的团队、一台机器撑得住的系统,搞微服务纯粹是给自己找麻烦。 单体系统的缺陷在于,缺乏自治和隔离能力。进程内调用虽然简单高效,但故障也难以隔离,某个模块的 bug 能导致整个系统崩溃。而在大型系统中,出错几乎是必然的 —— 大型系统意味着多人协作、频繁变更,缺乏隔离就意味着一个模块的内存泄漏能拖垮整个进程,一次局部的代码升级需要整体停机重启。 单体系统的设计哲学是「让每一部分都尽量不出错」,靠高质量来保证高可靠。但系统越大,出错越是必然。从「追求不出错」到正视「出错是必然」的观念转变,才是微服务架构得以挑战单体的底气所在。 单体时代面对的不是分布式复杂度 —— 它根本没有分布式。它面对的是规模带来的复杂度,而这种复杂度体现在多个维度上:可维护性(一次局部改动需要整体停机重启)、团队协作(多人改同一个代码库,互相踩脚)、可靠性(单点故障拖垮全局)。系统小的时候,这些问题都不存在;但规模一旦上去,它们会同时爆发,而且在单体架构下无解 —— 因为缺乏隔离和自治能力。 为了获得这种能力,人们再次走向分布式。 SOA 时代 但在微服务之前,业界走过一段弯路 —— SOA。 SOA 的野心极大,它不仅要解决技术问题,还想建立一套自上而下的软件研发方法论:如何挖掘需求、如何分解业务、如何编排服务,一揽子全包。它有 IBM、Oracle 等巨头撑腰,有 SOAP 协议族做底座,有企业服务总线(ESB)做通信管道,从技术可行性上看确实解决了分布式的主要问题。 但问题恰恰出在「太完美」上。过于精密的规范带来过度的复杂性,SOAP 之上层层叠加的 ESB、BPM、SCA、SDO,让整个技术栈变成了只有少数专业人员才能驾驭的奢侈品。 SOA 与 EJB 的失败如出一辙,一旦脱离人民群众,终究会淹没在群众的海洋之中。 ...

July 3, 2026 · 1 min · Icyyan

在阿里云 ECS 上最小化部署 Marinara Engine:不在服务器上跑本地模型

Marinara Engine 的官方介绍里把它称为一个本地 AI 聊天、角色扮演和游戏引擎。听起来很容易让人以为:既然是“本地”,是不是服务器上还要准备 GPU、下载模型、跑 llama.cpp 或者 embedding 服务? 如果只是想把 Marinara 当成一个 Web 应用来用,答案是不需要。我的阿里云 ECS 现在跑的就是这种最小化部署:Docker 里只跑 Marinara Engine 本体,模型能力走外部 API,容器端口只绑定到 127.0.0.1,再由反向代理对外提供访问。 本文记录的是这个部署方式。版本核对时间是 2026-06-29:官方最新稳定版是 v2.0.6,我当前服务器上实际运行的是 ghcr.nju.edu.cn/pasta-devs/marinara-engine:2.0.5-lite。新部署可以优先用最新稳定版的 *-lite tag;如果你想完全复刻本文环境,就固定到 2.0.5-lite。 先理解这个“最小化”是什么意思 最小化不是功能阉割到不能用,而是把不适合小云服务器承担的部分拿掉。 Marinara Engine 官方提供 lite 镜像。根据官方容器安装文档,lite 镜像去掉了三类较重的离线能力: 本地 sidecar 模型,也就是容器内直接跑本地 LLM 的那部分。 本地 embedding 模型。 依赖本地 embedding 的语义记忆检索。 保留下来的能力仍然包括聊天、角色、游戏模式、agent、lorebook、角色卡、远程 LLM API 连接等。也就是说,只要你本来就打算用 OpenAI、OpenRouter、Gemini、Anthropic 或者其他远程 OpenAI-compatible API,这种部署方式就够了。 我这里的结构是: 浏览器 -> 阿里云安全组开放 80/443 -> Pingap / Nginx / Caddy 这类反向代理 -> 127.0.0.1:7860 -> Docker 容器内的 Marinara Engine -> 远程 LLM / 图片 / TTS API 看到这里,问题就变成了:服务器上到底需要跑什么?答案很少:Docker、一个 compose 文件、一个 .env 文件、一个反向代理入口。 ...

June 29, 2026 · 3 min · Icyyan

Python 垃圾回收机制:引用计数、循环引用和分代 GC

很多人第一次关心 Python 的垃圾回收,不是因为写了多复杂的代码,而是因为遇到了几个很实际的问题: del obj 之后,内存为什么没有立刻降下来? 明明没有全局变量引用某个对象,它为什么还活着? Python 不是有垃圾回收吗,为什么还会出现内存泄漏? gc.collect() 到底该不该在业务代码里手动调用? 这些问题背后其实是同一个机制:Python 的内存回收不是单一算法,而是几层机制一起工作。平时我们说的“Python 垃圾回收”,在最常见的 CPython 解释器里,主要由两部分组成: 引用计数:对象引用数变成 0 时,通常立刻释放。 循环垃圾回收器:专门处理引用计数解决不了的循环引用。 这篇文章主要讨论 CPython,因为日常用 python 命令启动的解释器,大多数情况下就是 CPython。PyPy、Jython、IronPython 等实现可以采用不同策略,所以不要把本文所有细节直接套到每一种 Python 实现上。 先从变量和对象的关系说起 在 Python 里,变量不是装对象的盒子,更像是贴在对象上的名字。 a = [1, 2, 3] b = a 这段代码里没有创建两个列表。它创建了一个列表对象,然后让 a 和 b 都指向它: a ─┐ ├──> [1, 2, 3] b ─┘ 执行: del a 删除的也不是列表对象本身,而是删除名字 a 到列表对象的那条引用。因为 b 还指向这个列表,所以对象仍然活着: b ───> [1, 2, 3] 这就是理解 Python 垃圾回收的入口:对象什么时候能被回收,取决于还有没有地方能继续访问它。 del 的含义也要顺手纠正一下:del name 删除的是名字绑定;del obj.attr 删除的是属性引用;del some_list[i] 删除的是容器里的一个引用。它们都不等于“立刻把某块内存还给操作系统”。 ...

June 20, 2026 · 5 min · Icyyan

Rust 里的 Box 到底是什么

学 Rust 时,Box<T> 很容易被一句话带过:它可以把数据放到堆上。 这句话没错,但如果只记住这一句,后面看到 Box<dyn Trait>、Box<List>、Pin<Box<T>>、Box::leak 时,还是会觉得它像一个突然冒出来的语法补丁。 更好的理解方式是:Box<T> 不是“逃离所有权系统”的工具,而是 Rust 所有权系统里最基础的一种拥有型指针。它让一个值住在堆上,同时让所有权仍然清清楚楚地归某个变量管理。 先看 Box 解决了什么问题 普通变量通常可以这样理解: let n = 42; n 这个值本身就放在当前栈帧里。栈很快,进入函数时分配,函数返回时回收,生命周期也很清楚。 但有些场景光靠栈不够舒服: 一个类型递归地包含自己,编译器算不出它的大小。 你想把不同具体类型放进同一个集合,只要求它们实现同一个 trait。 一个值很大,你希望移动时只移动一个指针。 某些 API 需要一个固定地址的拥有型对象,比如配合 Pin 使用。 这时 Box<T> 就出现了。 let n = Box::new(42); 可以把它粗略想成这样: 栈上变量 n -> 保存一个指针 -> 指向堆上的 42 Box<T> 本身在栈上,里面保存指向堆数据的指针。真正的 T 在堆上。变量离开作用域时,Box<T> 会自动释放堆上的 T。 所以 Box<T> 同时有两个特点: 它是指针,可以间接访问堆上的值。 它拥有这个值,离开作用域时负责释放它。 这和 C 里的裸指针很不一样。你不用手写 free,也不能随便复制出多个拥有者。Rust 仍然会检查所有权、移动和借用。 最基本的用法:Box::new 创建一个 Box<T> 最常见的方式是 Box::new: fn main() { let name = Box::new(String::from("cloudside")); println!("{name}"); } 这里 String 这个值由 Box 放到堆上管理,name 是一个拥有它的 Box<String>。注意,String 自己内部还会管理一块字符串缓冲区;这里说的是 String 这个三字段结构本身的位置。 ...

May 24, 2026 · 5 min · Icyyan

请不要用 Redis 做任何缓存之外的事

Redis 是程序员工具箱里很容易被“过度信任”的东西。 它太顺手了。 想存个值,SET 一下。想自动过期,EXPIRE 一下。想做计数器,INCR 一下。想搞队列,LPUSH / BRPOP 一下。想搞锁,SET NX PX 一下。再看一眼 Stream,好像连消息系统也能顺手安排。 于是很多系统就这么一路滑坡: 第一天:Redis 只是缓存。 第二天:这个状态先放 Redis 吧。 第三天:队列也先放 Redis 吧。 第四天:库存扣减也先放 Redis 吧。 第五天:Redis 挂了,大家开始翻日志考古。 这篇文章想讲的观点很简单: Redis 能做很多事,但不要让它单独拥有真相。 如果一份数据不能丢、不能错、不能重复处理、不能悄悄过期、不能被内存策略淘汰,那它就不应该只待在 Redis 里。 Redis 最舒服的位置是缓存。它可以让系统跑得更快,但不应该决定系统还活不活得明白。 先看边界:缓存可以重建,事实不能只靠 Redis 先把话说在前面:这不是 Redis 不行。 Redis 很强,而且越来越强。它有 RDB,有 AOF,有复制,有 Sentinel,有 Cluster,有 Streams,还有很多高级数据结构。Redis 8 之后,JSON、Search、Time Series、概率数据结构等能力也被合进 Redis Open Source。 所以问题不是“Redis 能不能干更多活”。 问题是:你把活交给 Redis 之后,能不能接受它的失败方式。 缓存的失败方式很朴素: Redis 没了 -> 缓存 miss -> 回源数据库 -> 重新写缓存 这很烦,但不至于让业务当场失忆。 ...

May 23, 2026 · 3 min · Icyyan

Redis 底层数据结构:一个 key 背后到底藏着什么

很多人第一次学 Redis,记住的是五种常用类型:String、List、Hash、Set、ZSet。再往后背面试题,又会遇到 SDS、quicklist、listpack、intset、skiplist、hashtable。 这些名字放在一起很容易让人迷糊:Redis 不是 key-value 数据库吗?为什么一个 Hash 后面还会有 listpack 和 hashtable 两种实现?为什么同样是 Set,有时是 intset,有时又变成哈希表? 真正要理解的不是“Redis 有哪些数据结构”这张清单,而是一个更具体的问题: Redis 为什么要让同一种对外类型,在不同场景下切换不同的内部编码? 顺着这个问题往下看,Redis 的底层数据结构会清楚很多。它不是为了把实现弄复杂,而是在内存、CPU、查询速度、扩容成本之间做了一组很工程化的取舍。 本文写作和更新时的版本背景是:截至 2026 年 5 月 23 日,Redis Open Source 最新稳定版是 8.6.3,这是 2026 年 5 月发布的安全修复版本;Redis GitHub releases 里还能看到 8.8-RC1,但它是预发布版,官方说明不适合生产环境使用。 从底层数据结构这条线看,Redis 8.6 最值得关注的不是“又多背几个结构名”,而是几类更贴近日常排查的问题: 大 Hash 和大 ZSet 的内存占用继续被优化。 Stream 增加了 XADD IDMP / IDMPAUTO,用于幂等写入。 新增 HOTKEYS,能直接辅助定位热点 key。 新增 key 内存大小直方图相关能力,排查 List、Set、Hash、ZSet 的规模分布更方便。 新增 volatile-lrm 和 allkeys-lrm 淘汰策略,按“最近修改时间”参与淘汰。 旧版本里经常出现的 ziplist,在 Redis 7 之后大多已经被 listpack 替代,所以不要再只背“压缩列表”那套旧答案。 ...

May 23, 2026 · 6 min · Icyyan

布隆过滤器:用少量内存判断一个元素是否可能存在

如果一个接口被大量查询不存在的数据,比如用户不断请求不存在的商品 ID、缓存里没有的用户 ID,系统很容易被拖进一个尴尬局面:缓存没命中,请求继续打到数据库;数据库查不到,下一次同样的请求又会重复发生。 这类问题的关键不是“如何更快地查到数据”,而是先回答一个更便宜的问题:这个东西是不是一定不存在? 布隆过滤器解决的正是这个问题。它不能像哈希集合那样给出完全精确的答案,但它能用很少的内存,在极快的时间内告诉你: 如果结果是“不存在”,那它一定不存在。 如果结果是“存在”,那它只是可能存在。 这个“可能”就是布隆过滤器最重要的取舍。它用少量误判,换来了很高的空间效率。 先从一个普通集合说起 假设我们要记录 1 亿个已经注册过的用户 ID,最直接的做法是把这些 ID 放进一个集合: registered_users = set() registered_users.add("user:10086") if "user:10086" in registered_users: print("exists") 这个方案很好理解,也很精确。但问题是:集合为了支持快速查询,通常不只存储原始元素,还要维护哈希表、桶、指针、扩容状态等额外结构。数据量一大,内存会明显上去。 如果我们的目标只是提前过滤掉明显不存在的请求,真的需要保存完整的字符串吗? 布隆过滤器的答案是:不需要。它只保存一些 bit。 布隆过滤器的直觉 可以把布隆过滤器想成一个很大的位图,也就是一串只包含 0 和 1 的数组: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 普通位图通常要求元素能直接映射成整数位置,比如数字 13 就对应第 13 个 bit。但现实里的元素可能是字符串、URL、邮箱、订单号,不一定能直接当数组下标。 布隆过滤器在中间加了一层哈希函数。 对于一个元素,它不用一个位置表示,而是用多个哈希函数算出多个位置: element = "user:10086" hash1(element) -> 3 hash2(element) -> 9 hash3(element) -> 14 插入这个元素时,就把这些位置都置为 1: ...

May 21, 2026 · 5 min · Icyyan

位图 Bitmap:用一个 bit 记录海量状态

如果让你记录“今天哪些用户登录过”,最直接的想法可能是放进一个集合: logged_in_users = set() logged_in_users.add(10086) 这样当然能用。但如果用户 ID 范围很大、查询又特别频繁,集合会带来不少额外开销。有没有一种更省空间的办法? 位图,也就是 Bitmap,解决的就是这类问题:用一个 bit 表示一个状态。这个状态通常只有两种结果:有或没有、是或否、出现过或没出现过。 位图到底是什么 先从一个字节说起。 一个字节有 8 个 bit: 0 0 0 0 0 0 0 0 每个 bit 都可以表示一个编号是否存在。比如我们用 bit 记录数字是否出现过: bit 0 表示数字 0 是否出现 bit 1 表示数字 1 是否出现 bit 2 表示数字 2 是否出现 ... bit 7 表示数字 7 是否出现 如果数字 3 出现过,就把第 3 个 bit 置为 1: 0 0 0 0 1 0 0 0 ↑ 数字 3 当编号超过 7 时,就放到下一个字节里。比如数字 13: ...

May 18, 2026 · 3 min · Icyyan

用 Pingap 部署反向代理,以及从 Pingora 迁移时容易踩的坑

如果你已经看过 Pingora 的最小反向代理示例,很容易产生一个后续问题:生产环境是不是也要自己继续写配置解析、证书加载、日志、热更新和管理界面?通常不需要。Pingap 就是基于 Pingora 做好的反向代理应用,它更接近一个可以直接部署的 Nginx 替代方案。 这篇文章只讲 Pingap:怎么安装,怎么写一个最小反代配置,怎么加 HTTPS,怎么用 Docker 或 systemd 部署,以及从裸 Pingora 或 Nginx 迁移过来时有哪些容易忽略的坑。 本文示例版本核对时间:2026-05-17。示例固定使用 Pingap v0.13.4。实际部署前建议再看一次官方 GitHub Releases 和文档,因为 Pingap 还在快速变化。 Pingap 和 Pingora 的关系 Pingora 是框架,你通过 Rust 代码实现代理逻辑。Pingap 是应用,它把常见反向代理能力包装成 TOML 配置和 Web 管理界面。 一个最小 Pingap 配置大概长这样: [upstreams.app] addrs = ["127.0.0.1:3000"] [locations.app] host = "app.example.com" path = "/" upstream = "app" enable_reverse_proxy_headers = true [servers.http] addr = "0.0.0.0:6188" locations = ["app"] 这段配置和一个最小 Pingora 代理做的是同一件事:监听 6188,把匹配 app.example.com 的请求转发到 127.0.0.1:3000。差别在于,你不用实现 ProxyHttp,也不用自己处理常见代理应用需要的周边能力。 ...

May 17, 2026 · 5 min · Icyyan

用 Pingora 写一个最小反向代理,并把它部署起来

Cloudflare 开源 Pingora 之后,很多人会问:它能不能替代 Nginx?如果问题只是“能不能把请求转发到后端服务”,答案是能,而且几十行 Rust 就够了。但 Pingora 不是一个读取 nginx.conf 的服务器,它更像一套用来写代理、网关和网络服务的 Rust 框架。 这篇只做一件事:用 Pingora 写一个最小反向代理,让 http://127.0.0.1:6188/ 转发到本机的 127.0.0.1:3000,然后把这个二进制按服务方式部署起来。 本文示例版本核对时间:2026-05-17。示例使用 pingora = "0.8"。 先看等价的 Nginx 配置 如果用 Nginx,最小反代大概长这样: server { 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:请求进来以后,代码决定它要转发到哪里,转发前要改哪些请求头,最后日志怎么记。 创建项目 cargo new mini-pingora-proxy cd mini-pingora-proxy Cargo.toml: [package] name = "mini-pingora-proxy" version = "0.1.0" edition = "2021" [dependencies] async-trait = "0.1" env_logger = "0.11" log = "0.4" pingora = { version = "0.8", features = ["proxy"] } 这里用的是 pingora 聚合 crate,并启用 proxy feature。Pingora 没有默认打开所有能力;如果后面要做负载均衡,需要再启用 lb;如果要监听 HTTPS 或连接 HTTPS upstream,还要选择对应的 TLS feature,比如 openssl、boringssl、s2n 或实验性的 rustls。 ...

May 17, 2026 · 4 min · Icyyan