十. 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 的事务是什么?
- Redis 事务时一个单独的隔离操作 :事务中的所有命令都会序列化,按顺序地执行。
- 事务在执行的过程中,不会被其他客户端发送来的命令请求所打断,中停。
- Redis 事务的主要作用就是串联 多个命令防止别的命令插队 。
2. Redis 事务三特性
- 单独的隔离操作:
- Redis 事务时一个单独的隔离操作 :事务中的所有命令都会序列化,按顺序地执行。
- 事务在执行的过程中,不会被其他客户端发送来的命令请求所打断,中停。
- 没有隔离级别的概念:
队列中的命令(指令),在没有提交前都不会实际被执行。
- 不保证原子性:
事务执行过程中,如果有指令执行失败,其他的指令仍然会被执行,没有回滚 。
MySQL中的事务是支持回滚的,而 Redis 中的事务是不支持回滚的。
3. Redis 关于事务相关指令 Multi、Exec、discard和 "watch & unwatch"
Redis 事务指令示意图:
上图解读:
- 从输入
multi
命令开始,输入的命令都会依次 进入命令队列 中,但不会执行类似(MySQL的 start transaction 开始事务)。- 输入
Exec
命令后,Redis 会将之前的命令队列中的命令依次执行(类似于 MySQL的 commit 提交事务)。- 组队的过程中可以通过
discard
来放弃组队(类似 MySQL的 rollback 回滚事务)- 说明: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 注意事项和细节
- 组队的过程中, 可以通过
discard
来放弃组队。注意是在[TX]
队列当中,还没有执行exce
命令之前,才有效。
sh
127.0.0.1:6379(TX)> discard
- 如果在组队阶段报错(这里的报错信息指的是,命令输入错误,语法错误,编译上的错误) 会导致 exec 失败 那么事务的所有指令都不会被执行。
- 如果组队成功(
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
我们先来看一个经典的抢票 问题。
- 一个请求(用户)想购买 6 张票
- 一个请求(用户)想购买 5 张票
- 一个请求(用户)想购买 1 张票
上述解图:
一共只有10张票,但是并发开始,三个用户(三个请求),买 6 张,买 5 张,买 1 张票的。同时进入购票系统,并发同时刻进入判断,都显示还剩10张票(还没有减),最后执行减票,超卖了 2张票。
4.1 "悲观锁" 解决
上图解图:
- 悲观锁(Pessimistic Lock), 顾名思义,就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁。
- 这样别人/其他请求想要拿到这个数据都会被(block 锁上,因为被锁了就无法修改/拿到数据了),只有直到在他前面的人拿到数据/修改数据后,将锁释放了,它才能将数据拿到。
- 悲观锁是锁设计理念 ,传统的关系型数据库里面就用到了很多这种锁机制,比如行锁,表锁,读锁,写锁等等,都是在做操作之前先上锁(防止被其他的人/请求操作,修改了数据,导致数据不一致。)
4.2 "乐观锁" 解决
上图解读:
- 乐观锁(Optimistic Lock), 顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁。
- 但是在更新的时候会判断一下,在此期间别人/请求是否有去更新了这个数据,可以使用版本号等机制。版本号机制:就是当这个数据被修改了,那么就会产生一个版本信息,如果这个版本信息,与你一开始对应,并应该获取的版本信息不一致,那么就修改失败/无法修改数据(或者说获取的版本信息不一致,拿不到该数据信息)
- 乐观锁适用于多读的应用类型,这样可以提高吞吐量。 Redis 就是利用这种
check-and-set
机制实现事务的。- 乐观锁是锁设计理念。
4.3 watch & unwatch
- 基本语法:
watch key [key ...]
- 在执行
multi
之前,先执行 watch key1 [key2],可以监视一个(或多个) key,如果在事务执行之前这个(或这些) key 被其他命令所改动过,那么事务将被打断,停止执行 。 - 这里就可以结合乐观锁机制进行理解。
演示实操:
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:
unwatch
: 取消 watch 命令对所有 key 的监视。- 如果在执行 watch 命令后,
exec
命令和discard
命令先被执行了的话,那么久不需要再执行unwatch
了。
5. 案例演示:火车票-抢票(解决超卖,库存遗留)问题
5.1 案例思路分析:
这里我们使用 WEB 项目来演示
思路分析:
- 一个 user 只能购买一张票,即不能复购。
- 不能出现超购,也就是多卖的情况。
- 不能出现火车票遗留问题/库存遗留,即火车票不能留下
5.2 完成基本购票流程,暂不考虑事务和并发问题
- 创建 Java Web 项目, 参照以前讲过搭建 Java Web 项目流程即可
- 引入相关的 jar 包 和 jquery
- 创建 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 票
sh127.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
顺序安装
shapr-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
- 在 ab 指令执行的当前路径下 创建文件
postfile
sh
vi postfile
sh
vm postfile
properties
ticketNo=bj_cd&
- 执行指令 , 注意保证 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
命令解读:
ab
: 是并发工具程序-n 1000
: 表示一共发出 1000 次 http 请求-c 100
: 表示并发时 100次,你可以理解为 1000次请求,分 10 次发送完毕,每次发出 100次。-p ~/postfile
: 表示发送请求时,携带的参数从当前目录的postfile
文件读取(就是上述我们刚刚创建的文件)。~
表示当前路径位置。-T application/x-www-form-urlencoded
: 就是发送数据的编码时基于表单的 url 编码的~
的更多含义,可以移步至: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 连接池技术
连接池介绍
- 节省每次连接 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 介绍
- Lua 时一个小巧的脚本语言,Lua 脚本可以很容易的被 C/C++代码调用,也可以反过来调用C/C++的函数,Lua 并没有提供强大的库,一个完整的 Lua 解释器不过 200K,所以 Lua 不适合作为开发独立应用程序的语言,而是作为 嵌入式脚本语言。
- 很多应用程序,游戏使用 LUA 作为自己的嵌入式的脚本语言,以此来实现可配置性,可扩展性。
- 将复杂的或者多步的 Redis 操作,写为一个脚本,一次提交给 redis 执行,减少反复连接 redis 的次数。提升性能。
- LUA脚本是类似 Redis 事务,有一定的原子性,不会被其他命令插队 ,可以完成一些 Redis 事务性的操作。
- Redis 的 Lua 脚本功能,只有在 Redis 2.6 以上的版本才可以使用,这里我们使用的是 Redis 6 可以使用。
- 通过 lua 脚本解决争抢问题,实际上是 Redis 利用其单线程的特性 ,用任务队列 的方式解决多任务并发问题。
LUA 脚本, 解决库存遗留-思路分析图
上图解图:
- LUA 脚本是类似于 Redis 事务,有一定的原子性,不会被其他命令插队 ,能完成 Redis 事务性的操作。
- 通过 lua 脚本解决争抢问题,Redis 利用其单线程的特性,将请求形成任务队列, 从 而解决多任务并发问题
- 简单的理解: 就是将 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 数据
sh127.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. 最后:
"在这个最后的篇章中,我要表达我对每一位读者的感激之情。你们的关注和回复是我创作的动力源泉,我从你们身上吸取了无尽的灵感与勇气。我会将你们的鼓励留在心底,继续在其他的领域奋斗。感谢你们,我们总会在某个时刻再次相遇。"