系统设计

CAP

Consistency:所有节点同时看到相同的数据(强一致性)。

Availability: 99.999%的成功率,一年之内只有5分钟系统不可用

Partition Tolerance:系统在网络分区时仍能继续工作。

E-Commerce

QPS估计

假设:日活跃用户(DAU)为 1000 万,峰值流量是平均流量的 2 倍。

流量分布:大部分是浏览 / 搜索(约 80%),部分是结账(约 20%)。

计算: 1000 万用户 / 天 × 10 次页面浏览量 / 用户 = 1 亿次页面浏览量 / 天

1 亿次 /(24 × 3600 秒)≈ 1150 次 / 秒(平均每秒请求数,QPS )

峰值 QPS = 1150 × 2 = 2300 次 / 秒

数据库选型

  1. Inventory Service(库存服务)选择 Cassandra: 分布式架构和高写入吞吐量,适合秒杀等高并发扣减场景
  2. Cart Service(购物车服务)选择 DynamoDB:全托管、自动扩缩容和低延迟特性,能弹性应对流量波动
  3. Order Service(订单服务)选择 PostgreSQL:强一致性

商品浏览

Alt text

API Gateway,如Amazon API Gateway

  1. 对用户进行身份验证
  2. 限制每个用户的请求数量
  3. 通过诸如轮询(Round Robin)等方法进行负载均衡,将请求分配到产品服务的多个实例上

商品的信息存在 Inventory Database 里面和 Elastic Search 里面,redis充当Elastic Search的缓存。当想要查看一个商品信息时,先查redis, 查不到就去查Elastic Search。

当对数据库里的商品信息进行修改的时候,发送消息到MQ → 异步消费消息删除Redis并更新ES。

下单

采用热点商品缓存策略,符合二八定律

1
2
3
4
5
6
[Order Service] 
   ├── 热卖商品库存查询 → [Redis Cluster](毫秒级响应)
   └── 非热卖商品查询 → [Inventory Service] 
           (平均10-20ms,通过HTTP API)

缓存预热:秒杀活动开始之前,把热卖商品的库存手动缓存到 Redis 里面去。

来了一个请求,只有热卖商品能经过布隆过滤器,在 redis里查询并扣库存。

下单成功,远程调用 Inventory Service,给它扣库存

布隆过滤器:一个长为m的位数组和k个不同的哈希函数。每个元素经过哈希函数,得到k个索引,在位数组中这k个索引处置1.
如果检查元素对应的k个位置是否都为1,如果都是1则认为可能存在(可能有误判),如果有0则肯定不存在。
提高误判率:增大m值/k=(m/n)*ln2时误判率最低

秒杀系统设计

如果有如果有十万并发,我肯定需要 redis集群来实现,采用数据分片。但是,如果用户们同一时间买同一件商品,就会有数据倾斜的问题。怎么办?

限流

如果第一秒有十一万请求,那么多出来的一万请求直接被拒绝。

  1. 前端限流:在点击“秒杀”前增加验证码
  2. 网关层限流
  • 令牌桶/漏桶算法:通过Nginx或阿里云API网关限制每秒请求数(如QPS=5000)。
  • IP/用户限流:同一用户或IP在单位时间内只能提交一次请求,防止脚本刷单。

库存分片

将单个商品的库存拆分为多个子库存

例如: item_stock_{商品ID}shard1、item_stock{商品ID}_shard2… 每个分片存储部分库存(如总库存10000件,拆分为10个分片,每个分片1000件)。

请求分散

用户请求时,随机选择一个分片进行库存扣减。

1
2
int shard = ThreadLocalRandom.current().nextInt(0, shardCount);
String key = "item_stock_"+ itemId + "_shard" + shard;
  • 如果当前分片库存不足,可尝试其他分片(需设置重试次数上限,避免阻塞)。

Lua脚本保证查库存、扣库存的原子性

雪花算法生成订单ID,放到MQ里面

  1. 雪花算法的优势:
  • 生成的ID全局唯一(依赖时间戳+机器ID+序列号)

  • 避免因ID重复导致订单主键冲突

  1. 不足:
  • 页分裂

  • 网络重试:用户可能在请求超时后重复提交(相同雪花ID可能被生成两次)

  • 消息重复消费:RabbitMQ可能因消费者确认失败导致消息重新投递(相同消息多次到达)(用 redis)

MQ幂等性

数据库幂等表

在这个数据库中增加一个消息消费记录表,把消息插入到这个表,并且把原来的订单更新和这个插入的动作放到同一个事务中一起提交,就能保证消息只会被消费一遍了

  1. 开启事务
  2. 插入消息表,订单 ID 作为主键
  3. 更新订单表(原消费逻辑)
  4. 提交事务

如果插入成功,即可以确保该消息没有被消费过。

