大流量、高并发下的 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 时,锁已经被自动释放了,此时应该放弃会返回,否则可能将 落后数据 覆盖 其他线程写入的最新数据。
  • 并发控制时,确认新老版本在发布时吗,并发安全。

最最后

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

相关推荐
孟章豪5 小时前
从零开始:在 .NET 中构建高性能的 Redis 消息队列
redis·c#
隔窗听雨眠5 小时前
深入理解Redis的四种模式
java·redis·mybatis
北笙··5 小时前
Redis慢查询分析优化
数据库·redis·缓存
p-knowledge5 小时前
redis的三种客户端
数据库·redis·缓存
说淑人5 小时前
Redis & 线程控制 & 问题
redis·线程控制
积水成江5 小时前
Redis相关面试题
数据库·redis·缓存
Xvens7 小时前
thinkphp6 redis 哈希存储方式以及操作函数(笔记)
redis·php·哈希算法
瓜牛_gn7 小时前
redis详细教程(4.GEO,bitfield,Stream)
数据库·redis·缓存
昨天今天明天好多天7 小时前
【Linux】Redis 部署
linux·redis·bootstrap
0x派大星8 小时前
Golang 并发编程入门:Goroutine 简介与基础用法
开发语言·后端·golang·go·goroutine