[业务设计]-秒杀抢票问题

前言

最近在做项目的时候遇到一个并发设计的问题:项目的业务主体是一系列的任务,部门员工需要认领任务然后进行任务,更新进度。那么在认领任务的功能中,可能会遇到的场景就是多个员工同时打开未认领任务的列表,同时点开了同一个任务查看任务内容,最后甚至同时或极小时间间隔内同时点击该需求进行认领,那么就需要保证只会有第一个点击的人才能认领到该需求

奇怪的是想到这个问题后我第一个反应不是想怎么设计解决方案而是想到了秒杀或抢票问题,比如 12306 的抢票设计。虽然两个业务场景之间差的还是挺多的:

抢票的难点在于所有票在同一时刻同时开放给用户去抢,而且来抢的用户数目非常庞大,并发量非常大;与之相比,我做的这个项目中进行任务认领的员工数目虽然不少但肯定没有那么多,粗略估计整个员工规模在 1 万左右,而且与 12306 中许多用户同时抢同一张票不同,项目中多个员工同时想要认领同一需求的情况可能并不常有,综上这个功能的并发量并没有很大

尽管可能用不上,但我还是抱着对业务设计的好奇心去了解了一下 12306 的抢票问题的解决方案

Redis + Lua

看到的一个解决方案是使用 Redis + Lua。这里我们先不考虑服务器搭建集群并进行负载均衡的问题,只考虑单机的情况,而且对一些细节也不过于深究

从传统方案开始

从头讲起。由于在票放出的那一瞬间并发量会瞬间上升,如果按照传统设计,票的数目存在 MySQL 数据库中,每个请求都尝试去 update 票数令其减一,如果票数仍大于 0 的话,写一句伪 SQL 就是 update table set tickets = tickets - 1 where trainNum = 列车班次 and tickets > 0,每个请求都如此执行,由于数据库的行锁设计,是能保证不会卖多票的,假设票数 100,是能保证只有前 100 个请求能 update 成功的,于是 service 中就可以根据 update 的返回值,判断是否抢票成功

这种方案的问题在于,同一时刻并发量非常大,而且每个请求都需要打到数据库中,数据库需要承受巨大的压力;使用数据库的话又需要磁盘 I/O 以及数据库层面的行记录加锁阻塞,会延长处理请求的耗时,降低该时段的系统吞吐量,降低了计算资源的利用率,影响了用户的体验
说到这,对于本文开头说的项目中那个任务认领的问题,我认为在这个项目的应用场景下,这种解决方案虽然原始但已足够,不用引入更多的中间件去解耦,异步化磁盘 I/O 等

引入 Redis

直接使用数据库怎么都避免不了磁盘 I/O 跟加锁阻塞的操作,所以通常这种类似的场景我们一般都会想将处理过程迁移到内存中进行,因为不需要系统调用,磁盘寻址等额外操作,内存中的 I/O 比磁盘 I/O 要快非常多。那么说到内存数据库第一反应肯定就是 Redis,Redis 基于内存的操作速度非常快,而且能接受的并发量也非常乐观,号称 QPS 能达到 10 万

借助 Redis 的情况下,处理方案就是先将票的数目存到 Redis 中,抢票时直接在 Redis 中进行处理,处理完后续再将抢票的结果刷回数据库。具体来说,先在 Redis 中存车票的数目,当用户请求抢票时,判断当前车票数目是否大于 0,大于 0 就将票数减一,同时记录该用户的 ID 等标识来表示该用户抢到了票;如果车票数目不大于 0,说明票已被抢完,返回用户抢票失败的信息

可以看到对 Redis 的操作涉及到判断票的数目,票数减 1 等,每个操作都只能对应一条 Redis 命令,但判断票数跟票数大于 0 则减 1 或判断票数跟票数不大于 0 则直接返回两种行为是要求原子执行的,不能被打断,否则还是会有线程安全的问题,Redis 串行执行命令,因此每个系统线程只需要执行一个命令的话 Redis 是能保证线程安全的,但单个系统进程要执行多个命令且要求这些命令原子化的话就不能一个命令一个命令执行了

这种问题的解决方案就是使用 Redis 执行 Lua 脚本的功能,与命令串行执行一样,Lua 脚本也是串行执行的,一个时刻 Redis 保证只会执行一个 Lua 脚本,所以可以将需要保证原子性的多条命令写到同一个 Lua 脚本去执行,以此保证这几条命令的原子性

以上就是 Redis + Lua 方案的大体内容,使用 Redis + Lua 的解决方案后,来自数据库的问题基本就解决了

简单 Demo

虽然项目中的这个任务认领问题很简单,无需使用 Redis + Lua 的方案解决,但这里为了写一个 Demo 当练练手就用这个需求来试试,代码也很简单:

public void lua(){
	/*
	Lua脚本,使用字符串形式,要注意格式,如代码中的空格,因为没有使用明显的换行符,主要的点在于:
	1. 使用 redis.call 函数调用Redis的命令去执行
	2. 使用tonumber函数将命令结果转化为数字
	3. 整个脚本根据不同分支定义了明确的返回值
	*/
    String script = "if tonumber(redis.call('get','task')) > 0 then " +
                        "redis.call('decr','task') " +
                        "return 1 " +
                    "else " +
                        "return 0 " +
                    "end";
	//构造一个Redis脚本类来表示要执行的脚本,这里使用的构造方法参数为所要执行的脚本以及返回值类型                    
    DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script,Long.class);
    //执行脚本时需要键值集合跟参数集合,其实也就是Redis命令需要的键值跟参数。这里无需参数所以都为空
    List<String> keys = new ArrayList<>();
    Object[] args = new Object[]{};
    //调用redisTemplate的execute方法执行,传入脚本,键值跟参数集合即可
    Long execute = redisTemplate.execute(redisScript, keys, args);
    System.out.println(execute);
}

无脑 decr

除了 Redis + Lua 方案,在看一个讲解视频的时候,看到评论区有人发表意见,说一直去对票数的 key 调用 decr 命令就可以,返回值为 1 就说明 decr 成功,也即有余票且抢到了票,否则就是没票了。仔细一想好像确实可行,而且实现起来也不复杂,是个方案,这里简单 mark 一下

总结

虽然平时项目开发的规模比不上大型系统,但多借鉴大型系统应对高并发,高可用,高性能的设计还是非常有助于自己的技术成长的

另外,上面说到的方案只是大型系统缓解系统压力应对高并发的一部分解决方案而已,实际上还需要服务器搭建集群,负载均衡,多使用消息队列异步操作等其它设计,才能使整个系统具有良好的三高表现

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值