如果消息消费失败,可能是因为主键冲突,也可能是因为发生异常。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
try {
    // 尝试插入消息记录与业务操作
} catch (DuplicateKeyException e) {
    // 消息已处理,直接ACK
    channel.basicAck(deliveryTag, false);
    return;
} catch (Exception e) {
    // 系统异常,回滚事务并NACK(重试)
    rollbackTransaction();
    channel.basicNack(deliveryTag, false, true);
}

MQ可靠性

  1. 生产者未收到 ACK,重试
1
2
3
4
5
channel.confirmSelect(); // 开启Confirm模式
channel.basicPublish(exchange, routingKey, MessageProperties.PERSISTENT_TEXT_PLAIN, message.getBytes());
if (!channel.waitForConfirms(5000)) {
    // 消息未确认,重试或记录日志
}
  1. 消息队列中防止消息丢失
  • 设置消息与队列持久化
  • Rabbit MQ 集群。镜像队列: 队列数据在集群多个节点间同步(设置ha-mode=all或ha-sync-mode=automatic)
  1. 消费者防止消息丢失

手动ACK+死信队列

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
channel.basicConsume("order_queue", false, (consumerTag, delivery) -> {
    try {
        processMessage(delivery.getBody());
        channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false);
    } catch (BusinessException e) {
        // 业务异常(如幂等表主键冲突),直接ACK避免重试
        channel.basicAck(deliveryTag, false);
    } catch (Exception e) {
        // 系统异常,重试3次后进入死信队列
        if (retryCount < 3) {
            channel.basicNack(deliveryTag, false, true);
        } else {
            channel.basicNack(deliveryTag, false, false); // 进入死信
        }
    }
});

MQ中消息积压

比如现在有百万消息在MQ队列中,但用户还在前端等待。有以下几种方案可以解决:

  1. 增加消费者数量(或者 k8s动态扩容)
1
2
3
4
5
6
7
# Spring Boot配置(每个实例启动10个消费者线程)
spring:
  rabbitmq:
    listener:
      simple:
        concurrency: 10   # 初始并发数
        max-concurrency: 20 # 最大并发数
  1. 消费者处理完成后,将订单状态写入Redis。再开启一个异步任务,定期把插入的订单数据从 Redis 中写到数据库。
  2. 如果等了两分钟,还没有插入数据库成功,则返回失败,还要把库存+1,让用户重试下单。

如何保证订单消息不丢失、不重复?

生产者:

  • 开启Confirm模式,失败后重试+日志告警。当消息成功写入 Broker 的内存 / 磁盘后,Broker 会返回 ACK 确认。

  • 消息添加唯一ID(雪花ID),用于消费端去重

Broker:

  • 消息持久化+镜像队列(ha-mode=all)

  • 集群部署避免单点故障

消费者:

  • 手动ACK + 死信队列(重试3次后转人工处理)

  • 幂等表设计:CREATE TABLE msg_idempotent (msg_id VARCHAR(64) PRIMARY KEY)

Redis集群崩溃了怎么办

Rocket MQ对比Kafka的优点

  1. RabbitMQ 的 ACK/NACK 机制适合需要严格保证消息处理的场景(如订单创建失败后重试)。

  2. 项目初期预计 QPS 在千~万级,RabbitMQ 单节点或镜像集群即可满足

Redisson解决一人一单

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
private void handleVoucherOrder(VoucherOrder voucherOrder) {
    Long userId=voucherOrder.getUserId();
    //这里加个锁。预防多 JVM 情况下,来自同一用户的同一时间的多个请求,分别占用了不同 JVM 的 synchronized 锁。
    //SimpleRedisLock lock=new SimpleRedisLock("order:"+userId, stringRedisTemplate);
    RLock lock=redissonClient.getLock("lock:order:"+userId);
    boolean isLock=lock.tryLock();
    if(!isLock){
        return;
    }
    try{
        proxy.createVoucherOrder(voucherOrder);
    }finally{
        lock.unlock();
    }
}

多个JVM去竞争锁,只有一个能拿到锁。拿到锁的JVM可以改数据库。

把对数据库的操作改成直接插入,如果插入失败

唯一索引解决一人一单

在数据库表中设置 (user_id, voucher_id) 的唯一索引

直接执行插入语句,捕获唯一约束冲突异常

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) {
    try {
        // 先扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock=stock-1")
                .eq("voucher_id", voucherOrder.getVoucherId())
                .gt("stock", 0)
                .update();
        
        if(!success) {
            log.info("库存不足");
            return;
        }
        
        // 直接插入订单,依赖唯一约束防止重复
        save(voucherOrder);
        
    } catch (DuplicateKeyException e) {
        log.info("用户已购买过该优惠券");
    }
}

如何保证Redis集群崩溃时的系统可用性?

  1. 降级,使用本地缓存;Redis不可用时直接返回“服务繁忙”