195|可扩展架构:无状态化、水平扩展、分片、异步化和热点治理

架构设计与系统设计约 15 分钟阅读8,067 字符
知识库文档 196 / 251

阅读时间:约 45 分钟 | 难度:高级 | 适用场景:系统设计面试、架构评审、大规模系统演进


一、引言:可扩展性——架构的第一性原理

2010 年,Instagram 只有 2 台服务器支撑 100 万用户;2012 年被 Facebook 收购时,用户量已达 3000 万,服务器从 2 台变成了几百台。如果当初的架构不支持水平扩展,这个增长曲线根本不可能实现。

可扩展性(Scalability)不是"系统能跑多快",而是"当负载增长时,系统能否通过增加资源来线性提升处理能力"。它关注的是增长曲线,而非某个静态的性能指标。

一个可扩展的架构需要回答三个核心问题:

  1. 能不能加——系统是否支持通过增加机器来提升容量?
  2. 加多少——增加 N 台机器,处理能力是否接近 N 倍?
  3. 加哪里——瓶颈在哪一层?加机器真的能解决瓶颈吗?

本文围绕可扩展架构的五大支柱展开:

支柱 解决的问题 核心思想
无状态化 应用层能否任意复制 状态外置,服务节点可互换
水平扩展 如何增加处理能力 加机器而非换更大机器
分片 数据层如何扩展 数据拆分,分散存储和计算
异步化 如何应对流量尖峰 解耦同步链路,削峰填谷
热点治理 不均匀负载如何应对 识别并分散集中访问的压力

文章配图
文章配图


二、无状态化:水平扩展的基石

2.1 什么是"状态"

文章配图
文章配图

在分布式系统中,"状态"(State)指的是服务在处理请求时需要记住的信息。有状态和无状态的区别如同"速记员"和"翻译官":

  • 有状态服务:像一个速记员,需要记住之前说了什么才能继续记录。每个请求之间有关联,服务实例不能被随意替换。
  • 无状态服务:像一个翻译官,每句话独立翻译,不依赖之前的对话。任何翻译官都能处理任何一句话。
java
// ❌ 有状态:Session 存在本地内存
@RestController
public class OrderController {
    private Map<String, Cart> userCarts = new ConcurrentHashMap<>(); // 本地状态
    
    @PostMapping("/cart/add")
    public void addToCart(HttpSession session, @RequestBody Item item) {
        Cart cart = userCarts.get(session.getId());
        cart.add(item);
        // 问题:如果请求被负载均衡到另一台机器,购物车就"丢了"
    }
}

// ✅ 无状态:Session 外置到 Redis
@RestController
public class OrderController {
    @Autowired private RedisTemplate<String, Cart> redis;
    
    @PostMapping("/cart/add")
    public void addToCart(@RequestHeader("token") String token, @RequestBody Item item) {
        Cart cart = redis.opsForValue().get("cart:" + token);
        if (cart == null) cart = new Cart();
        cart.add(item);
        redis.opsForValue().set("cart:" + token, cart, 30, TimeUnit.MINUTES);
        // 任何机器都能处理,因为状态在 Redis 中
    }
}

2.2 无状态化的三个层次

文章配图
文章配图

无状态化不只是"把 Session 放到 Redis",它有三个递进的层次:

层次一:请求级无状态

每个请求携带所有必要信息,不依赖服务端上下文。典型实践:JWT Token 携带用户身份和权限信息,服务端不存 Session。

层次二:服务级无状态

服务实例本身不保存任何业务数据。所有数据从外部存储(DB、Redis、MQ)读取,处理完成后不保留痕迹。这意味着:

  • 服务可以随时重启,不丢失数据
  • 服务可以任意扩容缩容,不需要数据迁移
  • 服务宕机后,请求可以透明切换到其他实例

层次三:架构级无状态

整个系统的状态集中管理,计算与存储彻底分离。这是云原生架构的核心思想:

  • 计算层(容器/Pod):完全无状态,随时创建和销毁
  • 存储层(DB/Redis/OSS):集中管理,有专门的扩容和容灾策略
  • 配置层(Config Center):配置与代码分离,环境差异由配置中心管理

2.3 无状态化的工程实践

Session 外置

