程序员scholar 程序员scholar
首页
  • Java 基础

    • JavaSE
    • JavaIO
    • JavaAPI速查
  • Java 高级

    • JUC
    • JVM
    • Java新特性
    • 设计模式
  • Web 开发

    • Servlet
    • Java网络编程
  • Web 标准

    • HTML
    • CSS
    • JavaScript
  • 前端框架

    • Vue2
    • Vue3
    • Vue3 + TS
    • 微信小程序
    • uni-app
  • 工具与库

    • jQuery
    • Ajax
    • Axios
    • Webpack
    • Vuex
    • WebSocket
    • 第三方登录
  • 后端与语言扩展

    • ES6
    • Typescript
    • node.js
  • Element-UI
  • Apache ECharts
  • 数据结构
  • HTTP协议
  • HTTPS协议
  • 计算机网络
  • Linux常用命令
  • Windows常用命令
  • SQL数据库

    • MySQL
    • MySQL速查
  • NoSQL数据库

    • Redis
    • ElasticSearch
  • 数据库

    • MyBatis
    • MyBatis-Plus
  • 消息中间件

    • RabbitMQ
  • 服务器

    • Nginx
  • Spring框架

    • Spring6
    • SpringMVC
    • SpringBoot
    • SpringSecurity
  • SpringCould微服务

    • SpringCloud基础
    • 微服务之DDD架构思想
  • 日常必备

    • 开发常用工具包
    • Hutoll工具包
    • IDEA常用配置
    • 开发笔记
    • 日常记录
    • 项目部署
    • 网站导航
    • 产品学习
    • 英语学习
  • 代码管理

    • Maven
    • Git教程
    • Git小乌龟教程
  • 运维工具

    • Docker
    • Jenkins
    • Kubernetes
  • 算法笔记

    • 算法思想
    • 刷题笔记
  • 面试问题常见

    • 十大经典排序算法
    • 面试常见问题集锦
关于
GitHub (opens new window)
首页
  • Java 基础

    • JavaSE
    • JavaIO
    • JavaAPI速查
  • Java 高级

    • JUC
    • JVM
    • Java新特性
    • 设计模式
  • Web 开发

    • Servlet
    • Java网络编程
  • Web 标准

    • HTML
    • CSS
    • JavaScript
  • 前端框架

    • Vue2
    • Vue3
    • Vue3 + TS
    • 微信小程序
    • uni-app
  • 工具与库

    • jQuery
    • Ajax
    • Axios
    • Webpack
    • Vuex
    • WebSocket
    • 第三方登录
  • 后端与语言扩展

    • ES6
    • Typescript
    • node.js
  • Element-UI
  • Apache ECharts
  • 数据结构
  • HTTP协议
  • HTTPS协议
  • 计算机网络
  • Linux常用命令
  • Windows常用命令
  • SQL数据库

    • MySQL
    • MySQL速查
  • NoSQL数据库

    • Redis
    • ElasticSearch
  • 数据库

    • MyBatis
    • MyBatis-Plus
  • 消息中间件

    • RabbitMQ
  • 服务器

    • Nginx
  • Spring框架

    • Spring6
    • SpringMVC
    • SpringBoot
    • SpringSecurity
  • SpringCould微服务

    • SpringCloud基础
    • 微服务之DDD架构思想
  • 日常必备

    • 开发常用工具包
    • Hutoll工具包
    • IDEA常用配置
    • 开发笔记
    • 日常记录
    • 项目部署
    • 网站导航
    • 产品学习
    • 英语学习
  • 代码管理

    • Maven
    • Git教程
    • Git小乌龟教程
  • 运维工具

    • Docker
    • Jenkins
    • Kubernetes
  • 算法笔记

    • 算法思想
    • 刷题笔记
  • 面试问题常见

    • 十大经典排序算法
    • 面试常见问题集锦
关于
GitHub (opens new window)
npm

