跳转至

系统设计

面试追问地图

主问题 必讲关键点 下一层追问
秒杀 限流、库存、异步、幂等、防刷 Redis 扣减成功但 MQ 失败、如何对账
库存防超卖 DB 乐观锁、Redis Lua、分段库存 三种方案怎么选、超卖 vs 少卖优先级
Feed 流 推/拉/推拉结合、写扩散 vs 读扩散 大 V 阈值、收件箱只存 ID、归并排序
数据一致性 事实源、缓存、消息、补偿 重复/乱序消息、补偿失败
LBS 查询 空间索引粗筛、精确计算 边界、坐标系、热点区域和分片
实时轨迹 接入、队列、流处理、存储 乱序、重复、迟到数据、轨迹压缩
分布式事务 2PC/TCC/Saga/消息 业务侵入、悬挂、空回滚、幂等
限流 容量基线、令牌桶、分层限流 固定窗口临界问题、Redis+Lua 滑动窗口、RateLimiter 预热
熔断降级 慢调用/异常、状态转换、兜底 半开探测、兜底数据是否安全
RPC 代理、序列化、协议、连接、治理 超时、重试、服务发现、流控
短链 ID、编码、存储、重定向 冲突、热点、过期、风控
分布式 ID 唯一、趋势递增、高可用 时钟回拨、号段用尽、机房隔离
QPS 提升 找瓶颈、缓存、异步、扩容 容量估算、压测模型、数据一致性
微服务拆分 业务边界、数据归属、渐进迁移 跨服务事务、回滚、组织成本

系统设计题开场先澄清用户量、QPS、数据量、一致性和延迟目标;没有规模假设的架构图没有判断依据。


一、高并发系统设计

核心设计原则

应对高并发的三板斧:
1. 缓存(Cache)- 减少数据库访问
2. 异步(Async)- 削峰填谷
3. 分布式(Distributed)- 水平扩展

如何设计一个高并发秒杀系统?

难度 🔴

关键挑战: 瞬时大流量,库存有限

流程图:

流量 → 限流(令牌桶) → 请求队列 → 处理
                    ↓
            缓存检查(库存)
                    ↓
            写消息队列(异步)
                    ↓
            返回成功/失败
                    ↓
            异步线程处理订单、扣库存

关键实现:

@Service
public class SecKillService {
    private static final RateLimiter LIMITER = RateLimiter.create(1000);

    public SecKillResult secKill(int productId, int userId) {
        // 1. 限流
        if (!LIMITER.tryAcquire()) {
            return SecKillResult.fail("系统繁忙,请稍后重试");
        }

        // 2. 防刷检查
        String userBuyKey = "seckill:bought:" + productId + ":" + userId;
        if (redis.hasKey(userBuyKey)) {
            return SecKillResult.fail("您已参加过本次秒杀");
        }

        // 3. 检查库存(从缓存)
        String stockKey = "seckill:stock:" + productId;
        Long stock = (Long) redis.opsForValue().get(stockKey);
        if (stock == null || stock <= 0) {
            return SecKillResult.fail("库存已售罄");
        }

        // 4. 原子地减库存
        Long remaining = redis.opsForValue().decrement(stockKey);
        if (remaining < 0) {
            redis.opsForValue().increment(stockKey);
            return SecKillResult.fail("库存已售罄");
        }

        // 5. 异步处理订单
        mqProducer.send("seckill-topic", new SecKillEvent(productId, userId));

        // 6. 标记用户已购买
        redis.opsForValue().set(userBuyKey, "1", 24, TimeUnit.HOURS);

        return SecKillResult.success("抢购成功,请等待订单确认");
    }
}

关键优化点: 1. 缓存库存:使用 Redis 而不是数据库 2. 原子操作:Redis DECR 是原子的,避免超卖 3. 异步处理:消息队列异步处理订单,快速返回 4. 防刷机制:限流 + 用户购买记录


库存扣减怎么防超卖?三种方案怎么选?

是什么:秒杀示例用的是"Redis DECR 原子递减 + 负数回滚",这是最简单的一种;完整答题要覆盖三种方案的适用场景和边界。

