用 HSET 加 EXPIRE NX 共置命名空间校验防止键名冲突覆盖

多业务共用一套 Redis 时,最容易出的事故不是缓存击穿,而是键名冲突导致的静默覆盖:订单服务写 order:1001,营销服务也写 order:1001,两边都不报错,只是数据隔一段时间就"莫名其妙"变一次。排查时日志干净、监控无异常,因为从 Redis 的视角看,这就是一次合法的 HSET。本文给出一套低成本方案:把命名空间的归属信息写进同一个 Has

多业务共用一套 Redis 时,最容易出的事故不是缓存击穿,而是键名冲突导致的静默覆盖:订单服务写 order:1001,营销服务也写 order:1001,两边都不报错,只是数据隔一段时间就”莫名其妙”变一次。排查时日志干净、监控无异常,因为从 Redis 的视角看,这就是一次合法的 HSET。本文给出一套低成本方案:把命名空间的归属信息写进同一个 Hash 的保留字段里,借助 HSET 的返回值和 EXPIRE NX 的 NX 语义,在写入路径上做一次原子校验,让冲突从”静默覆盖”变成”可观测的失败”。

为什么键名前缀约定挡不住冲突

绝大多数团队的做法是定一份前缀规范,比如 svc:module:entity:id,靠 code review 保证。问题在于这套约定有三个天然漏洞:

  • 前缀本身会撞。两个团队都叫 user 是很常见的事,一个指 C 端账户,一个指后台运营账号,靠人眼根本看不出区别。
  • 同一个 key 可能被多种数据类型使用。业务 A 用 String 存 JSON,业务 B 用 Hash 存字段。此时 B 的 HSET 作用在一个 String 类型的 key 上,Redis 会直接返回错误,这类反而好排查;真正危险的是两个业务都用 Hash,字段名恰好重叠。
  • 动态 key 拼接。真正出问题的往往不是写死的常量,而是 "order:" + id 这种拼接,前缀在代码里散落几十处,改规范时根本改不干净。

先看清楚 Redis 在这种情况下到底怎么表现,才能设计出可用的校验。以下命令均可用 redis-cli 直接复现:

127.0.0.1:6379> HSET order:1001 amount 99
(integer) 1
127.0.0.1:6379> HSET order:1001 amount 199 status paid
(integer) 1
127.0.0.1:6379> HGETALL order:1001
1) "amount"
2) "199"
3) "status"
4) "paid"

关键点有两个。第一,HSET 的返回值是本次实际新增的字段数,不是被修改的字段数。第二次执行时 amount 已存在、status 是新增,所以返回 1;如果两个字段都已存在,返回 0。第二,被覆盖的旧值 99 没有任何痕迹留下。返回值 0 与 1 的这个差异,就是我们要利用的信号来源——它天然区分了”首次声明”和”已存在”。

方案核心:把命名空间写进同一个 Hash 的保留字段

思路是:每个业务 Hash 里固定保留一个字段(下文用 __ns__),值是该命名空间的唯一标识,形如 order-service:order。所有写入方在写业务字段之前,先尝试用 HSET key __ns__ <self_ns> EXPIRE_NX... 声明归属,再根据返回值判断是否安全。

不过单靠 HSET 还不够:如果两个业务都用不同的 key 前缀,那自然不会撞;真撞上时它们用的是同一个 key,此时 HSET key __ns__ A 和 HSET key __ns__ B 都会成功,后写的会把先写的覆盖掉——返回值和普通字段一样,无法区分”我是第一个写的”还是”我覆盖了别人”。

解决办法是利用 HSETNX 的思路,但 Redis 单条命令里 HSET 并不支持 NX 选项(SET 支持,HSET 不支持)。所以实际落地有两种写法:

写法一:用 Lua 脚本保证”检查 + 写入”原子化

这是最稳妥的方式。EVAL 内的脚本在执行期间不会被其他命令打断,天然满足原子性。

