十. Redis 事务和 “锁机制”——> 并发秒杀处理的详细说明

十. Redis 事务和 "锁机制"------> 并发秒杀处理的详细说明

@
目录

  • [十. Redis 事务和 "锁机制"------> 并发秒杀处理的详细说明](#十. Redis 事务和 “锁机制”——> 并发秒杀处理的详细说明)
  • [1. Redis 的事务是什么?](#1. Redis 的事务是什么?)
  • [2. Redis 事务三特性](#2. Redis 事务三特性)
  • [3. Redis 关于事务相关指令 Multi、Exec、discard和 "watch & unwatch"](#3. Redis 关于事务相关指令 Multi、Exec、discard和 “watch & unwatch”)
    • [3.1 快速入门(演示 Redis 事务控制)](#3.1 快速入门(演示 Redis 事务控制))
    • [3.2 注意事项和细节](#3.2 注意事项和细节)
  • [4. Redis 事务冲突及解决方案(悲观锁,乐观锁) watch & unwatch](#4. Redis 事务冲突及解决方案(悲观锁,乐观锁) watch & unwatch)
    • [4.1 "悲观锁" 解决](#4.1 “悲观锁” 解决)
    • [4.2 "乐观锁" 解决](#4.2 “乐观锁” 解决)
    • [4.3 watch & unwatch](#4.3 watch & unwatch)
  • [5. 案例演示:火车票-抢票(解决超卖,库存遗留)问题](#5. 案例演示:火车票-抢票(解决超卖,库存遗留)问题)
    • [5.1 案例思路分析:](#5.1 案例思路分析:)
    • [5.2 完成基本购票流程,暂不考虑事务和并发问题](#5.2 完成基本购票流程,暂不考虑事务和并发问题)
    • [5.3 抢票并发模拟,出现超卖问题](#5.3 抢票并发模拟,出现超卖问题)
    • [5.4 Redis 连接池技术](#5.4 Redis 连接池技术)
    • [5.5 利用 Redis 的事务机制,解决超卖问题(使用 watch,multi )](#5.5 利用 Redis 的事务机制,解决超卖问题(使用 watch,multi ))
    • [5.6 抢票并发模拟,分析出现库存遗留问题](#5.6 抢票并发模拟,分析出现库存遗留问题)
    • [5.7 运用 LUA 脚本(解决超卖,和库存遗留问题)](#5.7 运用 LUA 脚本(解决超卖,和库存遗留问题))
  • [6. 最后:](#6. 最后:)

1. Redis 的事务是什么?

  1. Redis 事务时一个单独的隔离操作 :事务中的所有命令都会序列化,按顺序地执行。
  2. 事务在执行的过程中,不会被其他客户端发送来的命令请求所打断,中停。
  3. Redis 事务的主要作用就是串联 多个命令防止别的命令插队

2. Redis 事务三特性

  1. 单独的隔离操作:
  1. Redis 事务时一个单独的隔离操作 :事务中的所有命令都会序列化,按顺序地执行。
  2. 事务在执行的过程中,不会被其他客户端发送来的命令请求所打断,中停。
  1. 没有隔离级别的概念:

队列中的命令(指令),在没有提交前都不会实际被执行。

  1. 不保证原子性:

事务执行过程中,如果有指令执行失败,其他的指令仍然会被执行,没有回滚

MySQL中的事务是支持回滚的,而 Redis 中的事务是不支持回滚的。

3. Redis 关于事务相关指令 Multi、Exec、discard和 "watch & unwatch"




Redis 事务指令示意图:

上图解读:

  1. 从输入 multi 命令开始,输入的命令都会依次 进入命令队列 中,但不会执行类似(MySQL的 start transaction 开始事务)。
  2. 输入 Exec 命令后,Redis 会将之前的命令队列中的命令依次执行(类似于 MySQL的 commit 提交事务)。
  3. 组队的过程中可以通过 discard 来放弃组队(类似 MySQL的 rollback 回滚事务)
  4. 说明:Redis 事务和 MySQL 事务本质是完全不同的。 ------> MySQL中的事务是支持回滚的,而 Redis 中的事务是不支持回滚的。

3.1 快速入门(演示 Redis 事务控制)

sh 复制代码
127.0.0.1:6379> multi
sh 复制代码
127.0.0.1:6379(TX)> set k1 v1
QUEUED
127.0.0.1:6379(TX)> set k2 v2
QUEUED
127.0.0.1:6379(TX)> set k3 v3
QUEUED
127.0.0.1:6379(TX)> 
sh 复制代码
127.0.0.1:6379(TX)> exec

3.2 注意事项和细节

  1. 组队的过程中, 可以通过 discard 来放弃组队。注意是在 [TX] 队列当中,还没有执行 exce 命令之前,才有效。
sh 复制代码
127.0.0.1:6379(TX)> discard
  1. 如果在组队阶段报错(这里的报错信息指的是,命令输入错误,语法错误,编译上的错误) 会导致 exec 失败 那么事务的所有指令都不会被执行
  1. 如果组队成功(multii ), 但是指令有不能正常执行的,那么 exec 提交,会出现有成功有失败情况,也就是事务得到部分执行, 这种情况下, Redis 事务不具备原子性。
sh 复制代码
127.0.0.1:6379> multi
OK
127.0.0.1:6379(TX)> set k1 "v1"
QUEUED
127.0.0.1:6379(TX)> incr k1
QUEUED
127.0.0.1:6379(TX)> set k2 "v2"
QUEUED
127.0.0.1:6379(TX)> exec
1) OK
2) (error) ERR value is not an integer or out of range
3) OK
127.0.0.1:6379> 

4. Redis 事务冲突及解决方案(悲观锁,乐观锁) watch & unwatch

我们先来看一个经典的抢票 问题。

  1. 一个请求(用户)想购买 6 张票
  2. 一个请求(用户)想购买 5 张票
  3. 一个请求(用户)想购买 1 张票

上述解图:

一共只有10张票,但是并发开始,三个用户(三个请求),买 6 张,买 5 张,买 1 张票的。同时进入购票系统,并发同时刻进入判断,都显示还剩10张票(还没有减),最后执行减票,超卖了 2张票。

4.1 "悲观锁" 解决

上图解图:

  1. 悲观锁(Pessimistic Lock), 顾名思义,就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁。
  2. 这样别人/其他请求想要拿到这个数据都会被(block 锁上,因为被锁了就无法修改/拿到数据了),只有直到在他前面的人拿到数据/修改数据后,将锁释放了,它才能将数据拿到。
  3. 悲观锁是锁设计理念 ,传统的关系型数据库里面就用到了很多这种锁机制,比如行锁,表锁,读锁,写锁等等,都是在做操作之前先上锁(防止被其他的人/请求操作,修改了数据,导致数据不一致。)

4.2 "乐观锁" 解决

上图解读:

  1. 乐观锁(Optimistic Lock), 顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁。
  2. 但是在更新的时候会判断一下,在此期间别人/请求是否有去更新了这个数据,可以使用版本号等机制。版本号机制:就是当这个数据被修改了,那么就会产生一个版本信息,如果这个版本信息,与你一开始对应,并应该获取的版本信息不一致,那么就修改失败/无法修改数据(或者说获取的版本信息不一致,拿不到该数据信息)
  3. 乐观锁适用于多读的应用类型,这样可以提高吞吐量。 Redis 就是利用这种 check-and-set 机制实现事务的。
  4. 乐观锁是锁设计理念。

4.3 watch & unwatch

  1. 基本语法: watch key [key ...]
  2. 在执行 multi 之前,先执行 watch key1 [key2],可以监视一个(或多个) key,如果在事务执行之前这个(或这些) key 被其他命令所改动过,那么事务将被打断,停止执行
  3. 这里就可以结合乐观锁机制进行理解。

演示实操:


sh 复制代码
# A 连接
127.0.0.1:6379> watch k1
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379(TX)> incrby k1 1
QUEUED
127.0.0.1:6379(TX)> exec
1) (integer) 100
127.0.0.1:6379> get k1
"100"
sh 复制代码
# B 连接
127.0.0.1:6379> watch k1
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379(TX)> incrby k1 100
QUEUED
127.0.0.1:6379(TX)> exec
(nil)
127.0.0.1:6379> get k1
"100"
127.0.0.1:6379> 

unwatch:

  1. unwatch : 取消 watch 命令对所有 key 的监视。
  2. 如果在执行 watch 命令后, exec 命令和 discard 命令先被执行了的话,那么久不需要再执行 unwatch 了。

5. 案例演示:火车票-抢票(解决超卖,库存遗留)问题

5.1 案例思路分析:

这里我们使用 WEB 项目来演示

思路分析:

  1. 一个 user 只能购买一张票,即不能复购。
  2. 不能出现超购,也就是多卖的情况。
  3. 不能出现火车票遗留问题/库存遗留,即火车票不能留下

5.2 完成基本购票流程,暂不考虑事务和并发问题

  1. 创建 Java Web 项目, 参照以前讲过搭建 Java Web 项目流程即可
  2. 引入相关的 jar 包 和 jquery
  3. 创建 D:sec_kill_ticket\web\index.jsp

index.jsp 代码编写

jsp 复制代码
<%--
  Created by IntelliJ IDEA.
  User: 韩顺平
  Version: 1.0
--%>
<%@ page language="java" contentType="text/html; charset=UTF-8"
         pageEncoding="UTF-8" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
  <title>Insert title here</title>
  <base href="<%=request.getContextPath() + "/"%>">
</head>
<body>
<h1>北京-成都 火车票 ! 秒杀!
</h1>


<form id="secKillform" action="secKillServlet" enctype="application/x-www-form-urlencoded">
  <input type="hidden" id="ticketNo" name="ticketNo" value="bj_cd">
  <input type="button" id="seckillBtn" name="seckillBtn" value="秒杀火车票【北京-成都】"/>
</form>

</body>
<script type="text/javascript" src="script/jquery/jquery-3.1.0.js"></script>
<script type="text/javascript">
  $(function () {
    $("#seckillBtn").click(function () {
      var url = $("#secKillform").attr("action");
      console.log("url->" , url)// secKillServlet,完整的url http://localhost:8080/seckill/secKillServlet
      console.log("serialize->", $("#secKillform").serialize())
      //
      $.post(url, $("#secKillform").serialize(), function (data) {
        if (data == "false") {
          alert("火车票 抢光了:)");
          $("#seckillBtn").attr("disabled", true);
        }
      });
    })
  })
</script>
</html>
java 复制代码
package com.rainbowsea.seckill.redis;


import org.junit.Test;
import redis.clients.jedis.Jedis;

/**
 * 秒杀类
 */
public class SecKillRedis {
    /**
     * 编写一个测试方法-看看是否能够连通指定的 Redis
     */

    @Test
    public void testRedis() {
        Jedis jedis = new Jedis("192.168.76.146", 6379);
        jedis.auth("rainbowsea");  // 设置了密码,需要进行一个验证
        System.out.println(jedis.ping());
        jedis.close(); // 关闭连接

    }
}

关于更多对应:Java程序连接 Redis 的内容,大家可以移步至:🌟🌟🌟 https://blog.csdn.net/weixin_61635597/article/details/145433348?spm=1001.2014.3001.5501

编写:秒杀过程

java 复制代码
package com.rainbowsea.seckill.redis;


import org.junit.Test;
import redis.clients.jedis.Jedis;

/**
 * 秒杀类
 */
public class SecKillRedis {
    /**
     * 秒杀过程
     *
     * @param uid      用户ID
     * @param ticketNo 票的编号,比如 北京-成都的 ticketNo为 bj_cd
     * @return
     */
    public static boolean doSecKill(String uid, String ticketNo) {
        // -uid 和 ticketNo 进行一个非空校验
        if (uid == null || ticketNo == null) {
            return false;
        }


        // - 连接 Redis ,得到一个 jedis 对象
        Jedis jedis = new Jedis("192.168.76.146", 6379);
        jedis.auth("rainbowsea");  // 设置了密码,需要进行一个验证

        // 判断 获取的jedis 是否为空
        if (jedis == null) {
            return false;
        }

        // 拼接票的库存 key
        String stockKey = "sk:" + ticketNo + ":ticket";

        // 拼接秒杀用户要存放到 set 集合对应的key,这个 set 集合可以存放多个 userId(同时set集合有着不可重复的特性,符合我们用户不够复购的特点)
        String userKey = "sk:" + ticketNo + "user";

        // 获取到对应的票的库存
        String stock = jedis.get(stockKey);
        // 获取到对应的票的库存,判断是否为 null
        if (stock == null) {
            System.out.println("秒杀还没有开始,请等待...");
            jedis.close(); // 关闭连接
            return false;
        }


        // - 判断用户是否重复秒杀/复购
        if (jedis.sismember(userKey, uid)) {
            System.out.println(uid + "不能重复秒杀...");
            jedis.close();
            return false;
        }

        // 判断火车票,是否还有剩余
        if (Integer.parseInt(stock) <= 0) {
            System.out.println("票已经卖完了,秒杀结束...");
            jedis.close();
            return false;
        }


        // 可以购买
        // 1.将票的库存量 -1
        jedis.decr(stockKey);
        // 2. 将该用户加入到抢购成功对应 set 集合中
        jedis.sadd(userKey, uid);

        System.out.println(uid + "秒杀成功");
        jedis.close();

        return true;

    }

    /**
     * 编写一个测试方法-看看是否能够连通指定的 Redis
     */

    @Test
    public void testRedis() {
        Jedis jedis = new Jedis("192.168.76.146", 6379);
        jedis.auth("rainbowsea");  // 设置了密码,需要进行一个验证
        System.out.println(jedis.ping());
        jedis.close(); // 关闭连接

    }
}

编写 ,Server 对外的服务

java 复制代码
package com.rainbowsea.seckill.web;

import com.rainbowsea.seckill.redis.SecKillRedis;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Random;

public class SecKillServlet extends HttpServlet {
    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        // 1. 请求时,模拟生成一个 userId
        String userId = new Random().nextInt(10000) + "";
        // 2. 获取用户购买的票的编号
        String ticketNo = request.getParameter("ticketNo");


        // 3. 调用秒杀的方法
        boolean isOk = SecKillRedis.doSecKill(userId, ticketNo);

        // 4. 将结果返回给前端,这个地方可以根据业务需要调整
        response.getWriter().println(isOk);
    }


    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        doPost(request, response);
    }
}
xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
         version="4.0">


    <servlet>
        <servlet-name>SecKillServlet</servlet-name>
        <servlet-class>com.rainbowsea.seckill.web.SecKillServlet</servlet-class>
    </servlet>

    <servlet-mapping>
        <servlet-name>SecKillServlet</servlet-name>
        <url-pattern>/secKillServlet</url-pattern>
    </servlet-mapping>
</web-app>

测试:

向 Redis 中, 增加测试数据。添加上 6 票

sh 复制代码
127.0.0.1:6379> set sk:bj_cd:ticket 6



5.3 抢票并发模拟,出现超卖问题

抢票并发模拟, 出现超卖问题:

1、安装工具 ab 模拟测试

说明: 工具 ab 可以模拟并发发出 Http 请求, 说明(模拟并发 http 请求工具还有 jemeter, postman,我们都使用一下, 开阔眼界, 这里使用 ab 工具)

安装指令: yum install httpd-tools (提示: 保证当前 linux 是可以联网的)

如果你不能联网, 可以使用 rpm 安装, 这里我使用 yum 方式安装


另外, 使用 rpm 方式安装我也给小伙伴说明一下, 如下: -先挂载 centos 安装文件 ios, 这个文件,



进入 cd /run/media/root/CentOS 7 x86_64/Packages

顺序安装

sh 复制代码
apr-1.4.8-3.el7.x86_64.rpm
apr-util-1.5.2-6.el7.x86_64.rpm
httpd-tools-2.4.6-67.el7.centos.x86_64.rpm



测试是否安装成功

这里我们使用 yum 安装,执行如下命令即可:前提是我们要是联网的状态才行。

sh 复制代码
yum install httpd-tools
sh 复制代码
[root@localhost ~]# yum install httpd-tools

安装完后,输入 ab 命令,测试是否安装成功

sh 复制代码
ab
  1. 在 ab 指令执行的当前路径下 创建文件 postfile
sh 复制代码
vi postfile
sh 复制代码
vm postfile 
properties 复制代码
ticketNo=bj_cd&
  1. 执行指令 , 注意保证 linux 可以访问到 Tomcat 所在的服务器.

先查看 Tomcat 所在 Windows 的网络配置情况。

在 Window 系统中输入 cmd ,进入命令行窗口,输入 ipconfig 命令,查看 VM虚拟机的IP地址情况。

确认 Linux 可以 ping 通 Windows

如果 Ping 不通, 确认一下 Windows 防火墙是否关闭.

防火墙关闭是一件比较危险的事情,记得学习操作完之后,回来将防火墙重新打开

指令 , 测试前把 Redis 的数据先重置一下。因为前面我们操作过来, 票已经被卖光了,我们需要重新设置票数,这里还是设置为 6 张票。

如下是我们使用 ab 工具要执行的命令:

sh 复制代码
ab -n 1000 -c 100 -p ~/postfile -T application/x-www-form-urlencoded http://192.168.76.1:9090/seckill/secKillServlet

命令解读:

  1. ab : 是并发工具程序
  2. -n 1000 : 表示一共发出 1000 次 http 请求
  3. -c 100: 表示并发时 100次,你可以理解为 1000次请求,分 10 次发送完毕,每次发出 100次。
  4. -p ~/postfile: 表示发送请求时,携带的参数从当前目录的 postfile 文件读取(就是上述我们刚刚创建的文件)。~ 表示当前路径位置。
  5. -T application/x-www-form-urlencoded: 就是发送数据的编码时基于表单的 url 编码的
  6. ~ 的更多含义,可以移步至:https://blog.csdn.net/m0_67401134/article/details/123973115


http://192.168.198.1:8080/seckill/secKillServlet 就 是 请 求 的 url, 注 意 这 里 的 IP:port/uri 必须写正确.

sh 复制代码
127.0.0.1:6379> ab -n 1000 -c 100 -p ~/postfile -T application/x-www-form-urlencoded http://192.168.76.1:9090/seckill/secKillServlet

执行之后。查看执行结果

5.4 Redis 连接池技术

连接池介绍

  1. 节省每次连接 Redis 服务带来的消耗,把连接好的实例反复利用。

连接池参数:

  • MaxTotal :控制一个 pool 可分配多少个 jedis 实例,通过 pool.getResource() 来获取,如果赋值为 -1 ,则表示不限制。
  • maxldle:控制一个pool最多有多少个状态为 idle(空闲) 的 jedis 实例。
  • MaxWaitMillis:表示当获取一个 jedis 实例时,最大的等待毫米数,如果超过等待时间,则直接抛 JedisConnectionException
  • testOnBorrow:获得一个 jedis 实例的时候是否检查连接可用性(ping()) ;如果为 true ,则得到的 jedis 实例均是可用的。

使用连接池, 优化连接超时:

创建:sec_kill_ticket\src\com\rainbowsea\seckill\utils\JedisPoolUtil. java

java 复制代码
package com.rainbowsea.seckill.uitl;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

/**
 * 使用连接池的方式来获取 Redis 连接
 */
public class JedisPoolUtil {


    // 解读 volatile 作用
    /*
    1. 线程可见性:当一个线程去修改一个共享变量时,另外一个线程可以读取这个修改的值
    2. 顺序的一致性:禁止指令重排
     */
    private static volatile JedisPool jedisPool = null;

    // 使用单例模式,将构造方法私有化

    private JedisPoolUtil() {
    }


    // 单例:保证每次调用返回的 jedisPool是单例的
    public static JedisPool getJedisPoolInstacne() {
        if (null == jedisPool) {

            synchronized (JedisPoolUtil.class) {

                if (null == jedisPool) {

                    JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
                    // 对连接池进行配置
                    jedisPoolConfig.setMaxTotal(200);
                    jedisPoolConfig.setMaxIdle(32);
                    jedisPoolConfig.setMaxWaitMillis(60 * 1000); // 单位是毫秒
                    jedisPoolConfig.setBlockWhenExhausted(true);
                    jedisPoolConfig.setTestOnBorrow(true);
                    jedisPool = new JedisPool(jedisPoolConfig, "192.168.76.146", 6379, 60000, "rainbowsea");

                }
            }
        }


        return jedisPool;
    }


    /**
     * 释放连接资源
     *
     * @param jedis
     */
    public static void release(Jedis jedis) {
        if (null != jedis) {
            jedis.close(); // 如果这个jedis 是从连接池获取的,这里 jedis.close()
            // 就是将 jedis对象/连接,释放到连接池中。
        }

    }
}

线程可见性

简单说一下指令重排

java 复制代码
   // 通过连接池获取 jedis对象/连接
        JedisPool jedisPoolInstacne = JedisPoolUtil.getJedisPoolInstacne();
        Jedis jedis = jedisPoolInstacne.getResource();
        System.out.println("--使用的连接池");
java 复制代码
      JedisPoolUtil.release(jedis);

使用连接池:测试:

5.5 利用 Redis 的事务机制,解决超卖问题(使用 watch,multi )

控制超卖-Redis 事务底层(乐观锁机制分析)

我们要处理的控制的就是,如下这部分代码,因为是这部分代码对 Redis 库当的数据进行操作和修改的,需要进行事务上的控制。

java 复制代码
 // 监控库存,开启 watch 的监控
        jedis.watch(stockKey);
java 复制代码
        // 使用事务,完成秒杀
        Transaction multi = jedis.multi();

        // 组成操作
        multi.decr(stockKey); // 减去票的库存
        multi.sadd(userKey, uid); // 将该抢到票的用户加入到抢购成功的 set 集合中
        // 执行
        List<Object> results = multi.exec();

        if (results == null || results.size() == 0) {
            System.out.println("抢票失败...");
            JedisPoolUtil.release(jedis);
            return false;
        }

        System.out.println(uid + "秒杀成功");
        //jedis.close();
        JedisPoolUtil.release(jedis);
        return true;

完成测试:

重启 Tomcat:

重置 Redis 相关数据:


执行指令:

sh 复制代码
[root@localhost ~]# ab -n 1000 -c 100 -p ~/postfile -T application/x-www-form-urlencoded http://192.168.76.1:9090/seckill/secKillServlet



5.6 抢票并发模拟,分析出现库存遗留问题

抢票并发模拟,出现库存遗留问题

这里我们演示一下:出现库存遗留问题

先重置一下 redis 的数据

这里我们把库存量设的较大为 600

sh 复制代码
127.0.0.1:6379> set sk:bj_cd:ticket 600
OK
127.0.0.1:6379> get sk:bj_cd:ticket
"600"
127.0.0.1:6379> del sk:bj_cd:user
(integer) 1
127.0.0.1:6379> smembers sk:bj_cd:user
(empty array)
127.0.0.1:6379> 

执行指令

sh 复制代码
[root@localhost ~]# ab -n 1000 -c 300 -p ~/postfile -T application/x-www-form-urlencoded http://192.168.76.1:9090/seckill/secKillServlet

这里我们将并发数变大 -c 300

运行结果:


从运行结果上看:我们可以看到600张票,1000个人抢,仅仅只卖出了 6 张票。这库存问题十分严重

出现库存遗留问题的分析

简单的理解就是:因为乐观锁的机制,这里 1000 次请求,分 4次,每次 300个请求,请求完毕的时候,当 300个请求同时涌入到 Redis ,向 Redis 要数据的时候,但是只有最前面的一次可以被请求到对应版本的信息(假设这里是 1.0 版本的数据),当最前面的一次请求获取到 Redis 当中的数据后,并修改了Redis 当中的数据,因为修改了数据,这时候(它们想要获取的数值的版本信息就变为了 1.1了)。因为它们一开始请求获取的是 1.0版本的数据,这时候的版本的数据变为了1.1 了,那它们这些请求就被打断了。所以这些请求都被打断无法获取到数据了,必须重新发出新的请求才行(获取到修改后的 1.1 版本的数据)。就这样导致大量的请求被打断了,无法获取到Redis 的数据值,也就自然无法修改Redis 当中的数值了。导致存有大量的票没有卖出。

5.7 运用 LUA 脚本(解决超卖,和库存遗留问题)

LUA 介绍

  1. Lua 时一个小巧的脚本语言,Lua 脚本可以很容易的被 C/C++代码调用,也可以反过来调用C/C++的函数,Lua 并没有提供强大的库,一个完整的 Lua 解释器不过 200K,所以 Lua 不适合作为开发独立应用程序的语言,而是作为 嵌入式脚本语言。
  2. 很多应用程序,游戏使用 LUA 作为自己的嵌入式的脚本语言,以此来实现可配置性,可扩展性。
  3. 将复杂的或者多步的 Redis 操作,写为一个脚本,一次提交给 redis 执行,减少反复连接 redis 的次数。提升性能。
  4. LUA脚本是类似 Redis 事务,有一定的原子性,不会被其他命令插队 ,可以完成一些 Redis 事务性的操作。
  5. Redis 的 Lua 脚本功能,只有在 Redis 2.6 以上的版本才可以使用,这里我们使用的是 Redis 6 可以使用。
  6. 通过 lua 脚本解决争抢问题,实际上是 Redis 利用其单线程的特性 ,用任务队列 的方式解决多任务并发问题。

LUA 脚本, 解决库存遗留-思路分析图

上图解图:

  1. LUA 脚本是类似于 Redis 事务,有一定的原子性,不会被其他命令插队 ,能完成 Redis 事务性的操作。
  2. 通过 lua 脚本解决争抢问题,Redis 利用其单线程的特性,将请求形成任务队列, 从 而解决多任务并发问题
  3. 简单的理解: 就是将 Redis 多个命令组合在一起,成为一个命令,然后,再将这个组合成的命令,按照顺序依次的存入到某个队列当中,存后之后,就算1000个请求来了,也得按照 Lua 编写的顺序,依次执行一个一个用户的请求。不会造成大量的请求同时涌入,让大量的请求失效,中断。

LUA 脚本, 解决库存遗留-代码实现:

lua 复制代码
local userid=KEYS[1]; --  获取传入的第一个参数
local ticketno=KEYS[2]; --  获取传入的第二个参数 local stockKey='sk:'..ticketno..:ticket; --  拼接 stockKey local usersKey='sk:'..ticketno..:user; --  拼接  usersKey
local userExists=redis.call(sismember,usersKey,userid); --  查 看 在 redis  的 usersKey set 中是否有该用户
if tonumber(userExists)==1 then
return 2; --  如果该用户已经购买,  返回 2
end
local num= redis.call("get" ,stockKey); --  获取剩余票数
if tonumber(num)<=0 then
return 0; --  如果已经没有票, 返回 0
else
redis.call("decr",stockKey); --  将剩余票数-1
redis.call("sadd",usersKey,userid); --  将抢到票的用户加入 set
end
return 1 --  返回 1 表示抢票成功

想要了解更多关于 LUA脚本的内容,大家可以移步至🌟🌟🌟 https://blog.csdn.net/qq_41286942/article/details/124161359

这里:我们根据 LUA脚本对 Java程序进行修改。如下图所示:

java 复制代码
 /**
     * 说明
     * 1. 这个脚本字符串是在lua脚本上修改的, 但是要注意不完全是字符串处理
     * 2. 比如 : 这里我就使用了 \" , 还有换行使用了 \r\n
     * 3. 这些都是细节,如果你直接把lua脚本粘贴过来,不好使,一定要注意细节
     * 4. 如果写的不成功,就在这个代码上修改即可
     */
    static String secKillScript = "local userid=KEYS[1];\r\n" +
            "local ticketno=KEYS[2];\r\n" +
            "local stockKey='sk:'..ticketno..\":ticket\";\r\n" +
            "local usersKey='sk:'..ticketno..\":user\";\r\n" +
            "local userExists=redis.call(\"sismember\",usersKey,userid);\r\n" +
            "if tonumber(userExists)==1 then \r\n" +
            "   return 2;\r\n" +
            "end\r\n" +
            "local num= redis.call(\"get\" ,stockKey);\r\n" +
            "if tonumber(num)<=0 then \r\n" +
            "   return 0;\r\n" +
            "else \r\n" +
            "   redis.call(\"decr\",stockKey);\r\n" +
            "   redis.call(\"sadd\",usersKey,userid);\r\n" +
            "end\r\n" +
            "return 1";
java 复制代码
package com.rainbowsea.seckill.redis;


import com.rainbowsea.seckill.uitl.JedisPoolUtil;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;

/**
 * 使用 LUA脚本进行编写完成秒杀
 */
public class SecKillRedisByLua {

    /**
     * 说明
     * 1. 这个脚本字符串是在lua脚本上修改的, 但是要注意不完全是字符串处理
     * 2. 比如 : 这里我就使用了 \" , 还有换行使用了 \r\n
     * 3. 这些都是细节,如果你直接把lua脚本粘贴过来,不好使,一定要注意细节
     * 4. 如果写的不成功,就在这个代码上修改即可
     */
    static String secKillScript = "local userid=KEYS[1];\r\n" +
            "local ticketno=KEYS[2];\r\n" +
            "local stockKey='sk:'..ticketno..\":ticket\";\r\n" +
            "local usersKey='sk:'..ticketno..\":user\";\r\n" +
            "local userExists=redis.call(\"sismember\",usersKey,userid);\r\n" +
            "if tonumber(userExists)==1 then \r\n" +
            "   return 2;\r\n" +
            "end\r\n" +
            "local num= redis.call(\"get\" ,stockKey);\r\n" +
            "if tonumber(num)<=0 then \r\n" +
            "   return 0;\r\n" +
            "else \r\n" +
            "   redis.call(\"decr\",stockKey);\r\n" +
            "   redis.call(\"sadd\",usersKey,userid);\r\n" +
            "end\r\n" +
            "return 1";

    //使用lua脚本完成秒杀的核心方法
    public static boolean doSecKill(String uid, String ticketNo) {
        //先从redis连接池,获取连接
        JedisPool jedisPoolInstance = JedisPoolUtil.getJedisPoolInstacne();
        Jedis jedis = jedisPoolInstance.getResource();
        //就是将lua脚本进行加载
        String sha1 = jedis.scriptLoad(secKillScript);
        //evalsha是根据指定的 sha1校验码, 执行缓存在服务器的脚本
        Object result = jedis.evalsha(sha1, 2, uid, ticketNo);
        String resString = String.valueOf(result);

        //根据lua脚本执行返回的结果,做相应的处理
        if ("0".equals(resString)) {
            System.out.println("票已经卖光了..");
            JedisPoolUtil.release(jedis);  // 归还连接给,redis 连接池
            return false;
        }

        if ("2".equals(resString)) {
            System.out.println("不能重复购买..");
            JedisPoolUtil.release(jedis);  // 归还连接给,redis 连接池
            return false;
        }

        if ("1".equals(resString)) {
            System.out.println("抢购成功");
            JedisPoolUtil.release(jedis);  // 归还连接给,redis 连接池
            return true;
        } else {
            System.out.println("购票失败..");
            JedisPoolUtil.release(jedis);  // 归还连接给,redis 连接池
            return false;
        }

    }
}

修改:SecKillServlet 当中的 doPost 方法,使用 LUA脚本完成秒杀

java 复制代码
package com.rainbowsea.seckill.web;

import com.rainbowsea.seckill.redis.SecKillRedis;
import com.rainbowsea.seckill.redis.SecKillRedisByLua;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Random;

public class SecKillServlet extends HttpServlet {
    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        // 1. 请求时,模拟生成一个 userId
        String userId = new Random().nextInt(10000) + "";
        // 2. 获取用户购买的票的编号
        String ticketNo = request.getParameter("ticketNo");


        // 3. 调用秒杀的方法
        //boolean isOk = SecKillRedis.doSecKill(userId, ticketNo);
        // 3.调用 Lua 脚本的完成秒杀方法
        boolean isOk = SecKillRedisByLua.doSecKill(userId, ticketNo);

        // 4. 将结果返回给前端,这个地方可以根据业务需要调整
        response.getWriter().println(isOk);
    }


    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        doPost(request, response);
    }
}

测试:

重启抢票程序-Tomcat

重置 Redis 数据

sh 复制代码
127.0.0.1:6379> set sk:bj_cd:ticket 600
OK
127.0.0.1:6379> get sk:bj_cd:ticket
"600"
127.0.0.1:6379> del sk:bj_cd:user
(integer) 1
127.0.0.1:6379> smembers sk:bj_cd:user
(empty array)
127.0.0.1:6379> 


执行指令

sh 复制代码
[root@localhost ~]# ab -n 2000 -c 200 -p ~/postfile -T application/x-www-form-urlencoded http://192.168.76.1:9090/seckill/secKillServlet



6. 最后:

"在这个最后的篇章中,我要表达我对每一位读者的感激之情。你们的关注和回复是我创作的动力源泉,我从你们身上吸取了无尽的灵感与勇气。我会将你们的鼓励留在心底,继续在其他的领域奋斗。感谢你们,我们总会在某个时刻再次相遇。"