最经典的无状态化手段。将 HTTP Session 从应用服务器内存迁移到 Redis 集群:

java
// Spring Session + Redis 自动完成 Session 外置
// 只需引入依赖和配置,代码零改动
@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800)
public class SessionConfig {
    @Bean
    public RedisConnectionFactory connectionFactory() {
        return new LettuceConnectionFactory();
    }
}

关键注意事项:

  • Redis 的可用性直接影响用户登录态——Redis 挂了,所有用户需要重新登录。需要 Redis 集群或 Sentinel 保障高可用
  • Session 序列化:对象要实现 Serializable,且版本升级时要考虑兼容性
  • 每次请求都读 Redis 会增加几毫秒延迟——可以配合本地缓存(Caffeine)减少 Redis 访问

文件存储外置

用户上传的文件(头像、附件)不能存在应用服务器本地磁盘,必须使用对象存储(OSS/MinIO/S3)。上传后返回 URL,文件访问走 CDN 或对象存储,不经过应用服务器。

定时任务的无状态化

多个应用实例同时运行定时任务怎么办?需要分布式调度框架:

  • Elastic-Job:基于 ZooKeeper 的分片调度,每个实例只执行属于自己的分片
  • XXL-JOB:中心化调度,通过路由策略(第一个/最后一个/轮询/分片广播)决定执行实例
  • SchedulerLock(ShedLock):基于数据库锁或 Redis 锁,确保同一时刻只有一个实例执行
java
// ShedLock 示例:基于 Redis 的分布式锁
@Scheduled(cron = "0 0 2 * * ?")
@SchedulerLock(name = "cleanExpiredOrders", lockAtMostFor = "PT30M")
public void cleanExpiredOrders() {
    // 多个实例同时触发,但只有获取到锁的实例真正执行
    orderService.cleanExpired();
}

2.4 无状态化的代价

无状态化不是免费的:

代价 说明 缓解措施
网络延迟 每次请求都要访问外部存储(Redis/DB) 本地缓存 + 连接池复用
外部依赖可用性 Redis 挂了,所有服务受影响 集群/Sentinel 保障高可用
序列化开销 对象需要序列化/反序列化才能存储到 Redis 选择高效序列化协议(Protobuf/Kryo)
数据一致性 多实例通过共享存储通信,存在并发问题 分布式锁、CAS 操作

三、水平扩展:从"换更大的"到"加更多的"

3.1 垂直扩展 vs 水平扩展

文章配图
文章配图

code
垂直扩展(Scale Up):换更大的机器
┌─────────────────────┐
│   4核 8G → 64核 512G  │    简单但有限,单机性能天花板
└─────────────────────┘

水平扩展(Scale Out):加更多的机器
┌────┐ ┌────┐ ┌────┐ ┌────┐
│ 4C │ │ 4C │ │ 4C │ │ 4C │    复杂但无限,理论上可无限扩展
└────┘ └────┘ └────┘ └────┘

垂直扩展的极限是硬件天花板。2024 年最强单机 CPU 约 192 核,内存约 24TB,但成本和可靠性都不可控——单机宕机等于整个服务宕机。水平扩展没有理论上限,但要求架构设计支持分布式。

3.2 水平扩展的前提条件

水平扩展不是"加机器就行",它需要满足三个前提:

前提一:无状态化

这是最基础的前提。如果每个服务实例都保存了独有的状态数据,负载均衡器不知道该把请求发到哪台机器,水平扩展就失去了意义。

前提二:负载均衡

请求如何均匀地分发给多个实例?常见策略:

策略 原理 优点 缺点
轮询(Round Robin) 依次分发 简单,绝对公平 不考虑实例负载差异
加权轮询 按权重分发 适合异构集群 需要手动配置权重
最少连接 发给连接数最少的实例 自适应负载 需要维护连接状态
一致性哈希 按请求特征哈希 适合有状态场景 可能不均衡
随机 随机选择 简单,统计均匀 短时间可能不均衡

前提三:共享资源的线性扩展

有些资源即使加机器也无法线性扩展。最典型的是数据库——单库写入能力有限,加应用服务器不能解决数据库瓶颈。这种情况需要分库分表(见第四节)。

3.3 扩展性的度量:Amdahl 定律与 Gunther 定律