-- namespace_guard.lua
-- KEYS[1]: 业务 key
-- ARGV[1]: 当前业务命名空间标识,形如 "order-service:order"
-- ARGV[2]: 过期秒数
-- ARGV[3]: 业务字段名
-- ARGV[4]: 业务字段值
local key = KEYS[1]
local self_ns = ARGV[1]
local ttl = tonumber(ARGV[2])
local field = ARGV[3]
local value = ARGV[4]

-- 保留字段名与其他业务字段隔离的约定
local NS_FIELD = "__ns__"

-- 1. 读取已有归属
local owner = redis.call("HGET", key, NS_FIELD)

-- 2. 已存在且不属于自己,直接拒绝,不做任何写入
if owner and owner ~= self_ns then
  return -1
end

-- 3. 首次声明归属
if not owner then
  redis.call("HSET", key, NS_FIELD, self_ns)
end

-- 4. 写入业务字段
local added = redis.call("HSET", key, field, value)

-- 5. 只在没有 TTL 时设置过期,避免每次写入都续期
if redis.call("TTL", key) == -1 and ttl > 0 then
  redis.call("EXPIRE", key, ttl)
end

-- 返回:1 = 字段新增,0 = 字段更新,-1 = 命名空间冲突
return added

脚本里用 TTL 判断”是否已有过期时间”再决定要不要 EXPIRE,这是有意为之:如果每次写入都无脑 EXPIRE,一个持续被访问的 key 会永远不过期,内存只增不减。只在 TTL 返回 -1(永不过期)时才补设,可以保证”首次创建时定生命周期,后续不续命”。

Python 调用示例(redis-py 5.x):

import redis

r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)

NAMESPACE_GUARD = """
local key = KEYS[1]
local self_ns = ARGV[1]
local ttl = tonumber(ARGV[2])
local field = ARGV[3]
local value = ARGV[4]
local NS_FIELD = "__ns__"
local owner = redis.call("HGET", key, NS_FIELD)
if owner and owner ~= self_ns then
  return -1
end
if not owner then
  redis.call("HSET", key, NS_FIELD, self_ns)
end
local added = redis.call("HSET", key, field, value)
if redis.call("TTL", key) == -1 and ttl > 0 then
  redis.call("EXPIRE", key, ttl)
end
return added
"""

guard = r.register_script(NAMESPACE_GUARD)


def hset_guarded(key: str, field: str, value: str,
                 ns: str = "order-service:order", ttl: int = 3600) -> int:
    result = guard(keys=[key], args=[ns, ttl, field, value])
    if result == -1:
        raise RuntimeError(
            f"namespace conflict on key={key!r}, owner is not {ns!r}"
        )
    return result


print(hset_guarded("order:1001", "amount", "99"))     # 1,字段新增
print(hset_guarded("order:1001", "amount", "199"))    # 0,字段更新
try:
    hset_guarded("order:1001", "amount", "0", ns="marketing-service:order")
except RuntimeError as e:
    print("blocked:", e)

输出为 1、0,然后抛出冲突异常。注意 register_script 会把脚本缓存为 EVALSHA,后续调用不再传输脚本体。

写法二:用 HSET 返回值 + EXPIRE NX 做轻量校验

如果不能引入 Lua(例如用了某些不支持 EVAL 的托管 Redis 兼容层),可以退一步,用纯命令组合。这里的 NX 不是加在 HSET 上,而是加在 EXPIRE 上:EXPIRE key seconds NX 表示仅在 key 没有过期时间时才设置,返回 1 表示设置成功,返回 0 表示已有 TTL 或 key 不存在。结合前面说的 HSET 返回值语义,可以拼出一个”尽力而为”的校验序列:

# 步骤 1:尝试声明归属字段,返回 1 表示本次新增(我们可能是第一个)
EXPIRE order:1001 3600 NX
# (integer) 1  -- 说明之前 key 无 TTL,可能是新建/无主

HSET order:1001 __ns__ order-service:order
# (integer) 1  -- 字段新增

# 步骤 2:读回校验
HGET order:1001 __ns__
# "order-service:order"

