Redis单节点换成Redis三主三从的分布式集群后,执行Lua脚本竟然报错了

参考redis官方文档

1. 背景

在前文springboot整合redis和kafka,分析并实现高并发秒杀业务中,将秒杀商品数据提前预热到redis中,并将库存查询及减库存操作放在redis中通过lua脚本实现。如果请求量巨大,考虑将redis单节点扩展成多主多从的分布式集群。因此我搭了个三主三从的redis集群,开开心心的去测试之前的代码,然后就遇到了报错信息

io.lettuce.core.RedisCommandExecutionException: ERR Error running script (call to f_7602ee342c5cb304c3a088fb531f0e4e754f2687): @user_script:19: @user_script: 19: Lua script attempted to access a non local key in a cluster node 
	at io.lettuce.core.ExceptionFactory.createExecutionException(ExceptionFactory.java:135) ~[lettuce-core-5.2.2.RELEASE.jar:5.2.2.RELEASE]
	at io.lettuce.core.ExceptionFactory.createExecutionException(ExceptionFactory.java:108) ~[lettuce-core-5.2.2.RELEASE.jar:5.2.2.RELEASE]
	at io.lettuce.core.protocol.AsyncCommand.completeResult(AsyncCommand.java:120) ~[lettuce-core-5.2.2.RELEASE.jar:5.2.2.RELEASE]
	at io.lettuce.core.protocol.AsyncCommand.complete(AsyncCommand.java:111) ~[lettuce-core-5.2.2.RELEASE.jar:5.2.2.RELEASE]
	at io.lettuce.core.protocol.CommandWrapper.complete(CommandWrapper.java:59) ~[lettuce-core-5.2.2.RELEASE.jar:5.2.2.RELEASE]
	at io.lettuce.core.cluster.ClusterCommand.complete(ClusterCommand.java:63) ~[lettuce-core-5.2.2.RELEASE.jar:5.2.2.RELEASE]
	at io.lettuce.core.protocol.CommandHandler.complete(CommandHandler.java:654) ~[lettuce-core-5.2.2.RELEASE.jar:5.2.2.RELEASE]
	at io.lettuce.core.protocol.CommandHandler.decode(CommandHandler.java:614) ~[lettuce-core-5.2.2.RELEASE.jar:5.2.2.RELEASE]
	at io.lettuce.core.protocol.CommandHandler.channelRead(CommandHandler.java:565) ~[lettuce-core-5.2.2.RELEASE.jar:5.2.2.RELEASE]
	at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:377) ~[netty-transport-4.1.45.Final.jar:4.1.45.Final]
	at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:363) ~[netty-transport-4.1.45.Final.jar:4.1.45.Final]
	at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:355) ~[netty-transport-4.1.45.Final.jar:4.1.45.Final]
	at io.netty.channel.DefaultChannelPipeline$HeadContext.channelRead(DefaultChannelPipeline.java:1410) ~[netty-transport-4.1.45.Final.jar:4.1.45.Final]
	at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:377) ~[netty-transport-4.1.45.Final.jar:4.1.45.Final]
	at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:363) ~[netty-transport-4.1.45.Final.jar:4.1.45.Final]
	at io.netty.channel.DefaultChannelPipeline.fireChannelRead(DefaultChannelPipeline.java:919) ~[netty-transport-4.1.45.Final.jar:4.1.45.Final]
	at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:163) ~[netty-transport-4.1.45.Final.jar:4.1.45.Final]
	at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:714) ~[netty-transport-4.1.45.Final.jar:4.1.45.Final]
	at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:650) ~[netty-transport-4.1.45.Final.jar:4.1.45.Final]
	at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:576) ~[netty-transport-4.1.45.Final.jar:4.1.45.Final]
	at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:493) ~[netty-transport-4.1.45.Final.jar:4.1.45.Final]
	at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:989) ~[netty-common-4.1.45.Final.jar:4.1.45.Final]
	at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) ~[netty-common-4.1.45.Final.jar:4.1.45.Final]
	at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) ~[netty-common-4.1.45.Final.jar:4.1.45.Final]
	at java.lang.Thread.run(Thread.java:745) [na:1.8.0_112]

2. 问题排查

2.1 代码回顾

要执行的Lua脚本如下:

local product_id = KEYS[1]
local user_id = ARGV[1]
-- 商品库存key
local product_stock_key = 'seckill:' .. product_id .. ':stock'
-- 商品秒杀结束标识的key
local end_product_key = 'seckill:' .. product_id .. ':end'

-- 存储秒杀成功的用户id的集合
local bought_users_key = 'seckill:' .. product_id .. ':uids'

--判断该商品是否秒杀结束
local is_end = redis.call('get',end_product_key)

if  is_end and tonumber(is_end) ~=1 then
    return -1