(进入注册为作者充电)

  • 缓存数据库 - Redis

    • Redis - 介绍
    • Redis - 安装
    • Redis - 五大数据类型及API
    • Redis - 新增三大数据类型
    • Redis - 配置文件
    • Redis - 发布和订阅
    • Redis - Java整合
    • Redis - 事务与锁
      • 1. 事务
        • 介绍
        • 三大特性
        • 三大指令
        • 案例 1: 使用exec执行事务
        • 案例 2: 使用discard取消事务
        • 错误处理
        • 案例图
        • 事务冲突的问题
      • 2. 锁
        • 悲观锁
        • 乐观锁
      • 3. 指令总结
      • 4. 秒杀案例
        • 环境准备
        • 配置类
        • 连接池
        • 秒杀版本一
        • 秒杀版本二
        • 测试
    • Redis - 两种持久化
    • Redis - 主从复制
    • Redis - 集群搭建
    • Redis - 缓存问题
    • Redis - 分布式锁
    • Redis - Spring Data Redis
  • 数据库
  • 缓存数据库 - Redis
scholar
2021-12-26
目录

Redis - 事务与锁

笔记

优秀的 SQL 数据库都拥有事务,而 Redis 作为 NoSQL 数据库的佼佼者,也有「事务」。

  • 1. 事务
    • 介绍
    • 三大特性
    • 三大指令
    • 案例 1: 使用exec执行事务
    • 案例 2: 使用discard取消事务
    • 错误处理
    • 案例图
    • 事务冲突的问题
  • 2. 锁
    • 悲观锁
    • 乐观锁
  • 3. 指令总结
  • 4. 秒杀案例
    • 环境准备
    • 配置类
    • 连接池
    • 秒杀版本一
    • 秒杀版本二
    • 测试

# 1. 事务

# 介绍

Redis 事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。

Redis 事务的本质是一组命令的集合。事务支持一次执行多个命令,一个事务中所有命令都会被序列化。在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中。

Redis 事务的主要作用就是 串联多个命令 防止别的命令插队。

  • Redis 事务没有隔离级别的概念
  • Redis 不保证原子性
  • Redis 事务的三个阶段
    • 开始事务
    • 命令入队
    • 执行事务

# 三大特性

  • 单独的隔离操作:Redis 保证事务内的命令序列化和按顺序执行,事务执行期间不会被其他命令请求打断。这实现了一种“隔离”的效果,尽管这种隔离与数据库事务的隔离级别不同。
  • 无隔离级别概念:Redis 事务没有传统意义上的隔离级别概念。这是因为,直到EXEC命令被调用之前,事务队列中的命令都不会被执行。这意味着,在事务执行前,任何命令都不会对数据库造成影响。
  • 不保证原子性:在Redis中,事务的原子性不是指事务内所有操作要么全部成功要么全部失败。如果事务中的某个命令执行失败,其余命令仍然会被执行。不过,需要注意的是,命令的失败通常是由于语法错误等原因,而不是运行时错误。Redis 3.2版本以后,引入了EXECABORT错误,能够在事务执行前的语法检查或内存问题导致的失败上提前终止事务。

# 三大指令

Redis事务的基本概念围绕三大核心指令:MULTI, EXEC, 和 DISCARD。

开启事务 - MULTI

  • 使用MULTI指令来开启一个事务。从输入MULTI命令的那一刻起,所有后续的命令都不会立即执行,而是被加入到一个队列中。这些命令将等待执行指令(EXEC)的发出,才会被执行。
  • 进入事务后,客户端发送的所有Redis命令都会被放入一个队列中。在这个阶段,Redis只是记录下了要执行的操作,但并没有实际执行这些命令。

执行事务 - EXEC

  • 当使用EXEC指令时,Redis会一次性、顺序地执行之前MULTI后所有排队的命令。如果队列中的所有命令都被成功执行,那么事务就被视为成功。EXEC命令的执行使得所有队列中的命令成为一个原子操作,要么全部执行,要么全部不执行。
  • 执行EXEC指令后,事务结束,所有队列中的命令将被执行。

事务回滚 - DISCARD

  • 在事务执行之前,如果决定放弃执行所有队列中的命令,可以使用DISCARD指令来结束事务并清空命令队列。这可以被看作是一种手动回滚,用于在执行EXEC之前放弃所有的更改。
  • 使用DISCARD指令后,事务被取消,所有已经入队的命令都会被清除,且不会有任何命令被执行。

image-20211226170941580

下面是两个Redis事务的案例代码,通过具体案例,以便更好地理解Redis事务的工作原理。

# 案例 1: 使用exec执行事务

multi           # 开始一个事务。后续命令将被加入队列,但不会立即执行。

