一、概述
以下内容是基于文章秒杀系统设计-敖丙加上自己思考所写的内容,主要分析了为什么使用Redis以及如何使用Redis实现一个抢票系统。
二、功能分析
2-1、读取余票数量以及控制开启时间功能
作为一个抢票系统,应该能够读取剩余票量,并且在售票时,进行检测当前票量是否大于0,如果大于0才能进行抢票,否则拒绝抢票,同时还应该控制抢票开始时间,在抢票未开始时,无法进行抢票。
如果使用MySQL数据库实现这两个功能,可以有三种实现。
第一种:将表的字段设计成------票的id、余票数量、抢票开始时间,每次对抢票API进行访问时,首先检查抢票是否开启(系统时间与抢票开始时间进行比较),然后再进行余票检测(大于0才能抢票),最后进行抢票。这种方法可以提前将数据插入MySQL数据库,然后能够很容易的将数据放到缓存中(只要抢票之前进行一次查询操作就行将结构放到缓存中),开启抢票后不是访问数据库而是访问缓存,能够提供很好的效率,但是存在着一个很显著的问题---每一次对抢票API进行访问时,都需要进行系统时间的获取以及比较,抢票开启前还好,一旦抢票开启了,这将会成为很大的负担。
第二种:将表的字段设计成------票的id、余票数量,同时只有到了抢票时间再进行数据插入操作,在抢票开启之前,数据库查不到数据,返回null,抢票开始后,能够查到数据,开始抢票。使用这种方法能够避免频繁地调用系统时间,但是这种方法存在一个问题,如果并发量很大,我刚将数据存放到数据库,还没有到缓存,就发生了很多的查询,很有可能会导致数据库挂掉。
第三种:将表的字段依旧设计成------票的id、余票数量,但是与第一种方案类似,提前将数据进行插入到数据库中,最后通过一个访问开关来确定这个API能否提供访问。在抢票开始前,访问开关返回False,抢票开始的时候,访问开关返回True,同时在抢票开始前,将数据加载到缓存中。
以上三种方法,对于能够保证查询的效率,但是减库存的操作会非常的慢,甚至可能导致MySQL数据库挂掉。
简而言之就是,如果使用MySQL数据库+缓存的方式提供服务,在一定条件下能够保证查询效率,但是一旦涉及到减库存(修改)操作,速度还是很慢的。
因此对于一个高并发的抢票系统来说,最好的选择可能就是将这些频繁修改以及访问的数据放入到Redis(这类基于内存)服务器中,提供访问以及修改(主要是为了修改操作),于此同时在抢票结束后,再异步地将数据更新到MySQL数据库中。
这里主要采用第二种方法:在开启抢票前,Redis中没有数据,当开启抢票时,通过一个定时任务(或者手动执行set命令),将余票数量进行插入,其存在Redis的键为---object-type: id:field---ticket:ticketId:stock---票:票的id:票量,而值就是余票,其实就是在键中保存了票的id,而值中保存了余票数量。
这里再讲一下预约挂号系统的实现,作为一个预约挂号系统其实没必要直接将数据存放在Redis中(预约挂号系统的并发量不会很高,使用MySQL数据库加上缓存足以)。如果还想要使用Redis来实现的话,我会将键设置为appointment: id: time---预约:诊室的id:预约时间(这个预约时间也可以使用时间戳),票量要么为0要么为1。
2-2、扣减库存、预防超卖
在获取库存后,如果库存大于0则简单地进行减一操作显然是只能满足一般的情况,如果不添加事务或者LUA脚本,在高并发的情况下很容易发生超卖(比如两个请求转过来,然后Redis先执行两个请求的查询数据操作再进行两个请求的减库存操作,注意这个执行顺序是按照提交顺序来的,多个客户端同时提交请求很有可能会交叉地执行多个请求中的命令,这种交叉执行命令就会导致数据的不准确,以至于超卖)。
因此我们需要添加事务或者LUA脚本,而这里选择使用LUA脚本,主要原因是Redis事务是基于乐观锁的,而乐观锁的核心就是写操作的冲突不会很多,显然对于抢票系统这种高并发的情况是非常不适合使用乐观锁的,因此使用LUA脚本。(实际上Redis的LUA脚本都快完全替代Redis事务了,Redis事务在高并发的情况下会产生大量的失败操作)。
2-3、隐藏抢票链接
如果用户能够提前知道抢票的连接,将会有很大一部分人能够整点进行秒杀,比如黄牛可能会通过抢票的程序,准时进行抢票,这样子对普通用户太不公平了,因此希望抢票链接被隐藏起来,同时希望连开发者都不知道抢票链接。
2-4、支持高并发、高可用
如果想要支持更高的并发,可以将系统设计成Redis集群(能够接受更多的请求,分发给redis集群),而如果想要高可用,可以让系统进行主从复制(防止某一台redis服务器挂了之后,这台服务器无法提供服务)、开启哨兵(服务器挂了之后,自动将从服务器升级为主服务器,继续提供服务)、开启持久化(防止redis服务器挂了之后导致数据的丢失)。
三、代码编写
3-1、JedisPool连接工具
// 单例模式(双重检验加锁)的方式来获取Jedis连接池,如果不适用连接池的话,只使用一个jedis连接,那这一个jedis出现问题就可能会导致服务无法提供,同时并发量会小
public class JedisPoolUtil {
private static volatile JedisPool jedisPool;
private JedisPoolUtil(){}
public static JedisPool getJedisPoolInstance() {
if (jedisPool == null){
synchronized (JedisPoolUtil.class){
if (jedisPool == null){
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
// 空闲jedis数
jedisPoolConfig.setMaxIdle(8);
// 最大连接数
jedisPoolConfig.setMaxTotal(16);
// 最大等待时长
jedisPoolConfig.setMaxWaitMillis(1000);
// 获取连接实例时,是否检查连接可用
jedisPoolConfig.setTestOnBorrow(false);
jedisPool = new JedisPool(jedisPoolConfig,"localhost",6379);
}
}
}
return jedisPool;
}
}
3-2、抢票控制器,设计上有点问题,控制器不应该用于业务的处理,这里只是一个抢票的小Demo
@Controller
public class GrabTicketController {
// Lua脚本,防止超卖,如果使用事务,失败的更多,同时在测试时,容易出现没卖完(比如短时间内请求了101次,库存100,由于出现了很多事务失败导致一部分没卖掉)
public static String LuaScript =
"local ticketNumber = redis.call('GET', KEYS[1])\n" +
" if not ticketNumber then\n" +
" return -1\n" +
"elseif tonumber(ticketNumber) <= 0 then\n" +
" return 0\n" +
"else \n" +
" redis.call('DECR', KEYS[1])\n" +
" redis.call('LPUSH', KEYS[2], ARGV[1])\n" +
" return 1\n" +
"end\n";
JedisPool jedisPool = JedisPoolUtil.getJedisPoolInstance();
Jedis jedis;
// Jedis jedis = new Jedis("localhost",6379);
@PostMapping("/ticket/grabTicket")
@ResponseBody
public String grabTicket(int goodsId){
// 用户id,应该通过登录信息获取,这里没有做登录系统,因此以uuid作为抢票的用户
String userId = UUID.randomUUID().toString().replaceAll("-", "");
// redis中某个商品的余票的键
String ticketNumberKey = "ticket:" + goodsId + ":ticketNumber";
// 抢到票的用户列表,这里应该是一个生成订单的服务,但是简化,就只将用户列表添加到一个list里面
String userForGrabTicketKey = "userForGrabTicket:" + goodsId + ":userId";
try {
// 获取redis连接
jedis = jedisPool.getResource();
// 获取返回值,这里有个问题,就是如果Lua脚本执行错误,这里将会发生一个类型转换错误(String->Long)
Long result = (Long) jedis.evalsha(jedis.scriptLoad(LuaScript), 2, ticketNumberKey,userForGrabTicketKey,userId);
if (result == -1){
// 抢票未开始
System.out.println("抢票未开始");
return "抢票未开始";
} else if (result == 0) {
// 票卖完了
System.out.println("票卖完了");
return "票卖完了";
} else {
// 抢票成功
System.out.println("抢票成功");
return "抢票成功";
}
} catch (Exception e){
e.printStackTrace();
} finally {
// 释放连接
jedis.close();
}
return "出现异常,抢票失败";
}
}
四、结果展示
4-1、单用户抢票
4-2、JMeter模拟高并发抢票
没有超卖,也没有库存没卖掉。