这是我学习系统架构设计时整理的常见问题清单。很多问题我第一次接触时都答不上来,后来一个个查资料、写代码验证,才慢慢想明白。这里按"问题 → 结论 → 为什么"的方式记下来,方便自己回看,也希望能帮到同样在学的人。
1. 单体架构和微服务架构怎么选?
结论:团队规模小、业务早期、交付周期紧,选单体;业务模块独立演进、团队多人并行、需要独立扩缩容,才考虑微服务。
很多人一上来就想上微服务,其实微服务解决的问题是"多人并行开发 + 独立部署 + 独立扩容",代价是分布式带来的通信、事务、排查问题复杂度。单体先跑通业务、模块边界清晰后再拆分,比一开始就拆成一堆服务更稳妥——这就是"单体优先(Monolith First)"的原则。拆分的时机看痛点:部署太频繁互相影响、某个模块要单独扩容、团队沟通成本过高,这些痛点出现时再拆。
2. 为什么一定要分层?分层到底分哪几层?
结论:分层的本质是"控制依赖方向",让每一层只关心一件事,改起来互不影响。
最常见的三层:Controller(接口层)→ Service(业务层)→ DAO/Mapper(数据层),再往上延伸出领域层、基础设施层。分层的好处:
- 职责单一:接口层管参数校验和协议转换,业务层管业务规则,数据层管持久化;
- 依赖单向:上层依赖下层,下层不依赖上层,改动被限制在单层内;
- 可测试:可以单独 mock 掉数据层测业务逻辑。
我见过最乱的项目就是"所有逻辑堆在 Controller 里",一个方法几百行,改一个字段要通读全文件。分层不是形式主义,是长期可维护性的地基。
3. CAP 定理到底是什么?分布式系统怎么取舍?
结论:CAP 说"一致性、可用性、分区容忍性三者最多满足其二",但正确理解是:网络分区(P)一定会发生,所以在 CP 和 AP 之间选。
- C(一致性):所有节点同一时刻读到同一份数据;
- A(可用性):任何请求都能在可接受时间内得到响应;
- P(分区容忍性):节点间网络断了系统仍能工作。
网络断线(分区)是必然事件,所以系统实际上只能在 CP(分区时拒绝部分请求保证一致,如 ZooKeeper)和 AP(分区时继续服务但数据可能不一致,如大多数缓存)之间选。业务上的取舍:钱、库存这类强一致数据走 CP;浏览、点赞这类能容忍短暂不一致的走 AP。
4. 接口幂等怎么做?
结论:幂等的本质是"同一个请求重复执行和只执行一次效果相同",常见做法是唯一键、状态机、token。
三个常用手段:
- 唯一键:订单号、业务单号做数据库唯一索引,重复插入直接撞唯一键失败;
- 状态机:只允许"待支付 → 已支付 → 已取消"单向流转,重复提交对不上当前状态直接拒绝;
- token / 幂等号:客户端请求带一个幂等号,服务端用 Redis
SETNX记住已处理过的幂等号,重复请求直接返回第一次的结果。
最典型的坑:支付回调重试导致订单重复入账,就是没做幂等。
5. 分布式事务有哪些方案,怎么选?
结论:强一致选 2PC/TCC,弱一致选 SAGA/本地消息表/事务消息;绝大多数业务场景用"本地消息表 + 最终一致"就够了。
| 方案 | 思路 | 适用 |
|---|---|---|
| 2PC(两阶段提交) | 准备 + 提交/回滚,DB 原生支持 | 单库多事务、强一致,但阻塞、性能差 |
| TCC | Try-Confirm-Cancel 三段业务补偿 | 跨服务强一致,实现复杂 |
| SAGA | 一串本地事务 + 反向补偿 | 长事务、弱一致可接受 |
| 本地消息表 | 本地事务写消息表 + 定时任务发 MQ | 简单可靠,最终一致 |
| 事务消息(RocketMQ) | MQ 半消息 + 回查 | 最终一致,依赖特定 MQ |
我的建议:先想清楚业务能不能接受"最终一致"。能接受,本地消息表/事务消息成本最低;真需要强一致(比如扣款),才上 TCC,并准备好大量的补偿代码。
6. 缓存和数据库一致性怎么保证?
结论:主流是 Cache Aside(旁路缓存):读 miss 时查库回填,写时先更新 DB 再删缓存。极端场景叠加"延迟双删"。
Cache Aside 的具体顺序很重要:
- 读:先查缓存,miss 则查库并回填;
- 写:先更新数据库,再删除缓存(不是更新缓存——缓存只当"副本",删了下次读 miss 自然回填最新值)。
为什么"更新 DB 后删缓存"而不是"更新 DB 后写缓存"?因为并发下"写缓存"容易把旧值覆盖成永久脏数据,而"删缓存"最多让下一次读多查一次库,代价小得多。极端并发下还会有"删缓存瞬间又有旧请求回填旧值"的窗口,这时候用延迟双删(删除 → 等几百毫秒 → 再删一次)兜底;更彻底的做法是订阅数据库 binlog 异步清缓存。
7. 高并发下接口限流怎么做?
结论:限流算法有计数器、漏桶、令牌桶,工程上优先令牌桶;落地用 Sentinel / Guava RateLimiter / 网关。
- 计数器:固定窗口计数,简单但有"窗口临界突刺"问题;
- 漏桶:请求像水进漏桶,以固定速率流出,削峰填谷,适合保护下游;
- 令牌桶:按速率往桶里放令牌,请求取令牌才能过,允许一定突发,兼顾平滑和利用率,所以最常用。
落地时一般分层做:Nginx 按 IP 限(最粗),网关按用户限(中),业务接口用 Sentinel @SentinelResource 按资源限(最细)。
8. 消息队列怎么防丢消息、防重复消费、防乱序?
结论:丢消息靠"生产确认 + 持久化 + 消费确认"三层保证;重复消费靠消费端幂等;乱序靠单分区顺序消费或业务侧排序。
- 防丢:生产者开启 confirm 机制确认发送成功;队列和消息设置持久化;消费者关闭自动 ack、手动确认成功后再 ack——任何一层漏了都会丢。
- 防重复:MQ 只保证"至少一次"投递,重复消费是常态。消费端必须幂等(唯一键/状态机,见问题 4),而不是指望 MQ 不重投。
- 防乱序:RabbitMQ 单队列单消费者天然有序;Kafka 同一 key 进同一分区可保序;乱序发生时就靠消息体带序号 + 业务侧校验。
9. 水平扩展和垂直扩展什么区别?"无状态化"为什么这么重要?
结论:垂直扩展是加单机配置(有上限),水平扩展是加机器(几乎无限);水平扩展的前提是应用无状态。
- 垂直扩展:加 CPU、加内存,改配置就行,但单机总有上限,钱也花得越来越不值;
- 水平扩展:加机器、加实例,配合负载均衡分摊流量,理论容量无限。
但水平扩展有一个硬前提:应用必须是"无状态"的——Session 不能存本地内存(要挪到 Redis),本地文件缓存不能用(要挪到 OSS/分布式缓存)。我最早踩过的坑就是把用户登录态存在应用内存里,扩到第二台机器就全部掉线,改造成 Redis 存 Session 才解决。
10. 数据库分库分表什么时候做?怎么分?
结论:先做缓存、读写分离、索引优化,扛不住了再分;分库分表是"最后的手段",因为查询和事务复杂度会急剧上升。
分之前先确认压到数据库的是读还是写:读压力大 → 加缓存 + 读写分离;写压力大、单表数据量过亿、单库连接不够 → 才分。拆分方式:
- 垂直拆分:按业务拆库/拆表(订单库、用户库各管各的),最简单;
- 水平拆分:同一张表按分片键拆到多库多表(
user_id % 16等),配合中间件(ShardingSphere)或应用层路由。
分片键的选择是灵魂:必须按最常用的查询维度定(比如按用户 ID 分,那"按订单号查"就要带用户 ID 或走映射表)。分完还要面对跨片查询、分布式 ID、全局唯一等问题,所以我的原则是:能不分就不分,非分不可再分。
小结
这十个问题基本覆盖了系统架构设计的高频知识点:从单体到微服务的演进、分层、CAP、幂等、分布式事务、缓存一致性、限流、MQ、水平扩展、分库分表。建议每个问题都跟着"结论"去翻一遍对应技术的官方文档,再动手写个小 demo 验证,比死记结论有用得多。