set k1 v1       # 将键k1的值设为v1。此命令加入队列,等待执行。
set k2 v2       # 将键k2的值设为v2。此命令也加入队列,等待执行。
get k1          # 获取键k1的值。此命令加入队列,等待执行。
get k2          # 获取键k2的值。此命令加入队列,等待执行。

exec            # 执行所有队列中的命令。输出结果将是v1和v2,因为此时事务内的命令被实际执行。
1
2
3
4
5
6
7
8

在执行exec之前,set和get命令并没有被立即执行。它们仅仅被加入到了命令队列中。一旦执行了exec命令,队列中的所有命令才会被依次执行。因此,此例中会输出v1和v2。

# 案例 2: 使用discard取消事务

set k1 v1       # 将键k1的值设置为v1。此命令立即执行。

multi           # 开始一个事务。后续命令将被加入队列,但不会立即执行。

set k1 v2       # 将键k1的值尝试设为v2。此命令加入队列,等待执行,但不会立即生效。

discard         # 取消事务。事务队列中的命令,包括上面的`set k1 v2`,都将被丢弃,不会执行。

get k1          # 获取键k1的值。由于事务被取消,k1的值仍然是v1,所以输出v1。
1
2
3
4
5
6
7
8
9

在这个案例中,discard命令用于取消事务。这意味着,事务队列中所有的命令(此例中是set k1 v2)都不会被执行。因此,尽管我们尝试在事务中将k1的值设置为v2,k1的值依然是事务开始前设置的v1。

通过这两个案例,可以看到Redis事务提供了一种机制,允许用户将多个命令打包后一次性执行,确保了在事务执行过程中的命令不会被其他客户端的命令所打断。同时,discard命令提供了一种方式来取消事务,避免执行事务队列中的命令。

使用场景

  • 实时消息系统:利用 Redis 的 Pub/Sub 功能,可以轻松实现实时消息推送,如实时聊天系统、实时通知等。
  • 事件通知:在应用中,可以使用 Pub/Sub 机制作为事件通知的基础架构,当某个事件发生时,通过发布消息到特定频道来通知所有订阅者。

注意事项

  • 消息的非持久化:由于发布的消息不会被持久化,因此一旦发送,未在线的订阅者将无法接收到这些消息。这一点在设计基于 Redis Pub/Sub 的系统时需要特别考虑。

# 错误处理

  • 组队中某个命令出现了报告错误(multi 中),执行时整个的所有队列都会被取消

    image-20211226171839361

  • 如果执行阶段(exec)某个命令报出了错误,则只有报错的命令不会被执行,而其他的命令都会执行,不会回滚

    image-20211226171906051

# 案例图

image-20210408101936847

放弃事务

image-20210408101955501

若在事务队列中存在命令性错误(类似于 Java 编译性错误),则执行 EXEC 命令时,所有命令都不会执行

image-20210408102023204

若在事务队列中存在语法性错误(类似于 Java 的 1/0 的运行时异常),则执行 EXEC 命令时,其他正确命令会被执行,错误命令抛出异常。

image-20210408102051072

# 事务冲突的问题

想想一个场景:有很多人有你的账户,同时去参加双十一抢购

  • 一个请求想给金额减 8000
  • 一个请求想给金额减 5000
  • 一个请求想给金额减 1000

结果如图:

image-20211226172110847

那么如何解决呢?我们需要利用 Redis 的锁机制。

# 2. 锁

# 悲观锁

悲观锁(Pessimistic Lock),顾名思义,就是很悲观,认为这个世界是黑暗的,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会 block 直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。

image-20211226172952960

# 乐观锁

乐观锁(Optimistic Lock),顾名思义,就是很乐观,认为这个世界是光明的,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量。Redis 就是利用这种 check-and-set 机制实现事务的。

Redis 使用的是乐观锁。

Redis 的乐观锁是一种在执行事务时用于确保数据一致性的机制。通过WATCH命令,Redis 能够监视一个或多个键,如果在事务执行前这些键的值发生了变化,那么事务将不会被执行。这种机制特别适用于多客户端环境,其中同一个键可能会被多个客户端同时修改。以下是关于Redis乐观锁的使用方法、场景以及注意事项的详细总结:

1. 通过指令(可指定多个),开启乐观锁

watch key [key] ...
1

一旦 watch 某个 key,则会一直监视这个 key,如果 key 发生了变化,就返回提示。

