火车抢票并发问题
一、可能出现的逻辑问题
1、一个 user 只能购买一张票, 即不能复购
2、不能出现超购,也是就多卖了
3、不能出现火车票遗留问题/库存遗留, 即火车票不能留下
二、初始化业务代码
新建原生的web项目模拟火车票抢购的场景
index.jsp
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>redis抢票</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>
<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"); //secKillServlet
$.post(url, $("#secKillform").serialize(), function (data) {
if (data == "false") {
alert("火车票 抢光了:)");
$("#seckillBtn").attr("disabled", true);
}
});
})
})
</script>
</body>
package com.redis;
import redis.clients.jedis.Jedis;
/**
* @author 左齐亮
* @version 1.0
*/
public class SeckillWithRedis {
/**
* 抢票秒杀
* @param userId 用户ID
* @param ticketNo 火车票编号
* @return
*/
public static boolean doSecKill(String userId, String ticketNo){
if(userId == null || ticketNo == null) return false;
Jedis jedis = new Jedis("your ip address to connect redis");
// 根据票的编号获取库存key
String stockKey = ticketNo + ":ticket";
// 根据票的编号获取成功抢到票的用户集合对应的key
String userKey = ticketNo + ":user";
String stock = jedis.get(stockKey);
if(stock == null){
System.out.println("抢票通道暂未开发,请稍后再试");
jedis.close();
return false;
}
// 判断用户是否复购
if(jedis.sismember(userKey,userId)){
System.out.println(userId + "已经购票,无法再次购票");
jedis.close();
return false;
}
// 判断火车票库存是否剩余
if(Integer.parseInt(stock) <= 0){
System.out.println("票已售罄,请等待");
jedis.close();
return false;
}
// 购票
jedis.decr(stockKey);
jedis.sadd(userKey,userId);
System.out.println(userId + "秒杀成功!");
jedis.close();
return true;
}
}
import com.redis.SeckillWithRedis;
import javax.servlet.*;
import javax.servlet.http.*;
import java.io.IOException;
import java.util.Random;
public class SecKillServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
doPost(request,response);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
// 模拟生成一个useID
String userId = new Random().nextInt(10000) + "";
// 获取用户购票的编号
String ticketNo = request.getParameter("ticketNo");
boolean res = SeckillWithRedis.doSecKill(userId, ticketNo);
response.getWriter().print(res);
}
}
Redis中设置了键值:bj_cd:ticket=6
访问Tomcat启动后的web地址,进行抢票,查看后台信息
正常情况下是逻辑正常的,但是在高并发情况下(可以使用ab、jmeter压测工具模拟),就有可能出现超卖问题
Ubuntu安装ab工具:
sudo apt-get install apache2-utils
sudo 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 实例均是可用的
package com.redis.util;
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;
//保证每次调用返回的是jedisPool是单例
public static JedisPool getJedisPoolInstance() {
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, "your host", 6379, 60000);
}
}
}
return jedisPool;
}
//释放连接资源
public static void release(Jedis jedis) {
if(null != jedis) jedis.close();
}
}
修改SeckillWithJedis类中的代码,不再新建Jedis对象而是通过连接池获取
//通过连接池获取连接
JedisPool jedisPool = JedisPoolUtil.getJedisPoolInstance();
Jedis jedis = jedisPool.getResource();
四、利用Redis事务机制解决超卖问题
控制超卖问题的关键代码
// 判断火车票库存是否剩余
if(Integer.parseInt(stock) <= 0){
System.out.println("票已售罄,请等待");
jedis.close();
return false;
}
使用事务
public class SeckillWithRedis {
public static boolean doSecKill(String userId, String ticketNo){
if(userId == null || ticketNo == null) return false;
//通过连接池获取连接
JedisPool jedisPool = JedisPoolUtil.getJedisPoolInstance();
Jedis jedis = jedisPool.getResource();
// 根据票的编号获取库存key
String stockKey = ticketNo + ":ticket";
// 根据票的编号获取成功抢到票的用户集合对应的key
String userKey = ticketNo + ":user";
// 监视库存
jedis.watch(stockKey);
String stock = jedis.get(stockKey);
if(stock == null){
System.out.println("抢票通道暂未开发,请稍后再试");
jedis.close(); //如果jedis是从连接池获取的,这里的close()会将jedis对象释放到连接池
return false;
}
// 判断用户是否复购
if(jedis.sismember(userKey,userId)){
System.out.println(userId + "已经购票,无法再次购票");
jedis.close();
return false;
}
// 判断火车票库存是否剩余
if(Integer.parseInt(stock) <= 0){
System.out.println("票已售罄,请等待");
jedis.close();
return false;
}
// 购票
// 使用事务
Transaction multi = jedis.multi();
multi.decr(stockKey);
multi.sadd(userKey,userId);
List<Object> result = multi.exec();
if(result == null || result.size() == 0) {
System.out.println("抢票失败!");
jedis.close();
return false;
}
System.out.println(userId + "秒杀成功!");
jedis.close();
return true;
}
}
重置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脚本文件
local userid=KEYS[1]; --获取传入的第一个参数
local ticketno=KEYS[2]; --获取传入的第二个参数
local stockKey=ticketno..:ticket; --拼接stockKey
local usersKey=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 表示抢票成功
2、编写加载脚本的程序
public class SecKillByLua {
static String secKillScript = "local userid=KEYS[1];\r\n" +
"local ticketno=KEYS[2];\r\n" +
"local stockKey=ticketno..\":ticket\";\r\n" +
"local usersKey=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";
public static boolean doSecKill(String userId, String ticketNo) {
JedisPool jedisPoolInstance = JedisPoolUtil.getJedisPoolInstance();
Jedis jedis = jedisPoolInstance.getResource();
String sha1 = jedis.scriptLoad(secKillScript); //加载lua脚本,得到校验码
Object result = jedis.evalsha(sha1, 2, userId, ticketNo);
String value = String.valueOf(result);
if("0".equals(value)){
System.out.println("票已售罄");
}else if ("1".equals(value)){
System.out.println("购票成功");
jedis.close();
return true;
}else if ("2".equals(value)){
System.out.println("不能重复购买");
}else {
System.out.println("购票失败");
}
jedis.close();
return false;
}
}
3、在Servlet中调用新的方法
public class SecKillServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
doPost(request,response);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
// 模拟生成一个useID
String userId = new Random().nextInt(10000) + "";
// 获取用户购票的编号
String ticketNo = request.getParameter("ticketNo");
//boolean res = SeckillWithRedis.doSecKill(userId, ticketNo);
boolean res = SecKillByLua.doSecKill(userId, ticketNo);
response.getWriter().print(res);
}
}
4、进行测试
重置Redis中的数据为1000张票后再次执行ab指令,结果如下:
此时Redis数据库中票数为0,没有再出现库存遗留问题