方案 做法 优点 风险
DB 乐观锁 UPDATE stock SET count=count-1 WHERE id=? AND count>0 不引入额外组件,天然强一致 高并发下大量请求打到同一行,行锁排队,DB 成为瓶颈
Redis 预扣减 + Lua 库存预热进 Redis,扣减和判断写成一个 Lua 脚本原子执行,再异步落库 抗并发能力最强,DB 只做异步兜底 Redis 和 DB 库存短暂不一致;Redis 数据丢失需要有恢复机制(如定时对账)
分段库存 总库存拆成 N 份(如 10 份),请求打散到不同分段各自扣减 把一行热点行拆成 N 行,从根上消除行锁竞争 某段提前扣完但其他段还有余量时,需要"退化到剩余段合并"兜底,实现复杂度最高

为什么 Redis 用 Lua 而不是"先 GET 判断再 DECR":GET+判断+DECR 是三步,中间可能被其他请求插入(先检查后操作的竞态);Lua 脚本在 Redis 里单线程原子执行,把"判断库存是否够 + 扣减"打包成一步,杜绝了这个窗口。

超卖 vs 少卖的边界: - 超卖(卖出数量 > 库存):致命,必须杜绝——本质是"读后写"没做原子化或没加锁 - 少卖(库存还有但显示已售罄):分段库存最容易出现(某一段先扣完,其他段还有余量却提前拒绝),代价远小于超卖,工程上优先保证不超卖,容忍轻微少卖,实在要压榨到零少卖再做"段间借调"

答题要点:先给结论——DB 乐观锁适合中低并发或没有 Redis 基础设施的场景;秒杀级流量要 Redis Lua 预扣减;如果 QPS 高到单 key 也扛不住(大促核心商品)才上分段库存。再主动提"超卖优先级远高于少卖"这句话,体现工程取舍判断。


高并发下如何保证数据一致性?

难度 🔴

三种一致性级别:
强一致性 → 弱一致性 → 最终一致性

具体方案:

1. 数据库层面:事务保证强一致性,主从复制存在延迟
2. 缓存层面:Cache-Aside 旁路缓存
3. 分布式层面:消息队列保证最终一致性,定时任务、人工兜底


二、LBS 地理信息系统

核心技术栈

技术 用途 选型
地理索引 快速查询周边点 R 树、Geohash、Grid
存储 持久化地点数据 MySQL + Redis
实时处理 轨迹更新、围栏 Kafka + Flink
地理库 地址解析、坐标转换 PostGIS、GDAL

如何实现亿级别地点库的快速查询?

难度 🔴

三种索引方案:

Geohash 方案(Redis 实现):

// 添加地点
redis.opsForGeo().add(key, new Point(longitude, latitude), locationName);

// 查询周围地点
GeoResults<RedisGeoCommands.GeoLocation<String>> results = 
    redis.opsForGeo().radius(key, circle);
- 优点:简单,Redis 原生支持,查询快 - 缺点:精度有限,不支持复杂查询

四叉树方案: - 优点:精度高,支持自适应细分,支持复杂查询 - 缺点:需要自实现,动态更新时需要重新组织树结构

R 树方案(PostGIS):

CREATE INDEX idx_location_geo ON locations USING GIST(location);
SELECT id, name FROM locations
WHERE ST_DWithin(location, ST_Point(116.3972, 39.9075)::geography, 5000);
- 优点:性能最好,支持复杂地理运算 - 缺点:需要 PostgreSQL + PostGIS

选型建议: - 初期:用 Redis GEO(快速上线) - 小规模(< 百万):用四叉树 - 大规模(> 百万):用 PostGIS + Redis 缓存


如何实现实时轨迹处理和地理围栏?

难度 🔴

架构:

GPS 设备 → 数据采集 → 消息队列 → 实时处理 → 围栏判断 → 告警/存储
           (LBS 服务)  (Kafka)    (Flink)    (Redis)     (DB)

轨迹存储优化:

