掘金 后端 ( ) • 2024-04-20 19:25

你好,我是猿java。

最近遇到一个线上事故,差点丢掉Q2绩效奖金,故事是这样的...

背景

阿里 P7二面:Redis 执行 Lua,能保证原子性吗? 这篇文章中,我们分析了 Redis中运行 Lua脚本是如何保证原子性的。实际上,在我们的电商业务中也是使用 Redis + Lua来保证库存的原子性操作,Redis是 Cluster集群部署,Lua脚本大致如下(本文的数据都经过脱敏处理):

-- type都是java代码中传入的String值,sku为Long型
local function availableRealSaleCal(type,sku)
    local key = formatKey(type, sku)
    -- 销售库存 =(if 可售卖量 then 销售库存 = min(可售库存,可售卖量)
    -- else 销售库存 = 可售库存 end)
    local availableRealSale = 0;
    local availableSale = redis.call('INCRBY', key..":AVAILABLE_SALE", 0);
    local saleLimit = redis.call('HGET', key, 'sale_limit');
    redis.call('SET', stocksKey .. ":AVAILABLE_REAL_SALE", availableRealSale);
    return availableRealSale
end

-- 拼接库存 key,比如:stock:sale:{13523551512}, 注意这里有一个 {sku}
local function formatKey(type, sku)
    return "stock:"..type..":"..":{"..sku.."}"
end;

在上面的 Lua脚本中,有 {sku}语法的使用,{}是在 Redis cluster 模式下特有的 Hash Tag,Redis 的哈希标签是一种特殊的语法,用于在执行命令时将多个 key 分组在一起。Hash Tag 由一对大括号 {} 包围,可以将其中的内容视为一个整体来处理。

{}的主要用途包括:

  1. 强制将多个 key 分组:在执行命令时,Redis 将哈希标签中的内容视为一个整体,这样就可以将多个 key 分组在一起,使它们被视为同一个分片。这对于在分片集群中对多个相关 key 执行原子操作非常有用。
  2. 提高数据在集群中的分布均衡性:当使用哈希标签时,Redis 将根据标签中的内容计算哈希槽(Hash Slot),而不是整个 key。这样可以确保具有相同标签的 key 被映射到相同的哈希槽,从而提高了数据在集群中的分布均衡性。

例如,假设有两个 key:{sku}:saleStock{sku}:avalibleStock。如果不使用哈希标签,即sku:saleStocksku:avalibleStock,这两个 key 将被视为不同的 key,可能被映射到不同的哈希槽。这样,同一个 sku的不同库存可能被 hash到不同的 slot,但是,如果使用哈希标签 {sku},这样,不管 {sku}拼接什么内容,都会被视为同一个分片,从而确保它们被映射到相同的哈希槽,以保证原子性操作的一致性。

更多{}使用,可以参考redis的官方文档:

Redis hash tags文档,

Redis hash slot计算

发现问题

监控报警,于是研发查排线上日志,如下:


Caused by: redis.clients.jedis.exceptions.JedisDataException: 
ERR Error running script (call to f_1fbde7f097d74a7d77c854c93b308d36d164dbf9): @user_script:371: @user_script: 371: 
Lua script attempted to access a non local key in a cluster node at redis.clients.jedis.Protocol.processError(Protocol.java:115)

看到这个错误,一脸懵,代码上线半年没有出现过问题,怎么会突然出问题呢?

搜索问题

因为第一次遇到这个问题,于是 Google了一下,找到几个类似的问题,大致意思差不多,下面给出一个stackover上面的例子,链接如下:stackoverflow相同的错误,Lua 脚本摘要如下:

local f3=redis.call('HGET',KEYS[1],'1');
local f4=redis.call('HGET',f3,'1') ;
return f4;

对于错误的解释是:在 Lua中执行多条语句,要保证key hash的 slot是同一个,否则就会出现上面的错误,比如:KEYS[1]和 f3 hash后不在同一个 slot就会出现上述错误。

定位问题

通过 Google例子的解释,开始排查我们线上的 Lua脚本,整个 Lua是使用{sku}进行 hash,然后{sku}的结果不稳定,导致几条语句执行的时候 hash到不同的 slot中?

顺着上面 Google 例子的思路,排查 {sku} hash后的值是否出现变更,线上跑的代码,sku都是 14位的 Long,先上新的 sku 变成了 15位的 Long,会不会是长度变更导致问题?

于是,在中间件部门同事的配合下,找到了中间件的执行log:

stockskey:stock:40-248-000008:{1.112422310001e+14}

太奇怪了,sku传入的是 Long类型,现在变成{1.112422310001e+14},最后发现在 Redis中间件有个cjson的操作,当传入的 Long类型位数大于 14时,会把 Long转成科学计数法,导致{sku}改变了原有的语义。

解决问题

在Java 端把 sku 从 Long型转成 String类型,再传入Lua。

事故定级

因为架构中有小流量集群,每次有新 sku上线,都会在小流量集群上进行灰度发布,所以受影响的面有限,最后定级 P4。

总结

  1. Redis中运行 Lua脚本能保证原子性,已经有生产经验进行验证
  2. 如果想对Lua中的多个 key hash到同一个slot,可以使用 {}语法,Hash Tag 由一对大括号 {} 包围,可以将其中的内容视为一个整体来处理
  3. 对于 Long类型会被转成科学记数法,这个点一定要特别注意。记得曾经和前端对接时,出现过过长的 Long 会被截断的问题
  4. 灰度发布在生产环境是个很不错的选择
  5. 告警系统可以帮助我们更快的感知问题

原创好文