目录

秒杀系统概述

1
2
3
4
【流量控制】: 使用Nginx从网关服务器反向代理,实现将请求分发到不同的服务器上,使用spring-session-redis来实现用户会话信息共享
【缓存】: 提前将秒杀商品信息缓存至Redis进行预热,并采用版本号和乐观锁预减库存以解决超卖问题,在高并发情况下显著减少数据库读写。
【消息队列】: 使用消息队列RabbitMQ实现异步下单,并对流量削峰。从而提升系统性能。
【微服务架构】: 搭建商品、鉴权、抢单、订单和通知五大微服务,有效减少代码耦合,实现代码复用。

库存扣减

使用redis存储库存脚本,基于lua脚本实现原子性。

但是要实现redis和数据库的一致性。可以基于异步消息队列,异步的把库存同步到数据库。

秒杀系统扣减库存方案 - 知乎

  • 库存拆分

长业务问题

引入状态模式,实现分布步骤可持久化。

部分可以放入异步后台。

redis热点数据

预先把商品数据放入redis。

消息队列使用

一般来说消息队列用于订单的解耦和晓峰。

秒杀实现中常见的消息队列有哪些? - 知乎

微服务通信

OpenFeign 是一个基于注解的声明式 HTTP 客户端,用于简化微服务之间的通信。它内置了负载均衡和错误处理等功能,可以与 Spring Cloud 集成,使得微服务之间的通信更加便捷。

使用 OpenFeign,你可以通过定义接口来描述目标服务的 HTTP API,然后在需要调用该服务的地方直接注入该接口,OpenFeign 将自动处理 HTTP 请求的生成和发送。

下面是一个简单的目录结构示例,展示了订单模块和用户模块的组织方式,并说明了如何通过 OpenFeign 实现两个模块之间的通信:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
project
├── order-module
│   ├── src
│   │   ├── main
│   │   │   ├── java
│   │   │   │   ├── com
│   │   │   │   │   ├── example
│   │   │   │   │   │   ├── order
│   │   │   │   │   │   │   ├── controller          # 订单模块的控制器
│   │   │   │   │   │   │   ├── model               # 订单模块的实体类
│   │   │   │   │   │   │   ├── service             # 订单模块的服务类
│   │   │   │   │   │   │   ├── OrderModuleApplication.java   # 订单模块的启动类
│   │   │   ├── resources
│   │   │   │   ├── application.properties           # 订单模块的配置文件
│   │   │   │   ├── ...
│   │   │   │
│   │   ├── test   # 测试目录
│   │   │   ├── ...
│   │
├── user-module
│   ├── src
│   │   ├── main
│   │   │   ├── java
│   │   │   │   ├── com
│   │   │   │   │   ├── example
│   │   │   │   │   │   ├── user
│   │   │   │   │   │   │   ├── controller          # 用户模块的控制器
│   │   │   │   │   │   │   ├── model               # 用户模块的实体类
│   │   │   │   │   │   │   ├── service             # 用户模块的服务类
│   │   │   │   │   │   │   ├── UserModuleApplication.java   # 用户模块的启动类
│   │   │   ├── resources
│   │   │   │   ├── application.properties           # 用户模块的配置文件
│   │   │   │   ├── ...
│   │   ├── test   # 测试目录
│   │   │   ├── ...
├── pom.xml    # Maven 依赖文件

在这个目录结构中,订单模块和用户模块各自独立,都包含了自己的控制器、实体类、服务类和启动类等。它们通过 RESTful API 来提供服务。

订单模块中的 OrderService 可以通过 OpenFeign 来调用用户模块中的 UserService。下面是一个简单的使用示例:

  1. 在用户模块中定义 OpenFeign 接口:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

@FeignClient(name = "user-module", url = "http://localhost:8081") // 根据实际情况修改 URL
public interface UserServiceClient {

    @GetMapping("/api/users/{userId}")
    User getUserById(@PathVariable("userId") Long userId);
}
  1. 在订单模块的服务类中注入 OpenFeign 接口,并调用用户模块的方法:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class OrderService {
    @Autowired
    private UserServiceClient userServiceClient;

    public User getUserById(Long userId) {
        return userServiceClient.getUserById(userId);
    }
}

通过这样的方式,订单模块和用户模块之间可以通过 OpenFeign 实现通信,而不需要直接调用 RESTful API。

redis预热

如果大部分时候没有预热,你可以在获取商品信息时,先尝试从Redis缓存中获取,如果缓存中不存在则从数据库中获取,并将获取到的数据写入Redis缓存,以便下次访问时可以直接从缓存中获取,从而提高系统性能和响应速度。以下是修改后的示例代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
javaCopy codeimport redis.clients.jedis.Jedis;
import redis.clients.jedis.exceptions.JedisException;

public class SeckillService {

    private static final String REDIS_HOST = "localhost";
    private static final int REDIS_PORT = 6379;