作用:在执行 multi 之前,先执行 watch key1 [key2],可以监视一个(或多个) key ,如果在事务 exec 执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断。

使用场景:很多人同时对一个值进行操作,一旦这个值被修改,且被其他人监听,则其他人无法修改这个值。

2. 取消 WATCH 命令对所有 key 的监视

unwatch key [key] ...
1

缺点:如果单纯使用 watch,可能导致 key 的值无法完全被修改。

使用场景:假设库存有 500 个商品,2000 个人进行秒杀购买(2000 个程序监听商品的 key),假设 1999 人同时购买,其内部程序监听的商品数量为 500,最后一个人却已经购买成功,商品数量变为 499,则前面的事务被打断(监听的 500 数量),导致 1999 人会购买失败,库存还有 499 个商品。

乐观锁的工作原理

  • WATCH命令:用于监视一个或多个键,如果在执行EXEC命令提交事务前这些键的值发生了改变,那么整个事务将被取消。
  • MULTI命令:标记一个事务块的开始。
  • EXEC命令:执行所有在MULTI和EXEC之间队列的命令。
  • UNWATCH命令:取消所有键的监视。

3. 测试

初始化信用卡可用余额和欠额

127.0.0.1:6379> set balance 100
OK
127.0.0.1:6379> set debt 0
OK
1
2
3
4

使用 watch 检测 balance,事务期间 balance 数据未变动,事务执行成功

127.0.0.1:6379> watch balance
OK
127.0.0.1:6379> multi	# 开启事务
OK
127.0.0.1:6379> decrby balance 20	# 可用余额 -20
QUEUED
127.0.0.1:6379> incrby debt 20	# 欠款 +20
QUEUED
127.0.0.1:6379> exec	# 执行事务
1) (integer) 80
2) (integer) 20
1
2
3
4
5
6
7
8
9
10
11

使用 watch 检测 balance,若事务期间 balance 数据变动,则事务执行失败

窗口一

# 窗口一
127.0.0.1:6379> watch balance	# 监视 balance
OK
127.0.0.1:6379> MULTI # 执行完毕后,执行窗口二代码测试
OK
127.0.0.1:6379> decrby balance 20
QUEUED
127.0.0.1:6379> incrby debt 20
QUEUED
127.0.0.1:6379> exec # 修改失败!因为被监视的 balance 值改变
(nil)
1
2
3
4
5
6
7
8
9
10
11

窗口二

# 窗口二
127.0.0.1:6379> get balance
"80"
127.0.0.1:6379> set balance 200
OK
1
2
3
4
5

窗口一:出现问题后放弃监视,然后重来

127.0.0.1:6379> UNWATCH # 放弃监视,这是取消所有的监视
OK
127.0.0.1:6379> watch balance	# 监视
OK
127.0.0.1:6379> MULTI	# 事务
OK
127.0.0.1:6379> decrby balance 20
QUEUED
127.0.0.1:6379> incrby debt 20
QUEUED
127.0.0.1:6379> exec # 成功
1) (integer) 180
2) (integer) 40
1
2
3
4
5
6
7
8
9
10
11
12
13

说明:

  • 一但执行 exec 指令或 descard 指令,无论事务是否执行成功,watch 指令对变量的监控都将被取消。

  • 故当事务执行失败后,需重新执行 watch 命令对变量进行监控,并开启新的事务进行操作。

# 3. 指令总结

Redis 事务相关指令:

序号 命令及描述 描述
1 DISCARD 取消事务,放弃执行事务块内的所有命令
2 EXEC 执行所有事务块内的命令
3 MULTI 标记一个事务块的开始
4 UNWATCH 取消 WATCH 命令对所有 key 的监视
5 WATCH key [key ...] 监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断。类似乐观锁

# 4. 秒杀案例

# 环境准备

首先创建一个 Spring Boot 项目,然后添加依赖:

<dependencies>
    <!-- redis普通依赖包 -->
    <dependency>
        <groupId>redis.clients</groupId>
        <artifactId>jedis</artifactId>
        <version>3.2.0</version>
    </dependency>

    <!-- spring boot + redis 整合   -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
    </dependency>
    <!-- redis   -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
</dependencies>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# 配置类

配置类我在 Redis - Java 整合 也提供了,如下:

