04. 后端中间件与基础设施
四、后端中间件与基础设施¶
很多系统的第一版都能靠“HTTP 服务 + 数据库”跑起来,但第二版开始,系统真正吃力的地方往往已经不在业务判断本身,而在那些被不断重复、不断放大的公共压力上。热点查询会把数据库打热,长任务会把同步请求拖死,大文件会把应用和数据库一起拖重,用户开始按内容和语义找信息以后,数据库又会被迫充当搜索系统,多实例部署后还会冒出“谁看到的是最新配置”“谁有资格执行这类任务”这类共享视图问题。
所以基础设施不是“架构图要显得完整”的装饰,而是在替主业务链接住几类数据库和业务服务都不擅长长期承受的压力。缓存接住的是重复读压力,消息链路接住的是异步推进和削峰填谷,对象存储接住的是大文件和中间产物,搜索与检索基础设施接住的是内容查询入口,配置发现与协调接住的是多实例共享运行视图。这一章的重点不是背产品名,而是建立一种判断力:当系统在某种压力下开始变形时,你应该知道它更像是哪一类基础设施问题。
先看清基础设施在系统里站哪一层¶
入口层先负责把请求稳稳送进系统。鉴权、限流、日志、trace、请求大小限制这些动作适合在这里统一做,因为它们本来就是横切约束。业务服务再接住真正的业务动作,例如创建文档任务、写入会话状态、发起审批、装配检索与模型链路。数据库继续扮演权威状态和事务边界。真正的分层价值,在于每一层只承担自己最擅长的那类约束。
基础设施层出现的信号,通常是业务服务和数据库开始被迫承受自己并不擅长的工作。例如数据库开始被当缓存顶热点读,应用服务开始自己维护一个越来越大的内存任务队列,文件原件和中间产物越来越多地挤进数据库,业务服务里开始夹杂搜索和复杂排序逻辑,多实例之间则开始靠人工维护配置和主从职责。只要你能看见这些变形,就会知道下一步该把哪段复杂度单独接出去。
先把一条真实业务链路走顺¶
企业知识库最适合拿来理解这条链,因为它几乎会把几类常见基础设施同时用到。用户上传一份制度 PDF 时,上传入口先完成鉴权、租户识别、限流和 trace。原件再进入对象存储,因为它需要的是容量、下载和生命周期治理,而不是事务字段治理。文档元数据和任务状态进数据库,因为那才是需要事务保护的权威状态。后续解析、OCR、切块、embedding 和索引刷新进入异步任务链,因为它们已经超出了同步 HTTP 请求应该承受的时长和失败语义。最终在线读路径又会命中缓存、全文检索、向量检索和配置中心,因为热点读、内容查询和多实例共享视图都不能继续压在数据库和业务服务上。
把这条链顺下来以后,基础设施的价值就会变得很具体。对象存储不是为了显得“云原生”,而是因为文件原件和中间产物天然需要另一套治理。消息链路不是因为 Kafka 很流行,而是因为同步请求不适合背长任务和削峰。缓存不是“随手加快一点”,而是在给数据库减轻稳定反复出现的读压力。配置发现和协调也不是为了看起来更分布式,而是在让多实例看到同一份现实。真正值得记住的不是组件名,而是每一层是在接哪一段原本不该继续压在主链上的复杂度。
缓存:解决重复读,不解决真相¶
缓存最核心的系统问题,不是“让东西变快”,而是“把大量重复读从权威存储上卸下来”。数据库负责真相和事务,不负责无限便宜地回答重复问题。只要某个查询既高频、又相对稳定、又允许短时间不完全强一致,它就值得进入缓存视野。
缓存机制最常见的有四种。cache-aside 是业务代码先读缓存,未命中再查数据库并回填缓存,它是默认起点,因为接入轻、控制力高,但代价是读写路径可能暂时不一致。read-through 让缓存层代你回源,更像把缓存提升成一个带加载能力的代理层,接入更统一,但控制面和调试面也更重。write-through 要求写请求同时更新缓存和存储,一致性更直接,但写路径更重。write-behind 则先写缓存再异步落存储,吞吐高,但失败恢复和持久性治理明显更难。多数业务第一版都从 cache-aside 起步,因为它最容易和现有数据库系统结合。只有当读路径非常稳定、缓存已经被平台化治理,或者写延迟极其敏感时,才值得切到更重的写策略。
如果把 cache-aside 讲到实现级,最小工作流通常是下面这样:
v, ok := cache.Get(key)
if ok {
return v
}
v, err, _ := singleflightGroup.Do(key, func() (any, error) {
fresh, err := repo.Load(ctx, id)
if err != nil {
return nil, err
}
cache.Set(key, fresh, ttlWithJitter)
return fresh, nil
})
这里真正的控制点有三个。第一,key 设计决定你到底在缓存什么现实,例如是“租户 + 文档 ID + 版本”还是一个过于粗糙的“文档 ID”。第二,singleflight 这类机制在做的是“把同一时刻的大量 miss 压成一次回源”,它解决的是击穿,不是普遍命中率问题。第三,ttlWithJitter 在做的是“打散同时过期”,它对抗的是雪崩,而不是脏数据本身。很多人知道这些名词,却说不清它们各自在挡哪一种故障。
缓存真正值得深讲的几个参数也都和系统行为直接相关。TTL 在调的是“允许多旧”,不是“随便设个过期时间”。它太短,回源抖动大,太长,脏数据风险高。最大容量和淘汰策略在调的是“把宝贵内存留给谁”,例如 LRU 更偏向最近访问,LFU 更偏向长期热点。热点键、负缓存、版本键和单飞机制则分别在处理四类常见问题:单个键被打穿、查不存在对象被反复回源、不同版本内容需要并存、同一时刻大量 miss 同时击穿数据库。
衡量缓存层是否真的在发挥作用,也最好讲成稳定指标,而不是一句“命中率还可以”。最基础的直觉是:
但命中率从来不是越高越好。一个命中率很高、却把旧版本制度和旧权限范围长期缓存住的系统,实际风险可能比命中率低一点但边界更正确的系统更大。所以命中率应该和平均回源耗时、热点键分布、脏读投诉和版本切换正确率一起看。只要把指标只盯在 hit_ratio 上,团队就很容易通过“加长 TTL”这种看似漂亮、实则放大脏读的方式把数字做高。
最常见的缓存事故其实只有几类。穿透是查本来就不存在的数据,导致缓存根本挡不住。击穿是某个热点键刚过期或被淘汰,大量请求同时回源。雪崩是大批热点键在短时间内一起失效。热点倾斜则是少数键把缓存实例和回源存储一起打热。工程上的处理顺序也很稳定:先看是不是缓存命中率本身低,再看是不是 TTL 和淘汰策略不合理,再看是不是热点键和单飞机制没收住,最后才看序列化、网络和实例容量。
缓存什么时候该切换方案,也要讲清楚。只读热点、可接受短时间旧值的配置和详情页,cache-aside 往往最稳。写频繁且要求读后立即一致的对象,不应该强上大 TTL 缓存,而更应该先想局部失效、版本键甚至直接不缓存。如果命中率始终起不来,问题可能根本不在缓存层,而在查询模式本身并不重复,这时应该回到索引、数据建模和查询形态,而不是继续给缓存加参数。
消息队列:解决异步推进,不直接保证业务成功¶
消息队列真正解决的是两类问题。第一类是解耦,把“请求已经可以返回”与“后续工作还要继续做”拆开。第二类是削峰,让上游突发流量和下游有限吞吐之间有一个缓冲层。它不直接替你保证业务一定成功,真正保证业务可恢复的,仍然是任务状态、幂等键、重试策略和补偿逻辑。
消息队列的最小机制可以压成四个对象。生产者负责投递消息,主题或队列负责存放待消费事件,消费者负责拉取或接收消息,offset 或位点负责记录“消费到哪了”。只要把这四件事想清楚,at-most-once、at-least-once 和 effectively-once 这些语义就不会显得神秘。at-most-once 的本质是“宁可丢,不重放”。at-least-once 的本质是“宁可重复,也尽量不丢”。effectively-once 通常不是底层真的只送一次,而是你在消费者侧通过幂等和去重把多次投递压成一次有效效果。
消息系统最值得直接量化的一条直觉,是积压增长速度:
只要生产速率长期高于消费速率,积压一定会上升。而一旦 backlog 上升,用户真正感受到的就不再只是“队列里消息多了一点”,而是处理时延被线性推高。粗略一点地看,处理等待时延 ≈ backlog / consume_rate。也就是说,消息队列不会自动把慢系统变快,它只是把同步阻塞换成了排队等待。所以成熟回答里一定要主动补一句:MQ 的价值是把等待从用户请求线程挪走,而不是消灭等待本身。
消息系统的关键参数,不同产品名字会变,但核心直觉差不多。批量大小、拉取间隔和消费者并发影响的是吞吐与延迟的平衡。重试次数和退避策略影响的是下游故障时的风暴放大程度。死信队列影响的是坏消息如何被隔离。保留时间影响的是回放和补数据窗口。分区数则影响并发度和顺序边界。很多人只知道“多分区更快”,却忘了分区也是顺序的边界,一旦你把同一业务键打散到多个分区上,严格顺序就已经被放弃了。
工程实现上,消息链路最容易出问题的通常不是“不会发消息”,而是消费者语义没讲清。比如文档解析任务消费一条消息后,先更新数据库状态,再调用 OCR,再写索引。这里任何一步失败,都要问自己:位点何时提交,重试是否会重复执行,幂等键写在什么位置,死信是否真的能让人工看到,回放时如何避免新旧版本混用。很多团队的第一版异步系统能跑,但一遇到抖动就开始丢单、重复执行或消费积压,本质都是消费语义没收住。
排查消息问题时,顺序也应尽量固定。先看积压是不是因为消费者吞吐不够,再看是不是下游故障把重试队列打满,再看位点提交和幂等是否正确,最后才看消息本身是否脏。消费者看起来“没处理成功”时,不一定是代码逻辑坏了,也可能是同一条消息被反复重试、下游限流、分区热点或死信策略过于保守。
对象存储:解决大对象承载,不替代事务状态¶
对象存储真正解决的是“大对象、二进制对象、中间产物和生命周期”这一整类问题。原件 PDF、扫描件、预览图、OCR 原始结果、模型生成报告、导出文件,都更适合放在对象存储,而不是数据库或本地磁盘。数据库应该保存的是对象元数据、状态、权限、版本和索引关系,而不是自己去承受大对象下载、预签名访问和冷热分层。
对象存储的关键对象通常有四个。桶或命名空间决定隔离边界,对象 key 决定寻址方式,元数据和标签决定检索与生命周期规则,预签名 URL 决定临时访问授权。很多工程问题其实都和 key 设计与生命周期策略直接相关。例如把租户、文档 ID、版本和产物类型编码进 key,后面做回收、归档和版本切换会清楚很多。相反,如果 key 设计一开始就混乱,后面清理旧对象、做灰度版本和回滚都很容易失控。
对象存储的调优抓手通常不是“再加带宽”,而是上传下载链路是否合理。大文件上传要不要分片,预览图和原件要不要分层保存,冷热数据是否应该用不同存储级别,下载是否必须通过应用转发还是可以走预签名直连,这些都直接决定应用层压力和存储成本。多数系统的默认路线通常是:元数据走数据库,对象内容走对象存储,下载优先用预签名直连,只有在必须插入权限审计、水印或下载限速时,才让应用层做中转。
对象存储最典型的失效模式也很固定。第一类是“对象存在,但元数据状态错了”,这通常是事务和异步收尾脱节。第二类是“元数据在,但对象已经过期或被清掉”,这通常是生命周期策略和业务状态没对齐。第三类是“下载链路把应用打爆”,这往往是没有用预签名而是让应用层承担了大规模带宽转发。排查时先核对元数据与对象状态是否一致,再看生命周期策略和权限,再看下载链路设计。
搜索与检索基础设施:解决内容入口,不替代业务过滤¶
数据库查询、全文搜索、语义检索和 RAG 检索放在同一条线上比较,会更容易形成选型判断。数据库最擅长的是结构化条件和事务边界。全文搜索擅长关键词、倒排索引和字段化检索。向量检索擅长语义相似。RAG 则是把检索结果进一步送进生成系统。它们不是彼此完全替代,而是层层叠加。系统如果只是按主键、时间、状态查数据,数据库已经够了。一旦问题变成“哪份制度里提过这个报销规则”,全文搜索就会开始出现。如果用户表达和文档表达差异很大,语义检索才开始有价值。如果还要在检索结果上组织自然语言回答,RAG 才成立。
真正的搜索基础设施最重要的不是“能搜”,而是索引边界和过滤边界清不清楚。倒排索引会受分词、停用词、同义词、字段权重影响。向量索引会受 embedding 模型、维度、索引参数、元数据过滤影响。两者都必须和业务过滤一起工作,例如租户、部门、时间范围、版本、生效状态。很多系统的问题并不是索引本身不够强,而是把不该在搜索层处理的权限判断和业务状态遗漏了,导致后续链路不得不用更高成本去纠错。
搜索基础设施里也有几组很值得直接讲出来的参数直觉。全文搜索侧,refresh interval 在调“新内容多久对搜索可见”,它越短,近实时性越强,但 segment 切得更碎、合并压力更高。字段分析器在调“什么 token 会进入倒排”,它如果把编号、术语、错误码或中英混排切坏,后面调多少 BM25 参数都救不回来。向量检索侧,核心不是“有没有 ANN”,而是候选集大小、filter pushdown 和后续 rerank 是否匹配。如果 metadata filter 只能放在召回后再做,系统就可能先把大量越权或旧版本候选查出来,再在后面白白丢掉。而如果过滤能前推进查询计划,召回、时延和成本都会更稳。面试里把这些参数和执行后果连起来,搜索基础设施就不再只是“上 ES / 上向量库”的产品名堆叠。
所以 Elasticsearch、OpenSearch、Milvus、pgvector 这类东西会分层出现。ES 类系统更适合全文、结构化过滤和字段检索都很重的场景。向量库更适合把语义检索做成主通道。pgvector 这种方案则适合第一版直接把结构化元数据和向量放在同一套数据库里,优先降低复杂度。默认路线通常不是一上来就堆最强搜索集群,而是先看入口形态、数据规模、过滤复杂度和团队运维能力,再决定从数据库、全文搜索还是数据库加向量起步。
配置、发现与协调:解决共享视图,不直接产生业务价值¶
单实例应用很少觉得这些东西重要,因为“配置就在环境变量里,地址就写死,任务谁执行也不用争”。一旦进入多实例、多环境、灰度发布和后台任务并存阶段,系统马上会碰到共享视图问题。什么配置现在生效,哪些实例还活着,当前谁来执行这类独占任务,哪些 watcher 应该感知变更,这些都不是业务代码本身能优雅解决的。
配置中心最小解决的是“动态配置如何一致地下发”。服务发现最小解决的是“调用方怎么知道可用实例列表”。协调系统最小解决的是“谁拥有当前这份租约或主执行权”。把它们再压成机制,通常离不开 watch、lease 和版本化视图。watch 负责把变更推到订阅方,lease 负责让存活性和独占权带过期语义,版本化视图负责让系统知道“看到的是第几版现实”。etcd、Consul、Nacos 这些产品名字不同,但工程语义大致都围绕这几件事。
这一层最容易被问深的一点,是“为什么 TTL 和 watch 参数会直接影响故障切换体验”。一个很好用的近似心智是:故障切换下界 ≈ lease_ttl + 探测抖动 + watch 传播延迟。也就是说,lease TTL 太短,网络抖动和 GC 抖动就会制造误判。太长,真正故障后的主切换和任务接管又会明显变慢。watch 也不是越灵敏越好。如果监听回调本身做了重活、阻塞了本地线程,配置虽然收到了,业务却可能很久才真正切到新版本。很多“有的实例已经切新配置,有的还没切”的问题,本质上不是产品选错了,而是 lease、watch、版本缓存和本地回调的配合没有设计好。
这一层的关键参数通常也很工程。心跳或租约 TTL 太短,网络抖动会导致频繁误判实例失活。太长,故障切换又会慢。watch 回调如果处理太重,配置更新本身会拖慢业务线程。本地缓存如果没有版本号或回退机制,实例之间就可能长时间看到不同配置。排查这类问题时,先看是不是共享视图不同步,再看租约和心跳,再看 watch 回调和本地缓存,最后才看业务层表现出来的“怎么有些实例行为不一样”。
什么时候该上,什么时候不该上¶
判断要不要上基础设施,最好不要从“别人都用了什么”出发,而要看某类压力是不是已经稳定出现。热点读稳定存在,才说明缓存要进主链。长任务和异步传播稳定存在,才说明消息链路值得单独长出来。文件已经拥有自己的生命周期,才说明对象存储不再是可选项。查询入口已经从结构化条件转成内容搜索,才说明搜索与检索基础设施该独立出来。多实例已经开始争抢共享视图,才说明配置发现与协调必须进入系统主线。
什么时候不该上,也要敢讲。数据量很小、访问模式不重复时,强上缓存可能只是引入脏数据。异步需求很弱时,先用数据库任务表比直接引入 MQ 更稳。文件规模和产物类型都很少时,早拆对象存储未必划算。搜索入口还没成形时,别急着上复杂检索集群。单实例、低变更系统里,环境变量可能比动态配置中心更简单可靠。工程选型真正成熟的地方,不在于知道更多组件,而在于知道哪些复杂度已经长出来了,哪些还没有。
读到这里真正该留下来的,不是某个产品名,而是五条稳定判断。缓存在接重复读,消息在接异步推进,对象存储在接大对象与生命周期,搜索基础设施在接内容入口,配置发现与协调在接共享视图。只要这五类受力点在你脑子里分得开,后面不管面试官问 Redis、Kafka、对象存储还是 Nacos,你都能先从系统压力本身讲起,而不是先背组件。