    public String getSeckillItem(int itemId) {
        try {
            // 连接Redis服务器
            Jedis jedis = new Jedis(REDIS_HOST, REDIS_PORT);

            // 尝试从Redis缓存中获取秒杀商品信息
            String seckillItemJson = jedis.get("seckill:item:" + itemId);

            if (seckillItemJson == null) {
                // 如果缓存中不存在,则从数据库中获取,并写入Redis缓存
                seckillItemJson = fetchSeckillItemFromDatabase(itemId);
                if (seckillItemJson != null) {
                    jedis.set("seckill:item:" + itemId, seckillItemJson);
                    // 设置秒杀商品信息的过期时间(假设设置为1小时)
                    jedis.expire("seckill:item:" + itemId, 3600);
                }
            }

            // 关闭连接
            jedis.close();

            return seckillItemJson;
        } catch (JedisException e) {
            e.printStackTrace();
            return null;
        }
    }

    private String fetchSeckillItemFromDatabase(int itemId) {
        // 模拟从数据库中获取秒杀商品信息的逻辑
        // 实际情况下根据业务需求从数据库中查询数据并返回
        if (itemId == 1) {
            return "{\"id\": 1, \"name\": \"商品1\", \"price\": 100, \"stock\": 1000}";
        } else if (itemId == 2) {
            return "{\"id\": 2, \"name\": \"商品2\", \"price\": 200, \"stock\": 500}";
        } else {
            return null; // 商品不存在
        }
    }

    public static void main(String[] args) {
        SeckillService seckillService = new SeckillService();
        String seckillItemJson = seckillService.getSeckillItem(1);
        if (seckillItemJson != null) {
            System.out.println("Seckill item found: " + seckillItemJson);
        } else {
            System.out.println("Seckill item not found.");
        }
    }
}

在这个修改后的示例代码中,getSeckillItem() 方法先尝试从Redis缓存中获取商品信息,如果缓存中不存在,则调用 fetchSeckillItemFromDatabase() 方法从数据库中获取,并将获取到的商品信息写入Redis缓存中。

加了redis预热的判断逻辑后,对于不用预热的普通阶段,有性能影响吗

对于普通阶段不使用预热的情况,添加了预热判断逻辑后,可能会对系统性能产生一定的影响,但通常情况下影响较小且可以忽略。这主要取决于预热判断逻辑的复杂程度和执行频率。

在使用 Redis 进行缓存预热时,确保与数据库保持同步是至关重要的,以确保缓存中的数据与数据库中的数据保持一致性。以下是一些常用的方法来实现缓存预热和与数据库保持同步:

  1. 定时任务更新缓存:编写定时任务,周期性地从数据库中读取数据,并更新到 Redis 缓存中。这样可以确保缓存中的数据与数据库中的数据保持同步。
  2. 数据库操作时更新缓存:在执行数据库写操作(如新增、更新、删除)时,同时更新 Redis 缓存。这样可以保证数据库和缓存中的数据始终保持一致。
  3. 订阅数据库变更并更新缓存:使用数据库的订阅(例如 MySQL 的 binlog)功能,监控数据库变更事件,一旦发生变更,立即更新对应的 Redis 缓存数据。
  4. 使用消息队列同步数据:将数据库变更事件发送到消息队列中,然后由消费者监听消息队列,一旦收到消息,即可更新对应的 Redis 缓存数据。
  5. 增量同步数据:记录每次缓存更新的时间戳或者版本号,在预热时只更新那些时间戳或版本号大于当前缓存中记录的时间戳或版本号的数据,从而减少不必要的数据传输和处理。

nginx分发策略

  • Round Robin(轮询):按照请求的顺序依次将请求分配给不同的后端服务器。

  • Least Connections(最小连接数):将请求发送到当前具有最少活动连接数的后端服务器。

  • IP Hash(IP哈希):基于客户端的IP地址将请求发送到同一个后端服务器,有助于保持会话一致性。

  • Weighted Load Balancing(加权负载均衡):根据后端服务器的处理能力配置权重,分发流量以反映其相对性能。

  • Server Health Checks(服务器健康检查):定期检查后端服务器的健康状态,避免将请求发往不可用的服务器。

  1. 添加Spring Session与Redis的依赖:
1
2
3
4
5
6
7
8
xmlCopy code<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
  1. 配置Redis连接信息:
1
2
propertiesCopy codespring.redis.host=127.0.0.1
spring.redis.port=6379

业务

单系统的业务逻辑流程通常包括以下步骤:

  1. 用户浏览商品
    • 用户通过前端界面浏览商品列表,选择感兴趣的商品。
  2. 商品详情页
    • 用户点击商品进入商品详情页,查看商品的详细信息,包括价格、库存、描述等。
  3. 加入购物车
    • 用户选择要购买的商品数量,并将商品加入购物车。
  4. 查看购物车
    • 用户可以查看购物车中的商品列表,修改商品数量或者删除商品。
  5. 结算订单
    • 用户确认购物车中的商品信息,并选择配送方式、支付方式等。
  6. 生成订单
    • 用户点击结算按钮后,系统根据用户的选择生成订单,包括订单编号、商品信息、配送信息、支付金额等。
  7. 支付订单
    • 用户选择支付方式,并完成支付流程,支付成功后订单状态更新为已支付。
  8. 订单处理
    • 商家收到订单后进行处理,包括准备商品、配送商品等。
  9. 配送商品
    • 商家将商品配送给用户,用户收到商品后确认收货。
  10. 订单完成
    • 用户确认收货后,订单状态更新为已完成,交易完成。

