实现思路
图5-1看起来很简单,但该方案在具体实施过程中要考虑6个问题。
• 图5-1 写缓存架构示意图
1)写请求与批量落库这两个操作同步还是异步?
2)如何触发批量落库?
3)缓冲数据存储在哪里?
4)缓存层并发操作需要注意什么?
5)批量落库失败了怎么办?
6)Redis的高可用配置。
下面一一介绍。
写请求与批量落库这两个操作同步还是异步
在回答这个问题前,先来对比一下同步与异步。
对于同步,写请求提交数据后,当前写操作的线程会等到批量落库完成后才开始启动。这种设计的优点是用户预约成功后,可在“我的预约”页面立即看到预约数据;缺点是用户提交预约后,还需要等待一段时间才能返回结果,且这个时间不定,有可能需要等待一个完整的时间窗。
对于异步,写请求提交数据后,会直接提示用户提交成功。这种设计的优点是用户能快速知道提交结果;缺点是用户提交完成后,如果查看“我的预约”页面,可能会出现没有数据的情况。
那到底应该使用哪种设计模式呢?下面再介绍下这两种设计模式的复杂度。
同步的实现原理是写请求提交数据时,写请求的线程被堵塞或者等待,待批量落库完成后再发送信号给写请求的线程,这个线程获得落库完成的信号后,返回预约成功提示给用户。
不过,这个过程会引出一系列的问题,比如:
1)用户到底需要等待多久?用户不可能无限期等待下去,此时还需要设置一个时间窗,比如每隔100毫秒批量落库一次。
2)如果批量落库超时了怎么办?写请求不可能无限期等待,此时就需要给写请求线程的堵塞设置一个超时时间。
3)如果批量落库失败了怎么办?是否需要重试?多久重试一次?
4)如果写请求一直堵塞,直到重试成功再返回吗?那需要重试几次?这些逻辑其实与Spring Cloud组件、Hystrix请求合并功能(Hystrix 2018年已经停止更新)等类似。
如果使用异步的话,上面的第2)点、第4)点基本不用考虑,从复杂度的角度来看,异步比同步简单很多,因此项目直接选用异步的方式,预约数据保存到缓存层即可返回结果。
关于异步的用户体验设计,共有两种设计方案可供业务方选择。
1)在“我的预约”页面给用户一个提示:您的预约订单可能会有一定延迟。
2)用户预约成功后,直接进入预约完成详情页,此页面会定时发送请求去查询后台批量落库的状态,如果落库成功,则弹出成功提示,并跳转至下一个页面。
其实,第一种方案在实际应用中也经常遇到,不过项目中主要还是使用第二种方案。因为在第二种方案中,大部分情况下用户是感受不到延迟的,用户体验比较好,而如果选择第一种方案,用户还要去思考:这个延迟是什么意思?是不是失败了?这无形中就影响了用户体验。
接下来讨论第二个问题。
如何触发批量落库
关于批量落库触发逻辑,目前共分为两种。
1)写请求满足特定次数后就落库一次,比如10个请求落库一次。
按照次数批量落库的优点是访问数据库的次数变为1/N,从数据库压力上来说会小很多。不过它也存在不足:如果访问数据库的次数未凑齐N次,用户的预约就一直无法落库。
2)每隔一个时间窗口落库一次,比如每隔一秒落库一次。
按照时间窗口落库的优点是能保证用户等待的时间不会太久,其缺点是如果某个瞬间流量太大,在那个时间窗口落库的数据就会很多,多到在一次数据库访问中无法完成所有数据的插入操作(比如一秒内堆积了5000条数据),它们只能通过分批次来实现插入,这不就变回第一种逻辑了吗?
那到底哪种触发方式好呢?当时项目采用的方案是同时使用这两种方式。
具体实现逻辑如下。
1)每收集一次写请求,就插入预约数据到缓存中,再判断缓存中预约的总数是否达到一定数量,达到后直接触发批量落库。
2)开一个定时器,每隔一秒触发一次批量落库。
架构示意图如图5-2所示。
• 图5-2 触发批量落库方案示意图
通过以上操作,既避免了触发方案一数量不足、无法落库的情况,也避免了方案二因为瞬时流量大而使待插入数据堆积太多的情况。
缓存数据存储在哪里
缓存数据不仅可以存放在本地内存中,也可以存放在分布式缓存中(比如Redis),其中最简单的方式是存放在本地内存中。
但是,Hystrix的请求合并也是存放在本地内存中,为什么不直接使用Hystrix?这是因为写缓存与Hystrix的请求合并有些不一样,请求合并更多考虑的是读请求的情况,不用担心数据丢失,而写请求需要考虑容灾问题:如果服务器宕机,内存数据就会丢失,用户的预约数据也就没有了。
其实也可以考虑使用MQ来当缓存层,MQ的一个主要用途就是削峰,很适合这种场景。不过这个项目选择了Redis,因为服务本身已经依赖Redis了。另外,项目想要使用批量落库的功能,项目组知道如何一次性从Redis中取多个数据项,但是还没有试过批量消费MQ的消息。
基于Redis触发批量落库的方案如图5-3所示。
• 图5-3 基于Redis触发批量落库方案示意图
接下来需要考虑批量落库的设计了。批量落库主要是把Redis中的预约数据迁移到数据库中。而当新的数据一直增加时,批量落库可能会出现多个线程同时处理的情况,此时就要考虑并发性了。
缓存层并发操作需要注意什么
实际上,缓存层并发操作逻辑与冷热分离迁移冷数据的逻辑很相似,这里讲一些不一样的地方。
先看下MySQL官方文档中关于Concurrent Insert的描述:
The MyISAM storage engine supports concurrent inserts to reduce contention between readers and writers for a given table:
If a MyISAM table has no holes in the data file(deleted rows in the middle), an INSERT statement can be executed to add rows to the end of the table at the same time that SELECT statements are reading rows from the table.
If there are multiple INSERT statements, they are queued and performed in sequence, concurrently with the SELECT statements.The results of a concurrent INSERT may not be visible immediately.
斜体部分的内容即:如果多个Insert语句同时执行,它们会根据排队情况按顺序执行,也可以与Select语句并发执行。所以多个Insert语句并行执行的性能未必会比单线程Insert更快。不过这个项目中并没有花时间去做具体的测试,一是时间紧,二是确实没必要,因为单线程当时是够用的,这个接下来会讲。
这里再结合上面的场景具体说明下缓存层并发操作时需要注意什么。
与冷热分离不一样的地方在于,这次并不需要迁移海量数据,因为每隔一秒或数据量凑满10条,数据就会自动迁移一次,所以一次批量插入操作就能轻松解决这个问题,只需要在并发性的设计方案中保证一次仅有一个线程批量落库即可。这个逻辑比较简单,就不赘述了。
批量落库失败了怎么办
在考虑落库失败这个问题之前,先来看看批量落库的实现逻辑。
1)当前线程从缓存中获取所有数据。因为每10条执行一次落库操作,不需要担心缓存中的数据量过多,所以也不用考虑将获得的数据分批操作了。
2)当前线程批量保存数据到数据库。
3)当前线程从缓存中删除对应数据(注意:不能直接清空缓存的数据,因为新的预约数据可能插入到缓存中了)。
以上各个步骤失败时的应对措施见表5-1。
表5-1 批量落库失败的应对措施
现在已经知道了批量落库失败的解决办法,接下来就是研究如何确保数据不丢失。
Redis的高可用配置
这一业务场景是先把用户提交的数据保存到缓存中,因此必须保证缓存中的数据不丢失,这就要求实现Redis的数据备份。
目前,Redis共支持两种备份方式,见表5-2。
表5-2 Redis备份方式
另外,Redis还有一个主从功能,这里就不展开了。如果公司已经存在一个统一管理的Redis集群方案,直接复用即可,至少运维有保障。
而如果需要从0开始搭建,最简单的解决方案如下。
1)先使用简单的主从模式。
2)然后在Slave Redis里使用快照(30秒一次)+AOF(一秒一次)的配置。
3)如果Master Redis宕机了,千万不要直接启动,先把Slave Redis升级为Master Redis。
4)这时代码里已经有预案了,写缓存如果失败直接落库。
不过这个方案有个缺点,即一旦系统宕机,手动恢复时大家就会手忙脚乱,但数据很有保障。
小结
写到这里,整体方案就基本完成了。这个项目经过两周左右就上线了,上线之后的某次活动中,后台日志和数据库监控一切正常。活动一共收到几十万的预约量,达到了市场预期的效果。
所以总体来说,这个方案性价比还是比较高的。
接下来再说说不足。写缓存这个解决方案可以缓解写数据请求量太大、压垮数据库的问题,但其不足还是比较明显的。
1)此方案缓解的只是短时(活动期间)数据库压力大的问题,当写数据量长期非常大时,这个方案是解决不了问题的。
2)此方案适合每个写操作都独立的情况,如果写操作之间存在竞争资源,比如商品库存,这个方案就无法覆盖。
在接下来的篇幅中,会专门讲解这两种情况对应的解决方案。