Amdahl 定律定义了加速比的上限:

code
加速比 = 1 / ((1 - P) + P/N)
其中 P = 可并行化的比例,N = 处理器数量

当 P = 0.95(95% 可并行化)、N = 10 时,加速比 ≈ 6.9(而非 10)。那 5% 的串行部分成为瓶颈。

Gunther 定律(通用扩展定律) 更贴近现实,引入了一致性延迟(Coherency Delay):

code
C(N) = N / (1 + α(N-1) + β·N(N-1))
其中 α = 争用系数,β = 一致性延迟系数

β 项解释了为什么扩展性会"倒退"——当节点数过多时,节点间通信和协调开销反而会让吞吐下降。这就是为什么分布式系统有"最优集群规模"。

3.4 不同层次的水平扩展

接入层扩展:最简单,Nginx/HAProxy/LVS 等反向代理本身无状态,DNS 轮询或 VIP 漂移即可实现。

应用层扩展:前提是无状态化。配合 K8s HPA(Horizontal Pod Autoscaler)可实现自动扩缩容:

yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: order-service-hpa
spec:
  scaleTargetRef:
    kind: Deployment
    name: order-service
  minReplicas: 3
  maxReplicas: 50
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
  - type: Pods
    pods:
      metric:
        name: http_requests_per_second
      target:
        type: AverageValue
        averageValue: "1000"

缓存层扩展:Redis Cluster 原生支持水平扩展,通过哈希槽分配实现数据分片。扩容时槽会重新分配,数据自动迁移。

数据库层扩展:最复杂,需要分库分表(见第四节)。


四、分片:数据层的水平扩展

4.1 为什么数据层最难以扩展

应用层可以无状态,但数据层天然有状态。数据不能随意复制(一致性要求),也不能随意拆分(查询需求)。分片(Sharding)是在一致性、可用性和查询灵活性之间取平衡。

4.2 分片策略

文章配图
文章配图

策略一:Range 分片(范围分片)

按数据的某个连续范围分片,如订单表按创建时间分片:

code
Shard-0: 2024-01 ~ 2024-06
Shard-1: 2024-07 ~ 2024-12
Shard-2: 2025-01 ~ 2025-06
  • 优点:范围查询高效,扩容简单(加新分片即可)
  • 缺点:写入热点——新数据总是写入最新分片,老分片几乎空闲

策略二:Hash 分片(哈希分片)

按分片键的哈希值取模:

code
Shard = hash(user_id) % N
  • 优点:数据分布均匀,无写入热点
  • 缺点:扩容困难——N 变化时,几乎所有数据都要重新分配(hash(key) % 4 ≠ hash(key) % 5)

策略三:一致性哈希

解决 Hash 分片扩容难的问题。将哈希空间组织成一个环(0 ~ 2^32-1),节点和 Key 都映射到环上,Key 分配给顺时针方向最近的节点。

code
扩容前:4 个节点,每个负责 1/4 的哈希空间
扩容后:5 个节点,只有约 1/5 的数据需要迁移(而非全部)

一致性哈希引入了虚拟节点(Virtual Node)概念——每个物理节点对应多个虚拟节点,分散在环上不同位置,进一步均衡数据分布。

策略四:Directory 分片(目录分片)

维护一个中心化的路由表,记录每条数据在哪个分片:

code
user_001 → Shard-A
user_002 → Shard-B
user_003 → Shard-A
  • 优点:最灵活,可以按任意规则分配,扩容时只需更新路由表
  • 缺点:路由表成为瓶颈和单点,需要缓存和高可用保障

4.3 分片键的选择黄金法则

分片键的选择是分片设计中最关键的决策,选错几乎无法纠正。三个原则:

  1. 查询模式决定分片键:90% 以上的查询应该能通过分片键精确定位到单个分片。如果选 user_id 作为分片键但 80% 的查询按 order_id 查,设计就失败了。
  2. 避免跨分片查询:跨分片查询需要 Scatter-Gather(分散到所有分片查询再聚合),性能随分片数线性下降。
  3. 分片键尽量不可变:如果分片键会变化(如用户修改了 user_id),数据需要跨分片迁移,操作复杂且风险高。

常见反模式:

