序言
这是一个导师的作业,要求在理论上实现发红包、抢红包的操作,且不使用Redis、MQ等不属于Java语言的技术,思考如何做秒杀内容。因此记录下整个作业流程,方便以后学习。
需求描述
- 在游戏中,玩家A向全服玩家发送红包,其他玩家可以通过“抢红包”的操作来获得红包中的部分奖励
- 尝试思考,当有1000名玩家几乎同时发起“抢红包”操作时,如何保证每个玩家的操作延时在 10ms以下?
(一) 实现思路和难点论证
根据需求分析,整体的业务流程如下:
实现思路
- 用户A发送红包,其他用户可以同时抢红包,根据场景可以划分对象为游戏用户(tb_user)、红包(tb_red_envelopes)
- 每一个用户都除了基本的属性外,还具备发红包、收红包两个动作
- 红包表主要记录流水记录,红包金额,抢夺人数等基础信息
可以将整个过程为:
-
用户A通过游戏界面包红包,后台生成对应红包记录,并调用支付系统让用户A支付金额,用户余额进行扣减。
-
用户A发红包,后台修改红包状态记录,并且在客户端展示红包消息
-
其他用户抢红包,查询红包记录,如果已经被抢光了,就显示抢红包失败
-
其他用户拆红包,计算抢夺的红包金额,记录领取的流水号,同时修改订单状态,更新用户的余额。
难点论证
- 请求并发大(1000名玩家同时操作,不使用Redis、MQ),要求时延要低。
- 大量线程同时操作同一个资源(红包),伴随着并发安全的问题,并且在此
抢红包
场景乐观锁不适用,需要结合性能综合考虑(使用请求线程组串行化争夺资源) - 设计的是拼手气红包,因此每个红包都应该有一定的随机性,不能比较前的玩家抢夺就比较多,比较晚的玩家抢夺的红包比较少,因此需要设计一套随机分配金额的算法。
优化方案:
结合JUC并发编程、Queue、Set等数据结构做优化
削峰排队:
考虑到1个红包划分10份让1000个人抢,最终能够抢夺成功的也仅有10人!并不是所有的请求服务器都需要认真处理,因此流量削峰。使用JUC下的 Semaphore 信号量,将前10个访问线程记录下来放入队列,其余的请求统统驳回即可。但是考虑到当前10个请求有失败请求的情况,因此可以考虑令牌桶
的思想,同一时间获取令牌的线程可以比10位数大一些,请求完的线程要将令牌放回桶中。
业务流程优化:
由于一瞬间的并发操作,尽量减少请求直接操作DB,因此将抢红包的步骤一分为二;用户先点击界面的抢夺按钮,然后再拆开红包;在‘抢’的过程中不能直接操作数据库,而是通过记录线程访问次数(信号量),在业务层中将对应的请求放入队列中,这样就能够保证请求是串行的,而不是并发,因此就可以解决并发安全的问题,同时兼顾了性能。
在’抢’的过程所有操作都在业务层上实现,采用Set数据类型存储用户id,解决用户重复操作等问题。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nitNmzs0-1646053540898)(https://tuchuang-1302263573.cos.ap-guangzhou.myqcloud.com/%E5%89%8A%E5%B3%B0%E6%B5%81%E7%A8%8B-%20(1)].png)
在数据库层面上:
红包的访问量会随着时间变化而变化;红包记录被创建之后,访问量和抢夺量会在段时间内急剧上升,随后大幅下降趋势。人们一般在24小时之后就便不在访问(或少量访问),因此在红包记录表中可以设定正常表(热表、在用表)和历史记录表(冷表),在凌晨时通过定时任务将数据从正常表移出到历史记录表。
为了提高DB的性能,还可以水平拓展数据库性能,如以时间或一定的哈希范围作为分片键,每次查询和操作时都带上分片键,从而减少单一DB的压力。
(二) 整体流程和伪代码
整体流程
整体步骤将围绕:包红包、发红包、抢红包、拆红包进行
整体流程实现如下:
对于用户请求的大量并发,关键在于点红包仅有10份,服务器并不会’真正’处理这1000名玩家的请求,而是在业务层将所有的请求入队,将多余的请求拒绝并在客户端提示已经抢完,这样一来,压力就会大大的降低。
为了保障并发的安全性,且在这个场景下不适用乐观锁这种思想,所以采用串行队列的形式去抢夺红包,并且在抢夺之前就会计算红包的个数,如果红包被抢完,就应该在客户端提示。
当用户线程抢到了资格,此时就可以进行拆红包:
可以将拆红包最核心的几个点划分为:异步编程、红包减扣、红包金额计算、最终一致性校验、生成记录
从以上的角度来说,随着业务的繁杂,还可能出现账户金额更新、对应系统金额更新等等复杂操作,如果将所有的 操作都放在一次操作,那可想而知请求响应的时间有多慢。
其实对于用户来说只需要有没有成功拆到红包、拆到的金额多少即可,所有的繁杂操作都可以交给异步线程完成,从这个角度来说,既提高了用户的响应时间,提升了用户体验,也降低了系统的复杂程度,给予CPU一定的喘息。
因此:基于异步的操作和思想,系统将生成对应的红包记录,如出现想要查看别人抢得的金额记录的需求,只需要查对应的红包记录表即可。
为了保障数据的准确性,所以在最后一步,应该检查所有的数据,即完成最终一致性校验操作,对于不符合的数据就应该进行回滚操作。
代码及数据库设计
数据库设计:
红包表
-- 红包表 用户发一个红包插入一条记录
CREATE TABLE `tb_red_envelopes` (
`id` bigint(20) NOT NULL COMMENT 'id,采用雪花id',
`state` varchar(20) NOT NULL DEFAULT '1000' COMMENT '记录状态',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录状态时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录更新时间',
`remark` varchar(2000) DEFAULT '' COMMENT '记录备注',
`total_money` decimal(10,0) DEFAULT NULL COMMENT '红包总金额',
`remain_people` int(11) DEFAULT NULL COMMENT '红包拆分数量',
`remain_money` decimal(10,0) DEFAULT NULL COMMENT '红包剩余余额',
`own_id` bigint(20) DEFAULT NULL COMMENT '所属者id',
`user_id` bigint(20) DEFAULT NULL COMMENT '抢夺者id',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='红包表';
抢红包记录表
-- 抢红包记录表 抢一个红包插入一条记录
CREATE TABLE `tb_red_envelopes_record` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`amount` int(11) NOT NULL DEFAULT '0' COMMENT '抢到红包的金额',
`name` varchar(32) NOT NULL DEFAULT '0' COMMENT '抢到红包的用户的用户名',
`user_id` int(20) NOT NULL DEFAULT '0' COMMENT '抢到红包用户的用户标识',
`red_envelopes_id` bigint(11) NOT NULL DEFAULT '0' COMMENT '红包id,采用雪花id',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录状态时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='抢红包记录表,抢一个红包插入一条记录';
支付订单记录
-- 红包记录表 判断支付信息
CREATE TABLE `tb_order` (
`id` bigint(20) NOT NULL COMMENT 'id',
`user_id` bigint(20) NOT NULL COMMENT '用户id',
`red_envelopes_id` bigint(20) NOT NULL COMMENT '红包id',
`state` varchar(20) NOT NULL DEFAULT '1000' COMMENT '记录状态',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录状态时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录更新时间',
`remark` varchar(2000) DEFAULT '' COMMENT '记录备注',
`money` decimal(10,0) DEFAULT NULL COMMENT '支付金额',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='订单记录';
游戏用户表
-- 游戏用户表
CREATE TABLE `tb_user` (
`id` bigint(20) NOT NULL COMMENT 'id',
`name` varchar(20) NOT NULL COMMENT '姓名',
`state` varchar(20) NOT NULL DEFAULT '1000' COMMENT '记录状态',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录状态时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录更新时间',
`remark` varchar(2000) DEFAULT '' COMMENT '记录备注',
`money` decimal(10,0) DEFAULT NULL COMMENT '账户金额',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户表';
部分实现思路:
包红包:判断账户金额是否足够,如果足够就生成红包记录和订单记录
/**
* 包红包
* @param userId 用户id
* @param redEnvelopesEntity (包含红包金额,红包个数等关键操作)
* @return 返回生成的红包id
*/
public Long packRedEnvelopes(RedEnvelopesEntity redEnvelopesEntity, Long userId) {
//先查账户信息
UserEntity user = userMapper.selectByPrimaryKey(userId);
BigDecimal userMoney = user.getMoney();
//判断是否足够金额
if(userMoney.compareTo(redEnvelopesEntity.getTotalMoney()) < 0){
throw new RuntimeException("余额不足请充值!");
}
//生成红包信息
redEnvelopesEntity.setOwnId(userId);
saveRedEnvelopes(redEnvelopesEntity);
//生成支付订单
saveOrder(redEnvelopesEntity);
//返回生成的红包id
return redEnvelopesEntity.getId();
}
在完成支付后,客户端根据发红包id查询红包信息,然后展示抢红包。
部分业务优化:
如果游戏业务有许多校验,如同组才可以抢,同类型才可以抢,这些校验在抢红包过程中必然会消耗一定时间来做这些判断,因此我们也可以在登录时,多颁布一份令牌用于校验这些参数,在抢红包时携带对应的令牌,这样就可以避免重复性校验。
(三) 关键逻辑的代码片段
抢红包是一个比较重要的步骤,其整体流程图如下:
其中在执行抢红包
操作中,需要做一些前置的业务逻辑判断,只有取得令牌的线程才有资格进行真正的业务操作。线程在操作完之后必须将令牌放回,以便后续线程的访问。
其整体伪代码如下:
/**
* 抢红包,只允许10个线程访问
* @param redEnvelopesId 红包id
* @param userId 用户id
* @return 1表示成功,0表示已经抢光
*/
public Integer seckillRedEnvelopes(Long redEnvelopesId,Long userId) {
Semaphore semaphore = mySemaphore.getSemaphore();
//当可用令牌为0时表示访问线程已满
if(semaphore.availablePermits() == 0){
log.info("红包已被抢光");
return 0;
}
try {
//开始抢红包,获取令牌
semaphore.acquire();
//执行抢红包操作
seckill(userId,redEnvelopesId);
//释放令牌
semaphore.release();
} catch (InterruptedException e) {
log.error("抢红包运行出错");
throw new RuntimeException("抢红包运行出错");
}
//返回结果
return 1;
}
需要注意的几点:
- 受制于相关业务规则下,并不是所有获得令牌的线程都可以正常的抢到红包,可能是业务限制,也可能是客户端脚本外挂,因此我们可以在判断令牌的时候加上一个业务判断是否用户重复操作,等到执行的线程做完业务操作后将令牌放回。
- 判断令牌和校验重复操作仅仅是业务的初步操作和基础的业务限流,在真正进入业务操作时应该加上校验和反馈提示,所有的红包记录都需要做最终一致性的校验,对于不符合的记录需要做数据回滚,也可以增加相应的补偿机制。
- 为了游戏性能,可能并不是所有的网络请求都是安全的,数据是由客户端发送给服务端的,因此对于敏感的操作,还是需要走安全的网络协议的,从而确保关键数据不会窜改。
当用户已抢到资格时,就可以开始拆红包,主要思想和特点:异步编程、红包减扣、红包金额计算、最终一致性校验、生成记录
拆红包整体业务流程图如图所示:
可以看到,即便抢到了红包,等到真正要拆红包时,仍然需要做一次红包剩余的校验;原因在于,抢
红包的操作在整个流程重点是做一次流量削峰和重复操作限制,让抢到资格的用户顺利进入到下一个环节。
为了解决线程安全的问题,此时还需要将操作的次数用一个计数器记下,从而防止超额抢红包的问题出现。
当抢到红包后,用户就可以选择拆
红包,以拼手气抢红包为例,就需要考虑让红包金额尽可能随机
,其功能实现如下:
/**
* 拼手气红包功能实现
* 尽可能的随机,成功后需要将剩余红包数量、金额更新
* @param redPackage 红包实体对象
* @return
*/
public static BigDecimal getRandomMoney(RedEnvelopesEntity redPackage) {
// remainSize 剩余的红包数量
// remainMoney 剩余的钱
if (redPackage.getRemainSize() == 1) {
redPackage.setRemainSize(redPackage.getRemainSize() - 1);
return new BigDecimal((double) Math.round(Float.valueOf(redPackage.getRemainMoney().toString()) * 100) / 100);
}
//随机对象
Random random = new Random();
//最大最小值
BigDecimal min = new BigDecimal(0.01);
BigDecimal max = redPackage.getRemainMoney().divide(new BigDecimal(redPackage.getRemainSize() * 2));
// 抢到的money
BigDecimal money = max.multiply(new BigDecimal(random.nextInt()));
money = money.compareTo(min) <= 1 ? min : money;
money = new BigDecimal(Math.floor(Double.valueOf(money.toString()) * 100) / 100);
redPackage.setRemainSize(redPackage.getRemainSize() - 1);
redPackage.setRemainMoney(redPackage.getRemainMoney().subtract(money).toString());
return money;
}
涉及到金额的运算,需要考虑溢出和精度问题,因此此处使用了Java提供的BigDecimal
类库进行计算。
对于红包的扣减这里使用CAS来确保线程安全;CAS是一种在不加锁的情况下实现多线程之间的变量同步算法,其核心的思想是基于乐观锁,相比悲观锁性能更好。
CAS相关变量:需要读写的内存位置 V;进行比较的值 A;拟写入的新值 B;
操作过程:
当且仅当 V 等于 A 的时候,CAS 通过原子方式用新值 B 来更新 V 的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。
CAS问题
如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,我们无法确认数据是否被修改过,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 "ABA"问题。
可以通过版本号机制解决。
这里比较推荐使用Java内置的CAS安全类AtomicStampedReference
来解决线程安全的问题,同时也可以从数据库层面来——给表增加一个version版本号,在新增或修改的时候比较version版本,伪代码如下:
update
tb_red_envelopes_record
set money = #{money},version = version+1
where id = #{id} and version = #{version}
在更新前查询当前记录的version版本,更新时比较版本号是否一致,如果一致就更新数据,同时让version+1
(四) 问题总结
抢红包操作与秒杀环节有许多相似之处,因此可以互相借鉴,如常用操作流量限流、削峰、异步编程等,重点在于将业务划分为 包、发、抢、拆四个步骤,一步一步将整个业务解耦,分散了每一步的压力。
1000个人抢10个红包,可以很轻松的发现会有大量的无效
请求,如果我们每一个请求都按照相同的流程处理,会给服务器带来压力,因此可以使用一个信号量的东西将线程拦截,让取得资格的线程获取令牌,进行下一步的业务操作。
红包金额的计算不是预先分配的,而是采用实时计算的方式,从而提高了效率。
- 这样设计会有什么问题?
抢和拆是分为两部操作的,有时候用户抢到红包,等到拆的时候会提示已经被抢完了;原因是抢的时候是阻绝了大部分流量,将小部分流量放入进行拆的操作,这个小部分流量并不是刚好10个,这样就会造成用户拆开前红包被领完的情况。
- 有哪些参考Redis的思路?
用Redis主要用于缓存,将红包信息压入Redis,红包的数量减扣直接在Redis上减扣,抢红包时直接判断Redis中红包的数量即可。
参照这个思路,我使用了JAVA内置并发类:Semaphore信号量、CountDownLatch计数器。
对于抢红包的线程使用信号量记录,达到对应阈值时统一反馈红包已抢完;拆红包时使用计数器记录抢红包的数量。从线程安全的角度,对红包记录操作时可以使用CAS的方式。
- 有哪些参考RabbitMQ的思路?
RabbitMQ主要是异步的方式,程序中可以直接开启新线程的做很多事情。
比如在拆红包时,最重要的就是红包有剩余,要算出红包的金额,也就是要有资格
才能抢到红包;至于后续账户同步,生成记录,更新红包等等一系列复杂的业务操作,对于用户来说都是无感知的,用户仅仅只需要知道抢没抢到红包,抢到的金额是多少即可,钱包的数据更新即便延迟几秒也是可以的。
- 数据库有哪些优化方案?
基本的措施是要保障编写的SQL必须高效,确保所有SQL都用上了索引;
表级设计上使用热表和冷表的方式,将超过一定时限的红包记录移到冷表,确保在用表数据量在一定范围内。
性能不够的话可以考虑水平拓展,使用多sharding的方式将数据库分片,查询时带上分片键查询。
- 如何防止外挂?比如客户端脚本大量请求
数据在客户端就容易被篡改,对于关键数据必须放在服务端验证,并且使用网络安全的协议。
客户端脚本大量请求抢红包本质是一个重复操作的问题;可以设置一个表专门记录某一个红包记录+用户id的键,如果重复操作次数过多就屏蔽请求。
还可以做一些异常措施检测,采用警告、封号等方式来防止外挂。
- 拆红包操作异步操作的核心重点是什么?
拆红包主要是用到了异步解耦、CAS更新数据记录、红包金额计算
异步的最大好处就是能够提升响应时间,将用户无感知的操作统统留给服务器自己去跑,会造成一定的时间差,但是提供非常可观的性能提升。
我觉得要保障这一系列核心的操作就是最终一致性
操作
这里有一个概念:CAP
- 一致性(Consistency)
- 可用性(Availability)
- 分区容忍性(Partition tolerance)
CAP指的是,这三个要素最多只能同时实现两点,不可能三者兼顾。常常出现在分布式架构系统中取舍。而对于分布式数据系统,分区容忍性是基本要求。因此设计分布式数据系统,就是在一致性和可用性之间取一个平衡。
强一致性和最终一致性需要根据场景来决定; 从客户端角度,多线程并发访问时,要求更新过的数据能被后续的访问都能看到,这是强一致性
最终一致性需要从客户端和服务器端两个角度来说, 从客户端来看,某些数据是无感知的,只需要结果或部分临时
数据,至于数据如何实际操作,就需要服务器内部运转了。从服务端来看,则是更新如何复制分布到整个系统,以保证数据最终一致。
最终一致性看重的是结果,过程交给服务器,对于不合规
的操作纷纷采用回滚的操作。
最终一致性的目的是从性能的角度考虑的,为了实现更快的响应,减少响应时间,提高用户体验,且该场景下适合,因此选择最终一致性的方案。
- CAS会带来什么问题?如何解决的。
典型的就是ABA问题。
CAS 的 "ABA"问题:ABA问题仅靠比较数值无法判断某个变量是否已经被更改过,某个变量这段时间它的值可能被改为其他值,然后又改回 A,那 CAS操作就会误认为它从来没有被修改过。
解决ABA问题的方法就是使用版本号机制
比较推荐使用Java内置的CAS安全类AtomicStampedReference
来解决线程安全的问题,同时也可以从数据库层面来——给表增加一个version版本号
- 系统的数据库回滚策略是怎样的?
如果是一个单体架构或单数据库架构设计,只需要遵循 ACID数据库事务,使用MySQL默认的Repeatable read隔离级别。代码层面,主要是用到了Spring 声明式事务 。
这里Spring主要是确保一个service使用的是同一个Connection对象,当出现用户先更新了金额,但是红包表写入失败的场景,由于Spring事务不会先提交commit用户的mapper,而是当出现失败时就rollback整个Connection,从而确保事务的成功。
当涉及到分布式系统时,还需要考虑到CAP问题,这里选择的是 定时补偿机制最终一致性分布式事务
借助消息队列(可用异步线程实现),在处理业务逻辑的地方发送消息,业务逻辑处理成功后,提交消息,确保消息是发送成功的,之后消息队列投递来进行处理,如果成功,则结束,如果没有成功,则重试,重试次数达到阈值时会单独开辟补偿任务机制, 回滚所有的操作。