常见的轨迹压缩算法:
1. DP 算法(Douglas-Peucker):移除不重要的点
2. 时间合并:相近时间的连续点合并
3. 空间量化:按网格划分,相同网格的点合并

优化后,存储量减少 80-90%


三、分布式事务

分布式事务的解决方案?

难度 🔴

方案 原理 一致性 性能 复杂度
两阶段提交(2PC) 协调器控制 强一致 低 低
Saga 编排补偿 最终一致 高 高
TCC Try-Confirm-Cancel 强一致 低 很高
本地消息表 本地事务 + 消息 最终一致 高 中等

Saga(最终一致,推荐):

原理:业务分为多个步骤,每个步骤是本地事务,失败时执行补偿操作
示例:订单 → 库存 → 支付 → 发货
失败时:取消订单 ← 恢复库存 ← 退款 ← 取消发货

TCC(强一致,最复杂):

Try 阶段:预留资源,不提交
Confirm 阶段:提交事务
Cancel 阶段:回滚,释放资源

本地消息表(最终一致,实用):

1. 业务操作和消息操作在同一个本地事务中
2. 发送消息给消费者
3. 定时扫描未完成的消息,重试

Seata AT 模式深度解析

频次 ★★★ · 难度 🔴 · 高频:阿里

Seata AT(Automatic Transaction)是阿里开源分布式事务框架的核心模式,自动补偿,业务侵入最低。

AT 模式执行流程:

阶段一(Branch Register): 1. 业务 SQL 前,Seata 拦截器解析 SQL,生成 undo log:「执行前数据快照」 2. 执行业务 SQL(本地事务提交) 3. 提交前再次生成「执行后数据快照」,将回滚日志写入 undo_log 表(同一本地事务) 4. 注册分支事务到 TC(Transaction Coordinator)

阶段二(全局提交 / 回滚): - 提交:TC 通知各分支提交,RM(资源管理器)异步删除 undo log - 回滚:TC 通知各分支回滚,RM 根据 undo log 做反向 SQL 恢复数据

// undo log 示例(伪 JSON)
before: {id:1, name:"旧值", version:1}
after:  {id:1, name:"新值", version:2}
→ 回滚时执行: UPDATE SET name="旧值", version=1 WHERE id=1

AT 的关键问题:

问题 说明 解决方案
脏写 T1 回滚恢复旧值时覆盖 T2 写入的新值 全局锁(TC 协调,行级别写锁)
全局锁死锁 跨服务事务互相等锁 锁超时 + 重试 + 超时回滚
隔离性 AT 默认读未提交 读操作加 SELECT FOR UPDATE 走全局锁
大事务 undo log 膨胀 拆小事务、调大 maxCommitRetry

AT vs TCC 选型对比:

维度 AT TCC
业务侵入 低(自动拦截 SQL) 高(需实现 Try/Confirm/Cancel 三段)
一致性 最终一致 最终一致
性能 中等(全局锁 + undo log) 高(无全局锁,资源预扣)
适用场景 旧项目改造、SQL 简单 新项目设计、需要预留资源
底层原理 反向 SQL 补偿 业务补偿逻辑

关联知识: 数据库中「事务回滚」也是靠 undo log——InnoDB 的 MVCC 读视图依赖 undo log 构建旧版本,与 Seata AT 的补偿思路异曲同工。

如何实现服务限流?

频次 ★★★★ · 难度 🟡 · 高频:字节/美团

算法 原理 突发流量 缺陷
固定窗口计数 每窗口一个计数器,到点清零 ❌ 窗口边界可透 2 倍流量
滑动窗口计数 窗口切小格,随时间滑动统计 ❌ 格子越细越准,内存越大
漏桶 恒定速率流出,满则拒绝 ❌ 削平一切尖峰 无法利用闲置容量
令牌桶 定速造令牌,桶存量=可透支的突发额度 ✅ 支持 冷启动需配预热

固定窗口的临界问题(先点破这个才算理解):incr + expire 的计数在窗口边界会漏——限 100/s 时,前一秒最后 100ms 进 100 个、后一秒前 100ms 再进 100 个,都合法,但 200ms 内实际透过 200 个,瞬时流量翻倍。