sql
-- 按 user_id 分片,但需要按 order_id 查询
SELECT * FROM orders WHERE order_id = 'ORD-2024-001';
-- 这会导致全分片扫描!

-- 解决方案:建立 order_id → user_id 的映射
-- 方案A:额外维护一张映射表
-- 方案B:order_id 中编码 user_id(如 ORD-{user_id}-{seq})
-- 方案C:通过 ES 等搜索引擎建立二级索引

4.4 分片带来的挑战

跨分片 Join

分库分表后,Join 操作无法跨分片执行。解决方案:

  • 应用层 Join:先查一个分片,再用结果查另一个分片(但性能差)
  • 数据冗余:将需要 Join 的数据冗余存储在同一分片
  • 宽表设计:提前将关联数据聚合为一张宽表

分布式事务

跨分片的事务无法使用数据库本地事务。解决方案:

  • 尽量避免跨分片事务(通过分片键设计)
  • 使用分布式事务方案(Seata AT/TCC/Saga)
  • 最终一致性 + 补偿机制

全局唯一 ID

分片后不能依赖数据库自增 ID。解决方案:

  • Snowflake 算法:64 位 Long,时间戳 + 机器 ID + 序列号
  • 数据库号段模式:批量预分配 ID 段
  • UUID:简单但无序,对索引不友好

扩容与再平衡

数据量增长需要增加分片。扩容策略:

  • 停服扩容:暂停写入,全量数据重新分配(简单粗暴,适用于可接受维护窗口的场景)
  • 双写扩容:新旧分片同时写入,异步迁移历史数据,完成后切换读流量(平滑但复杂)
  • 逻辑分片:物理分片数固定(如 64 个),初期部署在少量物理节点上,扩容时将部分逻辑分片迁移到新节点

五、异步化:解耦与削峰

5.1 同步 vs 异步:一个订单的生命周期

文章配图
文章配图

用户在电商平台下单,从点击"提交订单"到看到"下单成功",如果所有操作同步执行:

code
同步链路(用户等待):
┌──────────┐   ┌──────────┐   ┌──────────┐   ┌──────────┐   ┌──────────┐
│ 校验库存  │──→│ 锁定库存  │──→│ 创建订单  │──→│ 扣减优惠券 │──→│ 发送通知  │
│ 50ms     │   │ 100ms    │   │ 80ms     │   │ 60ms     │   │ 200ms    │
└──────────┘   └──────────┘   └──────────┘   └──────────┘   └──────────┘
总耗时:490ms,用户一直在等待

异步化后:

code
核心链路(同步,用户等待):
┌──────────┐   ┌──────────┐   ┌──────────┐
│ 校验库存  │──→│ 锁定库存  │──→│ 创建订单  │
│ 50ms     │   │ 100ms    │   │ 80ms     │
└──────────┘   └──────────┘   └──────────┘
总耗时:230ms,用户等待时间减少 53%

非核心链路(异步,后台处理):
┌──────────────┐   ┌──────────────┐   ┌──────────────┐
│ 扣减优惠券(MQ) │   │ 增加积分(MQ)  │   │ 发送通知(MQ)  │
└──────────────┘   └──────────────┘   └──────────────┘

5.2 异步化的四种模式

模式一:生产者-消费者(Producer-Consumer)

最基础的异步模式。生产者将消息发送到消息队列,消费者异步处理。Kafka/RocketMQ/RabbitMQ 是典型实现。

java
// 生产者
@Transactional
public OrderResult createOrder(OrderRequest req) {
    Order order = orderRepository.save(req.toOrder());         // 核心:同步
    rocketMQTemplate.send("order-created", order.toEvent());   // 非核心:异步
    return OrderResult.success(order.getId());
}

// 消费者
@RocketMQMessageListener(topic = "order-created", consumerGroup = "coupon-consumer")
public class CouponConsumer implements RocketMQListener<OrderEvent> {
    @Override
    public void onMessage(OrderEvent event) {
        couponService.deductCoupon(event.getUserId(), event.getCouponId());
    }
}

模式二:事件驱动(Event-Driven)

异步化的高级形态。业务操作产生事件,多个消费者独立响应事件,形成松耦合的事件驱动架构(EDA)。

