一、背景
- 服务用途:我们有一个自定义存储服务,承接各业务方碎片化的数据存储,如:开关标识、曝光频次、访问记录等类似场景。数据都是用户维度。现有60多个业务方在使用。
- 数据存储方式:数据库 + 缓存,所有的数据都会在Redis缓存。无论是存到数据库还是仅存到缓存,最终都会使用缓存来承接流量。
- 查询接口量级:持久化到数据库的查询接口量级达到4万QPS;存储到缓存的查询接口量级达到1万QPS。
- 数据量级:数据库,2亿条数据;缓存,4亿多缓存键,还在持续上升,数据量级 = 用户数 * 业务场景
- 机器指标:30台4C8G的云服务器,一对一部署
二、问题
- 优化接口耗时:业务方给到的接口耗时最高50ms
- 降低缓存存储:需要降低缓存中数据占用的存储空间
- 重建缓存键格式:历史缓存键的格式不具备业务含义,无法统计各业务数据的量级
- 缓存穿透隐患:解决数据库中不存在数据情况下,频繁查库导致数据库宕机的隐患
- 降低代码复杂度:对代码的可读性、重复度、单一职责做出优化
三、解决方案
我们在对生产运行中的服务,重构或者优化时,第一要素是考虑"系统稳定性",简而言之,不能出现生产问题。因此,在解决问题之前,要考虑几个会影响生产的因素,才能进行后续的具体优化方案。
有这么几个点:
- 不能产生脏数据
- 支持可回滚,随时可切换至旧数据
- 不能影响用户的正常体验
- 支持可灰度验证
- 对历史数据要做迁移或者同步
- 数据的存储空间是否满足优化条件
上述的几点因素,也在本次优化方案的考量中,下面对每一项的问题做出的方案,以及过程中要注意的地方都会提到,供大家在之后的工作中作为参考。
3.1 接口耗时
针对接口耗时,不同的业务场景可能存在不一样的因素,下面只会提到几个比较常见的因素:
- 算法性能不好,效率低,耗时长
- 数据报文越大,网络传输IO耗时长
- 接口中序列化和反序列化的次数较多也会增加接口的耗时
- ......
说了几个比较大而常见的几个点,大家也可以补充。
解决方案:
针对此次优化,主要从序列化速度、存储数据的大小两个方面,一个是降低接口的耗时、一个是将保存至缓存的数据进行压缩,减少报文的体积,从而减少网络传输的速度。
对于这两个的考虑,刚开始输出的方案是选择好的序列化技术,对序列化后的数据再进行压缩,这个方式主要是想减少耗时还能降低存储空间。结果是理想很美好,现实很骨感。为什么这么说?在大流量的冲击下,任何小问题都会被放大。
序列化技术:github.com/apache/fory
压缩算法:zlib、lz4
这个方案没通过,原因是经过压测,CPU会被快速打满,内存同样被打满。
当时zlib使用的是hutool封装好的工具类,第一次出现问题,经过搜集问题,发现是hutool封装的工具类有流未关闭的问题。问题地址:github.com/chinabugote…。但是真的是这个问题影响的吗?后续升级版本,经过第二次压测好了一点,但是没有达到预期。
第三次,选择别的压缩算法,不是压缩比低,就是占用CPU过高,最终放弃了压缩。
最终的解决方案:
fory具有基于JIT和零拷贝的性能因素,降低了序列化的耗时问题。它同样支持各种数据类型的压缩,和JSON相比,能有降低20%的效果。最终,在考虑性能和稳定性,仅使用fory来降低接口的耗时和存储空间的问题
3.2 重建缓存键格式
这个问题是对业务方有利,业务方会提出要统计目前的数据量级,进而分析出功能效果作为参考数据。
这里就出现了影响用户的几个要素,我们要改数据就要考虑到不能写入脏数据、支持切回老数据、新数据要预热、新老数据会不会将磁盘撑爆。
方案:
- 上线后,要进行数据双写,旧格式和新格式都要写一份
为什么这么做?要确保上线之后不能出现问题,可以支持切回旧数据
- 配置读开关
我们有60多个业务线,通过配置读开关,以业务的重要程度,先以低程度的业务进行验证功能是否正常,进而验证所有的业务
- 代码中同步旧数据,做数据兜底
在代码中,当我们切读之后,如果新数据没有,要查旧数据,若旧数据存在,要及时同步到新数据中,做逻辑上的兜底
- 数据同步
这个是做了数据同步的准备工作了,只是没有使用而已。因为时间过长,线上的数据通过第三步,数据已经同步完毕。
在较短的时间下,可以通过写同步数据的代码,将旧数据同步到新数据。我们预发和生产使用的是一套数据库,在语法环境已经验证过同步的代码,一次同步大约需要十个小时。
- 灰度验证
这个其实是利用了公司现有的能力,通过在网关层配置白名单,将白名单中的用户流量大道对应的机器,进行验证
- 双写下线,要支持双删
为什么双写下线还要支持双删?原因是我们上线过程中肯定是滚动部署,不能停服部署,如果有用户写了新数据,旧数据没有被删除,在第三步中还加着同步旧数据的兜底,这样用户会出现幻觉,现象是读完新数据之后,再读就是旧数据了,我们每次上线大约会持续30分钟,这个时间内就会引起客诉。
还有一个好处是,这样我们的旧数据就可以通过用户的操作,将旧数据进行下线。
3.3 缓存穿透
什么是缓存穿透:当缓存中没有数据时,就会读数据库,这样读一个不存在的数据,每次都会读数据库。
解决方案:
我们在写缓存时,其实并不是只缓存所需要的数据,会把类似这种结构写入进缓存:
{
"code": 1,
"data": "", // 一般存的是JSON串
"timestamp": 17523223400
}
如果用户请求的数据不在库中,会把一个code码进行缓存,结构是:
{
"code": 1,
"data": null
"timestamp": null
}
经过序列化之后存的数据就是:
{
"code": 1
}
这样接口返回的数据就不是空数据,也不会再读数据库。
3.4 降低代码重复度
这个可能只适用于我这个场景,不想引入第三方的框架。
背景:我们在用户维度加了分布式锁,减少非正常的访问。在代码中存在约20个接口,无论是写还是读接口都添加了分布式锁,但是除了加锁的模式一模一样,所以每个接口中都有相似的代码。
解决方案:
通过Java的函数式接口语法,解决代码重复度问题。
第一步:自定义函数式接口
@FunctionInterface
public interface LockFunction<T> {
Object lock(T request);
}
第二步:写一个通用的加锁方法
public class RedisLock {
public <T> Object addLock(T request, LockFunction<T> function) {
boolean lock = false;
String lockKey = ""; // 生成一个key
try {
// 加锁
lock = redisLock.tryLock(...);
// 执行业务逻辑
function.lock(request);
} finally {
if(lock) {
// 释放锁
}
}
}
}
第三步:接口中使用addLock方法,统一加锁
四、总结
从本次优化过程和结果来看,取得了显著成效并且平稳落地。
从中取的一次宝贵的经验,不要主观臆断觉得自己的方案绝对的好,一定要经过压测,大流量的冲击才能保证性能没有问题。