滑动窗口计数(单机版,把 1s 切成 10 格的环形数组):

public class SlidingWindowLimiter {
    private final long[] buckets = new long[10];   // 每格 100ms 的计数
    private final long[] bucketTime = new long[10]; // 该格对应的时间片
    private final int limit;

    public synchronized boolean tryAcquire() {
        long now = System.currentTimeMillis(), slice = now / 100;
        int idx = (int) (slice % 10);
        if (bucketTime[idx] != slice) {   // 环形复用:过期格子清零再用
            bucketTime[idx] = slice;
            buckets[idx] = 0;
        }
        long sum = 0;                      // 只统计最近 1s 内有效的格子
        for (int i = 0; i < 10; i++)
            if (slice - bucketTime[i] < 10) sum += buckets[i];
        if (sum >= limit) return false;
        buckets[idx]++;
        return true;
    }
}

分布式限流(Redis + Lua,ZSET 滑动窗口):member 记请求、score 记时间戳,一次原子完成"清过期 → 计数 → 判定 → 记录":

-- KEYS[1]=限流key  ARGV[1]=窗口ms  ARGV[2]=阈值  ARGV[3]=当前ms  ARGV[4]=请求id
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[3] - ARGV[1])  -- 移出窗口外的
local count = redis.call('ZCARD', KEYS[1])
if count >= tonumber(ARGV[2]) then return 0 end
redis.call('ZADD', KEYS[1], ARGV[3], ARGV[4])
redis.call('PEXPIRE', KEYS[1], ARGV[1])
return 1

必须用 Lua 的原因:读计数和写记录分两条命令的话,并发下会一起超发——原子性诉求与Redis"批量操作怎么选"一节的 Lua 场景相同。ZSET 精确但费内存(每请求一个 member),高 QPS 下可退化为"Lua 里维护分格 Hash"省空间。

Redis + Lua 令牌桶(分布式令牌桶,支持突发):

-- KEYS[1]=限流key  ARGV[1]=令牌桶容量  ARGV[2]=每秒生成令牌数  ARGV[3]=当前时间戳
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

-- 令牌桶状态:上一次取令牌的时间 + 当时剩余的令牌数
local last_time = redis.call('HGET', key, 'last_time') or now
local stored = tonumber(redis.call('HGET', key, 'stored') or capacity)

-- 计算这段时间生成的令牌数
local elapsed = math.max(0, now - last_time)
stored = math.min(capacity, stored + elapsed * rate)

if stored >= 1 then
    stored = stored - 1
    redis.call('HMSET', key, 'last_time', now, 'stored', stored)
    redis.call('PEXPIRE', key, 60000)
    return 1  -- 通过
else
    return 0  -- 限流
end
// 调用方式(Java 侧)
String lua = "..."; // 上方的 Lua 脚本
List<String> keys = Arrays.asList("ratelimit:api:order");
List<String> args = Arrays.asList("100", "50", String.valueOf(System.currentTimeMillis()));
Long result = redisTemplate.execute(new DefaultRedisScript<>(lua, Long.class), keys, args.toArray());
if (result == 0) { throw new RateLimitException("限流了"); }

令牌桶与 Guava RateLimiter 预热:

// 平滑突发:桶里最多攒 1s 的令牌,闲时攒、忙时透支
RateLimiter limiter = RateLimiter.create(100.0);
// 平滑预热:启动后 3s 内发放速率从慢爬升到 100/s
RateLimiter warm = RateLimiter.create(100.0, 3, TimeUnit.SECONDS);
if (!limiter.tryAcquire()) return TOO_MANY_REQUESTS;

预热(SmoothWarmingUp)解决的问题:下游刚启动时缓存是冷的、连接池是空的、JIT 没编译热点,真实容量远低于稳态值——限流器如果一上来就放稳态流量,等于亲手把下游打挂。这和Redis"缓存预热"是同一个思想:别让系统在零状态下承接满负载。实现上,Guava 用"存储令牌越多(说明越闲/越冷)、发放间隔越长"的斜坡函数爬升。

