前言
你是否想优化 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,会有问题吗?
你就说能不能用吧?
确实能用!
不过这样会带来三个问题:
- Redis QPS
- Redis 内存
- 业务 CPU
(PS:这里为了公司的数据安全,就不展示具体的监控信息)
QPS:平均一下,意味着每个接口能产生 30+ 次 Redis 操作。随着用户量的增多,这些请求 Redis 集群无疑也是慢慢增长的压力。特别每天固定时间的都会来的峰值流量,会更加放大其影响。
CPU:与此同时,也意味着一个请求要多次从 Redis 连接池中获取/回收连接,大概会吃掉10%~20%的CPU
内存:假设活跃用户是千万级别,而每个 Redis Key 都不可避免的有:前缀+名字+参数,以及每一个Key都存储一个,单个Key占用的内存看着不大,但是乘上千万级别活跃用户数量之后,就不小了。
而且 Hash 类型低层用压缩队列,存储占用比 String 类型小很多
业界做法
因为Redis本身性能很好,业界对于Redis集群的优化有成熟的处理方式,但主要还是集中在集群的主从复制、平滑扩容等操作上。
对于业务本身的使用,一般简单总结为: "优先设计合理的数据结构和逻辑"
对于第一章提到的这种情况来说,Hash 无疑是一种更加合理的数据结构和逻辑,于是设计了一个简单的规则:
- 凡是和用户向相关的信息,一定可以分配到某个 Hash Key 中。
- 多个用户向的 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 时,有几个注意点:
-
从节省内存的角度出发,Redis Hash Key 默认用 压缩列表(ziplist)最多可以节省10倍的内存;参考:redis.io/docs/latest...
但成员数量超过512 的时候,或者是单个元素的值超过64字节的时候,会转成 hashtable。
-
Redis Hash Key 的 name,和成员的 json name 都可以尽可能的设置得更短,牺牲部分可读性,来换取更少的内存占用,但是注意不能冲突重复。
-
理想状态下:同一个业务场景(一般是同一个接口),涉及到的缓存内容一般可以通过一个或几个 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 时,锁已经被自动释放了,此时应该放弃会返回,否则可能将 落后数据 覆盖 其他线程写入的最新数据。
- 并发控制时,确认新老版本在发布时吗,并发安全。
最最后
各位看官老爷们,如果觉得有小小收获,请点赞关注收藏,小小鼓励一下博主,多谢啦