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

1. Redis 的事务是什么?

  1. Redis 事务时一个单独的隔离操作 :事务中的所有命令都会序列化,按顺序地执行。

  2. 事务在执行的过程中,不会被其他客户端发送来的命令请求所打断,中停。

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

2. Redis 事务三特性

1、单独的隔离操作:

  1. Redis 事务时一个单独的隔离操作 :事务中的所有命令都会序列化,按顺序地执行。

  2. 事务在执行的过程中,不会被其他客户端发送来的命令请求所打断,中停。

2、没有隔离级别的概念:

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

3、不保证原子性:

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

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 事务控制)

复制代码
127.0.0.1:6379> multi
复制代码
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)> 
复制代码
127.0.0.1:6379(TX)> exec

3.2 注意事项和细节

1、组队的过程中, 可以通过 discard 来放弃组队。注意是在 [TX] 队列当中,还没有执行 exce 命令之前,才有效。

复制代码
127.0.0.1:6379(TX)> discard

2、如果在组队阶段报错(这里的报错信息指的是,命令输入错误,语法错误,编译上的错误) 会导致 exec 失败 那么事务的所有指令都不会被执行

3、如果组队成功(multii ), 但是指令有不能正常执行的,那么 exec 提交,会出现有成功有失败情况,也就是事务得到部分执行, 这种情况下, Redis 事务不具备原子性。

复制代码
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. 这里就可以结合乐观锁机制进行理解。

演示实操:

复制代码
# 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"

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 代码编写

复制代码
<%--
  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>
复制代码
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(); // 关闭连接

    }
}

编写:秒杀过程

复制代码
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 对外的服务

复制代码
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 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 票

复制代码
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

顺序安装

复制代码
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 安装,执行如下命令即可:前提是我们要是联网的状态才行。

复制代码
yum install httpd-tools

[root@localhost ~]# yum install httpd-tools

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

复制代码
ab

2、在 ab 指令执行的当前路径下 创建文件 postfile

复制代码
vi postfile

vm postfile 
复制代码
ticketNo=bj_cd&

3、执行指令 , 注意保证 linux 可以访问到 Tomcat 所在的服务器.

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

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

确认 Linux 可以 ping 通 Windows

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

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

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

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

复制代码
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 必须写正确.

复制代码
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

复制代码
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对象/连接,释放到连接池中。
        }

    }
}

线程可见性

简单说一下指令重排

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

使用连接池:测试:

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

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

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

复制代码
 // 监控库存,开启 watch 的监控
        jedis.watch(stockKey);
复制代码
        // 使用事务,完成秒杀
        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 相关数据:

执行指令:

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

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

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

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

先重置一下 redis 的数据

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

复制代码
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> 

执行指令

复制代码
[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 脚本, 解决库存遗留-代码实现:

复制代码
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脚本对 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";

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脚本完成秒杀

复制代码
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 数据

复制代码
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> 

执行指令

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

文章转载自: ++Rainbow-Sea++

原文链接: 十. Redis 事务和 "锁机制"------> 并发秒杀处理的详细说明 - Rainbow-Sea - 博客园

体验地址: 引迈 - JNPF快速开发平台_低代码开发平台_零代码开发平台_流程设计器_表单引擎_工作流引擎_软件架构

相关推荐
fredinators18 分钟前
数据库flask访问
数据库·oracle·flask
向葭奔赴♡24 分钟前
Spring Boot 分模块:从数据库到前端接口
数据库·spring boot·后端
JosieBook1 小时前
【数据库】时序数据库选型指南:在大数据与工业4.0时代,为何 Apache IoTDB 成为智慧之选?
大数据·数据库·时序数据库
程序员三明治1 小时前
详解Redis锁误删、原子性难题及Redisson加锁底层原理、WatchDog续约机制
java·数据库·redis·分布式锁·redisson·watchdog·看门狗
chenzhou__1 小时前
MYSQL学习笔记(个人)(第十五天)
linux·数据库·笔记·学习·mysql
一只自律的鸡2 小时前
【MySQL】第二章 基本的SELECT语句
数据库·mysql
liliangcsdn3 小时前
如何使用python创建和维护sqlite3数据库
数据库·sqlite
TDengine (老段)9 小时前
TDengine 数学函数 DEGRESS 用户手册
大数据·数据库·sql·物联网·时序数据库·iot·tdengine
TDengine (老段)9 小时前
TDengine 数学函数 GREATEST 用户手册
大数据·数据库·物联网·时序数据库·iot·tdengine·涛思数据
安当加密10 小时前
云原生时代的数据库字段加密:在微服务与 Kubernetes 中实现合规与敏捷的统一
数据库·微服务·云原生