常见追问: - 限流阈值怎么定?→ 压测得出单实例容量基线,留 20%~30% 余量;网关层按全局容量、服务层按单机容量分层限流 - 被限流的请求返回什么?→ HTTP 429 + Retry-After,RPC 返回特定错误码让调用方走降级,别让客户端盲目重试形成重试风暴(见API设计与接口治理) - 单机限流和分布式限流怎么配合?→ 分布式限流保护共享资源(DB、下游总容量),单机限流保护自身(CPU/线程池);Redis 挂了要降级为单机限流兜底,而不是放开不限

通用概念:令牌桶本质是用"存量额度"把平均速率约束和突发容忍解耦——平均由造令牌速率控制,突发上限由桶容量控制。同类:TCP 拥塞窗口(cwnd 就是动态额度,见网络)、线程池的队列+maxPoolSize(稳态容量与突发缓冲分开配,见并发编程)。


服务熔断 vs 服务降级

熔断 降级
目的 保护链路,防止雪崩 保护核心服务
触发条件 下游服务超时/不可用 系统负载过高
行为 停止调用,快速返回错误 非核心功能不处理或简化处理

熔断的三个状态:

Closed(正常)→ 请求失败率 > 50% → Open(打开)
                                      ↓
                              等待超时时间(30s)
                                      ↓
                            Half-Open(半开)
                                      ↓
                        请求成功 → Closed
                        请求失败 → Open

组件侧的落地(Hystrix 阈值、Sentinel 流量治理)见 SpringCloud微服务;deadline、重试预算、舱壁和并发限制如何组合见弹性工程。


五、CAP/BASE 理论

CAP、BASE 与「分布式 vs 微服务」的完整解释统一收录在分布式系统,此处只留设计题答题速记:

  • CAP:分区容错(P)是前提,发生分区时在一致性(C)和可用性(A)之间按具体操作取舍
  • BASE:基本可用 + 软状态 + 最终一致,是对强一致性的降级妥协
  • 设计题用法:先声明业务偏 CP 还是 AP(支付/库存偏 CP,点赞/浏览数偏 AP),再给对应手段(同步复制、多数派 vs 可靠消息 + 补偿对账)

六、系统设计 Q&A

让你设计一个 RPC 框架,怎么设计?

核心目标:调用远端服务时像调用本地方法一样简单——回答时按"动态代理拦截调用 → 序列化协议打包 → 网络通信收发 → 服务注册发现定位实例 → 负载均衡与容错处理失败"这条主链展开。每个环节的具体实现(动态代理源码、序列化方式对比、Netty 线程模型与 TCP 边界处理、注册中心选型、重试与幂等)已在专题篇有源码级展开,设计题的加分点是主动串联全链路而不是逐一背模块名,深挖见 Netty与RPC"RPC 框架"一章。


让你设计一个短链系统,怎么设计?

原理: HTTP 302 重定向(不用 301,因为 301 会被浏览器缓存,无法统计点击量)

生成短链: 分布式 ID + Base62 转换

1. 用分布式 ID 为每个长链分配唯一递增数字 ID
2. 将十进制 ID 转换为 62 进制(0-9、a-z、A-Z)
3. 6 位短链可存 62^6 ≈ 568 亿个链接

存储与抗并发: - MySQL 存储映射关系,给短串加唯一索引 - 引入 Redis 做缓存 - 布隆过滤器防范缓存穿透


如何设计一个点赞系统?

核心思路:核心读写完全交给 Redis,异步同步到 MySQL。

Redis 存储设计: - 用 Redis Set 记录谁点过赞,如 article:likes:10086 - 点赞:sadd,取消:srem,判断:sismember,总数:scard

异步持久化: - Redis 操作成功后往 MQ 发消息,消费者攒批批量写入 MySQL

大 V 热点防范: - 加一层本地缓存(Caffeine/Guava Cache),定时汇总更新 Redis


