一、可能出现的逻辑问题
1、一个 user 只能购买一张票, 即不能复购
2、不能出现超购,也是就多卖了
3、不能出现火车票遗留问题/库存遗留, 即火车票不能留下
二、初始化业务代码
新建原生的web项目模拟火车票抢购的场景
index.jsp
1<%@ page contentType="text/html;charset=UTF-8" language="java" %>2<html>3 <head>4 <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">5 <title>redis抢票</title>6 <base href="<%=request.getContextPath() + "/"%>">7 </head>8 <body>9 <h1>北京-成都 火车票!秒杀!</h1>10
11 <form id="secKillform" action="secKillServlet" enctype="application/x-www-form-urlencoded">12 <input type="hidden" id="ticketNo" name="ticketNo" value="bj_cd">13 <input type="button" id="seckillBtn" name="seckillBtn" value="秒杀火车票【北京-成都】"/>14 </form>15
15 collapsed lines
16 <script type="text/javascript" src="script/jquery/jquery-3.1.0.js"></script>17 <script type="text/javascript">18 $(function () {19 $("#seckillBtn").click(function () {20 var url = $("#secKillform").attr("action"); //secKillServlet21 $.post(url, $("#secKillform").serialize(), function (data) {22 if (data == "false") {23 alert("火车票 抢光了:)");24 $("#seckillBtn").attr("disabled", true);25 }26 });27 })28 })29 </script>30 </body>
1package com.redis;2
3import redis.clients.jedis.Jedis;4
5/**6 * @author 左齐亮7 * @version 1.08 */9public class SeckillWithRedis {10 /**11 * 抢票秒杀12 * @param userId 用户ID13 * @param ticketNo 火车票编号14 * @return15 */37 collapsed lines
16 public static boolean doSecKill(String userId, String ticketNo){17 if(userId == null || ticketNo == null) return false;18
19 Jedis jedis = new Jedis("your ip address to connect redis");20 // 根据票的编号获取库存key21 String stockKey = ticketNo + ":ticket";22 // 根据票的编号获取成功抢到票的用户集合对应的key23 String userKey = ticketNo + ":user";24
25 String stock = jedis.get(stockKey);26 if(stock == null){27 System.out.println("抢票通道暂未开发,请稍后再试");28 jedis.close();29 return false;30 }31 // 判断用户是否复购32 if(jedis.sismember(userKey,userId)){33 System.out.println(userId + "已经购票,无法再次购票");34 jedis.close();35 return false;36 }37 // 判断火车票库存是否剩余38 if(Integer.parseInt(stock) <= 0){39 System.out.println("票已售罄,请等待");40 jedis.close();41 return false;42 }43
44 // 购票45 jedis.decr(stockKey);46 jedis.sadd(userKey,userId);47
48 System.out.println(userId + "秒杀成功!");49 jedis.close();50 return true;51 }52}
1import com.redis.SeckillWithRedis;2
3import javax.servlet.*;4import javax.servlet.http.*;5import java.io.IOException;6import java.util.Random;7
8public class SecKillServlet extends HttpServlet {9 @Override10 protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {11 doPost(request,response);12 }13
14 @Override15 protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {8 collapsed lines
16 // 模拟生成一个useID17 String userId = new Random().nextInt(10000) + "";18 // 获取用户购票的编号19 String ticketNo = request.getParameter("ticketNo");20 boolean res = SeckillWithRedis.doSecKill(userId, ticketNo);21 response.getWriter().print(res);22 }23}
Redis中设置了键值:bj_cd:ticket=6
访问Tomcat启动后的web地址,进行抢票,查看后台信息
正常情况下是逻辑正常的,但是在高并发情况下(可以使用ab、jmeter压测工具模拟),就有可能出现超卖问题
Ubuntu安装ab工具:
1sudo apt-get install apache2-utils2sudo apt-get install man
如何使用ab工具?
ab -n 1000 -c 100 -p ~/postfile -T application/x-www-form-urlencoded http://192.168.198.1:8080/seckill/secKillServlet
(1) -n 1000 表示一共发出 1000 次 http 请求
(2) -c 100 表示并发时 100 次, 你可以理解 1000 次请求, 会在 10 次发送完毕
(3) -p ~/postfile 表示发送请求时, 携带的参数从当前目录的 postfile 文件读取 (事先要准备好)
(4) -T application/x-www-form-urlencoded 就是发送数据的编码是 基于表单的 url 编码
如图是使用ab工具模拟10s内1000次请求的结果,失败了967次,卖出去了33张票,但实际上初始值只有6张票
出现了超卖问题
三、连接池技术
在上述代码的核心方法doSecKill()中是每次请求都会通过Jedis新建一个连接,使用完进行close(),可以通过连接池进行代码优化,节省每次连接 redis 服务带来的消耗,把连接好的实例反复利用。
连接池参数:
- MaxTotal:控制一个 pool 可分配多少个 jedis 实例,通过 pool.getResource()来获取;如果赋值为-1,则表示不限制
- maxIdle:控制一个 pool 最多有多少个状态为 idle(空闲)的 jedis 实例
- MaxWaitMillis:表示当获取一个 jedis 实例时,最大的等待毫秒数,如果超过等待时间,则直接抛 JedisConnectionException
- testOnBorrow:获得一个 jedis 实例的时候是否检查连接可用性(ping());如果为 true,则得到的 jedis 实例均是可用的
1package com.redis.util;2
3import redis.clients.jedis.Jedis;4import redis.clients.jedis.JedisPool;5import redis.clients.jedis.JedisPoolConfig;6
7/**8 * 使用连接池方式获取Redis连接9 */10public class JedisPoolUtil {11 // volatile作用:12 // 1.线程可见性:当一个线程去修改一个共享变量时,其他线程立即得知改变13 // 2.顺序的一致性;禁止指令重排14 private static volatile JedisPool jedisPool = null;15
23 collapsed lines
16 //保证每次调用返回的是jedisPool是单例17 public static JedisPool getJedisPoolInstance() {18 if(null == jedisPool) {19 synchronized (JedisPoolUtil.class) {20 if(null == jedisPool){ //单例的双重校验21 JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();22 jedisPoolConfig.setMaxTotal(200);23 jedisPoolConfig.setMaxIdle(32);24 jedisPoolConfig.setMaxWaitMillis(60 * 1000);25 jedisPoolConfig.setBlockWhenExhausted(true);26 jedisPoolConfig.setTestOnBorrow(true);27 jedisPool = new JedisPool(jedisPoolConfig, "your host", 6379, 60000);28 }29 }30 }31 return jedisPool;32 }33
34 //释放连接资源35 public static void release(Jedis jedis) {36 if(null != jedis) jedis.close();37 }38}
修改SeckillWithJedis类中的代码,不再新建Jedis对象而是通过连接池获取
1//通过连接池获取连接2JedisPool jedisPool = JedisPoolUtil.getJedisPoolInstance();3Jedis jedis = jedisPool.getResource();
四、利用Redis事务机制解决超卖问题
控制超卖问题的关键代码
1// 判断火车票库存是否剩余2if(Integer.parseInt(stock) <= 0){3 System.out.println("票已售罄,请等待");4 jedis.close();5 return false;6}
使用事务
1public class SeckillWithRedis {2 public static boolean doSecKill(String userId, String ticketNo){3 if(userId == null || ticketNo == null) return false;4
5 //通过连接池获取连接6 JedisPool jedisPool = JedisPoolUtil.getJedisPoolInstance();7 Jedis jedis = jedisPool.getResource();8 // 根据票的编号获取库存key9 String stockKey = ticketNo + ":ticket";10 // 根据票的编号获取成功抢到票的用户集合对应的key11 String userKey = ticketNo + ":user";12
13 // 监视库存14 jedis.watch(stockKey);15
37 collapsed lines
16 String stock = jedis.get(stockKey);17 if(stock == null){18 System.out.println("抢票通道暂未开发,请稍后再试");19 jedis.close(); //如果jedis是从连接池获取的,这里的close()会将jedis对象释放到连接池20 return false;21 }22 // 判断用户是否复购23 if(jedis.sismember(userKey,userId)){24 System.out.println(userId + "已经购票,无法再次购票");25 jedis.close();26 return false;27 }28 // 判断火车票库存是否剩余29 if(Integer.parseInt(stock) <= 0){30 System.out.println("票已售罄,请等待");31 jedis.close();32 return false;33 }34
35 // 购票36 // 使用事务37 Transaction multi = jedis.multi();38 multi.decr(stockKey);39 multi.sadd(userKey,userId);40
41 List<Object> result = multi.exec();42 if(result == null || result.size() == 0) {43 System.out.println("抢票失败!");44 jedis.close();45 return false;46 }47
48 System.out.println(userId + "秒杀成功!");49 jedis.close();50 return true;51 }52}
重置Redis数据后,再次使用ab工具压测,没有再出现超卖:
五、库存遗留问题
例如:总共有600张票,但是在1000次高并发请求下,可能会出现请求结束后,还有剩余的票,这就是库存遗留问题
初始化Redis数据库票库存为600:set bj_cd:ticket 600
执行指令:
ab -n 1000 -c 100 -p ~/postfile -T application/x-www-form-urlencoded http://192.168.8.1:18080/seckill/secKillServlet
结果如下:
可以看到出现了库存遗留问题
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 以上的版本才可以使用
6、通过 lua 脚本解决争抢问题,实际上是 Redis 利用其单线程的特性,用任务队列的方式解决多任务并发问题
代码实现:
1、 编写lua脚本文件
1local userid=KEYS[1]; --获取传入的第一个参数2
3local ticketno=KEYS[2]; --获取传入的第二个参数4
5local stockKey=ticketno..:ticket; --拼接stockKey6
7local usersKey=ticketno..:user; --拼接usersKey8local userExists=redis.call(sismember,usersKey,userid); -- 查看在 redis 的usersKey set 中是否有该用户9if tonumber(userExists)==1 then10 return 2; -- 如果该用户已经购买, 返回 211end12local num= redis.call("get" ,stockKey); -- 获取剩余票数13if tonumber(num)<=0 then14 return 0; -- 如果已经没有票, 返回 015else4 collapsed lines
16 redis.call("decr",stockKey); -- 将剩余票数-117 redis.call("sadd",usersKey,userid); -- 将抢到票的用户加入 set18end19return 1 -- 返回 1 表示抢票成功
2、编写加载脚本的程序
1public class SecKillByLua {2 static String secKillScript = "local userid=KEYS[1];\r\n" +3 "local ticketno=KEYS[2];\r\n" +4 "local stockKey=ticketno..\":ticket\";\r\n" +5 "local usersKey=ticketno..\":user\";\r\n" +6 "local userExists=redis.call(\"sismember\",usersKey,userid);\r\n" +7 "if tonumber(userExists)==1 then \r\n" +8 " return 2;\r\n" +9 "end\r\n" +10 "local num= redis.call(\"get\" ,stockKey);\r\n" +11 "if tonumber(num)<=0 then \r\n" +12 " return 0;\r\n" +13 "else \r\n" +14 " redis.call(\"decr\",stockKey);\r\n" +15 " redis.call(\"sadd\",usersKey,userid);\r\n" +24 collapsed lines
16 "end\r\n" +17 "return 1";18 public static boolean doSecKill(String userId, String ticketNo) {19 JedisPool jedisPoolInstance = JedisPoolUtil.getJedisPoolInstance();20 Jedis jedis = jedisPoolInstance.getResource();21 String sha1 = jedis.scriptLoad(secKillScript); //加载lua脚本,得到校验码22
23 Object result = jedis.evalsha(sha1, 2, userId, ticketNo);24 String value = String.valueOf(result);25 if("0".equals(value)){26 System.out.println("票已售罄");27 }else if ("1".equals(value)){28 System.out.println("购票成功");29 jedis.close();30 return true;31 }else if ("2".equals(value)){32 System.out.println("不能重复购买");33 }else {34 System.out.println("购票失败");35 }36 jedis.close();37 return false;38 }39}
3、在Servlet中调用新的方法
1public class SecKillServlet extends HttpServlet {2 @Override3 protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {4 doPost(request,response);5 }6
7 @Override8 protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {9 // 模拟生成一个useID10 String userId = new Random().nextInt(10000) + "";11 // 获取用户购票的编号12 String ticketNo = request.getParameter("ticketNo");13 //boolean res = SeckillWithRedis.doSecKill(userId, ticketNo);14 boolean res = SecKillByLua.doSecKill(userId, ticketNo);15 response.getWriter().print(res);2 collapsed lines
16 }17}
4、进行测试
重置Redis中的数据为1000张票后再次执行ab指令,结果如下:
此时Redis数据库中票数为0,没有再出现库存遗留问题