登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  数据库 >  Redis

Redis 缓存穿透场景怎样区分空值占位和真实缺失

来源:17golang原创

时间:2026-11-11 20:47:27 397浏览 收藏

我在排查缓存穿透时最容易踩的坑,是把 Redis 的“没有拿到业务数据”都当成同一种空结果。实际上,GET 返回 nil 可能意味着 Key 根本不存在;返回一个空值标记,则说明应用已经确认过“这个业务对象不存在”;返回普通字符串或 JSON,才是缓存命中的真实记录。三者混在一起,回源次数和监控结论都会失真。

官方地址:https://redis.io/docs/latest/

最稳妥的判断方式不是只看业务值是否为空,而是给缓存结果定义明确状态:hit 表示有真实数据,negative 表示空值占位,miss 表示 Redis 中没有缓存。空值占位设置较短 TTL,真实缺失才回源;回源写回时使用带过期时间的写入策略。

先把三种缓存状态分开

Redis 的 Key 空间只知道一个 Key 是否存在,EXISTS 返回的是存在数量,并不会告诉应用“这个值是不是业务数据”。因此,EXISTS key = 1 不能直接等价于业务命中:它也可能只是一个代表“查过但不存在”的占位值。

我现在会把一次读取拆成下面三类结果:

状态Redis 表现应用动作
hitKey 存在,值能解析为真实记录直接返回缓存数据,不查库
negativeKey 存在,值是约定的空值信封返回业务层的“未找到”,不查库
missGET 返回 nil,Key 还没有缓存查询数据源,再写回真实记录或空值信封
Redis 请求键、缓存信封、hit 与 miss 状态以及 TTL 的静态关系说明图
图1:缓存信封把真实记录、空值占位和 TTL 放到同一条状态边界中;这是静态说明图,不是运行截图。

这里的关键是“空值占位仍然是缓存命中”。它只代表缓存层已经知道查询结果为空,并不代表数据库里有一条空记录。只要把这个语义写进状态字段,排查时就不会再用 value == nil 猜结果。

用状态信封保存结果

直接把特殊字符串当哨兵值虽然简单,但业务数据一旦可能出现同样的字符串,判断就会产生歧义。更稳的做法是给缓存值包一层轻量信封,让状态和数据分离。下面的 JSON 只是存储格式示意,字段名可以按项目命名约定调整:

{
  "state": "hit",
  "data": {"id": 42, "name": "示例对象"}
}

{
  "state": "negative",
  "data": null
}

读取时只接受三个状态:hit、negative 和未读到值时的 miss。未知状态不要默认为命中,也不要悄悄回源;可以把它记录为格式异常,再按照项目的兼容策略处理。

如果追求更小的载荷,也可以使用带前缀的字符串,例如 H|... 表示真实数据、N| 表示空值占位。无论选择 JSON 还是前缀,原则都是让“状态”成为显式协议,而不是把空字符串、零值和 nil 混为一谈。

给空值占位设置较短 TTL

空值占位的作用是挡住同一个不存在 Key 在短时间内重复回源,所以它需要过期时间;但它通常不应该和真实数据使用同一个 TTL。真实对象可能允许较长缓存,空值占位则应该更短,让后来创建的数据有机会被重新读取。

# 真实数据缓存时间较长,空值占位时间较短
SET user:42 '{"state":"hit","data":{"id":42}}' EX 600
SET user:4042 '{"state":"negative","data":null}' EX 60

# 用 TTL 判断占位是否仍在保护窗口内
TTL user:4042

SET 的 EX 选项会在写入时一起设置秒级过期时间;Redis 官方文档也说明成功的 SET 会处理原有 TTL,因此更新真实数据时要明确给出新的过期策略。排查时,Key 存在但 TTL 很短,通常更像空值占位;Key 不存在则是 miss,不能仅凭过期前后的业务结果倒推。

把回源写入收敛在缓存边界

真正让缓存穿透变严重的,通常不是一次 miss,而是大量并发请求同时发现 miss,然后一起查库。我的处理方式是让业务层只接收状态解析结果,把 Redis 读写、数据库回源和指标记录都集中在缓存边界内。