@EnableCaching
@Configuration
public class RedisConfig extends CachingConfigurerSupport {
   
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        RedisSerializer<String> redisSerializer = new StringRedisSerializer();
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new
                Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        template.setConnectionFactory(factory);
        //key 序列化方式
        template.setKeySerializer(redisSerializer);
        //value 序列化
        template.setValueSerializer(jackson2JsonRedisSerializer);
        //value hashmap 序列化
        template.setHashValueSerializer(jackson2JsonRedisSerializer);
        return template;
    }

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory factory) {
        RedisSerializer<String> redisSerializer = new StringRedisSerializer();
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        //解决查询缓存转换异常的问题
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        // 配置序列化(解决乱码的问题),过期时间 600 秒
        RedisCacheConfiguration config =
                RedisCacheConfiguration.defaultCacheConfig()
                        .entryTtl(Duration.ofSeconds(600))
                        .serializeKeysWith(RedisSerializationContext.SerializationPair.
                                fromSerializer(redisSerializer))
                        .serializeValuesWith(RedisSerializationContext.SerializationPair
                                .fromSerializer(jackson2JsonRedisSerializer))
                        .disableCachingNullValues();
        RedisCacheManager cacheManager = RedisCacheManager.builder(factory)
                .cacheDefaults(config)
                .build();
        return cacheManager;
    }
}
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

为什么要写配置类呢,因为自带的兼容性不好,我使用自带了报错,所以自己重写配置,覆盖官方自带的。

# 连接池

如果还是用 Jedis 自带的连接方式,那么容易出现超时问题,自带的连接方式:Jedis jedis = new Jedis("192.168.199.27",6379);。

我们需要使用连接池来连接 Redis,防止出现超时问题

public class JedisPoolUtils {
    
    private static volatile JedisPool jedisPool = null;

    private JedisPoolUtils() {
    }
    
    public static JedisPool getJedisPoolInstance(){
        if(null == jedisPool){
            synchronized (JedisPoolUtils.class){
                if(null == jedisPool){
                    JedisPoolConfig poolConfig = new JedisPoolConfig();
                    // 一个 pool 可分配多少个 jedis 实例
                    poolConfig.setMaxTotal(200);
                    // 一个 pool 最多有多少个状态为 idle(空闲)的 jedis 实例
                    poolConfig.setMaxIdle(32);
                    // 表示当 borrow 一个 jedis 实例时,最大的等待毫秒数
                    poolConfig.setMaxWaitMillis(100 * 1000);
                    poolConfig.setBlockWhenExhausted(true);
                    poolConfig.setTestOnBorrow(true);
                    
                    jedisPool = new JedisPool(poolConfig, "192.168.199.27", 6379, 60000);
                }
            }
        }
        return jedisPool;
    }
    
    public static void release(JedisPool jedisPool, Jedis jedis){
        if(null != jedis){
        }
    }
}
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

连接池参数:

  • MaxTotal:控制一个 pool 可分配多少个 jedis 实例,通过 pool.getResource() 来 获取;如果赋值为 -1,则表示不限制;如果 pool 已经分配了 MaxTotal 个 jedis 实例,则此时 pool 的状态为 exhausted

  • maxIdle:控制一个 pool 最多有多少个状态为 idle(空闲)的 jedis 实例

  • MaxWaitMillis:表示当 borrow 一个 jedis 实例时,最大的等待毫秒数,如果超过等待时间,则直接抛 JedisConnectionException

# 秒杀版本一

核心思想是利用Redis的事务和乐观锁特性来确保在高并发环境下对商品库存进行正确的减少,同时记录参与秒杀的用户。

# 调用接口前登录Redis设置库存key
[root@wuyimin ~]# redis-cli -p 6379
127.0.0.1:6379> set sk:0101:qt 10
OK
127.0.0.1:6379> keys *
1) "sk:0101:qt"
1
2
3
4
5
6
@RestController
public class SecKill {

    // 定义一个处理秒杀请求的接口
    @RequestMapping("/secKill")
    public boolean secKill(){
        Random random = new Random();
        StringBuilder sb = new StringBuilder();
        // 随机生成一个6位数字的用户ID,仅作示例
        for (int i = 0; i < 6; i++) {
            int code = random.nextInt(10);
            sb.append(code);
        }

        // 执行秒杀操作
        return doSecKill(sb.toString(), "123");
    }
    
