2026/10/24 12:47:19

大型网站高可用架构:分层、缓存、异步与伸缩取舍

大型网站高可用架构:分层、缓存、异步与伸缩取舍 简介这份文档面向具备一定IT基础的工程师、架构师与技术人员围绕大型网站如何实现高性能、高并发与高可用展开系统梳理帮助读者理解分层、分割、分布式部署、集群、缓存、异步与冗余等架构模式并对应到前端、浏览器、应用层、代码层和存储层的优化策略。压缩包共1个docx文件约3.19MB内容以专题文档形式组织便于按章节查阅和整理笔记。已有1124人学习说明其在架构设计学习者中具有一定参考价值。文中进一步讨论应用层、服务层与数据层的高可用手段、CAP理论、分库分表与一致性Hash等伸缩方案以及模块化、稳定接口、消息队列、分布式服务和基础设施、应用系统、数据保密等安全要点并结合电商网站架构演变案例帮助读者把原则落到实际系统设计与容量规划中。1. 先说一个反直觉的结论高可用不是不出故障而是预算内的故障很多人第一次接触大型网站架构会把高可用理解成把系统做得足够稳稳到不出问题。但真正做过线上系统的人都知道分布式、多节点、廉价服务器、开源数据库和操作系统的组合决定了单点故障是常态而不是意外。行业里用几个 9 衡量可用性四个 9 意味着一年的不可用时间约 53 分钟——这不是说系统可以随便停 53 分钟而是说架构要做的事情是把故障的影响半径压到最小、把发现和切换的时间压到最短。所以真正值得搞懂的不是怎么不出故障而是故障发生时请求、数据、会话这三样东西分别沿哪条路径被接管。理解了这一层后面讲的分层、集群、缓存、异步、伸缩本质上都是围绕同一个目标的不同工具而不是一堆互不相干的名词。2. 分层与分割一次下单请求穿过七层逻辑架构时到底发生了什么用户按一下提交订单请求从手机端出去到最终落进一张订单表中间至少穿过七层逻辑职责。很多人能背出客户层、前端层、应用层、服务层、数据层、大数据存储层、大数据处理层这七个名字但背下来不等于理解。这七层真正约束的不是有哪些层而是谁可以调用谁、谁在失败时负责兜底。把调用方向搞反性能再好的组件也会被拖成一锅粥。2.1 分工与调用方向为什么分层要先定禁止反向依赖七层逻辑架构的职责边界大致是这样划分的客户层承接 PC 浏览器和移动端移动端可以直接通过 IP 访问反向代理前端层承担 DNS 负载均衡、CDN 本地加速和反向代理应用层是按业务垂直拆分后的应用集群比如商品应用、会员中心各自独立服务层抽取公共能力提供用户、订单、支付这类领域服务数据层由关系型数据库集群含读写分离、NoSQL 集群、分布式文件系统集群和分布式缓存组成大数据存储层汇聚应用层与服务层产生的日志、结构化与半结构化数据大数据处理层负责批处理与实时流处理再把处理结果写回关系型数据库。这份清单里最容易被忽视的一条规则是依赖方向必须向下且稳定。也就是说应用层可以调用服务层服务层可以访问数据层但数据层不能反过来要求应用层改接口服务层不能被上层绕过直接暴露存储细节。常见的实现思路是把每一层对外暴露的能力收敛成接口底层实现比如换一个缓存组件、换一种存储引擎只影响本层不影响调用方。这条规则听起来朴素但它决定了后面所有优化能不能局部生效——分层没做对改一个前端缓存策略可能要连带动三个后端模块改一次发布就是一次全员回归。提示判断分层是否合理可以问一句——如果我把数据层从关系型换成另一种存储应用层的业务代码需要改多少行改得越少分层越干净。2.2 垂直切分与水平切分切错一刀后面全在还债分层解决的是纵向职责切分解决的是横向边界。垂直切分按业务属性把一个大应用拆成产品子系统、购物子系统、支付子系统、评论子系统、客服子系统、接口子系统水平切分则是把同一个应用部署多份前面用负载均衡分摊请求。两者经常被混着讲但目标完全不同垂直切分为了解耦和分工让专业团队负责专业模块水平切分为了分摊流量让同一份逻辑跑在多台机器上。切分时机是架构里最考验判断的地方。切得太早跨子系统的事务和远程调用链路变长原本一个本地方法调用能解决的问题变成两次网络往返加一次补偿切得太晚一个应用膨胀到几十万行代码任何一次发布都要全量回归一个模块的内存泄漏会拖垮所有业务。常见的判断依据是四条耦合度模块之间是否频繁共享数据表、团队边界是否由不同团队维护、发布节奏是否需要在不同时间上线、事务边界跨模块操作是否必须强一致。四条里满足三条以上就值得考虑拆一条都不满足先别动。拆完之后还有一步经常被漏掉给子系统定等级。核心系统产品、购物、支付和非核心系统评论、客服、接口对接分开目的不是贴标签而是在流量突发时能对非核心做优雅降级把资源让给核心链路。这一步不做双十一那种峰值一来评论服务把数据库连接池占满支付也跟着挂。2.3 稳定接口与消息队列模块之间靠什么解耦拆完模块下一个问题是模块之间怎么通信。最直接的方式是远程调用但它把调用方和被调用方在时间上绑死了——对方慢你也慢对方挂你也挂。所以大型系统在模块之间往往再加一层消息队列生产者只负责把消息投递出去不关心有多少消费者、消费者在哪、消费多快消费者按自己的能力拉取消息处理。这一层带来的直接收益是削峰填谷——秒杀时把洪峰灌进队列后端按固定速率消费不至于瞬间打穿数据库间接收益是依赖解耦——新增一个下单后发积分的消费者不需要改下单模块一行代码。代价也很明确消息队列把同步调用变成了最终一致用户看到结果的时间被拉长失败重试和幂等必须由消费者自己保证一条链路断了错误藏在队列里而不是抛在调用栈上。所以稳定接口 消息队列这套组合适合允许短窗口不一致、但要求高吞吐的场景订单创建、支付扣款这类必须强一致的环节仍然要走同步调用加事务。3. 缓存与异步为什么大部分性能优化本质上是在缩短数据路径把响应时间从 300 毫秒压到 30 毫秒最常见的动作不是算得更快而是根本不走进计算。缓存和异步看起来是两个技术其实是同一个思想的两面把耗时的工作挪出请求主路径。但一旦挪出去命中率、一致性和可观测性就成了必须还的债——这也是很多团队加了缓存反而更慢的根源。3.1 二八原则与多级缓存命中率是怎么一级一级衰减的缓存能成立的前提是热点数据的存在。大部分网站的访问都遵循二八原则80% 的请求最终落在 20% 的数据上。把这 20% 的数据放在离应用或用户最近的位置就能用很小的存储成本换掉大部分数据库访问。常见的落地方式是两级一级是本地缓存数据放在应用服务器内存或本地文件里特点是快没有网络往返但容量受单机内存限制而且多台机器之间的一致性难保证二级是分布式缓存数据集中存放容量可以水平扩展代价是每次读多一次网络往返。关键在于两级缓存的命中率是相乘关系而不是相加。假设本地缓存命中 60%剩下的 40% 请求打到分布式缓存分布式缓存命中 90%那么最终打到数据库的只有 40% × 10% 4%。这就是多级缓存的意义不是让某一级更快而是让每一级各自拦掉一部分最后落到数据库的压力被压到原来的几十分之一。反过来说如果本地缓存只命中 10%分布式缓存命中 50%数据库仍然要承接 45% 的流量加再多缓存层也救不回来。判断要不要上缓存业内常看读写比读写比达到 1:4 以上缓存才有明显收益读写比接近 1:1 甚至写多读少缓存只会增加一致性维护成本。读路径上还有一个必须防的问题——缓存击穿也就是某个热点 key 过期的瞬间大量并发请求同时回源。常见的实现思路是单飞同一个 key 在同一时刻只允许一个请求去查数据库其余请求等它把结果回填。下面这段 Python 伪代码把两级缓存和单飞保护放在一起参数和逻辑都值得逐行看。import threading _local_cache {} # 一级本地缓存放字典类和基本不变的热点数据 _singleflight_locks {} # 同一个 key 同时只允许一个请求回源 _lock_guard threading.Lock() def read_with_two_level_cache(key): # 一级命中直接返回不产生任何网络往返 if key in _local_cache: return _local_cache[key] # 二级分布式缓存命中后回填本地下次走一级 remote_val distributed_cache.get(key) if remote_val is not None: _local_cache[key] remote_val return remote_val # 两级都没命中进入回源保护同一 key 只放一个请求打到数据库 with _lock_guard: lock _singleflight_locks.setdefault(key, threading.Lock()) with lock: # 双重检查等锁期间别的线程可能已经把数据回填了 if key in _local_cache: return _local_cache[key] val db.query(key) # 真正的数据库访问 distributed_cache.set(key, val, ttl300) # 写二级缓存带过期时间 _local_cache[key] val return val这段代码里几个参数决定了实际效果。ttl300是二级缓存的过期时间单位秒设得太长数据陈旧设得太短缓存命中率下降生产上通常再叠加一个随机抖动避免大批 key 同时过期。_singleflight_locks是单飞锁的字典作用是防止缓存击穿但它本身没有回收机制key 多了会泄漏内存实际实现要配合引用计数或定期清理。db.query(key)才是真正需要被保护的部分——如果这一层没有保护前面两级缓存做得再好一次热点 key 过期就能把数据库打穿。3.2 同步转异步请求-响应-通知模式与它欠下的三笔债异步化的本质是把客户端等结果改成服务端处理完再告知。典型模式是请求-响应-通知客户端发出请求服务端立即返回已受理处理完成后通过推送或者客户端轮询告知最终结果。下单后发短信、发邮件、更新积分、生成对账单这些都是异步化的天然场景——用户不需要在页面上等短信发出去才能看到下单成功。异步化换来的是响应时间和吞吐量欠下的债有三笔。第一笔是结果延迟用户看到的成功只是受理成功真正完成要等通知如果通知丢失用户会以为没成功而重复提交。第二笔是幂等消息可能重复投递消费者必须用业务唯一键去重否则一次下单扣两次库存。第三笔是可观测性同步调用出错会抛在调用栈上异步链路出错藏在消息队列里没有链路追踪和消费积压监控故障往往是用户投诉了才发现。所以异步化不是把方法改成发消息这么简单配套的幂等表、重试策略、死信队列、积压告警缺一个都会在某个深夜找上门。3.3 缓存一致性先删缓存还是先写库以及为什么没有完美答案只要缓存和数据库同时存在就绕不开一致性问题。最经典的两难是更新数据时先操作数据库再删缓存还是先删缓存再操作数据库两种顺序都有窗口。先写库再删缓存问题出在删缓存之前——如果有读请求在这个窗口里把旧值读进了缓存删完之后缓存里是旧值要等下一次过期才恢复。先删缓存再写库问题出在写库之前——删完缓存、写库还没提交读请求进来发现缓存空了回源读到旧值又写回缓存同样不一致。常见的补救是延迟双删写完数据库后延迟一段时间再删一次缓存把窗口期内可能被回填的旧值清掉。更彻底的方案是订阅数据库的变更日志异步删除或更新缓存让缓存跟随数据变化而不是靠业务代码记得删。但无论哪种方案都要承认一个前提并发下不存在绝对一致的缓存只有把不一致窗口压到业务可接受的范围。所以真正需要先回答的问题不是用哪种删缓存策略而是这个数据能容忍多长的不一致窗口——商品详情页容忍几秒库存和余额容忍零秒答案不同方案就不同。4. 伸缩与扩展加机器为什么不是改个数字那么简单业务涨了最直觉的动作是加机器。但加机器能不能生效取决于三件事应用是不是无状态的、数据能不能被均匀切分、会话和本地状态放在哪。很多系统加完机器性能不升反降原因不在机器数量而在有状态的部分被复制成了 N 份、互相打架。伸缩性和扩展性这两个词还经常被混用其实一个靠加硬件一个靠加模块手段完全不同。4.1 无状态化伸缩的前提以及 Session 的三条出路应用层要能水平伸缩前提是每次请求交给哪台服务器处理结果都一样。但 HTTP 会话天然有状态——登录态、购物车、临时表单这些东西存在服务器本地就破坏了无状态。业内有三条出路代价各不相同。第一条是 Session 复制节点之间互相同步会话数据。好处是任意节点都能处理任意请求坏处是节点数一多同步产生的内存和带宽开销呈平方级增长超过十台基本不可行。第二条是会话粘滞负载均衡按 cookie 把同一个用户固定分发到同一台节点。实现简单但节点一挂粘在上面的用户会话全部丢失而且负载容易倾斜。第三条是集中式会话存储把会话放到分布式缓存或数据库里应用只带一个 token每次请求按 token 去取。多一次读取开销换来的是应用节点完全无状态、任意扩缩容、单节点故障不影响登录态。大型系统最终基本都走第三条粘滞只作为兼容老系统的过渡手段。4.2 一致性 Hash数据分片时加减机器为什么只搬一小部分应用层无状态之后数据层成了伸缩的瓶颈。单库单表扛不住时要分库分表而分片算法直接决定了扩容的代价。最朴素的方案是取模hash(key) % NN 是机器数。问题是 N 一变几乎所有 key 的归属都会变——加一台机器等于全量数据迁移扩容期间数据库和网络都被迁移流量占满。一致性 Hash 就是为了解决这个问题把整个哈希值空间组织成一个环机器和 key 都映射到环上key 顺时针找到的第一台机器就是它的归属。加一台机器只影响它和前一台机器之间那段区间的 key其余 key 不动。代价是数据倾斜机器少的时候环上的区间分布不均匀某台机器可能分到远超平均值的 key。解决办法是虚拟节点——每台物理机在环上放多个虚拟位置虚拟节点越多分布越均匀。下面这段 Python 实现把虚拟节点和环上查找都写全了。import hashlib import bisect class ConsistentHashRing: def __init__(self, replicas150): # replicas 是每台物理机的虚拟节点数越多分布越均匀内存和排序成本也越高 self.replicas replicas self.ring {} # hash 值 - 物理节点名 self.sorted_keys [] # 已排序的 hash 值用于二分查找 def _hash(self, key): # 取 sha256 摘要前 16 位十六进制转整数得到稳定的 64 位环位置 return int(hashlib.sha256(key.encode(utf-8)).hexdigest()[:16], 16) def add_node(self, node): # 为每台物理机生成 replicas 个虚拟节点形如 node#0、node#1 for i in range(self.replicas): vkey self._hash(f{node}#{i}) self.ring[vkey] node bisect.insort(self.sorted_keys, vkey) def remove_node(self, node): for i in range(self.replicas): vkey self._hash(f{node}#{i}) del self.ring[vkey] self.sorted_keys.remove(vkey) def get_node(self, key): if not self.ring: return None h self._hash(key) # 顺时针找到第一个 h 的虚拟节点越过环尾就绕回环首 idx bisect.bisect_left(self.sorted_keys, h) if idx len(self.sorted_keys): idx 0 return self.ring[self.sorted_keys[idx]]几个参数需要说清楚。replicas150是经验值机器数量少时可以调大机器多时可以调小目标是让环上的虚拟节点足够密、区间方差足够小。_hash用 sha256 而不是 Python 内置的hash()是因为内置哈希每次进程启动都会随机化重启后同一个 key 会落到不同节点等于每次重启都全量错位。bisect.insort维持有序数组让查找从线性扫描降到二分查找节点数上千时这个差别很可观。实际生产里还要考虑节点摘除后它上面的数据怎么迁移一致性 Hash 只解决了少搬没解决谁来搬数据迁移和副本再平衡是另一层要单独设计的机制。4.3 扩展性 vs 伸缩性一个加硬件一个加模块这两个词在中文语境里经常互换但工程上必须分开。伸缩性scalability是加硬件提高处理能力手段是无状态化、分片、负载均衡衡量指标是加一倍机器能不能接近加一倍吞吐。扩展性extensibility是加功能模块而不动原有代码手段是模块化、稳定接口、消息队列、分布式服务衡量指标是新增一个业务模块要改几处已有代码。前者做不好流量一涨系统就趴后者做不好需求一变代码就烂。大型系统两条腿都要走但投入顺序通常是先保伸缩性活下来再补扩展性活得久。5. 避坑与排查容量、缓存、主备切换现场的四个翻车点架构图画得再漂亮真正让系统半夜报警的往往是几个被忽略的细节。下面四条都来自模拟项目的压测和演练现场按现象→原因→解决写清楚能对上号的可以直接排查。5.1 容量预估翻车为什么按并发算出三十台机器上线还是排队超时现象按 1000 万注册用户、日均 200 万 UV、每人每天 30 次点击估算PV 约 6000 万集中访问按二八原则落在 4.8 小时内峰值 QPS 约 8340单机按 300 处理能力算需要约 28 台应用服务器实际部署 30 台活动开始十分钟就开始排队超时。原因有两个。一是把并发和QPS混为一谈——单机 300 指的是每秒处理请求数QPS 能力不是同时在途的连接数两者之间差一个响应时间。按利特尔法则同时在途的请求数 ≈ QPS × 平均响应时间响应时间从 50 毫秒恶化到 500 毫秒同样 QPS 下在途连接翻十倍线程池和连接池先被打满。二是把 CPU 70/90 原则里的 90% 当成了常态目标实际上 90% 是峰值红线常态应控制在 70% 左右留出突发余量。解决容量按峰值 QPS × 目标响应时间估算在途并发再反推线程池和连接池压测压到目标 QPS 的 1.5 到 2 倍重点看响应时间曲线的拐点而不是平均值应用容器的默认线程数常见是 150要在压测时显式调优并记录。下面这段估算代码把两个概念分开算能直观看到差距。# 并发数 ≠ QPS。利特尔法则在途并发 ≈ QPS × 平均响应时间(秒) qps_peak 8340 rt_ms 50 concurrency qps_peak * rt_ms / 1000 print(concurrency) # 约 417 个在途请求 # 活动期间响应时间恶化到 500ms同样 QPS 需要约 10 倍在途并发 concurrency_bad qps_peak * 500 / 1000 print(concurrency_bad) # 约 4170 个在途请求5.2 缓存雪崩与击穿为什么加了缓存数据库反而先挂了现象缓存集群一次重启或者大批 key 设置在同一时刻过期数据库连接数瞬间打满应用大面积超时比没加缓存时还惨。原因缓存失效的瞬间所有请求同时回源数据库承接的是原本被缓存挡住的全部流量。单个热点 key 失效叫击穿大批 key 同时失效叫雪崩两者表现相似但触发条件不同——击穿往往因为某个爆款商品的缓存过期雪崩往往因为批量写入时过期时间设成了同一个值。解决过期时间加随机抖动避免同时失效热点 key 用逻辑过期加后台异步刷新用户读到的永远是旧值但不会等读路径加单飞保护见 3.1 的代码同一 key 同时只有一个请求回源数据库前面加限流和降级缓存全挂时宁可拒绝部分非核心请求也要保住核心链路。5.3 主备切换后数据不一致为什么切过去了不等于切对了现象主库宕机主备切换脚本执行成功业务恢复访问半小时后用户投诉订单状态不对、余额和流水对不上。原因通常有三层。第一层是复制方式——主备异步复制时主库故障前最后一段变更日志还没传到备库切换后这段数据就丢了这是数据丢失窗口不是脚本能补的。第二层是连接池——应用侧的连接池还握着旧主库的地址切换后一部分请求继续打到已经降级的旧节点。第三层是读写分离配置——原本的从库承担读流量切换后新的主从关系变了读写分离配置没同步更新读请求打到旧从库读到过期数据。解决先明确业务能容忍的 RPO能丢多少数据和 RTO多久恢复据此选同步、半同步还是异步复制切换脚本必须包含连接池刷新和读写分离配置更新不能只切数据库切换完成后立刻跑一次关键业务对账把不一致的数据捞出来人工或自动修复核心交易链路尽量用半同步复制把丢失窗口压到最小。5.4 会话粘滞的隐性故障单台应用挂掉为什么一批用户被强制登出现象负载均衡开启了会话粘滞一台应用服务器重启粘在这台机器上的一批用户在几分钟内集中被要求重新登录投诉时间高度重合。原因粘滞把用户会话绑死在单台节点上节点一挂会话就没了如果同时还配了节点间的 Session 复制节点数一多同步产生的带宽和延迟反而拖慢整个集群形成越复制越慢、越慢越超时的恶性循环。解决会话集中存储到分布式缓存应用节点完全无状态任意节点都能处理任意请求粘滞只作为兼容老系统的过渡手段并明确标注下线时间切换前后各做一次会话可用性验证确认节点重启不会导致登录态丢失。6. 设计取舍CAP 不是选择题而是在哪个时间窗口里允许什么不一致CAP 经常被讲成三选二但分布式系统里网络分区P是客观存在的不是你能选的真正要选的是分区发生时优先保一致性CP还是保可用性AP。而且实践里几乎没有哪个大型系统整体选一个——都是按数据分级来选。订单、支付、库存扣减这类数据必须 CP宁可短暂拒绝服务也不能丢钱、不能超卖商品详情、评论、推荐位这类数据可以 AP短暂不一致用户根本感知不到可用性优先更重要。这个取舍落到架构上就是三列必须一起填的表数据分级、可容忍的不一致窗口、愿意为此付出的成本。数据类别一致性要求可容忍窗口常见手段成本支付、库存强一致0同步复制、分布式事务、行锁高吞吐受限订单状态准强一致秒级半同步复制、状态机 对账中商品详情最终一致分钟级多级缓存、异步刷新低评论、推荐最终一致小时级消息队列、离线计算低表格背后是一个经常被忽略的事实高可用不是免费的。同步复制要多一次网络往返多副本要更多存储异地多活要解决跨地域延迟和数据冲突每一项都在烧钱。所以架构评审时真正要问的不是我们能不能做到四个 9而是这块业务值不值四个 9。有一次我在模拟项目里给一个内部管理后台也上了双活加同步复制结果链路延迟翻倍、运维复杂度暴涨而那块业务一年宕机一小时根本没人发现——这就是典型的把成本花错了地方。从那以后我每次做架构评审都会先把这张数据分级 一致性窗口 成本上限的表填一遍填不出来的项一律按最保守处理。宁可先承认这里允许短暂不一致也不要假装我们做到了强一致然后在某个深夜被对账告警叫醒。希望帮到你。本文还有配套的精品资源点击获取