业务读取、Redis GET、空值占位、数据库查询和 SET EX NX 的缓存边界控制关系图
图2:缓存状态解析器连接 Redis、数据库回填和指标记录;这是静态说明图,不是运行截图。

下面是一个精简的 Go 伪实现,重点是返回值的语义,不依赖某个具体 Redis 客户端版本。生产代码中应把序列化、超时和错误分类接到项目已有的基础设施上:

type cacheState string

const (
	stateHit      cacheState = "hit"
	stateNegative cacheState = "negative"
	stateMiss     cacheState = "miss"
)

type envelope struct {
	State cacheState       `json:"state"`
	Data  json.RawMessage  `json:"data,omitempty"`
}

func classify(raw string, found bool) (cacheState, []byte, error) {
	// GET 没拿到 Key 才是 miss;Key 存在但 state=negative 是空值占位。
	if !found {
		return stateMiss, nil, nil
	}
	var item envelope
	if err := json.Unmarshal([]byte(raw), &item); err != nil {
		// 格式异常不能伪装成真实命中,交给上层做兼容或降级。
		return "", nil, fmt.Errorf("decode cache envelope: %w", err)
	}
	switch item.State {
	case stateHit:
		return stateHit, item.Data, nil
	case stateNegative:
		return stateNegative, nil, nil
	default:
		// 未知状态保守处理,避免把占位或旧格式当成业务数据。
		return "", nil, fmt.Errorf("unknown cache state %q", item.State)
	}
}

回源完成后,真实记录和空值占位都要带 TTL 写回。对“只有在 Key 仍不存在时才写入”的场景,可用 SET key value EX seconds NX。官方文档定义了 NX 为仅在 Key 不存在时写入,这能减少较晚完成的请求覆盖已经写入的结果;它不是完整的分布式锁,也不替代业务层的并发控制。

# 仅当 Key 仍不存在时写入空值信封,避免覆盖较新的结果
SET user:4042 '{"state":"negative","data":null}' EX 60 NX

建立排查清单

遇到“缓存穿透仍然很高”时,我会按状态而不是按接口耗时排查:

  1. 先对同一个业务 Key 执行一次 GET,区分返回 nil、state=negative 和 state=hit。
  2. 再查看 TTL:没有 TTL 或 TTL 过长,说明空值占位策略可能没有落地;TTL 为负数时还要结合 Key 是否存在解释。
  3. 把指标拆成 cache_hit、cache_negative、cache_miss 和 origin_lookup,不要只保留一个 cache_miss 计数。
  4. 如果 miss 与 origin_lookup 同时陡增,检查回源写回是否失败;如果 negative 很高但业务刚创建数据,检查占位 TTL 是否过长。
  5. 最后检查 Key 规范化,确认读写两侧使用同一个前缀、业务标识和序列化协议。

这份清单的重点不是多执行几个 Redis 命令,而是把“没有真实数据”拆成可观测的状态。只有这样,空值占位是在保护数据库,还是把新数据挡住了,才有证据可判断。

几个容易混淆的问题

EXISTS 返回 1,是否说明缓存命中了?

不说明。它只说明 Key 存在;空值占位同样会让 EXISTS 返回 1。业务命中必须由缓存值的状态信封判断。

可以直接把空字符串当空值占位吗?

不建议。除非业务协议能严格保证真实数据永远不会是空字符串,否则空字符串无法表达“占位”与“真实值为空”的区别。使用保留状态字段更容易扩展和审计。

空值占位 TTL 越短越好吗?

不一定。太短会让不存在的 Key 频繁回源,太长会让后来创建的数据等待更久。应结合数据创建频率、回源成本和指标中的 negative/miss 比例调整。

结论

Redis 缓存穿透场景中,真正需要区分的是三种状态:Key 不存在是 miss,存在空值信封是 negative,存在可解析业务数据才是 hit。把状态写进缓存值、给空值占位设置短 TTL,并在回源写回时使用 SET EX NX,就能让业务判断、并发控制和监控指标落在同一套语义上。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>