面试题:如何架构高性能读服务?
要求
我们来分析一下读服务的特点,读是获取内容,是无状态的或是无副作用的,每一次读都和上一次请求无关,是纯粹地从存储介质中获取原始数据,进行一些逻辑处理,返回给前端展示给用户。
比如,你打开某个电商APP,会进入到首页展示商品,后台服务接到请求后从存储中查询数据进行简单的加工后返回。
针对以上分析我们可以总结出读服务在实现上要满足以几点要求:
- 高可用:高可用是指系统在绝大部分时间内都是可以正常提供服务的,不管是遇到何种问题,比如电缆被挖断、机房失火、磁盘故障等,系统都可以一直提供服务或在极短的时间内恢复并正常提供服务。
- 高性能:一个合格的互联网产品必须要保证高性能,试想,一个电商APP打开后要过几秒才能展示页面,你还想使用吗?所以服务的性能一定要高,例如TP999保持在100ms以下。
-
高QPS:对于读服务,99%的请求都是读请求,标准的“读多写少”的场景,针对大流量并发请求,你的服务要能抗住。
针对以上技术指标要求,我们看一下如何实现。
cache-aside架构
即使对MySQL等关系型数据库采用了分库分表、读写分离、索引优化等手段,也很难将性能提升到200ms以内。
企业级应用中通常采用性能更高的Redis等内存型数据库来提升性能,常见的架构如下图所:
cache-aside架构也叫懒加载架构:
- 读请求先从缓存获取数据。
- 缓存未命中再从数据库获取。
- 查到数据后放入缓存,并设置过期时间,防止数据库变更后缓存还是旧数据。
这种架构的好处是实现简单,采用缓存抵御部分流量,带来了一定的性能提升。但是缺点和带来的问题也很明显。
-
性能毛刺: 缓存未命中时的接口性能比缓存命中时的接口性能明显要高,这种场景在性能监控上表现为性能毛刺。在性能要求高的系统上,该问题也是需要重视和解决的。
缓存与数据库存在短暂不一致问题:解决办法是更新数据库时,同步更新缓存,这就需要保证分布式事务了,MySQL和Redis的数据一致性维护成本较高。 - 缓存雪崩问题:特别需要注意大流量场景下大批量缓存不命中(缓存失效或未在缓存中的冷数据突然被请求)给MySQL带来的性能冲击,巨大的流量可能会把数据库打挂。解决办法是对缓存过期时间加上一个随机值,避免大批量的缓存数据同时失效。但是无法解决原本就不在缓存中的数据被请求的问题。
- 缓存击穿问题:某一热点数据失效时,对这一数据的大流量都降级到数据库。解决办法是加锁,只有一个请求降级到数据库,然后放到缓存,其余请求同步等待从缓存获取。
- 缓存穿透问题:大量非法请求来获取数据库中也不存在的数据,解决办法是缓存空值,但无法解决请求参数变换的非法请求,解决办法是对请求前置校验,过滤掉非法请求,可以采用布隆过滤器实现。
全量缓存架构
cache-aside架构虽然实现简单但带来很多问题,其中的性能毛刺问题没有好的方案完全解决,基于此演化出
全量缓存架构。
该架构是将需要提供高性能查询的历史数据和实时变更的数据全部同步到缓存,这样就天然的解决性能毛刺问题。
但是因为该架构也需要将MySQL变更的数据实时同步到缓存,所以也存在实时更新的分布式事务问题。
另外对数据的条件查询则需要额外的异构数据了,例如根据类型或状态查询,则需要维护一份根据类型或状态分类的数据id集合了。
基于Binlog的全量缓存架构
上述全量缓存架构存在数据库和缓存分布式事务问题,那如何解决呢?
全量缓存架构的痛点在于写请求对数据库修改的同时也要维护缓存,即程序复杂度增加,所有对数据库修改的地方都要同步修改缓存,所以能不能简化写请求的逻辑呢?答案是可以的。
写请求只修改数据库,同时,有一个模块消费binlog,将对数据库的变更同时施加到缓存。此时的架构如下图:
MySQL 的主从数据同步就是基于Binlog的,主库会将所有的变更按一定格式写入它本机的 Binlog 文件中。在主从同步时,从库会和主库建立连接,通过特定的协议串行地读取主库的 Binlog 文件,并在从库进行 Binlog 的回放,进而完成主从复制。
现在很多开源工具(如阿里的 Canal、MySQL_Streamer、Maxwell、Linkedin 的 Databus 等)可以模拟主从复制的协议。通过模拟协议读取主库的 Binlog 文件,可以理解为伪装成从库,从而获取主库的所有变更。对于这些变更,它们开放了各种接口供业务服务获取数据。
将 Binlog 的中间件挂载至目标数据库上,就可以实时获取该数据库的所有变更数据。对这些变更数据解析后,便可直接写入缓存里。
订阅Binlog那一刻起的,所有插入和更新的数据,都不会遗漏。
那在此之前的历史数据,怎么同步到缓存呢?
有一种简单的方式是:如果表里有类似【修改时间】这些非业务字段,可以在订阅Binlog之后,手动地更新所有历史数据的修改时间,就可以触发binlog,从而同步历史数据了。
采用基于Binlog的全量缓存架构,主要有以下优点:
-
降低了延迟:相较于缓存自动过期触发缓存更新,当前架构下的缓存基本上是准实时更新的,数据库的主从同步保持在毫秒级别,数据库的数据变更可以实时地反映到缓存里。
-
解决了分布式事务问题:Binlog 的主从复制是基于 ACK 机制,如果同步缓存失败了,被消费的 Binlog 不会被确认,下一次会重复消费,数据最终会写入缓存中。这就解决了因无法满足分布式事务而导致的丢数据问题,保障了数据的最终一致性。
-
降低了代码的复杂度:修改数据库不用考虑同步更新缓存,降低了代码的复杂度,提高了代码的可维护性。
任何架构都不是完美的,基于binlog的全量缓存架构同样存在问题,具体如下: -
增加了系统复杂度:原本系统只有数据库,增加了binlog同步中间件,系统故障率提升,整体复杂度提升。
-
缓存占用量增加,成本增加:空间换时间,必定需要付出代价,所以需要在性能要求和成本上作取舍。另外,在技术上可以尽可能的节约,首先是不要将业务无关的字段放入缓存,如更新人、更新时间等,其次是对数据进行压缩(比如Gzip、Zlib压缩算法,但是会额外消耗CPU,如果无法承受CPU消耗,还可以采用一些小技巧,比如将字段用简短的标识代替,例如替代前:{"field1":field1Value,"field2":field2Value},替代后:{"1":field1Value,"2":field2Value}
-
无法感知缓存丢失:虽然Redis提供了主从复制、持久化等功能,但是在某些极端情况下仍然会丢数据。可以采用异步校准的补偿方案。读服务查询缓存无数据后直接返回,同时发送MQ,消费方收到消息查询数据库,发现确实有数据时进行一次告警,然后将数据同步至缓存。这种情况出现的概率极低,可以在线上运行一段时间观察是否需要此补偿方案。
-
数据同步模块出现bug导致缓存与数据库不一致:数据同步随着需求需要不断变更和开发,难免会存在同步的BUG,因此数据会出现不一致,使用Binlog也避免不了。在上线后,可以开发数据校准模块作为补偿方案,定时校准缓存和数据库中的数据。
还是那句话,架构是基于当前业务场景考量后的问题解决方案,架构没有好坏之分,只有适合不适合。任何架构都有优缺点,享受架构带来的好处和便利的同时,同样需要承受一定的代价。
不要为了架构而架构,架构不是一味的堆技术,适合自己的才是最好的。
基于Binglog的全量缓存架构在落地时会有很多的细节和需要解决的问题我们还没有详细分析,例如Binlog日志如何发送,如何高效消费Binlog,缓存的数据结构如何选择及缓存更新存在的问题如何解决,这些问题都会在下一篇文章中体现。