code
订单创建事件(OrderCreatedEvent)
  ├── 库存服务:扣减库存
  ├── 优惠券服务:核销优惠券
  ├── 积分服务:增加积分
  ├── 通知服务:发送短信/推送
  └── 数据分析:实时统计

每个消费者独立演进、独立部署,互不影响。但需要确保事件的 Schema 兼容性(如使用 Avro/Protobuf + Schema Registry)。

模式三:请求-响应异步化(Async Request-Reply)

有些场景下,请求需要等待结果,但等待时间可能很长。异步化方案:

code
客户端 → 服务端(提交任务,返回 taskId)
客户端 → 轮询/回调(查询 taskId 的执行结果)

典型应用:报表导出、批量处理、AI 推理等耗时任务。

模式四:CQRS(命令查询职责分离)

读和写使用不同的模型,异步同步数据:

code
写操作 → Command Model(MySQL) → 事件 → Query Model(ES/Redis) ← 读操作

写的复杂性和读的灵活性各自由不同的模型最优处理。

5.3 异步化的可靠性保障

异步化解决了延迟问题,但引入了新的可靠性挑战:

消息不丢

  • 生产者端:使用同步发送 + 确认机制(Kafka acks=all,RocketMQ 同步刷盘)
  • Broker 端:多副本冗余(Kafka min.insync.replicas ≥ 2)
  • 消费者端:手动提交 Offset,处理成功后再 ACK

消息不重复(幂等性)

消息队列保证"至少一次"投递,消费者必须实现幂等:

java
@RocketMQMessageListener(topic = "order-created", consumerGroup = "coupon-consumer")
public class CouponConsumer implements RocketMQListener<OrderEvent> {
    @Autowired private RedisTemplate<String, String> redis;
    
    @Override
    public void onMessage(OrderEvent event) {
        // 幂等性:通过唯一键去重
        String dedupKey = "coupon:dedup:" + event.getOrderId();
        Boolean success = redis.opsForValue().setIfAbsent(dedupKey, "1", Duration.ofHours(24));
        if (Boolean.FALSE.equals(success)) {
            return; // 已处理过,跳过
        }
        couponService.deductCoupon(event.getUserId(), event.getCouponId());
    }
}

消息有序性

如果需要严格顺序消费,确保同一类消息发到同一分区/队列:

java
// Kafka:相同 key 的消息进入同一分区
producer.send(new ProducerRecord<>("topic", userId, message));

// RocketMQ:使用 MessageQueueSelector
producer.send(msg, (mqs, m, arg) -> {
    int index = Math.abs(arg.hashCode()) % mqs.size();
    return mqs.get(index);
}, userId);

5.4 异步化不是银弹

过度异步化的陷阱:

  • 调试困难:同步调用有清晰调用栈,异步调用需要追踪多个系统
  • 最终一致性:用户下单后优惠券可能还没扣,需要合理的用户提示
  • 死信处理:消息处理失败需要重试、死信队列和人工补偿机制
  • 事务边界:数据库事务和消息发送不在同一事务中,需要事务消息或 Outbox 模式

六、热点治理:不均匀负载的克星

6.1 热点的本质

文章配图
文章配图

在分布式系统中,热点(Hotspot)指的是负载严重不均衡的现象——某些资源(Key、分区、机器)承受了远超平均水平的访问压力。热点不是"系统整体不够强",而是"局部承受不住"。

三种典型热点:

热点类型 现象 案例
热点 Key 某个 Key 的 QPS 是平均水平的 100 倍 秒杀商品库存、明星微博
热点分区 某个分片的数据量和访问量远大于其他 按时间分片,最新数据集中在最新分片
热点用户 某类用户产生的请求量远超普通用户 爬虫、压测流量、大 V

6.2 热点 Key 治理

热点 Key 是 Redis 场景中最常见的问题。一个秒杀商品的库存 Key,在秒杀瞬间可能被 10 万 QPS 集中访问,而单个 Redis 分片的处理能力一般在 5~10 万 QPS。

检测热点 Key

bash
# Redis 热 Key 检测
redis-cli --hotkeys

# 或者通过 monitor 命令采样(生产慎用)
redis-cli monitor | grep "GET stock:item:12345"

# 更推荐:通过客户端 SDK 统计(如 Jedis/Lettuce 的 metrics)