Feed 流(关注列表/朋友圈)怎么设计?推模式还是拉模式?

核心问题:用户发一条动态,关注者刷新时要看到——数据在"发布时"还是"读取时"完成分发,是设计的分水岭。

模式 做法 优点 缺点
推(Push,写扩散) 发布时立即把动态 ID 写入每个粉丝的收件箱(Timeline 表/Redis List) 读取只需查自己收件箱,读性能极高 大 V 发一条要写千万份收件箱,写放大;风暴式写入拖垮系统
拉(Pull,读扩散) 只存一份到发布者的发件箱,粉丝刷新时实时拉取所有关注对象的发件箱再合并排序 发布只写一次,无写放大 关注数多时读要扇出查询 N 个人的发件箱再归并,读性能差、DB 压力大
推拉结合(主流方案) 普通用户走推模式;大 V(粉丝数超阈值)改成拉模式,粉丝刷新时单独合并大V的动态 兼顾两者:多数写扩散享受读性能,少数大 V 不引发写风暴 实现复杂度高,合并逻辑要处理"关注了多个大 V"的归并排序

收件箱存储选型: - Redis List/ZSet:LPUSH 写入、LRANGE 分页读,ZSet 可用发布时间戳做 score 天然有序;量大时定期归档旧数据到 MySQL/HBase - 只存 ID 不存内容:收件箱只放动态 ID,详情单独查(内容表可缓存、可去重展示),避免同一条动态在千万粉丝收件箱里重复存储大字段

答题要点:先给出推/拉两个模式的本质(写扩散 vs 读扩散),再说明大厂都用推拉结合(新浪微博经典方案),最后主动提"收件箱只存 ID"这个减少存储放大的细节——这是简历上有社交类项目经历常被追问到的点。发布瞬时峰值的应对可衔接本篇「服务限流」一节。


让你设计一个分布式 ID 发号器,怎么设计?

四种方案对比与雪花算法时钟回拨的处理详见分布式系统,此处给设计题答法。

方案一:雪花算法(Snowflake)

64 位 = 1 位空 + 41 位时间戳 + 10 位机器 ID + 12 位流水号
优势:纯本地计算,无网络 IO,速度极快
隐患:时钟回拨

方案二:数据库号段模式(美团 Leaf)

一次从数据库取一个号段(如 1~1000),缓存在本地内存
双 Buffer 机制:第一批用到 20% 时,异步加载下一批
优势:即使数据库宕机,靠本地缓存的号码仍能运转


订单半小时未支付自动取消怎么实现?

方案 优点 缺点
定时轮询 实现简单 数据库压力大
JDK DelayQueue 高效 无持久化,崩溃丢数据
Redis Sorted Set 分布式友好 依赖定时任务频率
MQ 延迟队列(推荐) 处理高效,异步 依赖 MQ 服务
MQ 死信队列(推荐) 隔离异常消息 处理不够精确

推荐方案: 下单时发送延迟消息,TTL 设为 30 分钟,消费者检查订单状态,未支付则取消。

追问链常问到"延迟任务底层怎么实现"——时间轮(Netty/Kafka)与堆(DelayQueue)的对比见数据结构与算法。


如果系统的 QPS 突然提升 10 倍,怎么设计应对?

紧急止血: 限流降级、关闭非核心业务、弹性扩容

架构演进: - 读请求:多级缓存(本地 Caffeine + Redis 双层) - 写请求:消息队列排队削峰 - 数据库:读写分离 + 分库分表


大流量网站,单 Redis 无法承压如何解决?

读写分离: 主节点负责写,从节点负责读

Redis Cluster 集群: - 16384 个槽,CRC16(key) % 16384 映射到具体节点 - 数据分片分散请求,自动处理故障转移


如何设计一个可重入的分布式锁?

核心思路:锁状态用 Hash 存({owner: 线程标识, count: 重入次数}),获取锁时 owner 相同则 count++,释放时 count-- 到 0 才真正删除 key;再叠加自动续期(看门狗)解决业务未跑完锁先过期的问题。这正是 Redisson 的生产级实现——不需要自己写,完整的 Lua 脚本、Hash 结构设计、看门狗续期机制、以及"看门狗能保证锁绝不失效吗"等追问,见分布式系统"Redisson 看门狗(Watchdog)如何实现自动续期?"一节。