end
-- 判断用户是否秒杀过
local is_in = redis.call('sismember',bought_users_key,user_id)

if is_in > 0 then
    return 0
end

-- 获取商品当前库存
local stock = redis.call('get',product_stock_key)

-- 如果库存<=0,则返回-1
if not stock or tonumber(stock) <=0 then
    redis.call("set",end_product_key,"1")
    return -1
end

-- 减库存,并且把用户的id添加进已购买用户set里
redis.call("decr",product_stock_key)
redis.call("sadd",bought_users_key,user_id)

return 1

通过StringRedisTemplate执行Lua脚本的代码如下:

/**
     * 数据预热到redis后的实现,且使用lua脚本解决用户重复秒杀的问题
     *
     * @param pid
     * @param uid
     * @return
     */
    @GetMapping("/{pid}/{uid}")
    public String seckillWithLua(@PathVariable long pid, @PathVariable long uid) {
    	//执行lua脚本
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        redisScript.setResultType(Long.class);
        redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("seckill.lua")));
        Long result = redisTemplate.execute(redisScript, Collections.singletonList(pid + ""), uid + "");
        log.info("result:{}", result);
        
		//判断lua脚本的执行结果
        if (result == 1) {
            //创建订单
            Order order = new Order();
            order.setPid(pid);
            order.setUid(uid);
            orderService.insert(order);
        }
        return result > 0 ? "秒杀成功" : "秒杀失败";
    }

2.2 问题解决

看一下报错内容的核心:Lua script attempted to access a non local key in a cluster node ,即Lua脚本试图访问集群节点中的非本地的key。这个问题的原因就是redis集群执行lua脚本要求lua脚本中涉及的所有key必须属于同一个hash槽。

先说问题的解决方案,在之前的lua脚本中声明的key有三个:

-- 商品库存key
local product_stock_key = 'seckill:' .. product_id .. ':stock'
-- 商品秒杀结束标识的key
local end_product_key = 'seckill:' .. product_id .. ':end'

-- 存储秒杀成功的用户id的集合
local bought_users_key = 'seckill:' .. product_id .. ':uids'

在整个lua脚本中都是对这三个key的操作,为了保证这三个key落在同一个hash槽上,需要修改为如下:

-- 商品库存key
local product_stock_key = 'seckill:{' .. product_id .. '}:stock'
-- 商品秒杀结束标识的key
local end_product_key = 'seckill:{' .. product_id .. '}:end'

-- 存储秒杀成功的用户id的集合
local bought_users_key = 'seckill:{' .. product_id .. '}:uids'

只需要修改这三个key,在product_id两边添加{}即可,在计算key的hash槽时,只会根据{}之间的内容计算,由于三个key大括号之间的内容都是同一个product_id,因此计算出来的hash槽肯定是相同的,就可以保证lua脚本操作的key在同一个hash槽,就可以解决当前的报错问题了。

3. 问题引申

3.1 redis集群数据分片

redis集群的数据分片没有使用一致性hash算法,而是使用hash slot(哈希槽)的方式。在redis集群中共有16384个hash槽,给定一个key,通过CRC16(key)%16384即可计算出该key属于哪个hash槽。redis集群中的所有hash槽分布在redis集群的各个节点上,比如我搭建的三主三从,三台主节点服务器上的hash槽分布如下:
A节点 0 ~ 5500.
B节点 5501 ~ 11000.
C节点 11001 ~ 16383.
redis集群要求:如果一个命令中设计到多个key,那么这些key必须要落在同一个hash槽上才允许执行该命令。就比如文中的lua脚本报错的问题。
我们如何才能保证所有的key属于同一个hash槽呢?

3.2 哈希标签(hash tags)

hash标签就是为了解决刚刚提到的问题,保证所有key落在同一个hash槽上:

  • 如果key中包含一个{},那么通过计算{}之间的内容得到key属于哪个hash槽。
  • 如果{}之间没有内容(例如:seckill{}stock),那么会将整个key作为一个整体去计算hash槽。
  • 如果有多个{},只会计算第一个{和第一个}之间的内容,例如seckill{product{id}},将会以product{id来计算hash槽

4. 总结

文章开始提到的报错的解决方案就是根据redis集群的数据分片机制以及redis提供的hash标签来解决的~
最后谈谈我理解的redis一主多从和多主多从。

  • 一主多从:一个主节点搭配多个从节点,所有数据都保存在主节点上,而从节点起数据备份的作用,如果主节点挂掉,可以通过redis的哨兵机制从从节点中选举出一个新的主节点
  • 多主多从:redis会将16384个hash槽分布在多台主机上,数据通过计算CRC16(key)%16384计算出该条数据将存在哪个hash槽上,然后对应于不同的主节点,多主多从即为所有数据分散在不同的主节点上,从节点和主节点的关系和一主多从一样
  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值