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槽上,然后对应于不同的主节点,多主多从即为所有数据分散在不同的主节点上,从节点和主节点的关系和一主多从一样