CodeArena

火车抢票并发问题

2023-04-23
Redis
最后更新:2024-05-23
12分钟
2259字

一、可能出现的逻辑问题

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"); //secKillServlet
21
$.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>
1
package com.redis;
2
3
import redis.clients.jedis.Jedis;
4
5
/**
6
* @author 左齐亮
7
* @version 1.0
8
*/
9
public class SeckillWithRedis {
10
/**
11
* 抢票秒杀
12
* @param userId 用户ID
13
* @param ticketNo 火车票编号
14
* @return
15
*/
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
// 根据票的编号获取库存key
21
String stockKey = ticketNo + ":ticket";
22
// 根据票的编号获取成功抢到票的用户集合对应的key
23
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
}
1
import com.redis.SeckillWithRedis;
2
3
import javax.servlet.*;
4
import javax.servlet.http.*;
5
import java.io.IOException;
6
import java.util.Random;
7
8
public class SecKillServlet extends HttpServlet {
9
@Override
10
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
11
doPost(request,response);
12
}
13
14
@Override
15
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
8 collapsed lines
16
// 模拟生成一个useID
17
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工具:

Terminal window
1
sudo apt-get install apache2-utils
2
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张票

20230213191215

20230213191418

出现了超卖问题

三、连接池技术

在上述代码的核心方法doSecKill()中是每次请求都会通过Jedis新建一个连接,使用完进行close(),可以通过连接池进行代码优化,节省每次连接 redis 服务带来的消耗,把连接好的实例反复利用。

连接池参数:

  • MaxTotal:控制一个 pool 可分配多少个 jedis 实例,通过 pool.getResource()来获取;如果赋值为-1,则表示不限制
  • maxIdle:控制一个 pool 最多有多少个状态为 idle(空闲)的 jedis 实例
  • MaxWaitMillis:表示当获取一个 jedis 实例时,最大的等待毫秒数,如果超过等待时间,则直接抛 JedisConnectionException
  • testOnBorrow:获得一个 jedis 实例的时候是否检查连接可用性(ping());如果为 true,则得到的 jedis 实例均是可用的
1
package com.redis.util;
2
3
import redis.clients.jedis.Jedis;
4
import redis.clients.jedis.JedisPool;
5
import redis.clients.jedis.JedisPoolConfig;
6
7
/**
8
* 使用连接池方式获取Redis连接
9
*/
10
public 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
//通过连接池获取连接
2
JedisPool jedisPool = JedisPoolUtil.getJedisPoolInstance();
3
Jedis jedis = jedisPool.getResource();

四、利用Redis事务机制解决超卖问题

控制超卖问题的关键代码

1
// 判断火车票库存是否剩余
2
if(Integer.parseInt(stock) <= 0){
3
System.out.println("票已售罄,请等待");
4
jedis.close();
5
return false;
6
}

使用事务

1
public 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
// 根据票的编号获取库存key
9
String stockKey = ticketNo + ":ticket";
10
// 根据票的编号获取成功抢到票的用户集合对应的key
11
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工具压测,没有再出现超卖:

20230213192916

五、库存遗留问题

例如:总共有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

结果如下:

20230213210609

可以看到出现了库存遗留问题

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脚本文件

1
local userid=KEYS[1]; --获取传入的第一个参数
2
3
local ticketno=KEYS[2]; --获取传入的第二个参数
4
5
local stockKey=ticketno..:ticket; --拼接stockKey
6
7
local usersKey=ticketno..:user; --拼接usersKey
8
local userExists=redis.call(sismember,usersKey,userid); -- 查看在 redis 的usersKey set 中是否有该用户
9
if tonumber(userExists)==1 then
10
return 2; -- 如果该用户已经购买, 返回 2
11
end
12
local num= redis.call("get" ,stockKey); -- 获取剩余票数
13
if tonumber(num)<=0 then
14
return 0; -- 如果已经没有票, 返回 0
15
else
4 collapsed lines
16
redis.call("decr",stockKey); -- 将剩余票数-1
17
redis.call("sadd",usersKey,userid); -- 将抢到票的用户加入 set
18
end
19
return 1 -- 返回 1 表示抢票成功

2、编写加载脚本的程序

1
public 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中调用新的方法

1
public class SecKillServlet extends HttpServlet {
2
@Override
3
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
4
doPost(request,response);
5
}
6
7
@Override
8
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
9
// 模拟生成一个useID
10
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指令,结果如下:

20230214104755

此时Redis数据库中票数为0,没有再出现库存遗留问题

本文标题:火车抢票并发问题
文章作者:Echoidf
发布时间:2023-04-23
感谢大佬送来的咖啡☕
alipayQRCode
wechatQRCode