大家好,我是 方圆。在上篇文章 如何实现百万 QPS 下服务本地缓存的同步?,我们聊到了多服务实例本地缓存数据同步的方案,它实现起来比较复杂,也不容易上手。如果团队内有多个不同业务的服务都要刷新本地缓存的话,将同一套方案在不同的服务中实现多次显然也是不合适的,所以在本篇文章中主要讨论如何设计一套 “合适的” 架构来避免重复实现和降低技术门槛,让各个业务专注于业务实现而不必去关注缓存同步的细节。此外,我们还会讨论一些缓存相关设计常遇到的问题。
整体架构设计
假如 A_业务 和 B_业务 都需要依赖本地缓存来处理相关的业务,只不过它们依赖的缓存类型不同,但是如果我们抽象一层再细想一下:在它们提供业务的背后都需要一套缓存的管理、刷新和同步机制,对不同的业务线而言也只是使用了不同的缓存数据来做不同的业务而已。在前文中我们已经了解到,缓存数据的同步是分布式系统中一个非常棘手的问题,那么我们是否可以抽象出一套通用的缓存管理机制来屏蔽掉这个问题呢?在这个想法下,我想到了如下架构设计:
缓存数据在该架构中都被定义为一个个 “数据资产”,图中数据资产层便展示了现有的缓存资产。我们再来回想开篇提到的:“不同的业务线依赖不同类型的缓存”,而在此时的架构中,将数据资产抽象出了 “数据资产层” 来管理这些数据资产,这样它便能提供 通用能力:提供不同缓存的组合来满足不同的业务诉求,比如某业务可能依赖 数据资产_A 和 数据资产_B 便能满足业务诉求,就好像 一个个数据资产活灵活现了起来,需要哪部分便依赖哪部分即可。
当然,这只是业务层面上的体现,在技术层面上,在这种架构下我们能做到依赖缓存的应用不需要关注缓存的更新或同步细节,只负责用,提供的是一种数据资产的能力。如果我们能让更加专业的团队将这部分能力囊括起来,只对外提供数据资产的使用,那么各个业务线在使用缓存时便不需要再关注这些技术细节,不论它是增量同步、全量同步、存储的介质是什么以及做了技术上不同取舍等等都不需要去关注,只需要关注自己需要哪部分缓存,然后使用即可,其他的技术难题全部被屏蔽掉了。
在具体实现上我举一个简单的例子,不同的业务线要使用哪些不同的数据资产了,可以通过配置的形式接入哪些资产,在应用中添加上这些资产标签,便加载这些缓存来使用,甚至我们可以再想得远一点,我们可以提供一种 平台化接入 的能力,针对各个不同的数据资产各业务线按需申请,我们来提供数据管理的能力,让一项项数据成为真正意义上的数据资产。
在讨论完整体架构设计后,接下来我们再看一下设计缓存相关服务时遇到的问题:
高并发场景下如何做好技术选型?
使用缓存有两套方案:本地缓存(如Guava 或 Caffeine)或分布式缓存,该如何选型呢?两套方案各有利弊:
| 缓存方案 | 本地缓存 | 分布式缓存 |
|---|---|---|
| 性能 | 极高(微妙级) | 高(毫秒级) |
| 网络开销 | 无 | 有 |
| 数据容量 | 较小,受限于服务器内存 | 较大,集群可以水平扩展 |
- 本地缓存:性能更佳,不受网络影响,始终保证高可用;但各个服务器的缓存独立,保证数据一致性成为挑战。多数遵循分布式的 AP 定理,适合性能要求高、且能够容忍最终一致性的场景;
- 分布式缓存:性能逊于本地缓存,且受网络影响;好处是各个服务器从分布式缓存中获得的数据一定是一致的。遵循分布式的 CP 定理,更适合强一致性场景。
针对本地缓存的技术选型可以参考之前的文章:缓存之美:万文详解 Caffeine 实现原理。
分布式缓存(Redis)大 Key 问题如何解决?
我们规定符合以下两点的键值对均称为大 Key:
- Key 的名称超过 1024 字节
- Value 若为集合类型,元素数量 > 5000 或 者单个 Value 大于 10k
以电商业务中的签约商家记录为例,假如商家数据有 120w 条,为了避免大 Key,不能将所有数据保存在一个键值对中,需要进行分片,拆分为多个小 Key,那么如何分片和分片的查询规则便需要解决。
第一种方案:将每个 Key 的 Value 中存储 5,000 条数据,且商家在整个集合中唯一,这样会将数据分片为 240 个。当商家数据增加时,每满 5,000 便会创建一个新的分片去存储。查询目标商家数据时,采用 遍历查询 的方式:检查每一个分片直到查询到或遍历完所有分片,时间复杂度为 O(n)。
这种方法并不高效,因为查询每个分片都会有网络开销,查询的分片越多,处理查询请求的延时就会越高,这对于查询性能有较高要求的场景并不合适,那么这个问题我们该如何解决呢?
针对这个问题我们提出了:分层索引 + 哈希的方案。首先,要解决性能问题那便不能再通过遍历的形式查询每一个分片得到结果,最好是以 O(1) 的时间复杂度来完成查询操作。我们便想到了通过哈希算法来实现:对于各个分片不再是简单地按照数量划分,而是根据哈希算法划分不同商家编码所在的分片,这样根据商家编码确定对应的分片后,便可以实现以 O(1) 的时间复杂查询,具体方案如下:
创建以下 数据结构 记录所有分片,原理与 Java 中 HashMap的实现类似:
- Key: VENDER_INDEX_KEY
- Value:
[["segment_1"], ["segment_2"],..., ["segment_512", "segment_512_1"]]
Value 为 二维数组,行维度为固定大小,用于保证同一商家编码能始终哈希到同一位置;列维度表示的是一个个 “桶”,其中记录的是具体分片,它是一个个 数组,这是为了 保证它能实现哈希位置不变的扩容。在我们的方案中如果某个桶中的某个分片(通常为一个)已经达到最大容量限制,那么需要创建出新的分片来记录新的商家,也就是在桶中添加一个新的元素,而 不是增加行维度的大小,这也是采用二维数组数据结构的原因,这样就省去了数据 ReHash 的麻烦。商家数据查询流程如下所示,先在索引中获取到对应的分片 Key,在根据分片 Key 去具体的分片获取数据:
如果哈希到的是多个分片的桶 segment_512 和 segment_512_1,简单的方案是去检查两个分片的数据,也就相当于是方案一的遍历,只不过检查的分片数量很少,对性能损失的也少;如果担心桶内分片数量增多造成性能损失,也可以再采用哈希的形式。
最后确定一下分片的规则,定义每个分片的最大大小为 5,000,桶的数量定义为 2 的 N 次幂,256 个桶满足 1,280,000 的商家,以现有商家数据规模来看,256 个桶将在不远的将来触发分片的扩容,为了保证扩容不频繁,将桶的数量定义为 512,尽可能少的扩容以保证查询效率。
在这个基础上还能做一层优化:添加 布隆过滤器 前置判断,若商家不存在布隆过滤器中,则不必再去各个分片中查询数据,实现 “快速返回”。
如何解决最终一致性带来的多次查询不一致的问题?
如果缓存服务遵循的是分布式 AP 定理,保证了可用性而不保证数据的强一致性,那么一定会存在部分服务实例已经刷新了最新的缓存,而部分实例还没有刷新最新的缓存的情况。假如请求过来采用的是 轮询 的负载均衡机制,相同的请求打到不同的实例上可能结果不同,结果可能是一会儿这个数据能查到,一会儿这个数据又查不到了。如果想解决这种情况,可以考虑在网关层分发流量时使用一致性哈希算法,并在哈希环中引入虚拟节点以使流量尽可能均分,这样固定的请求总是被路由到同一个服务实例,那么便不会出现相同请求不同结果的情况。
关于“高并发、高性能设计”的思考
不同流量系统采用不同的方案
- 1w QPS 以下系统如何设计?
如果流量不大,几百到几千,并且对性能要求不高,那么采用最简单的方式:直接操作数据库进行 CRUD 即可。若直接操作数据库对库压力过大,有风险,则可以增加一层缓存来减少数据库的操作。
- 几十万 QPS 系统如何设计?
如果流量较大,几万、十几万甚至几十万,则优先考虑直接操作缓存。目前缓存集群能够抗大几十万至百万级流量非常轻松。将需要访问的数据提前放到缓存中,操作时直接查询缓存即可,但是这可能对会涉及 数据库 和 缓存 的数据一致性问题需要注意。
- 百万级QPS系统如何设计?
若流量来到了百万级别,以及对性能要求非常苛刻(20ms,甚至 10ms 以内),面对这种地狱级的难度,就得考虑采用内存了。使用内存便涉及到了本地缓存的数据同步和更新,这在分布式系统设计中是一个难题。
同时在这种设计下,采用串行操作或许是更好的选择,使用多线程可能会因为多线程之间的切换和调度造成系统抖动。
性能提升基本原则
以目前业务系统中遇到的情况为例,我想简单谈谈我的看法:
-
服务启动阶段:可以尽可能地进行初始化,进行大量的提前计算,减少交易环节的计算量,尽可能完成框架内需要组件的加载。在实际生产中,我们会通过 “流量预热” 来完成这些操作
-
减少网络传输报文大小:以业务中商品主数据接口为例,需要获取的字段信息需要申请,只返回自己需要的字段,不会返回过多别的字段,以减少报文大小、字段精简、长度压缩、内容压缩等也是常用手段
-
减少网络调用:将大量需要通过 RPC 来获取的数据在本地内存进行存储,访问时不需要再进行RPC调用,但是这也需要考虑到数据一致性问题。一般情况下,RPC 接口性能较差时最好不要主动缓存请求结果,而是督促接口提供方进行性能优化,否则接口请求结果的多次缓存造成的数据不一致问题难以排查
-
同机房调用:全链路服务器采用同机房调用
-
做好流量漏斗:可以采用布隆进行流量漏斗,或者将热点请求提前计算好,减少对下游系统的调用
-
批量调用:这也是减少网络消耗的常用手段,对于缓存可以使用 hashtag 将相同用户的数据打到一个集群的一个分片中,然后使用 pipeline 一次获取
-
选择适合的数据结构和集合大小:减少大集合的 copy 操作;java 的集合操作 addAll、putAll 等操作会消耗大量CPU
-
快速失败机制:这个话题有很长的历史,但真正能做好的系统非常少;还是需要对系统进行深入研究,做好流量漏斗;如果后续流程中都可能存在流程中断的情况,那么就可以好好考虑能否前置;
-
并行处理:多线程设计,需要了解一下多线程设计的不同设计模式
-
异步处理:异步处理的好处不只是解耦,还能够大幅提高系统的性能。这在系统设计中经常被采用,不过需要考虑异步操作的兜底:异步操作失败怎么进行补偿
-
扩容:上面的方法都无法解决时,大概率就得考虑扩容了。横向扩容、纵向扩容均可,选择适合自己的;但需要注意中间件连接池的配置:如数据库连接池等等
-
空间换时间:大多数提高性能的手段都是空间换时间,比如采用本地内存或缓存的方式,大家在设计时可以多朝着这个方向去想
减少 GC 基本原则
GC 会造成系统的抖动,除了对 JVM 本身的参数进行优化以外,我还想谈一谈其他的方式:
-
选择合适的数据结构:能够使用的数据结构越简单越好,数据类型越简单越好;例如能用 int 就不要使用Integer,能用 int 不要考虑使用 long
-
减少复制:最常见的错误是在使用集合类操作时,集合很容易会进行扩容,但每次扩容都会产生大量的垃圾,在使用时初始化好集合的容量
-
减少长生命周期的大对象:尽可能减少大对象的创建,可将大对象拆分成多个小对象;以及大对象快速销毁,尽可能的缩短生命周期是对 JVM 的 GC 比较友好的方式
-
引入堆外技术:堆外操作不会影响 GC,考虑使用 OHC 堆外内存
-
内存管理:随着业务的发展,内存会不断膨胀,但是好钢(内存)需要用在刀刃上,可以考虑调整本地内存的驱逐策略、数据压缩、分层存储:本地内存只保存热数据,冷数据放到二级缓存中并尽可能少用嵌套结构:用扁平化数据替代
Map<String, Map<String, Object>>
在系统设计时,有时候需要考虑未来业务的扩展,因此我们可能会提前做一些扩展性开发来支持未来业务的发展,但往往很多功能可能很久都不会变化,我觉得开发时先针对眼前能看得到的扩展进行支持,对于目前还看不到,未来好几年的事情,先做好眼前的事情即可,当需要支持的时候再考虑也不迟,倒不如先专注于提高当前代码的可读性和系统架构的合理性。