    // 执行秒杀的具体逻辑
    public boolean doSecKill(String uid,String productId){
        
        // 参数检查
        if(uid == null || productId == null){
            return false;
        }
        
        // 获取Redis连接
        Jedis jedis = JedisPoolUtils.getJedisPoolInstance().getResource();
        // 定义秒杀商品的库存键和参与秒杀的用户集合键
        String kcKey = "sk:" + productId + ":qt"; // 库存Key
        String userKey = "sk:" + productId + ":user"; // 用户Key
        
        // 使用watch命令对库存进行监视
        jedis.watch(kcKey);
        
        // 获取商品库存
        String kc = jedis.get(kcKey);
        // 如果库存不存在,表示秒杀尚未开始
        if(kc == null){
            System.out.println("秒杀没有开始");
            jedis.close();
            return false;
        }
        
        // 判断库存是否已经卖完
        if(Integer.parseInt(kc) <= 0){
            System.out.println("秒杀已经结束");
            jedis.close();
            return false;
        }
        
        // 使用事务处理秒杀逻辑
        Transaction multi = jedis.multi(); // 开启事务
        multi.decr(kcKey); // 减少库存
        multi.sadd(userKey, uid); // 将用户添加到秒杀成功的集合中
        List<Object> exec = multi.exec(); // 提交事务
        // 判断事务是否执行成功
        if(exec == null || exec.size() == 0){
            System.out.println("秒杀失败");
            jedis.close();
            return false;
        }

        System.out.println("秒杀成功");
        jedis.close();
        return true;
    }
}
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
59
60
61
62
63
64
65
66
67
68

关键点解析:

  1. 秒杀接口:secKill方法模拟了一个秒杀请求,其中随机生成了一个用户ID,然后调用doSecKill方法执行秒杀逻辑。
  2. 监视库存:通过WATCH命令监视商品库存的键,这是乐观锁的关键,确保在事务执行期间库存没有被其他操作改变。
  3. 事务操作:MULTI命令开始事务,随后的DECRBY和SADD操作被放入事务队列中,最后使用EXEC命令提交事务。
  4. 事务执行结果判断:如果EXEC命令返回结果为空或大小为0,表示事务执行失败,这通常是因为在事务执行前监视的库存键被修改。

存在的问题:

该代码示例虽然使用了Redis的乐观锁来防止库存变为负数,但在高并发场景下可能导致“库存遗留”问题,即实际可以秒杀的库存没有完全被秒杀完。原因在于,如果有大量用户同时参与秒杀,除了第一个抢购成功的用户外,其他用户的事务都会因为库存键被修改而失败。

解决方案:

  • 使用Lua脚本:将检查库存、减库存、记录用户这几个操作

# 秒杀版本二

秒杀版本二通过Lua脚本解决了秒杀场景中的库存遗留问题。这个问题主要是由于Redis的乐观锁机制导致的,其中大量的并发请求导致只有少数请求能成功修改库存,而大多数请求因为库存未改变而失败,造成实际上还有库存但系统显示已经售完的状况。

什么是库存遗留问题? 即系统告诉用户已经秒光,可是还有库存。原因:就是乐观锁导致很多请求都失败,先点的没秒到,后点的可能秒到了。

通过 lua 脚本解决争抢问题,实际上是 Redis 利用其单线程的特性,用任务队列的方式解决多任务并发问题。

脚本主要执行以下逻辑:

  1. 检查用户是否已经秒杀过:通过sismember命令查询用户是否已经在秒杀成功的用户集合中。如果用户已经存在,则返回特定的状态码(示例中为2)表示用户重复秒杀。
  2. 检查商品库存:获取商品库存数量,如果库存数量小于等于0,则返回状态码(示例中为0)表示库存已抢空。
  3. 执行秒杀操作:如果库存充足,且用户未秒杀过,使用decr命令减少库存数量,并用sadd命令将用户添加到秒杀成功的用户集合中。操作成功后返回状态码(示例中为1)表示抢购成功。
@RestController
public class SecKillByScript {
    