# 步骤 3:写入业务字段
HSET order:1001 amount 99
# (integer) 1

这套序列的缺陷很明确:三条命令之间不是原子的。如果两个业务在同一毫秒竞争,都可能拿到 EXPIRE NX 的 1、都用 HSET 覆盖 __ns__、最后都以为自己是主人。它只能降低冲突概率,不能消除。所以这个写法只适合两种场景:写入频率极低(比如天级配置同步),或者作为第一道粗筛,后面还有别的兜底。

结论:要求严格正确性时用写法一(Lua);只要求”能发现大部分冲突”时用写法二。不要用写法二却声称它解决了覆盖问题,那是自欺。

把 EXPIRE NX 用在正确的位置:生命周期治理

上面提到了 EXPIRE key seconds NX,这个选项从 Redis 7.0 开始支持(同时还有 XX、GT、LT)。它在”共置命名空间”方案里的真正价值,是防止不同业务互相改掉 key 的生命周期——这也是键名冲突的第二种表现形式:不覆盖数据,但把 TTL 改没了。

命令 语义 返回 1 的条件 返回 0 的条件
EXPIRE key s 无条件设置 TTL 设置成功 key 不存在
EXPIRE key s NX 仅当 key 无 TTL 时设置 原来无 TTL,现设置成功 已有 TTL 或 key 不存在
EXPIRE key s XX 仅当 key 已有 TTL 时设置 原来有 TTL,现更新成功 无 TTL 或 key 不存在
EXPIRE key s GT 仅当新 TTL 大于当前 TTL 时设置 新值更大,设置成功 新值更小或 key 不存在
EXPIRE key s LT 仅当新 TTL 小于当前 TTL 时设置 新值更小,设置成功 新值更大或 key 不存在

实践建议:业务方一律用 EXPIRE ... NX,只有生命周期管理方(通常是创建者)才能用无条件 EXPIRE 或 GT 延长。这样即使键名冲突,恶意/错误的那一方也至少改不掉别人的 TTL,事故范围被限制在”数据字段被写脏”,而不是”数据永久驻留把内存打满”。

127.0.0.1:6379> HSET cfg:region:cn max_conn 200
(integer) 1
127.0.0.1:6379> EXPIRE cfg:region:cn 600 NX
(integer) 1
127.0.0.1:6379> TTL cfg:region:cn
(integer) 600
127.0.0.1:6379> EXPIRE cfg:region:cn 60 NX
(integer) 0
127.0.0.1:6379> TTL cfg:region:cn
(integer) 590

第二次 EXPIRE NX 返回 0,TTL 没有被改成 60,而是自然递减到 590。如果这条命令返回 0 但你的代码期待 1,这就是一个明确的”有人在动我这个 key”的信号,应该打点上报而不是忽略。

完整落地:封装一个带命名空间校验的写入器

真实项目里不建议每个业务手写脚本,应该收敛到一个 SDK 里。下面是一个可直接运行的 Python 实现,包含 TTL 补设、冲突上报、以及”字段名撞保留字段”的防御:

import redis

NS_FIELD = "__ns__"


class NamespaceConflict(Exception):
    pass


class GuardedHash:
    def __init__(self, client: redis.Redis, ns: str, default_ttl: int = 3600):
        if ns == NS_FIELD or not ns:
            raise ValueError(f"invalid namespace: {ns!r}")
        self.client = client
        self.ns = ns
        self.default_ttl = default_ttl
        self._script = client.register_script(
            """
            local key = KEYS[1]
            local ns = ARGV[1]
            local ttl = tonumber(ARGV[2])
            local field = ARGV[3]
            local value = ARGV[4]
            local NS_FIELD = "__ns__"
            local owner = redis.call("HGET", key, NS_FIELD)
            if owner and owner ~= ns then
              return -1
            end
            if not owner then
              redis.call("HSET", key, NS_FIELD, ns)
            end
            local added = redis.call("HSET", key, field, value)
            if redis.call("TTL", key) == -1 and ttl > 0 then
              redis.call("EXPIRE", key, ttl)
            end
            return added
            """
        )

    def set(self, key: str, field: str, value: str, ttl: int = None) -> bool:
        if field == NS_FIELD:
            raise ValueError("field name is reserved")
        ttl = self.default_ttl if ttl is None else ttl
        ret = self._script(keys=[key], args=[self.ns, ttl, field, value])
        if ret == -1:
            owner = self.client.hget(key, NS_FIELD)
            raise NamespaceConflict(
                f"key={key!r} owned by {owner!r}, refused by {self.ns!r}"
            )
        return bool(ret)

    def owner(self, key: str):
        return self.client.hget(key, NS_FIELD)

