我正在参加「掘金·启航计划」
在上一篇文章中我们讨论了缓存架构方案,使用这个架构方案可以减轻读数据库的压力,但是这个方案在大量并发写操作的情况下会造成数据库性能降低。那么这篇文章我将讲解一下如何处理大量并发写的问题。
一、案例
在一个电商系统中,我们要增加一个预约功能,用户可以在指定的时间段内对商品进行购买预约。预约功能和其他功能不一样的地方是,预约功能会出现短时间内大量并发写的情况,很有可能造成数据库和服务器崩溃,对于服务器来说我们可以增加服务器数量,但是对于数据库来说简单的增加数据库数量是不够的。一定有人会说使用分表分库啊,这个方案确实不错,但是预约功能并不是每天都有的,这么做又有些大材小用。因此在这里我推荐使用缓存层(又称写缓存)作为解决大量并发写的方案。每个预约请求经过校验后将先将数据存放在高性能的缓存中,这样大量的写操作会首先冲击缓存层,当缓存层中的数据达到某个阈值的时候再将其中的数据批量落库。(这里的缓存层大部分情况优会使用NoSQL来实现)优点是利用缓存的高吞吐能力来承受洪峰流量。
二、思路
写缓存需要考虑五个问题:
- 写请求和批量落库是同步操作还是异步操作;
- 落库如何触发;
- 如何存储缓存数据;
- 并发操作缓存层应注意哪些问题;
- 批量落库失败了怎么处理。
2.1 异步还是同步
2.1.1 . 同步
写请求提交数据后,当前写操作线程会等到批量落库完成后才返回结果给用户。优点是用户预约成功后,可以在预约页面马上看到自己预约的信息,缺点是用户在提交预约请求后需要等待一段时间(时间长短不定)才能收到返回的结果。 写请求和批量落库会同步执行出现四个问题:
- 用户要等多久才能收到结果:作为用户,不可能一直等待,因此我们需要设置一个时间窗口,比如每隔500毫秒就执行一个批量落库操作;
- 批量落库超时如何处理:写请求不可能一直等待落库返回结果,因此需要给写请求线程设置一个等待时间,超过这个等待时间就执行响应的逻辑进行处理;
- 批量落库失败如何处理:设置重试机制;
- 如果写请求一直在等待,是否需要批量落库重试成功后再返回结果:使用第三方组件的请求合并功能,例如Spring Cloud 组件、Hystrix组件。
2.1.2. 异步
用户提交预约后马上就能收到预约成功的提示,但是用户在查看预约记录的时候有可能会看不到自己刚才提交的预约。 我们使用异步的话,2.1.1节中所提到的问题2和4就不用考虑了,和同步相比异步方法要简单很多,而且复杂度也不高,因此大部分情况下我们会选择异步的方式。异步中存在一个问题:用户无法及时看到预约记录。要解决这个问题我们有两种方法:
- 在预约记录页面增加一个提示,告知用户预约记录可能存在延迟。 这个方法是比较常见的一种,优点是用户体验较好,缺点是用户需要手动刷新页面才有可能看到新的预约记录。
- 预约完成后,跳转到预约完成详情页,详情页定时向服务端发送请求获取批量落库状态,一旦获取到落库成功的状态,就跳转到后续页面。 这个方法用户基本上感受不到延迟,也不用手动去刷新来获得最新的预约记录,在这里我们选择这种方法。
2.2 如何触发落库
触发批量落库的方法有三种:
- 当写请求到达一定数量时就执行一次批量落库 这种方法的优点是访问数据库的次数变成了1/N(N表示写请求的次数),减轻了数据库的压力,缺点是如果写请求数量一直达不到N次,那么缓存中的数据将无法落库。
- 每个时间窗口就执行一次批量落库 这种方法的优点是避免了方法1的缺点,并且可以保证用户等待的时间不会太久,缺点是如果某个瞬间写请求突然剧增,那么在这个时间窗口内批量落库的数据量会很大,甚至数据库我发一次落库完毕。
- 方法1和方法2结合
这种方法的实现逻辑是每次收到写请求后,将数据写入写入缓存中,然后再判断缓存中的数据总数是否达到设置的阈值,如果达到阈值就执行一次批量落库。另外再设置一个定时器,在设定的时间窗口内执行一次批量落库,这样就避免了方法1和放法中的缺点。流程逻辑如下:
2.3 如何存储缓存
缓存数据一般存储在分布式缓存中,对于比较小的项目一般会存储在服务器本地内存中。但是实际项目中不建议存储在本地内存中,因为写缓存和读缓存不一样,服务器宕机后读缓还能从数据库中重新加载数据,但是写缓存则不能。因此这里我们使用分布式缓存来当作写缓存使用。
2.4 并发操作缓存注意事项
缓存的并发操作逻辑和我们第一篇文章中所说的冷热数据迁移逻辑很相似,但又有一些不一样的地方,比如我们迁移的数据量并不大,因为我们在每个时间窗口内容和达到一定阈值后都会执行一次数据迁移,因此只需要保证只有一个线程执行批量落库就可以了。
2.5 批量落库失败如何处理
批量落库的步骤有三步,从缓存中获取所有数据,然后将数据批量保存到数据库中,最后删除缓存中的对应的数据。这三步中任何一步出问题,都会给系统造成影响,下面就列出每个步骤操作失败的处理方法:
- 从缓存中读取数据失败:无需处理,下次落库操作时会将未处理的所有数据都读取出来并落库;
- 批量保存到数据库中:使用事务,如果保存失败就会滚;
- 删除换从中对应的数据:不需要将步骤二执行回滚操作,但要确保数据库支持幂等性,例如使用用户id为唯一索引,这样就会自动忽略已经入库的数据。
三、总结
写缓存这个方案相对来说开发成本较低,性价比较高,但是这个方案只能解决短时间写数据库压力大的问题,对于长期大量写入数据库的问题它无法解决,另外这个方案无法解决多个线程竞争同一个资源的问题,例如100个线程请求购买库存只有50个的商品。