系统架构设计
开篇:架构设计的第一原则——先解决核心问题
很多人一聊架构就开始谈"微服务"、"中台"、"Serverless",好像不用上这些大词,就对不起"架构师"这个称号。
但你认真观察那些真正优秀的架构,会发现一个共同特点:它们都不是为了炫技而设计的,而是为了解决一个具体的核心问题。
架构设计的第一原则很朴素:对现有问题有方案,对未来系统有预案,但绝不过度复杂化。
这就像装修房子——你不会因为"将来可能来客人"就把客厅改成酒店大堂。你会根据家里几口人、日常怎么住来合理规划空间。等真的需要接待客人了,再想办法腾出一间客房。架构设计也是一样,永远从核心问题出发,随着业务增长逐步演进。
接下来我们就从设计原则开始,一步步搭建起系统架构的知识体系。
一、设计原则
1.1 无状态原则
什么叫"无状态"?简单说:处理一个请求所需的全部信息,要么在这个请求里,要么可以从外部存储获取。服务器本身不保存任何和请求相关的状态。
打个比方。你去银行办业务,如果柜员需要翻看上次的对话记录才能继续服务——那这个柜台就是"有状态"的,你被绑死在这一个柜台上,换一个柜台就得从头来。但如果你拿着身份证和填好的表单,任何一个柜台都能直接帮你办理——这就是"无状态"。
无状态的最大好处是:可以无限水平扩展。 因为任何一个服务实例都能处理任何请求,加机器就能扛更多流量,不存在"只有那台机器能处理"的局面。
那如果确实需要保存状态怎么办?把状态外移——放到 Redis、数据库、配置中心这些公共存储里。服务实例变成纯粹的"计算节点",来一个请求算一个,算完就忘。这样,10 台机器和 100 台机器的效果就是线性提升的。
做集群的时候,有一条铁律:不要修改服务内存中的状态数据。必须保证所有接口都是无状态的,内存中存储的数据不能因为请求而发生变化。
集群协作的问题也值得注意: 比如集群中每个服务都有定时任务,发短信的逻辑在每台机器上都跑一遍,那不就重复发了?解决方案有两个:加分布式锁(执行前判断是否已经发过),或者用外部调度器(如 XXL-JOB)统一触发,通过负载均衡只落到一台机器执行。
1.2 拆分原则
当一个系统变得越来越大,一台机器装不下、一个团队维护不过来的时候,就需要拆分。拆分有三个维度:
- 水平拆分:同一个功能部署多份(集群化),用来扛更多并发。就像餐厅高峰期多开几个窗口,每个窗口卖的菜都一样。
- 垂直拆分:按功能模块拆,比如电商系统拆成订单、商品、用户三个独立服务。就像大医院分科室,骨科和眼科各管各的。
- 业务拆分:按业务场景拆,比如"日常交易"和"秒杀活动"拆开,各自独立保障。就像高速公路的应急车道和行车道。
拆分的核心原则是六个字:高内聚,低耦合。 一个模块内部的代码应该紧密相关、为同一类需求服务;模块之间的依赖应该尽可能少,改了 A 模块不应该连带着要改 B 模块。
服务化的演进路径是渐进的:单节点 -> 节点集群 -> 服务注册与发现。当流量不可控时,还要加上限流、熔断、降级、隔离、恢复等保护手段。不是一步到位,而是业务发展到哪一步,架构就演进到哪一步。
微服务拆分有一个重要参考——一个服务的代码不要太多,一两万行代码撑死了。 大部分系统需要进行多轮拆分。第一次拆分往往只是把大模块分开(订单、商品、用户),后续每个模块又会变得复杂,需要进一步拆(订单系统再拆成购物车、价格、订单管理)。
少扇出、多扇入 也是微服务设计的重要原则——一个服务不要依赖太多其他服务(扇出),但可以被很多其他服务依赖(扇入)。
1.3 读写分离原则
大多数互联网系统有一个共同特点:读请求远多于写请求。 社交平台上,100 个人刷动态,可能只有 1 个人发帖。电商网站上,1000 个人浏览商品,可能只有 10 个人下单。
如果让读和写都挤在同一条路上,写操作的数据库锁会拖慢所有读请求。这就像一条双车道的公路,大卡车和小轿车混在一起,谁都快不了。
读写分离的思路就是把它们拆成两条路:
- 读服务:用多级缓存加速。请求先查浏览器缓存,没有就查 CDN,再没有查应用层缓存(Redis),最后才查数据库从库。
- 写服务:通过分区、分库分表来分散写压力,写入主库。
数据库层面通常是一主多从架构:主库负责写,从库负责读。读流量太大?再加从库就好。
当然,主从架构有一个绕不开的问题:主从同步延迟。 主库写入后,从库可能还没同步到最新数据。怎么办?两个实用方案:
- 刚写完的数据,第一次查询直接走主库。
- 核心业务走主库,非核心业务(报表、统计)走从库。
主库开启 binlog,传给从库写入 relayLog,从库解析 relayLog 重现数据。这就是 MySQL 主从复制的基本原理。
1.4 幂等设计原则
幂等的意思是:同一个操作执行一次和执行多次,效果完全一样。
为什么需要幂等?因为在分布式系统中,网络抖动、用户手抖、消息重发、超时重试……都可能导致同一个请求被执行多次。如果你的"扣款"接口不是幂等的,用户点了两次提交按钮就扣了两笔钱——这就是灾难。
幂等设计的核心是两步:
- 识别重复:通过唯一标识(订单号、请求 ID、业务流水号)判断这个操作是否已经处理过。
- 拒绝重复:如果已处理,直接返回上次的结果,不再执行业务逻辑。
读操作天然幂等——查 100 次,结果都一样。写操作需要特别设计。常见做法包括:数据库唯一索引约束、Redis 做请求去重、业务流水号 + 状态机等。
1.5 其他重要原则
除了上面四条核心原则,还有几条在日常开发中同样重要:
- 模块复用原则:当你想要"复制粘贴"代码的时候,就该停下来想想是不是该重构了。复制粘贴一时爽,后续维护火葬场。
- 可追溯原则:任何问题都要有据可查,好定位。日志、监控、链路追踪,一个都不能少。线上出了问题查不到原因,比问题本身更可怕。
- 反馈原则:尽量给调用方一个明确的反馈——成功了告诉成功,失败了告诉原因和错误码。"静默失败"是最危险的 bug——数据丢了你都不知道。
- 备份原则:代码备份(Git + 分支管理)、数据备份(定期全量 + 增量 + binlog)、人员备份(代码规范 + 定期 Review,不因一个人离职导致项目停摆)。
二、系统设计的核心指标
设计一个系统时,有四个核心指标你必须搞清楚。不管是做容量规划还是面试被问"你们系统怎么样",都是围绕这四个维度来回答。
2.1 吞吐量 (QPS/TPS)
吞吐量是系统在单位时间内能处理的请求数量。
- QPS (Queries Per Second):每秒查询数,侧重衡量读性能。
- TPS (Transactions Per Second):每秒事务数,侧重衡量写性能。这里的"事务"是数据层面不可分割的操作单元。
不同存储系统的单机承受能力差异巨大,了解这些数字对做容量评估至关重要:
| 存储类型 | 单机 QPS(约) | 说明 |
|---|---|---|
| MySQL | 1K | 适合结构化数据,但并发能力有限 |
| Redis | 20K | 内存级别,读写都很快 |
| Memcache | 200K | 纯缓存,极致读性能 |
这意味着:QPS 1000 的系统,MySQL 单机就能撑住;但如果 QPS 到了 1 万,就必须考虑 Redis 缓存来分担读压力;到了 10 万级别,就需要 Redis 集群甚至多级缓存了。
2.2 响应时间 (RT)
RT 是从发出请求到收到响应的时间。用户对"快"的感知非常敏感——200 毫秒以内觉得是"即时"的,超过 1 秒就觉得"有点慢"了,超过 3 秒可能直接关掉页面走人。
一个系统通常由多个模块串联组成。想提升整体 RT,应该优先优化最慢的那个模块——这就是阿姆达尔定律 (Amdahl's Law) 的核心思想。举个例子:你的系统有 5 个模块,其中一个模块占了 80% 的耗时。你花大力气把其他 4 个模块各优化 50%,不如把最慢的那个模块优化 20% 来得效果显著。
客户端拿到数据后还要做三件事:资源获取、资源处理、资源展示。前端优化也是系统性能的重要一环。
2.3 并发数
并发有很多维度:并发用户数、并发连接数、并发请求数、并发线程数。不同维度的数字可能差好几个数量级,讨论的时候一定要先对齐口径。
线程数的计算有两个经典公式:
公式一:线程数 = CPU核数 × (1 + 等待时间/计算时间)
公式二:线程数 = CPU核数 / (1 - 阻塞系数)两个公式本质上是一回事。把公式一和公式二联立,可以推导出:阻塞系数 = 等待时间 / (等待时间 + 计算时间)。
直觉上很好理解:
- 计算密集型任务(阻塞系数趋近于 0):线程数约等于 CPU 核数。CPU 一直在干活,线程多了反而增加上下文切换开销。
- IO 密集型任务(阻塞系数趋近于 1):线程数可以是 CPU 核数的好几倍。因为线程大部分时间在等 IO,多开几个线程填满 CPU 的空闲时间。
公式是起点,实际以压测结果为准。实验室算出来的数和生产环境跑出来的数,往往差距不小。
2.4 可用性
可用性用"几个9"来衡量:
| 级别 | 可用性 | 年停机时间 |
|---|---|---|
| 3个9 | 99.9% | 8.76 小时 |
| 4个9 | 99.99% | 52.56 分钟 |
| 5个9 | 99.999% | 5.26 分钟 |
从 3 个 9 到 4 个 9,停机时间减少了一个数量级,但架构复杂度和成本也上了一个台阶。不是简单加一台机器的事——需要在冗余、故障自动转移、监控告警、灰度发布等方方面面都做到位。
消除单点故障、用并联替换串联,是提升可用性最基本的方法。
三、容量评估与存储选型
当你设计完系统,领导问你"需要多少台机器"的时候,就需要做容量评估了。
3.1 需求收集
先搞清楚几个关键问题:
- 谁在用? ToC 面向消费者(高并发、低延迟是核心诉求),ToB 面向企业(高可用、数据安全是核心诉求)。
- 什么场景? 即时通信(低延迟)、电商秒杀(强一致性)、游戏(高性能计算)。
- 多大量级? 万级用户(双机就够)、百万级(集群)、亿级(弹性分布式、容器化编排)。
- 读写比例? 读多写少(加缓存加从库)、写多读少(分库分表)。
3.2 不同量级的应对策略
一个实际的 Redis 集群规划案例:10 台机器,5 主 5 从。每个节点读写高峰 QPS 可达 5 万,5 个主节点总计 25 万读写请求/秒。单机配置:32GB 内存 + 8 核 CPU + 1TB 磁盘,分配给 Redis 进程 10GB 内存。
线上生产环境,Redis 单实例内存尽量不要超过 10GB。超过可能会导致 RDB 持久化时 fork 耗时过长等问题。
3.3 存储选型
不同类型的数据,选不同的存储。千万不要"一个 MySQL 打天下"——这是很多系统性能瓶颈的根源:
| 存储类型 | 代表 | 适用场景 |
|---|---|---|
| 键值存储 | Redis | 热点数据、会话、排行榜 |
| 文档存储 | MongoDB | 半结构化数据、内容管理 |
| 分词倒排 | Elasticsearch | 全文搜索、日志分析、统计查询 |
| 列型存储 | HBase、BigTable | 海量数据、大数据分析 |
| 图形存储 | Neo4j | 社交关系、推荐系统 |
| 对象存储 | FastDFS、S3 | 图片、视频、文件 |
3.4 分布式事务方案选择
当系统拆分成微服务后,跨服务的数据一致性怎么保证?这就涉及到分布式事务。
| 类型 | 特点 | 性能影响 | 常见方案 |
|---|---|---|---|
| 强一致性 | 需要实现 rollback | 受损严重 | 2PC、XA、Seata AT |
| 最终一致性 | 允许短暂不一致 | 受损较少 | TCC、MQ 可靠消息、自研补偿 + 人工介入 |
实践经验总结:
- 首选方案:自研补偿/MQ + 人工介入。 最"轻",性能损失最少,可掌控性好。分布式事务问题是小概率事件,留有补救余地就行——性能损失却是实打实反映在每一个请求上的。
- Seata AT 模式平均性能会降低 35% 以上,不是特殊场景不推荐。
- 单进程内用数据库事务,跨进程用消息队列。尽量避免分布式事务。
两个铁律:下游 MQ 消费方一定要能成功消费消息,否则转人工介入。千万要实现幂等性。
四、软件质量属性
一个系统好不好,不能只看功能对不对。ISO 25010 从八个维度来评判软件质量:
- 功能性:满足功能需求——这是底线,功能都不对其他免谈。
- 性能:投入多少资源,产出多少效能。响应快、吞吐高、资源利用合理。
- 兼容性:和已有系统能协作,数据格式能互通。
- 易用性:接口设计清晰,文档完善,学习成本低。
- 可靠性:容错能力强,出问题可恢复,不会动不动崩溃。
- 安全性:数据不泄露,操作有权限控制,防注入防攻击。
- 可维护性:代码好改、好调试、好上线。新人能快速上手。
- 可移植性:不和特定平台强绑定,迁移成本可控。
这八个维度有时候互相矛盾——追求极致性能可能牺牲可维护性,追求极致安全可能影响易用性。架构设计的本质就是在这些维度之间做权衡。 没有完美的架构,只有适合当前业务场景的架构。
五、缓存体系设计
缓存几乎是所有高性能系统的标配。它的本质是用空间换时间——把计算或查询的结果存起来,下次直接用,不用重新算。
5.1 什么时候用缓存
缓存最适合两类场景:耗时特别长的查询(复杂 SQL、多表 JOIN、跨服务聚合)和读多写少的数据(商品信息、配置数据、用户档案)。
引入缓存后,一次数据查询的总时间变成:
总时间 = 计算Key + 查找Key + 反序列化 + (1 - 命中率) × 原始查询时间如果命中率太低(比如低于 80%),缓存非但不能加速,反而增加了额外的计算和查找开销。所以,命中率是缓存最重要的指标。
缓存的物理位置从快到慢依次是:CPU 缓存 > JVM 内存 > Redis(网络 IO)> 磁盘。位置越近越快,容量越小越贵。
5.2 缓存键的设计
缓存就是 Key-Value 结构,Key 的设计直接影响查询效率和碰撞概率。
- 防碰撞:用 Hash 函数(如 SHA-256)生成 Key,碰撞概率极低。
- 查询速度:主要取决于物理位置——内存级别的查找是纳秒级的,磁盘级别的是毫秒级的。
- 序列化选择:Value 的序列化/反序列化也有开销,JSON 可读性好但慢,Protobuf 快但可读性差。
5.3 四种缓存更新策略
"怎么保证缓存和数据库一致"是面试必考题,也是实际开发中最容易踩坑的地方。我们逐个分析:
方案一:被动过期(TTL 策略)
给缓存设置过期时间,到期自动失效,下次访问时重新从 DB 加载回填。
优点是极其简单,不需要任何额外逻辑。缺点是在 TTL 窗口内,缓存和 DB 的数据可能不一致。适合对实时性要求不高的场景,比如商品的"关注人数"。
方案二:先更新 DB,再删除缓存(推荐)
这是实际开发中最常用的方案。写操作时先更新数据库,成功后删除缓存。下次读请求来发现缓存没了,就从 DB 加载最新数据回填。
会不会出问题?理论上有一个极低概率的场景:A 读缓存未命中去查 DB(拿到旧值),B 更新 DB 并删缓存,A 再把旧值写回缓存。但这需要"读操作比写操作还慢"——在实际系统中概率极低。
如果删除缓存失败怎么办?两种兜底方案:
- 通过消息队列做异步重试——重试可以重发消息。
- 用 Canal 监听数据库 binlog,自动触发缓存删除,让业务代码和缓存清理彻底解耦。
为什么不选其他方案?
- "先更新缓存再更新 DB"——数据库可能回滚,缓存却已经改了,不一致风险高。
- "先更新 DB 再更新缓存"——并发场景下 A 先改 DB,B 后改 DB,但 B 先改缓存 A 后改缓存,最终缓存是旧值。
- "先删缓存再更新 DB"——A 删缓存后阻塞,B 查缓存没有就从 DB 回填旧值,A 再更新 DB。缓存又是旧数据。
方案三:Read/Write Through
以缓存为主——系统启动时把 DB 数据预热到缓存中,后续读写都先操作缓存,缓存层负责同步到 DB。缓存和 DB 都操作完成才算请求完成。
优点是对业务层透明。缺点是缓存层的实现复杂度很高。
方案四:Write Behind(异步回写)
写操作只写缓存,立即返回。缓存到 DB 的同步通过后台异步任务或消息队列完成。
优点是写性能极高,系统吞吐量大幅提升。缺点是如果缓存挂了,还没同步的数据就丢了。适合对数据丢失容忍度较高的场景。
5.4 缓存清理机制
内存不是无限的,缓存空间总会满。用有限的空间发挥最大作用,就需要合理的清理策略:
- 时效性清理:给缓存设置 TTL,到期自动删除。通过定时任务轮询或惰性删除实现。
- 数目阈值清理:缓存数量达到上限时触发清理。常用策略有:
- FIFO(先进先出)
- Random(随机淘汰)
- LRU(最近最少使用)——最常用的策略
Java 中可以用 LinkedHashMap 来实现简单的 LRU 缓存——构造时设置 accessOrder=true,重写 removeEldestEntry 方法指定容量上限即可。
- 软引用清理:利用 Java 的
SoftReference包装缓存对象。内存充足时尽量保留,内存不足时 GC 自动回收。这是一种"尽力而为"的缓存策略——空间够就多缓存,不够就释放,不会导致 OOM。
5.5 缓存的三大风险
在系统中每增加一个环节,就多一份风险。缓存也不例外:
| 风险 | 含义 | 应对方案 |
|---|---|---|
| 穿透 | 查询的数据 DB 也没有,每次请求都穿过缓存打到 DB | 缓存空值(短 TTL)+ 布隆过滤器 |
| 雪崩 | 大量缓存同时过期,请求潮水般涌向 DB | TTL = 固定时间 + 随机偏移量 |
| 击穿 | 一个热 Key 突然过期,瞬间大量请求打到 DB | 互斥锁(只让一个线程回填)+ 逻辑过期 |
缓存预热也很重要:在项目启动时就把高频访问的热点数据提前加载到缓存中,避免启动后的"冷启动"问题导致 DB 被瞬间打满。
5.6 静态数据缓存
凡是与用户个体无关的、具有较强通用性的数据,都可以作为静态数据缓存。 不仅是传统意义上的 HTML 页面,还包括商品基本信息、系统配置、分类列表等。
静态数据要放到离用户最近的地方:浏览器 -> CDN -> Nginx 本地缓存 -> JVM 堆内缓存 -> Redis。链路越短,性能越好。
六、数据库设计
6.1 设计基础与三范式
数据库表的设计有两种思路:先设计表关系再推导业务对象,或者先分析业务对象再设计表结构。实际工作中,后者(从业务出发)更常用。
三范式的核心思想:
- 第一范式:字段不可再拆分(原子性)。比如"地址"字段不要把省、市、区、街道全写在一个字段里。
- 第二范式:必须有主键,非主键字段必须完全依赖于主键。如果只依赖主键的一部分,说明表该拆了。
- 第三范式:非主键字段不能间接依赖于主键(消除传递依赖)。比如订单表里存了用户名——用户名依赖于用户 ID,用户 ID 依赖于订单 ID,这就是传递依赖。
反范式不是错误,而是有意识的取舍。 如果系统是重业务的后台管理系统,对性能和并发要求不高,尽量遵守三范式。如果是高并发的 C 端系统,某些频繁查询的场景适当冗余字段(反范式),用空间换查询性能。
6.2 数据量增长的应对路径
6.3 表分区
当单表数据量大到一定程度(比如千万行),即使加了索引,查询也开始变慢。表分区把一个物理文件(ibd)拆成多个,但逻辑上还是一张表,对业务代码完全透明。
分区的关键点:
- 分区条件要和最常用的查询条件一致。 大部分查询都带日期条件?那就按日期分区。
- 分区条件要放到 WHERE 子句中。 这样优化器才能做分区裁剪,只扫描目标分区。
- 跨分区查询会适得其反。 如果查询经常需要扫描多个分区,分区反而增加了开销。
6.4 分库分表
当表分区也撑不住了,就要上分库分表了。
什么时候需要分库分表?
- 业务拆分:核心业务和非核心业务的数据隔离。
- 读少写多:数据库写压力大(加从库没用,写还是在主库)。
- 数据量超过千万级别:单表性能下降明显。
两种拆分方式:
- 垂直分表:把字段拆开。比如用户表的基本信息(常查)和详细信息(少查)拆成两张表。数据条数不变,字段数变少。
- 水平分表:把数据行拆开。每个表的结构完全一样,但各自只存一部分数据。
两种路由方式:
- 范围路由:按某个有序字段分段。扩容时只需要加新表,历史数据不动。缺点是数据分布可能不均。
- Hash 路由:对某个字段做 hash 取模。数据分布均匀,但扩容需要数据迁移。
分库分表带来的挑战:
- 分布式 ID:不能再用数据库自增 ID,需要全局唯一方案(雪花算法:时间戳 + 机器号 + 序列号)。
- 跨库 JOIN:分库后 SQL JOIN 就废了。解决方案:代码层面聚合,或者把数据同步到 ES 做查询。
- 拆分维度变更:如果业务变化导致拆分字段不再合适,短期靠索引表/映射表救急,最终要重新分库分表。
- 成本:非必要不分库——不要过度设计。
数据库越简单越好。禁止三张表以上的 JOIN,禁用存储过程和触发器。
七、应用保护机制
再强大的系统也有极限。当请求量超过系统承受能力,或者下游服务出了故障,系统需要"自我保护"而不是硬扛到崩溃。
核心思想:优先保证核心业务,优先保证大部分用户。
7.1 四道防线
降级手段有很多种: 停止读数据库直接走缓存、精确结果转近似结果("猜你喜欢"兜底)、同步转异步、展示静态结果页面、加验证码/滑块题来减缓请求速度。
熔断策略通常基于两个指标:请求失败率超过阈值,或响应时间超过阈值。类似电路中的保险丝——电流过大时自动断开,保护整个电路。
限流的四种经典算法:
| 算法 | 特点 | 适用场景 |
|---|---|---|
| 固定窗口 | 简单,但有边界突增问题 | 粗粒度限流 |
| 滑动窗口 | 平滑,无边界问题 | 精细限流 |
| 漏桶 | 匀速处理,削峰填谷 | 稳定输出场景 |
| 令牌桶 | 允许突发流量,最灵活 | 大多数场景首选 |
除了基于请求的限流,还有基于资源的限流:连接池(控制数据库连接数)、线程池(控制并发线程数)、请求队列(超过队列长度就拒绝)。
7.2 隔离策略
隔离的核心思想是"爆炸半径控制"——一个地方出问题,不能把整个系统带崩:
- 数据隔离:核心数据和非核心数据分库存储。
- 机器隔离:VIP 用户单独分配服务器,通过用户标识路由。
- 线程池隔离:不同业务用不同线程池,A 业务的线程池耗尽不影响 B 业务。
- 集群隔离:通过注册中心做服务分组。
- 读写隔离:主从架构,读写走不同实例。
- 动静分离:静态资源走 CDN,动态请求走应用服务器。
- 冷热隔离:秒杀等热数据走缓存 + 队列,普通数据走正常链路。
7.3 恢复与异地多活
当故障解除后,恢复也要讲究方法。不是一股脑放开流量——灰度恢复,逐步提升阈值,观察系统是否真的稳住了。
异地多活是高可用的终极形态:在多个地理位置部署独立的数据中心,每个中心都能独立处理请求。一个数据中心挂了,流量自动切到其他数据中心,保证服务不中断。
核心原则:保证核心业务多活、保证核心数据最终一致性、保证大部分用户正常使用。
八、实战案例:电商秒杀系统
秒杀的本质是什么?短时间内(瞬时)大量请求抢购少量商品——高并发读 + 高并发写。
8.1 设计目标
- 高可用:系统不能被大量请求击垮。
- 数据一致性:绝对不能产生超卖。
- 高性能:响应要快,慢了用户就流失了。
8.2 核心设计思路
减少交互: 请求参数和响应参数尽量少,避免不必要信息的传输。合并请求减少数量。访问路径越短,可靠性越高。减少外部依赖,根据优先级取舍。
动静分离: 如果 URL 不同、用户不同、访问时间不同、地区不同、登录状态不同——显示内容都相同,这就是静态数据,可以推到 CDN 和负载均衡器缓存。只有和当前用户、当前时刻相关的数据才走后端服务。
分层过滤:
每一层都过滤掉大量无效请求,最终只有极少数请求真正操作数据库。
识别热点 + 削峰: 通过分析日志、监控追踪来识别热点数据(哪些商品最抢手),然后通过消息队列把瞬间的写请求缓冲起来,让后端按自己的节奏慢慢消化。
九、实战案例:微博系统设计
设计一个微博系统,是架构面试中的经典题目。我们按照"需求 -> QPS 评估 -> 拆分 -> 选型 -> 建表"的思路来走一遍。
9.1 核心功能
先筛选出核心功能:发微博(Post Tweet)、信息流(Timeline / News Feed)、关注/取关(Follow / Unfollow)、注册/登录。
9.2 QPS 决定架构复杂度
| QPS 量级 | 架构复杂度 |
|---|---|
| 100 | 单机就够,注意备份 |
| 1K | 一台好服务器 + 考虑单点故障 |
| 1M | 千台集群 + 动态扩容 + 负载均衡 + 故障转移 |
9.3 微服务拆分与存储选型
按业务领域拆成四个服务,每个服务选择最适合的存储:
| 微服务 | 职责 | 存储选型 | 理由 |
|---|---|---|---|
| User Service | 注册、登录 | MySQL | 结构化数据,强一致性 |
| Tweet Service | 发微博、Timeline | MongoDB | 文档型,Schema 灵活 |
| Media Service | 图片/视频上传 | S3 | 海量文件存储 |
| Friendship Service | 关注/取关 | MySQL + Redis | 关系查询 + 高速缓存 |
9.4 核心数据表设计
User 表: id / username / email / password
Friendship 表: from_user_id / to_user_id / created_at
Tweet 表: id / user_id / content / created_at
到这里就形成了一个可运行的方案。后续持续优化——缓存、消息队列、分库分表、监控告警……不断打磨,小步快跑。
十、架构设计方法论
10.1 系统设计四步法
核心理念: 程序 = 算法 + 数据结构,系统 = 服务 + 数据存储。
10.2 架构的三个核心维度
| 维度 | 关注点 | 优化手段 |
|---|---|---|
| 性能与延迟 | 快 | 缓存、CDN、多线程、动静分离、边缘计算 |
| 可扩展与吞吐 | 大 | 负载均衡、水平扩展、异步、批处理、读写分离 |
| 可用与一致 | 稳 | 主从复制、哨兵模式、集群、分布式事务 |
10.3 面试时怎么介绍你的项目架构
面试被问"介绍下你们的架构",按这四个维度来组织回答:
业务架构(首选): 系统有哪些核心模块,核心业务流程是什么。用一个完整的用户操作流程(如下单流程)来串联所有模块,比干巴巴地列名字更有说服力。
技术架构(首选): 用了什么技术栈(Spring Cloud / Dubbo),服务间怎么通信(RPC / MQ),用了哪些中间件(Redis / MySQL / ES / XXL-JOB / Sentinel / Nacos)。
部署架构(面试官问才说): 多少台机器、多少个微服务、容器化(K8s)、异地多活。
数据架构(面试官问才说): 哪些数据在 MySQL、哪些在 Redis、哪些在 ES。有没有数仓、实时(Flink)和离线(Spark/Hive)处理链路。
十一、常见面试题精选
面试题 1:MVC 和三层架构有什么区别?
MVC 是一种设计模式,重点在用户界面——把界面拆成 Model(数据)、View(展示)、Controller(交互) 三个角色。三层架构是一种软件架构,把整个应用分成表示层、业务逻辑层、数据访问层。
它们和"3"有关,但本质完全不同。MVC 聚焦于界面交互,三层架构聚焦于整个应用的分层组织。两者不互斥——在三层架构的表示层里使用 MVC 模式是很常见的做法。
面试题 2:为什么说做架构就是做权衡?
因为架构设计的多个目标经常互相矛盾。追求极致性能可能牺牲可维护性,追求极致可用性成本会飙升,追求安全性可能影响易用性。
没有银弹 (No Silver Bullet)——这个概念来自 Fred Brooks 的经典论文:由于软件的复杂性本质,不存在一种技术或方案能解决所有问题而不带来任何负面影响。进步是靠按部就班、持之以恒而来的,不是靠找到一颗"银弹"。
最后,架构没有好坏之分,只有适不适合。所谓好与坏只是不同历史背景下的客观评判。适合的架构就是好架构。
面试题 3:微服务怎么拆分?
核心原则有七条:
- 职责单一:一个微服务只干一件事。
- 业务边界:不同业务独立拆分。
- 中台复用:公共能力抽取成独立服务。
- 系统保障:秒杀和日常交易分开,在线任务和离线任务分开。
- 技术栈一致:不同技术栈不要硬融。
- 依赖单向:不能有循环依赖——循环依赖说明拆分不合理。
- 康威定律:架构要和组织结构匹配。多个团队维护一个微服务,必然出现沟通和发布冲突。
面试题 4:什么是单元化架构?
单元化就是把系统部署成多个"单元",每个单元包含完整的一套业务服务,但只包含部分用户的数据。
比如淘宝分成上海单元、北京单元、新加坡单元。杭州用户的请求路由到上海单元,在单元内完成所有操作。各单元的数据汇总在"中心",中心保存全量数据。
好处: 低延迟(就近处理)、高扩展(直接加单元)、天然容灾(一个挂了切另一个)。
代价: 全链路改造成本极高——只要链路上有一个环节没做单元化,就会产生跨单元调用,后面的所有环节也只能在中心完成。路由规则复杂(用户出差到另一个地区怎么办?),数据一致性挑战大。只有大厂的核心业务才值得做。
小结
系统架构设计没有标准答案,只有适合的答案。记住这几个核心理念:
- 先解决核心问题,不要过度设计。技术债务可以慢慢还,过度设计造成的复杂度却很难消除。
- 无状态是扩展的基础,有状态是扩展的敌人。
- 读写分离、缓存、异步是三把最常用的性能武器。
- 限流、降级、熔断、隔离是四道最重要的安全防线。
- 架构服务于业务——适合的就是好的,没有最好只有更合适。
- 小步快跑,让架构随业务一起成长。不要试图在项目第一天就设计出完美架构——那不叫远见,叫过度设计。
附:负载均衡算法速查
在集群架构中,负载均衡是把请求分配到不同服务器的"调度员"。常见的负载均衡算法有五种:
| 算法 | 原理 | 适用场景 |
|---|---|---|
| 轮询 (Round Robin) | 挨个分配,一人一次 | 所有服务器硬件配置相同 |
| 加权轮询 | 按权重比例分配,性能好的多分 | 服务器配置不同 |
| 随机 | 完全随机选择一台 | 简单场景 |
| 源地址哈希 | 对请求 IP 做 Hash,固定映射到某台 | 需要会话保持(Session 亲和) |
| 最少连接 | 分配给当前连接数最少的服务器 | 请求处理时间差异大 |
在实际项目中,Nginx 通常做 7 层反向代理(根据 HTTP 协议、URL、Header 等信息路由),而 LVS 等做 4 层代理(根据 IP 和端口路由)。7 层代理信息更丰富、更智能,但性能不如 4 层代理。
附:前端性能优化速查
系统优化不只是后端的事。前端性能直接影响用户体验,常见优化手段包括:
减小资源体积:
- 开启 gzip 压缩(对文本文件可压缩到原来的 40% 以下)
- JS/CSS 压缩混淆(删除无效字符、注释、语义合并)
- 使用矢量图(SVG)或 Base64 内联小图片
减少请求次数:
- 雪碧图(CSS Sprites):多个小图片合成一张大图
- JS/CSS 文件合并打包
- 利用浏览器缓存(
Cache-Control、ETag、Last-Modified)
加速资源加载:
- DNS 预获取:
<link rel="dns-prefetch" href="//cdn.example.com"> - 预加载关键资源:
<link rel="preload" href="critical.js"> - 预取下一页资源:
<link rel="prefetch" href="next-page.js">
减少页面回流和重绘:
- 回流(Reflow):元素位置或大小变化,浏览器需要重新计算布局——性能开销大。
- 重绘(Repaint):回流后需要重新绘制——性能开销大。
- 优化目标:缩小回流和重绘的范围,用批量 DOM 操作替代频繁单次操作。
懒加载与预加载:
- 懒加载:只加载当前可视区域的内容,用户滚动时再加载后续内容。树形组件、标签页、折叠面板都适合懒加载。
- 预加载:在当前页面闲置时,提前加载下一页可能用到的大资源。
动静分离架构:
把不变的静态资源(CSS、JS、图片)放到 CDN,变化的动态数据走后端 API。让静态资源离用户最近,动态请求走最短链路。