治理策略一:本地缓存

将热点数据缓存在应用进程内存中,直接从本地读取,避免每次访问 Redis:

java
@Service
public class StockService {
    // 本地缓存:Caffeine,5 秒过期
    private final Cache<String, Integer> localCache = Caffeine.newBuilder()
        .expireAfterWrite(5, TimeUnit.SECONDS)
        .maximumSize(1000)
        .build();
    
    @Autowired private RedisTemplate<String, Integer> redis;
    
    public int getStock(String itemId) {
        // 先查本地缓存
        Integer stock = localCache.getIfPresent(itemId);
        if (stock != null) return stock;
        
        // 本地缓存未命中,查 Redis
        stock = redis.opsForValue().get("stock:" + itemId);
        if (stock != null) {
            localCache.put(itemId, stock);
        }
        return stock != null ? stock : 0;
    }
}

治理策略二:Key 拆分

将热点 Key 拆分为多个子 Key,分散到不同分片:

code
原始:stock:item:12345 → 分片 A(单点压力)

拆分后:
stock:item:12345:0 → 分片 A
stock:item:12345:1 → 分片 B
stock:item:12345:2 → 分片 C
...
stock:item:12345:9 → 分片 J

读取时随机选一个子 Key,写入时更新所有子 Key(或异步同步)

治理策略三:读写分离

Redis Cluster 中,热点 Key 所在分片可以增加只读副本,读请求分散到副本:

java
// Lettuce 支持读写分离
@Bean
public LettuceConnectionFactory connectionFactory() {
    LettuceClientConfiguration config = LettuceClientConfiguration.builder()
        .readFrom(ReadFrom.REPLICA_PREFERRED) // 优先读副本
        .build();
    return new LettuceConnectionFactory(redisClusterConfig(), config);
}

6.3 热点分区治理

热点分区最常见于 Range 分片场景——按时间分片时,最新时间段的数据总是访问最频繁的。

治理策略:

  • 预分区:提前创建足够多的分区,避免数据倾斜。如 HBase 建表时指定 100 个预分区。
  • Hash 分区替代 Range 分区:如果不需要范围查询,使用 Hash 分片消除热点分区。
  • 分区拆分:当某个分区过大时,动态拆分为两个分区(如 HBase Region Split)。
  • 写入缓冲:写入先进入 MQ 缓冲,批量写入存储,减少存储层的热点压力。

6.4 热点用户治理

热点用户可能是爬虫、恶意攻击或正常的大 V。治理策略需要区分对待:

  • 识别:通过请求频率、请求模式(是否遍历 ID)、User-Agent 等特征识别
  • 限流:对单个用户/IP 实施细粒度限流(Sentinel 热点参数限流)
  • 降级:对非核心用户(如爬虫)返回降级数据或空结果
  • 隔离:将热点用户的请求路由到专用集群,避免影响普通用户
java
// Sentinel 热点参数限流
@GetMapping("/item/{itemId}")
@SentinelResource(value = "getItem", 
    blockHandler = "getItemBlockHandler",
    fallback = "getItemFallback")
public Item getItem(@PathVariable String itemId) {
    // 对热点商品 ID 实施不同限流阈值
    return itemService.getItem(itemId);
}

6.5 热点的"打散"与"隔离"

所有热点治理最终归结为两个方向:

打散(分散压力):将热点拆分为多个子节点,分散到不同物理资源上。适用于读多写少场景。 隔离(物理隔离):将热点放在独立的高性能资源上,不影响其他业务。适用于写多或无法拆分的场景。


七、五者协同:一个完整的可扩展架构

文章配图
文章配图

将无状态化、水平扩展、分片、异步化和热点治理串联起来,看一个完整的可扩展架构实例——社交 Feed 流系统。

需求

设计一个支持 1 亿用户、峰值 QPS 50 万的 Feed 流系统(类似微博/朋友圈)。

架构设计

第一层:无状态化 + 水平扩展

  • Feed 服务、用户服务、关系服务全部无状态化,Session 存储在 Redis 中
  • 每个服务部署 100+ 个 Pod,通过 K8s HPA 自动扩缩容
  • 接入层使用 Nginx + LVS,通过轮询策略分发请求到各 Pod