使用与验证:

r = redis.Redis(decode_responses=True)
order = GuardedHash(r, ns="order-service:order", default_ttl=1800)

print(order.set("biz:1001", "amount", "99"))    # True
print(order.set("biz:1001", "amount", "199"))   # False(字段已存在,是更新)
print(order.owner("biz:1001"))                  # order-service:order

other = GuardedHash(r, ns="promo-service:order")
try:
    other.set("biz:1001", "amount", "1")
except NamespaceConflict as e:
    print(e)   # key='biz:1001' owned by 'order-service:order', refused by 'promo-service:order'

try:
    order.set("biz:1001", "__ns__", "hijack")
except ValueError as e:
    print(e)   # field name is reserved

注意 set 返回 False 不代表失败,而是”字段已存在、本次是更新”。把这两种语义混在一起返回 True/False 会让人误判,所以如果你需要区分,让 set 返回 ret 原值(1/0)更清晰。

必须知道的坑与真实报错

1. WRONGTYPE:key 已经不是 Hash 了

127.0.0.1:6379> SET cfg:region:cn "hello"
OK
127.0.0.1:6379> HSET cfg:region:cn __ns__ order-service:order
(error) WRONGTYPE Operation against a key holding the wrong kind of value

这是另一个业务用 String 占了同一个 key。脚本里 HGET 会直接抛错,EVAL 整体失败,调用方拿到 ResponseError: WRONGTYPE ...。处理方式:在 SDK 里捕获 redis.exceptions.ResponseError,把 WRONGTYPE 归类为 NamespaceConflict 的子类型上报,而不是当成程序 bug 重试——重试一万次也还是错。

2. 脚本里用 KEYS 与集群模式

Lua 脚本在 Redis Cluster 中要求所有 key 落在同一个 slot。上面的脚本只操作 KEYS[1] 一个 key,安全。但如果你的脚本顺手读了另一个 key(比如读配置),就必须用 hash tag 把它们绑到一起,例如 {order:1001}:ns 和 {order:1001}:data,否则会报:

(error) CROSSSLOT Keys in request don't hash to the same slot

3. 脚本里调用 EXPIRE 的旧版本兼容

如果你的 Redis 版本低于 7.0,EXPIRE key s NX 会报语法错误:

127.0.0.1:6379> EXPIRE cfg:region:cn 600 NX
(error) ERR syntax error

在脚本里自行判断 TTL 再决定是否 EXPIRE(如前文脚本所示)就不依赖 NX 选项,6.x 及更早版本同样可用。这也是我在 Lua 方案里没用 EXPIRE ... NX 的原因——兼容性优先。

4. 保留字段会占用内存并出现在 HGETALL 里

__ns__ 每个 key 多一个字段,量级上可以忽略(几十字节),但它会出现在 HGETALL 的结果里。如果下游有代码用 HGETALL 转 JSON 直接对外输出,会多出一个字段。要么在序列化时过滤,要么给保留字段起一个不会与业务冲突且语义明确的名字(如 __owner_ns__),并在文档中写清。

5. 大 Hash 场景下的 TTL 补设成本

脚本中 TTL 是 O(1),HSET 是 O(1),整体开销极小。但如果你的 key 是超大 Hash(百万字段),任何一次 HGETALL 都会阻塞主线程,这与本文方案无关但常被一起踩到,建议改用 HSCAN 分批,单次 COUNT 控制在 500 以内。

