Redis 事务
Redis 事务、锁机制秒杀
Redis 事务定义
- Redis 事务是一个单独的隔离操作:事务中的所有命令都会序列化、
按顺序地执行
。 - 事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
- Redis 事务的主要作用就是
串联多个命令
防止别的命令插队
。
Multi、Exec、discard
- Redis 事务中有 Multi、Exec 和 discard 三个指令,在 Redis 中,从输入 Multi 命令开始,输入的命令都会依次进入命令队列中,但不会执行。
- 直到输入 Exec 后,Redis 会将之前的命令队列中的命令依次执行。
- 而组队的过程中可以通过 discard 来放弃组队。
multi
:组队阶段,exec
:执行阶段,discard
:放弃组队
案例说明:
- 组队成功,提交成功
127.0.0.1:6379> multi # 开启事务
OK
127.0.0.1:6379(TX)> set key1 value1 # 命令组队
QUEUED
127.0.0.1:6379(TX)> set key2 value2 # 命令组队
QUEUED
127.0.0.1:6379(TX)> exec # 执行命令
1) OK
2) OK
事务的错误处理方式
方式一:组队中某个命令出现了报告错误,执行时整个的所有队列都会被取消
- 组队阶段报错,提交失败
127.0.0.1:6379> multi # 开启事务
OK
127.0.0.1:6379(TX)> set b1 v1 # 命令组队
QUEUED
127.0.0.1:6379(TX)> set b2 v2 # 命令组队
QUEUED
127.0.0.1:6379(TX)> set b3
(error) ERR wrong number of arguments for 'set' command
127.0.0.1:6379(TX)> exec # 执行命令
(error) EXECABORT Transaction discarded because of previous errors.
方式二:如果执行阶段某个命令报出了错误,则只有报错的命令不会被执行,而其他的命令都会执行,不会回滚
- 组队成功,提交有成功有失败情况
127.0.0.1:6379> multi # 开启事务
OK
127.0.0.1:6379(TX)> set c1 v1 # 命令组队
QUEUED
127.0.0.1:6379(TX)> incr c1 # 命令组队
QUEUED
127.0.0.1:6379(TX)> set c2 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
事务冲突的问题
为什么要做成事务 ?
想象一个场景:有很多人拥有你的账户,同时去参加双十一抢购。
举个🌰
总共有三个请求:
- 一个请求想给金额减 8000;
- 一个请求想给金额减 5000;
- 一个请求想给金额减 1000。
最终我们可以发现,总共金额是 10000,如果请求全部执行,那最后的金额变为 - 4000,很明显不合理。
悲观锁
- 悲观锁 (Pessimistic Lock),顾名思义,就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会 block 直到它拿到锁
- 传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。
乐观锁
- 乐观锁 (Optimistic Lock),顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会先判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。
- 乐观锁适用于
多读
的应用类型,这样可以提高吞吐量。Redis 就是利用这种check-and-set
机制实现事务的。
WATCH key [key …]
命令
在执行 multi 之前,先执行 watch key1 [key2],可以监视一个 (或多个) key ,如果在事务执行之前这个 (或这些) key 被其他命令所改动,那么事务将被打断。
- 客户端一
127.0.0.1:6379> set balance 100
OK
127.0.0.1:6379> keys *
1) "balance"
127.0.0.1:6379> watch balance
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379(TX)> incrby balance 10
QUEUED
127.0.0.1:6379(TX)> exec
1) (integer) 110
- 客户端二
127.0.0.1:6379> keys *
1) "balance"
127.0.0.1:6379> watch balance
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379(TX)> incrby balance 20
QUEUED
127.0.0.1:6379(TX)> exec
(nil)
unwatch
命令
取消 WATCH 命令对所有 key 的监视。如果在执行 WATCH 命令之后,EXEC 命令或 DISCARD 命令先被执行了的话,那么就不需要再执行 UNWATCH 了。
Redis 事务三特性(背诵)
- 单独的隔离操作
- 事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
- 没有隔离级别的概念
- 队列中的命令没有提交之前都不会实际被执行,因为事务提交前任何指令都不会被实际执行。
- 不保证原子性
- 事务执行过程中如果有一条命令执行失败,其后的命令仍然会被执行,没有回滚 。
Redis 事务秒杀案例
解决计数器和人员记录的事务操作
创建 index.jsp
<%@ 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>
</head>
<body>
<h1>iPhone 13 Pro !!! 1元秒杀!!!
</h1>
<form id="msform" action="${pageContext.request.contextPath}/doseckill" enctype="application/x-www-form-urlencoded">
<input type="hidden" id="prodid" name="prodid" value="0101">
<input type="button" id="miaosha_btn" name="seckill_btn" value="秒杀点我"/>
</form>
</body>
<script type="text/javascript" src="${pageContext.request.contextPath}/script/jquery/jquery-3.1.0.js"></script>
<script type="text/javascript">
$(function () {
$("#miaosha_btn").click(function () {
var url = $("#msform").attr("action");
$.post(url, $("#msform").serialize(), function (data) {
if (data == "false") {
alert("抢光了");
$("#miaosha_btn").attr("disabled", true);
}
});
})
})
</script>
</html>
web.xml
<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>
<description></description>
<display-name>doseckill</display-name>
<servlet-name>doseckill</servlet-name>
<servlet-class>cn.xx.SecKillServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>doseckill</servlet-name>
<url-pattern>/doseckill</url-pattern>
</servlet-mapping>
</web-app>
SecKillServlet.java
/**
* 秒杀案例
*/
public class SecKillServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
public SecKillServlet() {
super();
}
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
String userid = new Random().nextInt(50000) + "";
String prodid = request.getParameter("prodid");
boolean isSuccess = SecKill_redis.doSecKill(userid, prodid);
response.getWriter().print(isSuccess);
}
}
SecKill_redis.java
public class SecKill_redis {
public static void main(String[] args) {
Jedis jedis = new Jedis("IP地址", 6379);
System.out.println(jedis.ping());
jedis.close();
}
//秒杀过程
public static boolean doSecKill(String uid, String prodid) throws IOException {
// 1.uid和proid非空判断
if (uid == null || prodid == null) {
return false;
}
// 2.连接redis
Jedis jedis = new Jedis("IP地址", 6379);
// 3.拼接key
// 3.1 库存key
String kcKey = "sk:" + prodid + ":qt";
// 3.2 秒杀成功用户key
String userKey = "sk:" + prodid + ":user";
// 4.获取库存,如果库存null,秒杀还没有开始
String kc = jedis.get(kcKey);
if (kc == null) {
System.out.println("秒杀还没有开始,请等待!!!");
jedis.close();
return false;
}
// 5.判断用户是否重复执行秒杀操作
if (jedis.sismember(userKey, uid)) {
System.out.println("已经秒杀成功了,不能重复秒杀!!!");
jedis.close();
return false;
}
// 6.判断如果商品数量,库存数量小于1,秒杀结束
if (Integer.parseInt(kc) <= 0) {
System.out.println("秒杀已经结束了!!!");
jedis.close();
return false;
}
// 7.秒杀过程
// 7.1 库存-1
jedis.decr(kcKey);
// 7.2 把秒杀成功的用户添加到清单里面
jedis.sadd(userKey, uid);
System.out.println("秒杀成功了!!!");
jedis.close();
return true;
}
}
测试
- 先在redis中设置库存数10,启动项目,进项秒杀
127.0.0.1:6379> set sk:0101:qt 10
OK
- 启动项目
- 开始秒杀
- 商品库存数量减少、用户入库
127.0.0.1:6379> get sk:0101:qt
"5"
127.0.0.1:6379> smembers sk:0101:user
1) "17193"
2) "22216"
3) "29826"
4) "45547"
5) "49681"
- 商品数量秒杀完了
ab工具模拟并发暴露出来的问题
- 使用命令安装ab
yum -y install httpd-tools
- 部分参数说明
-n 请求数量
-c 并发数量
-p post提交
-T content-type
- 编写参数文件:
vim postfile
,模拟表单提交参数,以&符号结尾
prodid=0101&
- 启动项目,通过ab命令并发秒杀
ab -n 1000 -c 100 -p ~/postfile -T application/x-www-form-urlencoded http://192.168.1.4:8080/Seckill/doseckill
并发暴露出来的问题
- 超卖问题
- 会出现超卖问题,卖光了还能秒杀成功,库存为负数
- 连接超时问题
- 乐观锁造成的商品遗留问题
- 秒杀结束了,还有商品库存
问题的解决
利用连接池解决连接超时问题
节省每次连接 redis 服务带来的消耗,把连接好的实例反复利用。通过参数管理连接的行为,代码见项目中:
JedisPoolUtil.java
public class JedisPoolUtil {
private static volatile JedisPool jedisPool = null;
private JedisPoolUtil() {
}
public static JedisPool getJedisPoolInstance() {
if (null == jedisPool) {
synchronized (JedisPoolUtil.class) {
if (null == jedisPool) {
JedisPoolConfig poolConfig = new JedisPoolConfig();
poolConfig.setMaxTotal(200);
poolConfig.setMaxIdle(32);
poolConfig.setMaxWaitMillis(100 * 1000);
poolConfig.setBlockWhenExhausted(true);
poolConfig.setTestOnBorrow(true); // ping PONG
jedisPool = new JedisPool(poolConfig, "IP地址", 6379, 60000);
}
}
}
return jedisPool;
}
public static void release(JedisPool jedisPool, Jedis jedis) {
if (null != jedis) {
jedisPool.returnResource(jedis);
}
}
}
SecKill_redis.java
public class SecKill_redis {
public static void main(String[] args) {
Jedis jedis = new Jedis("IP地址", 6379);
System.out.println(jedis.ping());
jedis.close();
}
//秒杀过程
public static boolean doSecKill(String uid, String prodid) throws IOException {
// 1.uid和proid非空判断
if (uid == null || prodid == null) {
return false;
}
// 2.通过连接池得到jedis对象
JedisPool jedisPoolInstance = JedisPoolUtil.getJedisPoolInstance();
Jedis jedis = jedisPoolInstance.getResource();
// 3.拼接key
// 3.1 库存key
String kcKey = "sk:" + prodid + ":qt";
// 3.2 秒杀成功用户key
String userKey = "sk:" + prodid + ":user";
// 4.获取库存,如果库存null,秒杀还没有开始
String kc = jedis.get(kcKey);
if (kc == null) {
System.out.println("秒杀还没有开始,请等待!!!");
jedis.close();
return false;
}
// 5.判断用户是否重复执行秒杀操作
if (jedis.sismember(userKey, uid)) {
System.out.println("已经秒杀成功了,不能重复秒杀!!!");
jedis.close();
return false;
}
// 6.判断如果商品数量,库存数量小于1,秒杀结束
if (Integer.parseInt(kc) <= 0) {
System.out.println("秒杀已经结束了!!!");
jedis.close();
return false;
}
// 7.秒杀过程
// 7.1 库存-1
jedis.decr(kcKey);
// 7.2 把秒杀成功的用户添加到清单里面
jedis.sadd(userKey, uid);
System.out.println("秒杀成功了!!!");
jedis.close();
return true;
}
}
利用乐观锁淘汰用户,解决超卖问题
SecKill_redis.java
public class SecKill_redis {
public static void main(String[] args) {
Jedis jedis = new Jedis("IP地址", 6379);
System.out.println(jedis.ping());
jedis.close();
}
// 秒杀过程
public static boolean doSecKill(String uid, String prodid) throws IOException {
// 1.uid和proid非空判断
if (uid == null || prodid == null) {
return false;
}
// 2.通过连接池得到jedis对象
JedisPool jedisPoolInstance = JedisPoolUtil.getJedisPoolInstance();
Jedis jedis = jedisPoolInstance.getResource();
// 3.拼接key
// 3.1 库存key
String kcKey = "sk:" + prodid + ":qt";
// 3.2 秒杀成功用户key
String userKey = "sk:" + prodid + ":user";
// 4.1 监视库存
jedis.watch(kcKey);
// 4.2 获取库存,如果库存null,秒杀还没有开始
String kc = jedis.get(kcKey);
if (kc == null) {
System.out.println("秒杀还没有开始,请等待!!!");
jedis.close();
return false;
}
// 5.判断用户是否重复执行秒杀操作
if (jedis.sismember(userKey, uid)) {
System.out.println("已经秒杀成功了,不能重复秒杀!!!");
jedis.close();
return false;
}
// 6.判断如果商品数量,库存数量小于1,秒杀结束
if (Integer.parseInt(kc) <= 0) {
System.out.println("秒杀已经结束了!!!");
jedis.close();
return false;
}
// 7.秒杀过程
// 7.1 使用事务
Transaction multi = jedis.multi();
// 7.2 组队操作
// 库存-1
multi.decr(kcKey);
// 把秒杀成功的用户添加到清单里面
multi.sadd(userKey, uid);
// 7.3 执行
List<Object> result = multi.exec();
if (result == null || result.size() == 0) {
System.out.println("秒杀失败了!!!");
jedis.close();
return false;
}
System.out.println("秒杀成功了!!!");
jedis.close();
return true;
}
}
商品遗留问题
用Lua脚本解决商品遗留 问题
Lua 是一个小巧的脚本语言,Lua脚本可以很容易的被C/C++ 代码调用,也可以反过来调用C/C++的函数,Lua并没有提供强大的库,一个完整的Lua解释器不过200k,所以Lua不适合作为开发独立应用程序的语言,而是作为嵌入式脚本语言。很多应用程序、游戏使用LUA作为自己的嵌入式脚本语言,以此来实现可配置性、可扩展性。
LUA 脚本在 Redis 中的优势
- 将复杂的或者多步的 redis 操作,写为一个脚本,一次提交给 redis 执行,减少反复连接 redis 的次数,提升性能。
- LUA 脚本是类似 redis 事务,有一定的原子性,不会被其他命令插队,可以完成一些 redis 事务性的操作。
- 但是注意 redis 的 lua 脚本功能,只有在 Redis 2.6 以上的版本才可以使用。
- 利用 lua 脚本淘汰用户,解决超卖问题。
- redis 2.6 版本以后,通过 lua 脚本解决争抢问题,实际上是 redis 利用其单线程的特性,用任务队列的方式解决多任务并发问题。
SecKill_redisByScript.java
public class SecKill_redisByScript {
private static final org.slf4j.Logger logger = LoggerFactory.getLogger(SecKill_redisByScript.class);
public static void main(String[] args) {
JedisPool jedispool = JedisPoolUtil.getJedisPoolInstance();
Jedis jedis = jedispool.getResource();
System.out.println(jedis.ping());
Set<HostAndPort> set = new HashSet<HostAndPort>();
// doSecKill("201","sk:0101");
}
static String secKillScript = "local userid=KEYS[1];\r\n" + "local prodid=KEYS[2];\r\n" + "local qtkey='sk:'..prodid..\":qt\";\r\n" + "local usersKey='sk:'..prodid..\":usr\";\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\" ,qtkey);\r\n" + "if tonumber(num)<=0 then \r\n" + " return 0;\r\n" + "else \r\n" + " redis.call(\"decr\",qtkey);\r\n" + " redis.call(\"sadd\",usersKey,userid);\r\n" + "end\r\n" + "return 1";
static String secKillScript2 = "local userExists=redis.call(\"sismember\",\"{sk}:0101:usr\",userid);\r\n" + " return 1";
public static boolean doSecKill(String uid, String prodid) throws IOException {
JedisPool jedispool = JedisPoolUtil.getJedisPoolInstance();
Jedis jedis = jedispool.getResource();
//String sha1= .secKillScript;
String sha1 = jedis.scriptLoad(secKillScript);
Object result = jedis.evalsha(sha1, 2, uid, prodid);
String reString = String.valueOf(result);
if ("0".equals(reString)) {
System.err.println("已抢空!!");
} else if ("1".equals(reString)) {
System.out.println("抢购成功!!!!");
} else if ("2".equals(reString)) {
System.err.println("该用户已抢过!!");
} else {
System.err.println("抢购异常!!");
}
jedis.close();
return true;
}
}
修改 SecKillServlet.java
/**
* 秒杀案例
*/
public class SecKillServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
public SecKillServlet() {
super();
}
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
String userid = new Random().nextInt(50000) + "";
String prodid = request.getParameter("prodid");
boolean isSuccess = SecKill_redisByScript.doSecKill(userid, prodid);
response.getWriter().print(isSuccess);
}
}