简介:这份文档面向具备一定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, ttl=300) # 写二级缓存,带过期时间 _local_cache[key] = val return val这段代码里几个参数决定了实际效果。ttl=300是二级缓存的过期时间,单位秒,设得太长数据陈旧,设得太短缓存命中率下降;生产上通常再叠加一个随机抖动,避免大批 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) % N,N 是机器数。问题是 N 一变,几乎所有 key 的归属都会变——加一台机器,等于全量数据迁移,扩容期间数据库和网络都被迁移流量占满。一致性 Hash 就是为了解决这个问题:把整个哈希值空间组织成一个环,机器和 key 都映射到环上,key 顺时针找到的第一台机器就是它的归属。加一台机器,只影响它和前一台机器之间那段区间的 key,其余 key 不动。
代价是数据倾斜:机器少的时候,环上的区间分布不均匀,某台机器可能分到远超平均值的 key。解决办法是虚拟节点——每台物理机在环上放多个虚拟位置,虚拟节点越多,分布越均匀。下面这段 Python 实现把虚拟节点和环上查找都写全了。
import hashlib import bisect class ConsistentHashRing: def __init__(self, replicas=150): # 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]]几个参数需要说清楚。replicas=150是经验值,机器数量少时可以调大,机器多时可以调小,目标是让环上的虚拟节点足够密、区间方差足够小。_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"。有一次我在模拟项目里给一个内部管理后台也上了双活加同步复制,结果链路延迟翻倍、运维复杂度暴涨,而那块业务一年宕机一小时根本没人发现——这就是典型的把成本花错了地方。
从那以后我每次做架构评审,都会先把这张"数据分级 + 一致性窗口 + 成本上限"的表填一遍,填不出来的项一律按最保守处理。宁可先承认"这里允许短暂不一致",也不要假装"我们做到了强一致"然后在某个深夜被对账告警叫醒。希望帮到你。
本文还有配套的精品资源,点击获取