火车抢票并发问题

  • Redis
  • 数据库
  • 抢票
  • Redis
  • 高并发
大约 8 分钟

一、可能出现的逻辑问题

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/secKillServletopen in new window

(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 实例均是可用的
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工具压测,没有再出现超卖:

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

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指令,结果如下:

20230214104755

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

上次编辑于: