大流量、高并发下的 Redis 重构与优化指南(业务向)

前言

你是否想优化 Redis 集群内存、Qps、CPU?

你是否想优化业务机器性能,但无从下手?

你是否经常在开发业务中为了并发问题头疼?

你是否经常在业务中遇到不一致的问题?

来花10分钟阅读一下此文,你将得到答案!

背景:

先问大家一个问题: 在较高流量下:某个业务,用户进入首页,需要加载用户信息资源;需要从 Mysql 中获取,并缓存到 Redis 中,请问如何 Code?

golang 复制代码
// 摘自 祖传代码

func GetUser(ctx context.Context, uid int64) (*model.User, error) {
    data, err := u.Client.Get(key).Bytes()
    if err != nil {
       ...
    }
    ...
    user := &model.User{}
    if err = json.Unmarshal(data, user); err != nil {
       log.Error("parse user error", zap.Error(err))
       return nil, err
    }
    ...
    return user, nil
}

看上去好像也没什么问题,对吧?

接着,加载用户的资源,请问如何 Code ?

摘自祖传代码

golang 复制代码
// 摘自 祖传代码
func GetResource(ctx context.Context, uid int64) (*model.Resource, error) {
    data, err := c.Client.Get(key).Result()
    if err != nil {
       ...
    }
    resource := &model.Resource{}
    if err := json.Unmarshal([]byte(data), resource); err != nil {
       log.Error("parse resource error", zap.Error(err))
       return nil, err
    }
    return resource, nil
}

看上去好像确实没什么问题?

OK,服务端需要在多个接口中加载并返回:

  • 用户背包
  • 用户引导
  • 各种弹窗(参考腾讯游戏一样,玩之前要关闭N多弹窗)
  • 任务
  • 邮箱信息
  • 好友互动信息
  • 签到
  • 商品等各种活动

如果说这些信息都像上面代码一样,有一个业务版本,就新开一个 Redis Key,会有问题吗?

你就说能不能用吧?

确实能用!

不过这样会带来三个问题:

  1. Redis QPS
  2. Redis 内存
  3. 业务 CPU

(PS:这里为了公司的数据安全,就不展示具体的监控信息)

QPS:平均一下,意味着每个接口能产生 30+ 次 Redis 操作。随着用户量的增多,这些请求 Redis 集群无疑也是慢慢增长的压力。特别每天固定时间的都会来的峰值流量,会更加放大其影响。

CPU:与此同时,也意味着一个请求要多次从 Redis 连接池中获取/回收连接,大概会吃掉10%~20%的CPU

内存:假设活跃用户是千万级别,而每个 Redis Key 都不可避免的有:前缀+名字+参数,以及每一个Key都存储一个,单个Key占用的内存看着不大,但是乘上千万级别活跃用户数量之后,就不小了。

而且 Hash 类型低层用压缩队列,存储占用比 String 类型小很多

业界做法

因为Redis本身性能很好,业界对于Redis集群的优化有成熟的处理方式,但主要还是集中在集群的主从复制、平滑扩容等操作上。

对于业务本身的使用,一般简单总结为: "优先设计合理的数据结构和逻辑"

对于第一章提到的这种情况来说,Hash 无疑是一种更加合理的数据结构和逻辑,于是设计了一个简单的规则:

  1. 凡是和用户向相关的信息,一定可以分配到某个 Hash Key 中。
  2. 多个用户向的 Hash Key 之间,以合适的过期时间区分。

如下代码(时间维度可以更多,下文仅举两个例子)

golang 复制代码
MergedInfoDailyKey = &RedisKey{
    key:    "MIDK_%v", // uid
    expire: time.Duration(24) * time.Hour,
}

MergedInfoWeeklyKey = &RedisKey{
    key:    "MIWK_%v", // uid
    expire: time.Duration(24*7) * time.Hour,
}

那么上文中的各种信息,可以变为一个Struct,映射到同一个 Hash Key 中。

这里用 Hash Key 时,有几个注意点:

  1. 从节省内存的角度出发,Redis Hash Key 默认用 压缩列表(ziplist)最多可以节省10倍的内存;参考:redis.io/docs/latest...

    但成员数量超过512 的时候,或者是单个元素的值超过64字节的时候,会转成 hashtable。

  2. Redis Hash Key 的 name,和成员的 json name 都可以尽可能的设置得更短,牺牲部分可读性,来换取更少的内存占用,但是注意不能冲突重复。

  3. 理想状态下:同一个业务场景(一般是同一个接口),涉及到的缓存内容一般可以通过一个或几个 HGetAll 命令获取到,即查询 I/O 一般来说只有一两次。

如果可以完全做到上述三点,Redis 集群高峰时的 CPU、内存、集群最大Ops 会得到显著的优化。

实战中,部分优化(仅合并四个最常用 String Key)之后的效果:10%+,10%+,40+%

平滑迁移

(因为业务形态要求高性能,所以本文讨论的缓存方式均为 Write Behind Caching Pattern)

如果业务本身的 Redis Key 维护得很优雅了,已经和上述所差不多,那么也不必迁移。反之,则需要进行新建 Redis Hash Key 和将原来的数据迁移至新Key中。

所谓迁移,即将原来的分散到多个 Key 中的数据,挪到新的 Redis Hash Key 中。

1 为什么要迁移?

为了提高性能,业务大多都采用异步落库的方式(Write Behind Caching Pattern)

如果不迁移的话,就需要直接读取数据库,此时读到的数据往往不是最新的。如果刚好覆盖了用户的一次写操作就会造成用户损失。

如何挑选需要合并的 Key?

如果需要合并的 Redis Key 比较少,那么一次性改完就好了。

反之如果是运行了几年的业务,往往需要合并的 Redis Key 比较多,一次性改完的成本会比较大,因此可以挑选 ROI 比较高的来改。

  • 首选前缀占用内存比较多的,且带有 {uid} 的 Redis Key。

  • 首选高峰时期,接口最高的 Qps 涉及到的 Redis Key。

平滑?

平滑,换言之实现:

  • 不停服 && 用户无感知(相对)

  • 如果遇到预期外的错误,可以快速回滚

再简单点说就是:最终一致性(且逻辑正确)

既然是两份数据,那么就必然会出现不一致,不同的做法,缺别不一致时间的长短。

所谓的用户无感知,这里认为不一致的时间在秒级别即可(或者超过了这个时间,但在用户下一次请求时会修正。)

4 怎么做到?

双读双写 和 并发控制,下面详细描述一下:

双读双写

双读:如果读不到新的 Redis Hash Key, 则先从原来的 Redis Key 中获取数据,都读不到最后从 Mysql 中获取数据。

双写:在同一个写请求中同时更新 原 Redis key 和 新 Redis Hash key。

回到一致性,可以简单地想到如下几个问题:

双写如何保证原子性?

vbnet 复制代码
1.  Lua 脚本,但 DBA 不推荐,甚至会禁止业务使用。
2.  舍弃原子性,允许两个Key可能出现不一致,但保证有一个 Redis Key 存储正确的数据。即要么都失败,要么先写的 Redis Key 一定成功。
3.  对于失败的 Redis Key,用 Lazy 的方式修正。(或是用其他重试的方式修正

因为接受了双写的不一致,那么双读如果读到不一致的数据怎么处理?

markdown 复制代码
1.  首先,因为双写不是原子操作,所以应该考虑是否出现在双写之中的不一致,选择加锁(这里讨论的锁都是全局互斥锁,下同),并且再次双读,判断是否仍然不一致。
2.  根据某些逻辑(比如某些时间戳),选择版本更新的一方,返回
3.  如果无法判断,则选择双写中,先写的 Key。(其实取到的是同一份数据
4.  可观测性:上报监控,打引好日志,方便分析错误原因。
5.  Lazy 式修复:选择完数据并返回之后,应当用正确的数据修正错误的 Redis Key。

总结:

成功双写,两个 Redis Key 不一致的时间极短,而失败的双写,选择等用户双读查询时修复不一致。

以此来确保最终一致性。

并发控制

在修改之前,读、写请求并发都应该是正常的。

简单分析一下:

  • 对于读请求,如果能读到 Redis,那么并发安全。

  • 对于读请求,如果读不到 Redis,那么需要从 Mysql 中加载信息(下称 Load),Load 应当具有排他性(即加锁,并且在锁时间内写缓存)

  • 对于写请求

    • 首先,一般认为对同一份资源的 写请求 具有排它性。

    • 其次,在写请求前会先进行一次读请求,以及在更新 Redis 时,和其他读请的Load 应该排他性(即加同一把锁)。

另外要考虑的一点:新老版本代码的并发

因为在发布新代码的过程中,集群存在一个中间状态:部分机器运行着老版本代码,部分机器运行着新版本代码。

针对这种情况,新老代码的 读请求的Load、写请求更新Redis时 这四者的锁必须一致,即控制:同一份资源,只能同时被一个线程更新 Redis 。

因为老版本使用的是单独的Key,可能会不加锁,利用 Redis SetNX 命令来实现。那么这里就需要做两个版本上线,第一个版本需要给老版本代码加上锁。

总结

于是,结合上述描述,能够得到如下流程图:

提前加锁的版本:

双读双写版本:

DiffCheck 版本:

可观测性: 加锁的等待时间/次数 增加监控,方便查看线上的并发情况,正常应该很少有超时。参考下图,1s超时率为0.008%:

总结:

在高并发、洪锋流量等复杂的背景下,本文针对业务逻辑,特别是遗留了较多随意使用 Redis Key 问题的业务,提供了一种用 Hash 合并 Key 的优化方法,并详细介绍了实现方法和注意事项

对 Redis 集群的Ops、Cpu、包括业务的 Cpu 均能有一定的优化效果。

另外对于 一致性 和 并发控制 讨论和解决方法部分也适用于:平时业务中涉及到 Redis Key 的增加以及数据迁移的情况。

最后再回顾和提醒一下,可能会踩的坑:

  • Load 时,加锁应该是第一步,即在读 Mysql 之前,并且加锁成功后,应再判断一次是否已有 Redis 值(其他线程已写入)。
  • Load 时,锁成功了,但到写 Redis 时,锁已经被自动释放了,此时应该放弃会返回,否则可能将 落后数据 覆盖 其他线程写入的最新数据。
  • 并发控制时,确认新老版本在发布时吗,并发安全。

最最后

各位看官老爷们,如果觉得有小小收获,请点赞关注收藏,小小鼓励一下博主,多谢啦

相关推荐
星星点点洲6 小时前
【Redis】谈谈Redis的设计
数据库·redis·缓存
Lion Long10 小时前
CodeBuddy 中国版 Cursor 实战:Redis+MySQL双引擎驱动〈王者荣耀〉战区排行榜
数据库·redis·mysql·缓存·腾讯云·codebuddy首席试玩官·codebuddy
柯南二号18 小时前
MacOS 用brew 安装、配置、启动Redis
redis
星星点点洲20 小时前
【Redis】RedLock实现原理
redis·缓存
我来整一篇20 小时前
用Redis的List实现消息队列
数据库·redis·list
加什么瓦21 小时前
Redis——数据结构
数据库·redis·缓存
lybugproducer1 天前
浅谈 Redis 数据类型
java·数据库·redis·后端·链表·缓存
青山是哪个青山1 天前
Redis 常见数据类型
数据库·redis·bootstrap
杨不易呀1 天前
Java面试全记录:Spring Cloud+Kafka+Redis实战解析
redis·spring cloud·微服务·kafka·高并发·java面试·面试技巧