有哪些负载均衡方案?

方案 示例 优点 缺点
硬件负载均衡 F5 设备 性能强大 成本极高
软件负载均衡 Nginx 灵活性高,成本低 性能不如硬件
DNS 负载均衡 DNS 分配 IP 简单易用 无法感知实时状态
CDN 就近节点 减少延迟 主要适用于静态内容

微服务架构是什么?

核心:把大应用拆分成小的独立服务,各自负责具体业务,可独立开发、部署、扩展。

核心组件: - 服务注册发现:Nacos/Eureka - 服务调用:RESTful HTTP 或 RPC(Dubbo/gRPC) - 链路追踪:Skywalking/Zipkin - 配置管理:配置中心统一管理 - 网关:Spring Cloud Gateway/Nginx - 异步解耦:消息队列 - 数据管理:每个服务独立数据库,分布式事务用 Seata

适用场景:业务复杂、团队规模大、需要快速迭代。小项目单体更合适。

服务网格(Service Mesh)

频次 ★★★ · 难度 🟡 · 高频:字节/阿里/腾讯

服务网格是微服务架构的演进——将服务间通信(负载均衡、熔断、重试、认证、监控)从业务代码中剥离到基础设施层,由 Sidecar 代理统一处理。

核心架构:

┌──────────────────────┐
│     控制平面         │  ← istiod(Pilot/Citadel/Galley 合并)
│ 配置/证书/服务发现    │
└──────┬───────────────┘
       │ 下发 xDS 配置
       ▼
┌──────────────────────┐
│     数据平面          │  ← Envoy Sidecar 代理
│ 拦截所有进出流量       │
│ 实现流量管理/安全/观测 │
└──────────────────────┘

Istio 核心组件:

组件 功能
Envoy(数据面) L4/L7 代理,拦截所有服务流量,实现熔断、重试、负载均衡、流量分割和故障注入
Pilot(控制面) 服务发现和流量管理,将路由规则转换为 Envoy 可理解的 xDS 配置下发
Citadel(控制面) 证书签发和 mTLS 管理,服务间自动加密通信
Galley(控制面) 配置验证和分发,Istio 1.5+ 后与 Pilot/Citadel 合并为单一 istiod

解决了什么问题: - 代码无侵入:熔断/重试/超时/限流不再需要每个微服务自己实现,Sidecar 统一处理 - mTLS 自动化:服务间加密、身份认证零代码改动 - 灰度发布:通过 VirtualService + DestinationRule 按百分比/Header/Cookie 分流,支持金丝雀/A-B 测试 - 可观测性:自动收集 Metrics/Logs/Traces,无需手动埋点

面试追问: - 服务网格一定比传统微服务好吗?→ 不一定。小团队/简单服务引入 Istio 会增加运维复杂度(Envoy 额外资源消耗约 5%-15%、Istio 本身需要专人维护)。服务网格在大规模、多语言、频繁灰度、强安全的场景下优势明显。 - Sidecar 模式与传统的 SDK 模式对比?→ SDK 模式(如原始 Dubbo/Spring Cloud)在代码层集成,性能好、延迟低,但升级 SDK 需要全量服务重新发布;Sidecar 升级只换代理配置不影响业务,但多一跳延迟(Envoy 本地转发通常在 2-5ms)。 - Linkerd vs Istio?→ Linkerd 更轻量(控制面只一个组件,无 Envoy 换用 Rust 写的微代理),适合中规模集群;Istio 功能更全面,适合大型企业级场景。

分布式链路追踪(Distributed Tracing)

频次 ★★★ · 难度 🟡 · 高频:阿里/腾讯/字节

为什么需要链路追踪: 微服务中一次请求可能跨越数十个服务,传统日志无法将分散的调用串联成完整路径。链路追踪通过 TraceID 贯穿整条调用链,还原请求的完整拓扑。