第二层:分片

  • 用户关系数据(关注/粉丝)按 user_id 分片,使用一致性哈希,便于扩容
  • Feed 数据按 user_id 分片,保证同一用户的 Feed 在同一分片,避免跨分片聚合
  • 使用 Snowflake 生成全局唯一 Feed ID

第三层:异步化

  • 用户发帖后,写入操作通过 MQ 异步分发给所有粉丝的 Feed 收件箱
  • 大 V 发帖采用"推拉结合"模式:活跃粉丝推(写入 inbox),非活跃粉丝拉(查询时聚合)
  • 点赞、评论、转发等非核心操作全部异步化

第四层:热点治理

  • 大 V 的 Feed 是天然热点——被大量粉丝同时读取
  • 热点 Feed 使用本地缓存(Caffeine),过期时间 5 秒
  • 大 V 发帖时,Feed 写入操作限流(防止瞬间写入数百万粉丝的 inbox)
  • 热点 Feed 的评论使用独立 Redis 分片,避免影响普通 Feed

第五层:容量冗余

  • 每层 N+1 冗余
  • 核心链路配置熔断和降级
  • 定期全链路压测,验证扩展性

八、总结

可扩展架构的五大支柱,记住这个口诀:

无状态化是前提(没它不能加机器) 水平扩展是手段(加机器而非换机器) 分片是数据层的解(数据分散才能扩展) 异步化是润滑剂(解耦和削峰,让系统更流畅) 热点治理是保险(局部不崩,全局才稳)

这五大支柱不是孤立的,它们相互依赖、协同工作。无状态化让水平扩展成为可能,分片解决了数据层的扩展瓶颈,异步化解耦了同步链路,而热点治理则确保扩展的均匀性。

面试中如何展现架构思维:

  1. 被问到"如何设计一个高并发系统"时,不要直接跳到方案,先展示你的分析框架——从无状态化开始,依次讨论各层扩展策略
  2. 每个设计决策都要能说出"为什么"——为什么选一致性哈希而非取模?为什么选推模式而非拉模式?
  3. 主动提及每个选择的代价和替代方案——面试官更看重你的权衡能力,而非"正确答案"
  4. 用数据说话——"这个方案能把热点 QPS 从 10 万降到 1000",比"这个方案能解决热点问题"有说服力得多

九、面试官视角提问

以下问题来自面试官视角,考察你对可扩展架构的深度理解和实战经验。建议在阅读完正文后,尝试独立回答。

Q1:你设计了一个无状态的服务,部署了 10 台机器,每台能处理 1000 QPS。但实际压测发现,10 台机器的总 QPS 只有 7000 而非 10000,线性扩展比只有 70%。请分析可能的原因。如果要定位具体瓶颈,你会怎么做?

Q2:你决定对订单表按 user_id 进行 Hash 分片,分 16 个库。运营提了一个需求:需要按订单金额排序,查询近 7 天金额最高的 100 个订单。这个查询在分片架构下如何实现?请至少给出两种方案,并分析各自的优劣。

Q3:一致性哈希中,如果节点数量较少(如 3 个),即使加了虚拟节点,数据分布也可能不均匀——某个节点可能分配到 45% 的数据,另一个只有 25%。你如何改进一致性哈希的均匀性?除了虚拟节点,还有哪些手段?

Q4:你的团队将一个同步下单流程改为异步化之后,客服每天收到大量投诉:"下单成功了但优惠券没扣"、"积分没有增加"。请分析异步化引入了什么问题,以及如何系统性地解决这些问题。

Q5:Redis Cluster 中,某个热点 Key 的 QPS 达到 20 万,单分片 CPU 100%。你决定用本地缓存解决,但本地缓存带来了数据不一致问题——库存数量在多个实例间不一致。请设计一个方案,既能解决热点问题,又能保证库存扣减的准确性。

Q6:你负责的系统从单体架构演进到微服务架构,需要进行数据拆分。原来的订单表有关联查询用户表和商品表。请设计数据拆分方案,并说明如何处理跨库 Join、分布式事务和全局 ID 三个核心问题。


本文是"Java 技术专家 / 架构师面试:250 个高频考点"系列的第 195 篇。下一篇预告:系统容量规划——峰值流量、吞吐、延迟、存储、带宽和资源预算。