我们在上一篇文章里面详细讨论了缓存的架构方案,它可以减少数据库读操作的压力,却也存在着不足。比如写操作并发量大时,这个方案并不奏效,那该怎么办呢?我们先来看一个具体的业务场景。

一、业务场景五

有一个这样的场景:一场超低价预约大型线上活动,在某天9:00-9:15期间,用户可以前往预约详情页半价预约抢购一款热门商品。根据市场部门的策划方案,这次活动运营目标是几十万左右的预约量。

面对如此之大的预约量,如何防止涌进来的请求压垮数据库?

之前,公司给系统做个一次压测:并发量保持在8000左右时,系统响应速度最高,并发量数据要是再上升,系统响应速度就会急剧变慢。如果几十万用户同时在那个期间预约商品,可以预见高峰期(特别是九点那一瞬间)的并发量肯定超过10000,到时候数据库肯定会因为承受不住而宕机。

为避免活动上线后出现此问题,我们必须提前做好预备方案。不过,在这场活动中,领导要求在架构上不要做太大调整。(说白了就是工期不能太长,也不能购买太多服务器。)

为此,最终采用的方案是不让预约的请求直接插入数据库,而是先存放到性能很高的缓冲地带,以此保证洪峰期间先冲击洪峰地带,之后再从缓冲地带异步匀速搬运到数据库中。

因使用的方案比较简单,所以这个方案设计不到2周就上线了,且活动期间用户体验全程没有卡顿,领导很满意,绩效保住了。。。

你肯定会有疑问,这个问题分库分表不就能解决吗?是倒是可以,不过代价太大且性价比不高。毕竟这次仅仅是一个临时性市场活动,且前面也说了,这次活动运营目标是几十万的预约量,这点数量采取分库分表的话,未免有点得不偿失。

其实,以上介绍的解决方案就是写缓存,这也是我们接下来要讲解的重点内容。

二、写缓存

什么是写缓存呢?写缓存的思路就是后台服务接收到用户请求时,如果请求校验没问题,数据并不会直接落库,而是先存储在缓冲层中,待缓冲层中写请求达到一定数量再进行批量落库。这里所说的缓冲地带,实际上指的就是写缓存。它的意义在于利用写缓存比数据库高几个量级的吞吐能力来承受洪峰流量,再平速搬运数据到数据库中。

设计图如下图:

从以上设计方案中,不难看出写缓存可以大幅减少数据库写操作频率,从而减少数据库压力。

上面这张图看起来简单,但该方案在具体实施过程中,往往需要考虑六大问题。

(一)、写请求与批量落库这两个操作是同步还是异步?

在讨论这个问题前,我们先来聊聊同步与异步之间的区别。

1、同步与异步的区别

比如同步,写请求提交数据后,写操作的线程会等到批量落库完成后才开始启动。这种设计的好处是用户预约成功后,可在我的预约页面立即看到预约数据,坏处是用户提交预约后,还需要在页面上等待一段时间才能返回结果,且这个时间不定,有可能需要等待一个完整的时间窗。

比如异步,写请求提交数据后,会直接提示用户提交成功。这种设计的好处是用户能快速知道提交结果,坏处是用户提交完成后,如果手痒前往我的预约页面查看,可能会出现没有数据的情况,这时用户就蒙圈了。

那我们到底应该使用哪种设计模式呢?先别急,我们再来讨论下这两种设计模式的复杂度。

2、同步与异步的复杂度

同步的实现原理是写请求提交数据时,写请求的线程被堵塞住或者等待,待批量落库完成后再发送信号给写请求的线程,这个线程获得落库完成的信号后,最后反馈预约成功给用户。

不过,这个过程会引出一系列的问题,比如:

  • 用户到底需要等待多久?用户不可能无限期等待下去,此时我们还需要设置一个时间窗,比如每隔100ms批量落库1次。
  • 如果批量落库超时了怎么办?写请求也不可能无限期等待,此时就需要给写请求的线程的堵塞设置一个超时时间。
  • 如果批量落库失败了怎么办?是否需要重试?多久重试一次?
  • 如果写请求一直堵塞在那直到重试成功再返回?那需要重试几次?这些逻辑其实与springcloud组件、hystrix请求合并功能(hystrix2018年已停止更新)等类似。

如果使用异步的话,上面的第二点、第四点基本不用考虑,从复杂度的角度来说,异步会比同步简单很多,因此后面我们直接选用异步的方式,预约数据保存到缓冲层即可返回结果。

关于异步的用户体验设计,共有2种设计方案可供业务方选择。

1、在我们的预约界面给用户一个提示:您的预约订单可能会有一定时间延迟。

2、用户预约成功后,直接进入预约完成详情页,此页面会定时发请求查询后台批量落库的状态,如果落库成功,则弹出成功提示,并跳转至下一个界面。

(二)如何触发批量落库

关于批量落库触发逻辑,目前市面上共分为2种触发方式。

1、写请求满足特定次数后就落库1次,比如10个请求落一次。

按照次数批量落库的优点是访问数据库的次数变为1/N,从数据库压力上来说会小很多。不过也存在不足,如果访问数据库的次数未凑齐N次,用户的预约就一直无法落库。

2、每隔1个时间窗口落库1次,比如每隔1s落库1次。

按照时间窗口落库的优点是能保证用户等待的时间不会太久,其缺点就是某个瞬时流量太大,在那个窗口落库的数据就会很多,多到在1次数据库访问中没法完成所有数据的插入(比如1s内堆积了5000条数据),它们只好通过分批次实现插入,这不就变回第1种逻辑了吗?

那到底那种触发逻辑好呢?我们之前方案是这两种方式同时使用,具体实现逻辑如下:

1、每收集1次写请求,插入预约数据到缓存中,再判断缓存中预约数的总数是否达到一定数量,达到后直接触发批量落库。