核心数据模型(源自 Google Dapper 论文):

概念 说明 示例
Trace 一次请求的完整调用链路,由多个 Span 组成 DAG TraceID = abc123
Span 一个服务/组件内的一个操作单元 Span:OrderService.createOrder,耗时 45ms
SpanContext 跨服务传递的追踪元数据(TraceID + SpanID + 采样标识) HTTP Header:traceparent: 00-abc123-def456-01

全链路追踪流程:

客户端请求 → 网关(Gateway)生成 TraceID
  → 服务 A(生成 SpanA,透传 TraceID 到 HTTP Header)
    → 服务 B(解析 Header 得到 TraceID,生成 SpanB,记录耗时和状态)
      → 服务 C(同上)
    ← 返回
  ← 返回
← 返回
所有 Span 上报到 Collector → 存储(ES/Cassandra)→ UI 展示

主流实现方案对比:

方案 埋点方式 存储 特点
SkyWalking Java Agent 自动埋点(字节码增强),无代码侵入 H2/ES/BanyanDB 国产,自动感知能力强,国内大厂广泛使用
Jaeger 应用 SDK 或 OTel SDK 手动/半自动埋点 ES/Cassandra/Kafka CNCF 毕业,与 OTel 生态集成最佳
Zipkin SDK 埋点 ES/Cassandra Twitter 开源,轻量简单
OpenTelemetry 统一 SDK + Collector(不包含 UI 存储) 后端无关(Jaeger/Zipkin/Prometheus) 可观测性标准,不是追踪系统而是采集层

关键设计考量: - 采样策略:全量采样性能成本太高(尤其高 QPS 服务),需要分层采样:错误请求 100% 采样 + 高流量接口按 1/100 采样 + 低频接口全局采样 - TraceID 传递:HTTP 用 traceparent Header(W3C TraceContext 标准),RPC 用 metadata,MQ 用消息属性——所有跨服务调用都必须透传 TraceContext - 采样率的计算:通过 Head-based(请求入口决策是否采样)或 Tail-based(服务结束时根据结果决策),Head-based 更常见但可能漏采错误,Tail-based 准确但成本高

常见追问: - 接入链路追踪后性能影响多大?→ 主要开销在 TraceID 生成和 Span 序列化上报。采样率 1% 时通常 <1% 的 CPU 开销;Agent 模式(SkyWalking)采用异步批量上报,对业务线程影响极小 - 如何排查 Trace 中没有的错误?→ Trace 没记录通常是采样率导致的漏采(错误请求概率低赶上不采),或 Span 上报失败。应配合 error log(自带 TraceID)兜底——日志和 Trace 相互补充见生产排障 - OpenTelemetry 为什么成为标准?→ 统一了 OpenTracing(API)+ OpenCensus(采集)分裂局面,单 SDKG 即可输出 Trace/Metrics/Logs 三类数据,支持多后端导出,是 CNCF 可观测性事实标准


单体项目怎么拆分成微服务架构?

1. 梳理依赖:画模块依赖图
2. 划分服务边界:遵循领域驱动设计(DDD)
3. 优先拆分:业务独立、变更频繁的模块
4. 绞杀者模式:新功能用微服务,老功能逐步迁移
5. 数据库拆分:先逻辑拆分,再物理拆分
6. 服务间调用:改为 Dubbo/Feign,加超时控制、熔断降级
7. 公共代码:抽成 SDK 或做成基础服务
8. 监控升级:ELK 日志收集 + 链路追踪

核心:业务驱动,不为拆而拆。


对外提供 API 服务,客户说请求接口超时了,怎么排查?

核心思路:先分清"偶发还是持续、所有客户还是个别"判断范围,再从全局监控(延迟分位数/错误率/流量)到 Trace 拆分逐段耗时,最后落到慢 SQL/连接池/下游依赖等具体环节——完整的排查步骤、判断优先级表和"下一条命令看什么",见生产排障"接口突然变慢怎么排查?"一节,这里不重复展开。