监控与兜底

方案能不能长期生效,取决于冲突是否可被看见。至少要落到三个指标:

  • 冲突次数:NamespaceConflict 异常计数,按 key 前缀和 owner_ns 打 tag。持续增长说明两套代码真的在争同一个 key。
  • 无主 Hash 数:定期 SCAN 抽样,统计没有 __ns__ 字段的 Hash,说明有绕过 SDK 的直连写入,需要治理。
  • 永不过期 key 数:INFO keyspace 里的 expires=0 占比,以及 redis-cli --scan --pattern '*' | xargs -n1 redis-cli TTL(小心使用)抽样确认 TTL 是否被意外清掉。

如果发现某个 key 已被写脏,修复方式是人工确认归属后,用 HDEL key __ns__ 清掉归属,再让正确的业务重新声明。注意这中间存在竞态,应该在低峰期做,或先用 RENAME 把旧 key 挪走:

RENAME order:1001 order:1001.conflict.20240612
HSET order:1001 amount 99
EXPIRE order:1001 1800 NX

小结

键名冲突的本质是”命名空间是隐式的、不可验证的”。把归属信息显式写进同一份数据结构里,配合 HSET 返回值区分新增与更新、用 Lua 保证检查与写入的原子性、用 EXPIRE NX 锁住生命周期不被第三方改写,就能把一个静默故障变成一个有明确返回值的可观测事件。代价是每个 Hash 多一个保留字段和一次脚本调用,收益是再也不用靠日志猜”这条数据是谁改的”。适用边界要清楚:单 key Hash 结构、写入方数量有限、能接受统一走 SDK;如果你的场景是海量小 String,更合适的做法是给每个业务分配独立的 Redis DB 或实例,而不是在 key 层面做校验。

FAQ

Q1:为什么不直接用 HSETNX 声明归属字段?

HSETNX key field value 只在字段不存在时写入,返回 1。但它的返回值只告诉你”写成功了”,无法区分”我是第一个声明者”和”我覆盖了别人”。而且它不会告诉你当前 owner 是谁。所以脚本里用 HGET 先读 owner 再比较,语义更完整,也能在冲突时把 owner 返回给调用方定位问题。

Q2:Redis 7.0 之后 HSET 支持 NX 选项了吗?

不支持。SET 有 NX/XX/GET/EX/PX 等选项,HSET 至今没有 NX 选项。EXPIRE 从 7.0 起支持 NX/XX/GT/LT,别把两者混淆。

Q3:这个方案的性能损耗有多大?

脚本只包含 3 到 4 次 O(1) 操作(HGET、可能的 HSET、HSET、TTL),与直接执行一条 HSET 属同一数量级。使用 EVALSHA 后不重复传输脚本,实测在本地回环下差异在几十微秒量级。真正的瓶颈是网络 RTT,不是脚本本身。别在脚本里做循环或 HGETALL。

Q4:如果一个 key 需要由多个业务共同写入,怎么办?

那它们本质上是同一个命名空间的使用者,应该共用同一个 ns 标识,或者进一步把字段做二级前缀(如 order:amount、promo:discount)。本文方案的前提是”一个 key 属于一个命名空间”,多写者共享场景应当拆 key,而不是放宽校验。

Q5:脚本执行报 NOSCRIPT No matching script 是什么原因?

用了 EVALSHA 但脚本缓存因 SCRIPT FLUSH、实例重启或主从切换而丢失。客户端 SDK(如 redis-py 的 register_script)会自动降级到 EVAL 重试一次,自研客户端需要自己处理这个错误码。

Q6:能不能不用 Lua,只用 Redis 事务 MULTI/EXEC?

不能。MULTI/EXEC 不具备”根据前一条命令的返回值决定后续行为”的能力,WATCH 只能做乐观锁重试,在竞争激烈时重试成本高且实现复杂。这类”读-判断-写”的逻辑,Lua 是 Redis 中唯一的原子手段。