记一次生产优化之旅

59 阅读7分钟

一、背景

  1. 服务用途:我们有一个自定义存储服务,承接各业务方碎片化的数据存储,如:开关标识、曝光频次、访问记录等类似场景。数据都是用户维度。现有60多个业务方在使用。
  2. 数据存储方式:数据库 + 缓存,所有的数据都会在Redis缓存。无论是存到数据库还是仅存到缓存,最终都会使用缓存来承接流量。
  3. 查询接口量级:持久化到数据库的查询接口量级达到4万QPS;存储到缓存的查询接口量级达到1万QPS。
  4. 数据量级:数据库,2亿条数据;缓存,4亿多缓存键,还在持续上升,数据量级 = 用户数 * 业务场景
  5. 机器指标:30台4C8G的云服务器,一对一部署

二、问题

  1. 优化接口耗时:业务方给到的接口耗时最高50ms
  2. 降低缓存存储:需要降低缓存中数据占用的存储空间
  3. 重建缓存键格式:历史缓存键的格式不具备业务含义,无法统计各业务数据的量级
  4. 缓存穿透隐患:解决数据库中不存在数据情况下,频繁查库导致数据库宕机的隐患
  5. 降低代码复杂度:对代码的可读性、重复度、单一职责做出优化

三、解决方案

我们在对生产运行中的服务,重构或者优化时,第一要素是考虑"系统稳定性",简而言之,不能出现生产问题。因此,在解决问题之前,要考虑几个会影响生产的因素,才能进行后续的具体优化方案。

有这么几个点:

  1. 不能产生脏数据
  2. 支持可回滚,随时可切换至旧数据
  3. 不能影响用户的正常体验
  4. 支持可灰度验证
  5. 对历史数据要做迁移或者同步
  6. 数据的存储空间是否满足优化条件

上述的几点因素,也在本次优化方案的考量中,下面对每一项的问题做出的方案,以及过程中要注意的地方都会提到,供大家在之后的工作中作为参考。

3.1 接口耗时

针对接口耗时,不同的业务场景可能存在不一样的因素,下面只会提到几个比较常见的因素:

  • 算法性能不好,效率低,耗时长
  • 数据报文越大,网络传输IO耗时长
  • 接口中序列化和反序列化的次数较多也会增加接口的耗时
  • ......

说了几个比较大而常见的几个点,大家也可以补充。

解决方案:

针对此次优化,主要从序列化速度、存储数据的大小两个方面,一个是降低接口的耗时、一个是将保存至缓存的数据进行压缩,减少报文的体积,从而减少网络传输的速度。

对于这两个的考虑,刚开始输出的方案是选择好的序列化技术,对序列化后的数据再进行压缩,这个方式主要是想减少耗时还能降低存储空间。结果是理想很美好,现实很骨感。为什么这么说?在大流量的冲击下,任何小问题都会被放大。

序列化技术:github.com/apache/fory

压缩算法:zlib、lz4

这个方案没通过,原因是经过压测,CPU会被快速打满,内存同样被打满。

当时zlib使用的是hutool封装好的工具类,第一次出现问题,经过搜集问题,发现是hutool封装的工具类有流未关闭的问题。问题地址:github.com/chinabugote…。但是真的是这个问题影响的吗?后续升级版本,经过第二次压测好了一点,但是没有达到预期。

第三次,选择别的压缩算法,不是压缩比低,就是占用CPU过高,最终放弃了压缩。

最终的解决方案:

fory具有基于JIT和零拷贝的性能因素,降低了序列化的耗时问题。它同样支持各种数据类型的压缩,和JSON相比,能有降低20%的效果。最终,在考虑性能和稳定性,仅使用fory来降低接口的耗时和存储空间的问题

3.2 重建缓存键格式

这个问题是对业务方有利,业务方会提出要统计目前的数据量级,进而分析出功能效果作为参考数据。

这里就出现了影响用户的几个要素,我们要改数据就要考虑到不能写入脏数据、支持切回老数据、新数据要预热、新老数据会不会将磁盘撑爆。

方案:

  1. 上线后,要进行数据双写,旧格式和新格式都要写一份

为什么这么做?要确保上线之后不能出现问题,可以支持切回旧数据

  1. 配置读开关

我们有60多个业务线,通过配置读开关,以业务的重要程度,先以低程度的业务进行验证功能是否正常,进而验证所有的业务

  1. 代码中同步旧数据,做数据兜底

在代码中,当我们切读之后,如果新数据没有,要查旧数据,若旧数据存在,要及时同步到新数据中,做逻辑上的兜底

  1. 数据同步

这个是做了数据同步的准备工作了,只是没有使用而已。因为时间过长,线上的数据通过第三步,数据已经同步完毕。

在较短的时间下,可以通过写同步数据的代码,将旧数据同步到新数据。我们预发和生产使用的是一套数据库,在语法环境已经验证过同步的代码,一次同步大约需要十个小时。

  1. 灰度验证

这个其实是利用了公司现有的能力,通过在网关层配置白名单,将白名单中的用户流量大道对应的机器,进行验证

  1. 双写下线,要支持双删

为什么双写下线还要支持双删?原因是我们上线过程中肯定是滚动部署,不能停服部署,如果有用户写了新数据,旧数据没有被删除,在第三步中还加着同步旧数据的兜底,这样用户会出现幻觉,现象是读完新数据之后,再读就是旧数据了,我们每次上线大约会持续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方法,统一加锁

四、总结

从本次优化过程和结果来看,取得了显著成效并且平稳落地。

从中取的一次宝贵的经验,不要主观臆断觉得自己的方案绝对的好,一定要经过压测,大流量的冲击才能保证性能没有问题。