前言
在之前公司做C端产品的时候。初始上线的时候业务需求很少并发量也不高 我们项目最开始就是搭建的redis单机模式,基本上能够满足业务需求并提供稳定的服务。后面随着我们产品的推广和用户量的激增 我们的业务场景也逐渐多样化,Redis 的使用需求也越来越复杂。所以后面我们基本上会根据业务情况进行架构升级。后续我们就直接把Redis升级为集群架构模式了,本来是打算借助 Redis 集群的分布式特性来提高性能、扩展性以及可用性。
背景
然而,在我们的项目架构升级过后,针对于Redis集群模式的升级并没有像我们预期的那样顺利 ,由于当时我们技术经验的不足也或者是考虑不周到(禁止说菜❌),不出意外的触发了 升级必出bug 特点,大概业务场景是用户支付后需要扣除余额并增加积分这个场景, 大概伪逻辑代码如下:
package main
import (
"context"
"fmt"
"github.com/go-redis/redis/v8"
"log"
"time"
)
var rdb *redis.ClusterClient
func init() {
rdb = redis.NewClusterClient(&redis.ClusterOptions{
Addrs: []string{
"r-xxxxxx.redis.rds.aliyuncs.com:6379",
},
Password: "xxxxx",
DialTimeout: 10 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
})
}
func main() {
ctx := context.Background()
// Lua 脚本:修改余额和积分
luaScript := `
-- 获取用户的余额和积分
local balance = redis.call('GET', KEYS[1])
local points = redis.call('GET', KEYS[2])
-- 如果任何一个键的值为空,返回错误
if not balance or not points then
return redis.error_reply('One of the keys does not exist')
end
-- 扣除余额
local new_balance = tonumber(balance) - tonumber(ARGV[1])
-- 增加积分
local new_points = tonumber(points) + tonumber(ARGV[2])
-- 更新 Redis 中的余额和积分
redis.call('SET', KEYS[1], new_balance)
redis.call('SET', KEYS[2], new_points)
-- 返回修改后的余额和积分
return {new_balance, new_points}
`
// 用户 ID
userID := "1000001"
keyBalance := fmt.Sprintf("user:%s:balance", userID)
keyPoints := fmt.Sprintf("user:%s:points", userID)
// 将 keyBalance 和 keyPoints 放入一个切片传给 executeLuaScript
keys := []string{keyBalance, keyPoints}
// 执行 Lua 脚本,假设扣除金额为 10,增加积分为 20
result, err := executeLuaScript(ctx, luaScript, keys, 10, 20)
if err != nil {
log.Fatalf("Failed to execute Lua script: %v", err)
}
// 输出 Lua 脚本执行结果
fmt.Printf("The updated balance and points are: %v\n", result)
}
// executeLuaScript 执行 Lua 脚本
func executeLuaScript(ctx context.Context, script string, keys []string, args ...interface{}) (interface{}, error) {
// 创建 Lua 脚本对象
lua := redis.NewScript(script)
// 执行 Lua 脚本
result, err := lua.Run(ctx, rdb, keys, args...).Result()
if err != nil {
return nil, fmt.Errorf("error executing Lua script: %w", err)
}
return result, nil
}
问题
上面这段代码是我简化写的一个Redis结合Lua脚本执行的原子性操作Demo例子。在我们还在使用Redis单机模式时,这个业务场景基本没什么大问题。可是从升级到Redis集群模式后,当我们尝试执行相同的Lua脚本时,系统就开始有报错。经过研发的排查,我们在日志中发现类似以下错误信息:
2025/01/20 16:53:02 Failed to execute Lua script: error executing Lua script: ERR 'EVALSHA' command keys must be in same slot
exit status 1
这个报错的意思是:执行Lua脚本时,所涉及的键必须位于Redis集群中的同一个槽内。
报错分析
-
EVALSHA:
EVALSHA是Redis用来执行已经加载到Redis服务器上的Lua脚本的命令。为了优化性能,Redis集群模式通常会通过EVALSHA来执行Lua脚本,而不是每次都重新加载脚本。这样可以减少对Redis的重复调用,提升执行效率。- 在集群模式下,
EVALSHA命令用于在集群节点中执行一个已经存在的脚本。
-
keys must be in the same slot:
- 这个是报错的核心信息,意味着在Redis集群模式下,执行Lua脚本时,脚本涉及到的多个键必须位于Redis集群的同一个槽(slot)内。
- Redis集群通过哈希槽(hash slot)将数据分布到不同的节点上,每个节点负责一定数量的槽。当我们执行Lua脚本时,Redis要求脚本操作的所有键必须位于同一个槽,才能保证原子性操作。如果涉及到多个槽,则无法保证操作的原子性,因此Redis会抛出这个错误。
问题原因
在我们升级到Redis集群模式后,由于数据被分散到了多个节点,每个节点负责处理一定范围的哈希槽。说到这里 我们就再次简单回顾一下Redis集群的工作原理吧。
Redis集群的工作原理:
Redis集群是一种分布式的Redis部署方式,其目标是将数据分布到多个节点上,以提高系统的性能、可扩展性和可用性。与单机模式不同,Redis集群的每个节点并不存储所有数据,而是存储数据的一个子集。
- 分布式架构:Redis集群由多个主节点和副本节点组成,数据分布在多个主节点之间,副本节点则用于提供高可用性和故障恢复。
- 哈希槽(Hash Slots) :Redis集群将所有的键映射到16384个固定的哈希槽(slots)。这意味着无论有多少个数据,每个键都会根据它的哈希值被映射到其中一个哈希槽。
当我们在Redis集群中执行Lua脚本时,涉及到的多个键必须位于同一个哈希槽才能确保原子性。这是因为:
- 原子性操作:Redis通过对单个哈希槽内的所有操作加锁来保证原子性。如果多个键分布在不同的哈希槽,它们可能会被不同的节点处理,因此不能确保在同一个事务中同时执行这些操作。
- 跨槽操作问题:假设我们执行的Lua脚本涉及键A和键B,如果键A在槽1,键B在槽2,Redis就无法确保在同一个操作中原子性地执行这两个键的操作,因为它们分别由不同的节点负责处理。
所以当我们使用Redis执行Lua脚本时,如果脚本操作的多个键分布在不同的节点上(即它们不在同一个槽内),Redis会无法保证在同一事务中原子性地执行这些操作。因此,Redis抛出了 'EVALSHA' command keys must be in same slot 错误。
为了确保操作的原子性,Redis集群架构下,我们在执行Lua脚本时,所有参与操作的键必须位于同一个槽(slot)内。
解决方案
为了解决这个问题,我们改用了 哈希标签(Hash Tags) 语法。哈希标签是一种强制将多个键分配到同一哈希槽的方法,确保它们在同一个节点上,从而能够顺利执行Lua脚本。然而,对于之前在单机模式下使用的旧数据,键的分布已经固定,且这些键未必符合集群模式下的哈希标签规则。因此,我们设计并实施了数据迁移脚本,进行数据清洗。通过这个过程,我们确保旧数据能够根据哈希标签规则重新分布,保证在新的架构下,所有数据能够正确分配到相应的哈希槽中,确保原子性操作得以顺利执行,从而为系统提供稳定可靠的服务。
哈希标签语法
哈希标签是通过在键名中使用 {} 包围的部分来指定的。{}是在 Redis cluster 模式下特有的 Hash Tag, 例如,如果我们希望多个键通过哈希标签保证分配到同一个槽,可以将它们的某部分键名放在大括号 {} 内部。这样Redis就会根据大括号内的内容来计算哈希槽,这样只要哈希标签部分相同,这些键就会被分配到同一个槽。
举个栗子:
"user:{123}:balance"
"user:{123}:points"
在这个例子中,{123} 是哈希标签,Redis会根据 {123} 计算哈希槽,确保 user:{123}:balance 和 user:{123}:points 被分配到同一个槽中。
哈希标签的优势
-
保证原子性操作:使用哈希标签可以确保在Redis集群模式下,多个键位于同一个槽中,避免了执行Lua脚本时遇到“
keys must be in the same slot”的错误,保证了操作的原子性。 -
提高数据在集群中的分布均衡性:通过合理使用哈希标签,可以帮助Redis集群更好地分配键到不同的槽,从而提高数据在集群中的分布均衡性。这样不仅能避免某些节点的过载问题,还能提升整个集群的性能和吞吐量。
例如,如果我们将用户ID作为哈希标签的一部分(如 {123}),那么所有关于用户123的键都会被分配到同一个槽。如果我们合理设计哈希标签,使得键的分布更均匀,可以提高集群的负载均衡,避免某些节点承受过重的负载。
举个栗子
假设我们有以下两个键:user:1:balance 和 user:1:points,我们希望确保它们在执行Lua脚本时,能够位于同一个哈希槽。我们可以通过修改这两个键的命名方式,使它们共享相同的哈希标签,如下所示:
user:{1}:balance
user:{1}:points
在这个例子中,{1} 是哈希标签,Redis会将这两个键分配到同一个哈希槽。这样,无论这两个键在集群的哪个节点上,它们都保证位于同一个槽,从而可以在同一个Lua脚本中进行原子性操作。
更新后的代码
// 修改后的键名,使用哈希标签确保它们位于同一槽
keyBalance := fmt.Sprintf("user:{%s}:balance", userID)
keyPoints := fmt.Sprintf("user:{%s}:points", userID)
这样,当我们执行Lua脚本时,user:{1}:balance 和 user:{1}:points 会被分配到同一个槽,确保操作的原子性。
思考:Redis操作Lua脚本能保证原子性吗?
在之前我们Redis架构没升级前 在Redis单机模式下,Lua脚本的执行是原子性的。Redis会确保在执行Lua脚本时,不会受到其他客户端操作的干扰。但是在后面我们升级了集群模式后 由于数据被分散到多个节点,当我们在使用Redis执行Lua脚本时,如果涉及到不同哈希槽的键,Redis就会抛出错误(如“keys must be in the same slot”)。也就无法保证原子性。所以通过上面问题我们可以得到以下结论:
Redis单机模式下的原子性
在Redis单机模式下,Lua脚本的执行是原子性的。Redis会确保在执行Lua脚本时,不会受到其他客户端操作的干扰。也就是说,单个Redis实例在执行Lua脚本时,会锁定整个实例,直到脚本执行完成。这种方式确保了在脚本执行期间,所有的命令都会按顺序执行,不会被中断或被其他操作干扰。
Redis主从模式下的原子性
这里要说一下,在Redis主从模式下,原子性执行Lua脚本的机制基本与单机模式相同。主节点执行Lua脚本时,依然是通过单线程事件循环保证原子性。主节点会锁定整个实例,直到Lua脚本执行完成。但是,主从模式的复杂性在于数据同步:从节点通常会从主节点同步数据,但在同步过程中,从节点并不会执行Lua脚本,因为Redis的复制是异步的。即使主节点成功执行了Lua脚本,并且在同步到从节点时,从节点也不会保证脚本的执行原子性。
Redis集群模式下的原子性
然而在Redis集群模式下,数据被分散到多个节点,每个节点负责一定范围的哈希槽。Redis就要求在执行Lua脚本时,所有涉及的键必须位于同一个哈希槽中。如果涉及到不同哈希槽的键,Redis会抛出错误(如“keys must be in the same slot”)。即在同一个哈希槽内,Redis会保证原子性,但如果操作跨多个槽,Redis就无法保证分布式原子性。