2、开一个定时器,每隔1s触发1次批量落库。

通过以上操作,我们既可以避免数据量不足导致的无法落库,也避免了因瞬时流量大,待插入数据堆积太多的情况。

(三)缓冲数据存储在哪里?

缓冲数据不仅可以放在本地内存中,也可以存在分布式缓存中(比如Redis),其中最简单的办法是存在本地内存中。

你可能想问,hystrix的请求合并好像也是放在本地内存中?嗯,确实是,不过写缓存与hystrix的请求合并有点不一样,请求合并更多的是考虑读请求的合并,不用担心数据丢失,而写请求需要考虑容灾问题。如果服务器出现宕机,内存数据就会丢失,用户的预约数据也就没有了,后果就不用说了吧。。。。

因此我们就使用分布式缓存好了。在上一篇文章里我们对分布式缓存技术选型做了介绍,所以我们这次就直接选用Redis了

接下来,我们需要考虑批量落库的设计了,批量落库主要是把Redis中的预约数据移动到数据库中。那么问题就来了,当新的数据一直增加,批量落库可能会出现多个线程同时处理的问题,此时就需要考虑并发性了。

(四)缓冲层并发操作需要注意什么?

实际上,缓冲层并发操作逻辑与冷热分离搬运逻辑很相似,但这里我们来聊个不一样的。

如果你对下面英文感兴趣,可以先看下 MySQL 官方文档中关于 Concurrent Inserts 的描述:

TheMyISAMstorage engine supports concurrent inserts to reduce contention between readers and writers for a given table: If aMyISAMtable has no holes in the data file (deleted rows in the middle), anINSERTstatement can be executed to add rows to the end of the table at the same time thatSELECTstatements are reading rows from the table.

If there are multipleINSERTstatements, they are queued and performed in sequence, concurrently with theSELECTstatements. The results of a concurrentINSERTmay not be visible immediately.

加粗地方大致意思是:如果多个 insert 语句同时执行,它们会按根据排队情况按顺序执行,也可以与 select 语句并发执行。

这里,我们再结合上面的场景具体说明下缓冲层并发操作时需要注意什么。

与冷热分离不一样的地方在于,这次我们并不需要搬运海量数据,因为每隔 1 秒或数据量凑满 10 条,数据就会自动搬运一次,所以 1 次 batch insert 操作就能轻松搞定这个问题,我们只需要在并发性的设计方案中保证一次仅有一个线程批量落库就行。这个逻辑比较简单,我们就不赘述了。

(五)批量落库失败了怎么办?

在考虑落库失败这个问题之前,我们先来看下批量落库的实现逻辑。

  • 首先,当前线程从缓存中获取所有数据,因为每10条执行1次落库操作,不需要担心缓存数据量过多,也不用考虑将获得的数据分批次操作了;
  • 其次,当前线程批量保存数据库;
  • 最后,当前线程从缓存中删除对应数据。(注意:不能直接清空缓存数据,因为新的预约数据可能插入到缓存中了。)

那在批量落库的过程中,如果这个操作失败了怎么办?我们自有妙招。

可能失败的步骤 处理方案
从缓存中获取所有的数据 如果数据未修改完无需回滚<br />下次触发的落库线程会自动把前面未处理的数据进行处理
批量保存数据库 用事务包裹,如果失败就回滚,下次直接从第1步开始
从缓存中删除对应数据 失败就失败,不用回滚到前面的步骤<br />这就会出现有些数据插入数据库后还在缓存中,下次线程会重复将这些数据插入到数据库的情况,所以我们需要确保数据库保存支持幂等(方法是使用手机号做唯一索引,这样就会自动忽略重复插入)

我们已经知道了批量落库每一步可能失败的处理的解决办法,接下来就是如何确保数据不丢失。

(六)Redis的高可用配置

在上面的业务场景里,我们是先把用户提交的数据保存到缓存中,因此必须保证缓存中的数据不丢失,这就要求我们实现Redis的数据备份。

现如今,Redis共支持2种备份方式,我们一起来看下。

备份方式 描述
快照 1、Redis满足特定条件时,会将内存中所有的数据保存到硬盘。<br />2、Redis崩溃恢复后,可通过快照还原数据,但快照最后一次生成的数据会丢失。
AOF 1、AOF类似MySQL的binglog,Redis的每个操作都会记录到AOF文件中,Redis崩溃后可通过AOF恢复数据(比快照恢复慢一些)<br />2、AOF的fsync可以是每秒1次,也可以每个请求都fsync,前者会丢失1秒的数据,后者会影响请求性能。

另外,Redis还有一个主从的功能,这里我们就不深度展开了。如果公司存在一个统一管理的Redis集群方案,直接复用公司的就行,最起码运维有保障。

而如果需要从0开始搭建,我认为最简单的解决方案如下:

1、先使用简单的主从模式;

2、然后在Slave Redis里使用快照(30秒1次)+AOF(1秒1次)的配置;

3、如果master宕机了,千万别直接启动,先把slave升级为master;

4、这时代码里已经有预案了,写缓存如果失败直接落库;

不过这个方案有个缺点,一旦系统宕机,手动恢复时大家会手忙脚乱,但数据很有保障。

三、此方案的价值和不足

写缓存这个解决方案可以缓解写数据请求量太大压垮数据库的问题,但还是存在不足。

不足一:此方案缓解的只是短时(活动期间)数据库压力的问题,当写数据量依旧非常大时,这个方案还是无法解决。

不足二:此方案适合每个写操作都是独立的情况,如果写操作之间存在竞争资源,比如商品库存,这个方案就无法覆盖。

在后面的文章里,我们继续来讨论应对这两点不足的解决方案。欢迎继续关注!!!