第一章 事务
1.1 事务定义
Redis 事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
事务可以一次执行多个命令,本质是一组命令的集合。一个事务中的所有命令都会序列化,按顺序地串行化执行而不会被其他命令插入,不许加塞。
在一个队列中,一次性、顺序性、排他性地执行一系列命令。Redis 事务的主要作用就是串联多个命令防止别的命令插队。
注意与关系型数据库中的事务进行区分。
1.2 事务相关指令
- 从输入
multi
命令开始,输入的命令都会依次进入命令队列中,但不会执行,至到输入exec
后,Redis 会将之前的命令队列中的命令依次执行。 - 组队的过程中可以通过
discard
来放弃组队。
- 示例
1.3 事务的错误处理
组队中某个命令出现了报告错误,执行时整个的所有队列会都会被取消。
如果执行阶段某个命令报出了错误(类比运行时异常),则只有报错的命令不会被执行,而其他的命令都会执行,不会回滚。
1.4 事务冲突的问题
场景
有 3 个人有你的账户,同时去参加双十一抢购
悲观锁&乐观锁
悲观锁(Pessimistic Lock),顾名思义,就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁。这样别人想拿这个数据就会 block 直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。
乐观锁(Optimistic Lock),顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁。但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量。Redis 就是利用这种 check-and-set 机制实现事务的。
watch|unwatch
watch
在执行 multi
之前,先执行 watch key1 [key2 ...]
,可以监视一个(或多个) key,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断。
unwatch
取消 watch
命令对所有 key 的监视。如果在执行 watch
命令之后,exec
命令或 discard
命令先被执行了的话,那么就不需要再执行 unwatch
了。
1.5 Redis 事务三特性
- 单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断
- 没有隔离级别的概念:队列中的命令没有提交之前都不会实际的被执行,因为事务提交前任何指令都不会被实际执行,也就不存在“事务内的查询要看到事务里的更新,在事务外查询不能看到”这个让人万分头痛的问题
- 不保证原子性:Redis 同一个事务中如果有一条命令执行失败,其后的命令仍然会被执行,没有回滚
第二章 秒杀案例
2.1 测试
页面核心代码:
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>
doseckill → SecKillServlet:
public class SecKillServlet extends HttpServlet { private static final long serialVersionUID = 1L; public SecKillServlet() { super(); } protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, 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:
public class SecKill_redis { public static boolean doSecKill(String uid, String prodid) throws IOException { // 拼接 key String kcKey = "Seckill:" + prodid + ":kc"; String userKey = "Seckill:" + prodid + ":user"; Jedis jedis = new Jedis("192.168.33.128", 6379); // 获取库存 String kc = jedis.get(kcKey); // 1. 秒杀还没开始:库存为 NULL if(kc == null) { System.out.println("秒杀还没开始!"); jedis.close(); return false; } // 2. 已经秒杀成功:存储秒杀成功的用户 set 中已经有该用户id if(jedis.sismember(userKey, uid)) { System.out.println("已经秒杀成功,不能重复秒杀!"); jedis.close(); return false; } // 3. 判断库存 // 3.1 若 <= 0,秒杀结束 if(Integer.parseInt(kc) <= 0) { System.out.println("秒杀已结束!"); jedis.close(); return false; } // 3.2 若 > 0,则减库存加人 jedis.decr(kcKey); jedis.sadd(userKey, uid); System.out.println("秒杀成功!"); jedis.close(); return true; } }
2.2 ab工具测试
ab全称为:apache bench
ab是Apache超文本传输协议(HTTP)的性能测试工具。其设计意图是描绘当前所安装的Apache的执行性能,主要是显示你安装的Apache每秒可以处理多少个请求ab是apache自带的压力测试工具。ab非常实用,它不仅可以对apache服务器进行网站访问压力测试,也可以对或其它类型的服务器进行压力测试。比如nginx、tomcat、IIS等。
安装
- CentOS 6 默认安装
- CentOS 7 需要手动安装
yum install httpd-tools
使用
ab -n 请求数 -c 最大并发数 -p 请求的数据文件 -T "application/x-www-form-urlencoded" 测试的请求
-n 测试会话中所执行的请求个数,默认仅执行一个请求 -c 一次产生的请求个数,即同一时间发出多少个请求,默认为一次一个 -t 测试所进行的最大秒数,默认为无时间限制....其内部隐含值是[-n 50000],它可以使对服务器的测试限制在一个固定的总时间以内 -p 包含了需要POST的数据的文件 -T POST数据所使用的Content-type头信息 -v 设置显示信息的详细程度 -w 以HTML表格的形式输出结果,默认是白色背景的两列宽度的一张表 -i 以HTML表格的形式输出结果,默认是白色背景的两列宽度的一张表 -x 设置<table>属性的字符串,此属性被填入<table 这里> -y 设置<tr>属性的字符串 -z 设置<td>属性的字符串 -C 对请求附加一个Cookie行,其典型形式是name=value的参数对,此参数可以重复 -H 对请求附加额外的头信息,此参数的典型形式是一个有效的头信息行,其中包含了以冒号分隔的字段和值的对(如"Accept-Encoding: zip/zop;8bit") -A HTTP验证,用冒号:分隔传递用户名及密码 -P 无论服务器是否需要(即是否发送了401认证需求代码),此字符串都会被发送 -X 对请求使用代理服务器 -V 显示版本号并退出 -k 启用HTTP KeepAlive功能,即在一个HTTP会话中执行多个请求,默认为不启用KeepAlive功能 -d 不显示"percentage served within XX [ms] table"的消息(为以前的版本提供支持) -S 不显示中值和标准背离值,且均值和中值为标准背离值的1到2倍时,也不显示警告或出错信息,默认会显示最小值/均值/最大值等(为以前的版本提供支持) -g 把所有测试结果写入一个'gnuplot'或者TSV(以Tab分隔的)文件 -e 产生一个以逗号分隔的(CSV)文件,其中包含了处理每个相应百分比的请求所需要(从1%到100%)的相应百分比的(以微妙为单位)时间 -h 显示使用方法 -k 发送keep-alive指令到服务器端
可以发现,在大量并发请求下,出现了超卖问题。
2.3 超卖问题
加入事务,监视库存。
public static boolean doSecKill(String uid, String prodid) throws IOException { // 拼接 key String kcKey = "Seckill:" + prodid + ":kc"; String userKey = "Seckill:" + prodid + ":user"; Jedis jedis = new Jedis("192.168.33.128", 6379); // 监视库存 jedis.watch(kcKey); // 获取库存 String kc = jedis.get(kcKey); // 1. 秒杀还没开始:库存为 NULL if(kc == null) { System.out.println("秒杀还没开始!"); jedis.close(); return false; } // 2. 已经秒杀成功:存储秒杀成功的用户 set 中已经有该用户id if(jedis.sismember(userKey, uid)) { System.out.println("已经秒杀成功,不能重复秒杀!"); jedis.close(); return false; } // 3. 判断库存 // 3.1 若 <= 0,秒杀结束 if(Integer.parseInt(kc) <= 0) { System.out.println("秒杀已结束!"); jedis.close(); return false; } // 3.2 若 > 0,则减库存加人 Transaction transaction = jedis.multi(); transaction.decr(kcKey); transaction.sadd(userKey, uid); List<Object> execList = transaction.exec(); if(execList == null || execList.size() == 0) { System.out.println("秒杀失败!"); jedis.close(); return false; } System.out.println("秒杀成功!"); jedis.close(); return true; }
2.4 请求超时问题
使用连接池,节省每次连接 Redis 服务带来的消耗,把连接好的实例反复利用。
通过参数管理连接的行为:
- MaxTotal:控制一个 pool 可分配多少个 Jedis 实例,通过
pool.getResource()
来获取;如果赋值为 -1,则表示不限制;如果 pool 已经分配了 MaxTotal 个 Jedis 实例,则此时 pool 的状态为 exhausted - maxIdle:控制一个 pool 最多有多少个状态为 idle(空闲) 的 Jedis 实例
- MaxWaitMillis:表示当 borrow 一个 Jedis 实例时,最大的等待毫秒数,如果超过等待时间,则直接抛出 JedisConnectionException
- testOnBorrow:获得一个 Jedis 实例的时候是否检查连接可用性
ping()
;如果为 true,则得到的 Jedis 实例均是可用的
代码演示
JedisPool jedisPool = JedisPoolUtil.getJedisPoolInstance(); Jedis jedis = jedisPool.getResource(); System.out.println(jedis.ping());
2.5 库存遗留问题
已经秒光,可是还有库存。原因,就是乐观锁导致很多请求都失败。先点的没秒到,后点的可能秒到了。
Lua 脚本
Lua 是一个小巧的脚本语言,Lua 脚本可以很容易的被 C/C++ 代码调用,也可以反过来调用 C/C++ 的函数,Lua 并没有提供强大的库,一个完整的 Lua 解释器不过 200k,所以 Lua 不适合作为开发独立应用程序的语言,而是作为嵌入式脚本语言。
很多应用程序、游戏使用 Lua 作为自己的嵌入式脚本语言,以此来实现可配置性、可扩展性。这其中包括众多游戏插件或外挂。
Lua 脚本在 Redis 中的优势:
- 将复杂的或者多步的 Redis 操作,写为一个脚本,一次提交给 Redis 执行,减少反复连接 Redis 的次数。提升性能。
- Lua 脚本是类似 Redis 事务,但脚本整体执行有一定的原子性,一次性执行完一整个脚本,不会被其他命令插队,故可以完成一些 Redis 事务性的操作。
- 但是注意 Redis 的 Lua 脚本功能,只有在 2.6 以上的版本才可以使用。
解决问题
Redis 为单线程模型,Lua 脚本执行具有原子性。
Redis 使用单个 Lua 解释器去运行所有脚本,并且,Redis 也保证脚本会以原子性(atomic) 的方式执行:当某个脚本正在运行的时候,不会有其他脚本或 Redis 命令被执行。这和使用 MULTI / EXEC 包围的事务很类似。在其他别的客户端看来,脚本的效果(effect) 要么是不可见的(not visible),要么就是已完成的(already completed)。
在高并发下,很多看似不大可能是问题的,都成了实际产生的问题了。要解决“超抢/超卖”的问题,核心在于保证检查库存时的操作是依次执行的,再形象的说就是把“多线程”转成“单线程”。即使有很多用户同时到达,也是一个个检查并给与抢购资格,一旦库存抢尽,后面的用户就无法继续了。
我们需要使用 Redis 的单线程和 Lua 脚本的原子性来实现这个功能。假设有 10 件库存,就往 Redis 中 set Seckill:prodid:kc 10
,这个数没有实际意义,仅仅只是代表一件库存。抢购开始后,每到来一个用户,就执行一遍 Lua 脚本(也就是 decr 库存),表示用户抢购成功。当库存为 0 时,表示已经被抢光了。因为 Redis 的单线程,所以即使有很多用户同时到达,也是依次执行的。
SecKillServlet
boolean if_success = SecKill_redisByScript.doSecKill(userid, prodid);
SecKill_redisByScript
public class SecKill_redisByScript { static String secKillScript ="local userid=KEYS[1];\r\n" + "local prodid=KEYS[2];\r\n" + "local qtkey='Seckill:'..prodid..\":kc\";\r\n" + "local usersKey='Seckill:'..prodid..\":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\" ,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 = jedis.scriptLoad(secKillScript); // [1] 加载脚本之后的结果 // [2] 参数个数 // [3 ..] 参数.. 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; } }