    // Lua脚本字符串,用于实现秒杀逻辑的原子操作
    static String secKillScript = 
        // KEYS[1] 是用户ID, KEYS[2] 是产品ID
        "local userid=KEYS[1]; \n" +
        "local prodid=KEYS[2];\n" +
        // 生成库存键和用户集合键
        "local qtkey=\"sk:\"..prodid..\":qt\";\n" +
        "local usersKey=\"sk:\"..prodid..\":user\";\n" +
        // 检查用户是否已经参与秒杀
        "local userExists=redis.call(\"sismember\",usersKey,userid);\n" +
        "if tonumber(userExists)==1 then \n" +
        " return 2;\n" +
        "end\n" +
        // 获取产品当前库存
        "local num= redis.call(\"get\" ,qtkey);\n" +
        // 如果库存不足,返回0
        "if tonumber(num)<=0 then \n" +
        " return 0; \n" +
        // 否则,减少库存,并将用户添加到秒杀成功集合中
        "else \n" +
        " redis.call(\"decr\",qtkey);\n" +
        "redis.call(\"sadd\",usersKey,userid);\n" +
        "end\n" +
        // 返回1表示秒杀成功
        "return 1";

    @RequestMapping("/secKillByScript")
    public boolean secKill(){
        // 随机生成一个用户ID,用于模拟不同的用户请求
        Random random = new Random();
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < 6; i++) {
            int code = random.nextInt(10);
            sb.append(code);
        }

        // 调用doSecKill方法执行秒杀逻辑
        return doSecKill(sb.toString(), "123");
    }

    @RequestMapping("/doSecKillByScript")
    public boolean doSecKill(String userid,String productId){
        // 从连接池获取Jedis连接
        Jedis jedis = JedisPoolUtils.getJedisPoolInstance().getResource();
        
        // 将Lua脚本加载到Redis服务器,返回脚本的SHA1校验和
        String shal = jedis.scriptLoad(secKillScript);
        // 执行Lua脚本,并传入参数:用户ID和产品ID
        Object evalsha = jedis.evalsha(shal, 2, userid, productId);

        // 解析Lua脚本执行的返回值
        String value = String.valueOf(evalsha);
        
        // 根据返回值,输出不同的结果
        if("0".equals(value)){
            System.out.println("已抢空");
        }else if ("1".equals(value)){
            System.out.println("抢购成功");
        }else if("2".equals(value)){
            System.out.println("该用户已经抢过了");
        }else {
            System.out.println("抢购异常");
        }
        
        // 关闭Jedis连接
        jedis.close();
        return true;
    }
}
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
59
60
61
62
63
64
65
66
67
68
69
70
71
72

使用Lua脚本的优势

  • 原子性操作:Lua脚本在Redis中是原子性执行的,这意味着一旦开始执行脚本,就不会被其他命令中断,直到脚本执行完成。这保证了在高并发的秒杀操作中,每一个操作都是完整执行的。
  • 减少网络开销:将整个秒杀逻辑封装在一个Lua脚本中执行,避免了多次网络往返带来的延迟。
  • 利用Redis单线程的特性:通过队列的方式顺序执行任务,有效地解决了并发问题。

# 测试

利用能并发的工具访问 /secKillByScript 即可。

这里使用 ab 工具,首先安装它

yum install -y httpd-tools
1

安装完后,在某个目录创建一个文件,模拟表单提交参数

vim postfile
1

添加内容:(以 & 符号结尾)

prodid=0101&
1

启动测试:

ab -n 2000 -c 200 -k -p ~/postfile -T application/x-www-form-urlencoded http://192.168.199.27:8080/secKill
1
  • -n 2000:总共发起 2000 次请求。
  • -c 200:并发数为 200,即同时有 200 个请求。
  • -k:启用 HTTP KeepAlive 特性,即在一个 HTTP 会话中执行多个请求,而不是每个请求都打开一个新的连接。
  • -p ~/postfile:指定 POST 请求的数据文件,位于用户主目录下的 postfile 文件。
  • -T application/x-www-form-urlencoded:设置请求内容类型为表单数据。

命令的目的是对 192.168.199.27 这个 IP 地址上运行的、监听在 8080 端口的 Web 应用的 /secKill 路径发起一次性能测试,通过模拟 200 个并发用户共发送 2000 次请求来测试服务器的处理能力。

通过ipconfig查看本机IP地址,192.168.199.27 是你本地的 IP 地址,因为是从 Linux 系统访问本机的项目。

image-20240323011355440

编辑此页 (opens new window)
上次更新: 2024/12/28, 18:32:08
Redis - Java整合
Redis - 两种持久化

← Redis - Java整合 Redis - 两种持久化→

Theme by Vdoing | Copyright © 2019-2025 程序员scholar
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式