订单超时逻辑与方案

假设我这里有一个订单系统,要实现订单延时取消,具体实现方案:

时间轮

时间轮可被视为一种环形结构,分割为多个时间槽,每个槽表示一个时间段,其中可以存放多个任务。采用链表结构保存每个时间槽中所有到期的任务**。随着时间的推移,时间轮的时针逐渐移动,执行每个槽中所有到期的任务。**

https://raw.githubusercontent.com/kengerlwl/kengerlwl.github.io/refs/heads/master/image/1f4068f1aaaf6bb422f55c70fb8ec23e/4bace14e4527d7f9495a1e0c2109ce11.png

RabbitMQ死信队列

RabbitMQ中的TTL可以设置任意时长。一旦一条正常消息因为TTL过期、队列长度超限或被消费者拒绝等原因无法被及时消费,它将成为Dead Message,即死信,会被重新发送到死信队列

rabbitmq_delayed_message_exchange 插件

在RabbitMQ中,也可以利用rabbitmq_delayed_message_exchange插件实现延时消息,该方案解决了通过死信队列引起的消息阻塞问题

Redis实现延迟执行功能

基于Redis也可以实现延时消息的功能,有以下三种方案:

Redis过期监听

通过在配置文件redis.conf中增加配置notify-keyspace-events Ex 即可实现消息的过期监听,然后可以在业务代码实现KeyExpirationEventMessageListener监听器来接收过期消息,这样就可以实现延时关闭订单的操作

Redisson

Redisson是在Redis基础上实现的框架,提供了分布式的Java常用对象和许多分布式服务。其中,Redisson定义了分布式延迟队列RDelayedQueue,这是基于zset实现的延迟队列,它允许将元素以指定的延迟时长放入目标队列中。

分布式全局id

分布式系统 - 全局唯一ID实现方案 | Java 全栈知识体系

  • UUID
  • Snowflake 算法(雪花算法):Snowflake 是 Twitter 提出的一种分布式唯一 ID 生成算法。
  • Redis 实现分布式全局唯一ID,它的性能比较高,生成的数据是有序的,对排序业务有利,但是同样它依赖于redis,需要系统引进redis组件,增加了系统的配置复杂性

分布式事务(seata框架)

参考:两天,我把分布式事务搞完了 - 知乎

由于引入了微服务,可能导致原有的的一个事务需要跨越多个容器节点的多条执行逻辑。具体应用场景

  1. 库存服务与订单服务
    • 在电子商务平台中,下单时需要扣减库存。
    • 应用逻辑:
      1. 订单服务创建订单后,需要调用库存服务扣减商品库存。
      2. 库存服务收到扣减库存请求后,检查库存是否充足,如果充足则扣减库存,否则返回错误信息。
      3. 扣减库存成功后,库存服务更新商品库存信息。
      4. 订单服务收到库存扣减成功的响应后,更新订单状态为已支付。

在这些场景中,需要保证跨服务的操作是原子性的,即要么全部成功,要么全部失败,以确保数据的一致性。为了实现分布式事务,可以采用以下几种方案:

  • 两阶段提交(2PC):在第一阶段,所有参与者向协调者发送准备请求,协调者收到所有的准备请求后,向所有参与者发送提交请求。在第二阶段,所有参与者收到提交请求后,如果都同意提交,则执行事务提交操作,否则回滚事务。

https://raw.githubusercontent.com/kengerlwl/kengerlwl.github.io/refs/heads/master/image/1f4068f1aaaf6bb422f55c70fb8ec23e/cfc4ab71de65961779e7b2dc7bb766db.png

由事务协调者给每个参与者发送准备命令,每个参与者收到命令之后会执行相关事务操作,你可以认为除了事务的提交啥都做了。

然后每个参与者会返回响应告知协调者自己是否准备成功。

协调者收到每个参与者的响应之后就进入第二阶段,根据收集的响应,如果有一个参与者响应准备失败那么就向所有参与者发送回滚命令,反之发送提交命令。

1
2
3
start transaction
commit
rollback

2PC 主要有三大缺点

  • 同步阻塞(所有节点一起阻塞)
  • 单点故障(协调者挂了就全g)
  • 数据不一致问题。(网络问题导致数据不一致)

微服务架构示意图

https://raw.githubusercontent.com/kengerlwl/kengerlwl.github.io/refs/heads/master/image/1f4068f1aaaf6bb422f55c70fb8ec23e/7b90ecc1d9cdf726f3380cedf3618e74.png