ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Redis实战:从缓存原理到AI时代向量检索与语义缓存

Redis实战:从缓存原理到AI时代向量检索与语义缓存

做后端开发这些年,Redis 几乎是绕不过去的一个组件。很多人把它当成一个简单的缓存工具来用,get、set、delete 三步走,等线上出问题才回头补课。其实 Redis 能做的事远不止缓存,尤其是 AI 应用开始普及之后,它又多了一重身份:向量检索和语义缓存的基础设施。

这篇内容不是把官方文档翻译一遍,我想结合自己平时实战的经验,把 Redis 的基础原理、高并发用法、常见事故排查讲明白,再聊一聊它在 AI 时代新增的向量检索、JSON 文档、全文搜索这些能力到底怎么用、适合什么场景。无论你是刚接触 Redis 的初学者,还是已经用了一段时间但总在出问题边缘试探的开发者,这篇文章都值得花十几分钟读完。

1. Redis 的定位:它首先是个内存数据库,然后才是缓存

1.1 为什么需要独立的缓存层

最原始的系统架构其实没有 Redis 什么事:客户端请求打到应用服务,应用服务直接读写数据库。数据库把数据落在磁盘上,为了保证读写性能又引入了缓冲池、索引这些机制。但无论怎么优化,磁盘 IO 和锁竞争都会成为瓶颈。数据库连接数是有限的,当一个接口在高峰期被大量重复请求,后端数据库会先撑不住,表现出来就是慢查询堆积、CPU 飙升、连接池耗尽。

这时候大家会想到"加一层本地缓存",也就是在应用进程里放一个字典,把热点数据放在进程内存里。本地缓存速度快到离谱,但它有两个问题:一是多实例部署时每个进程各缓存一份,数据不一致;二是缓存放在应用进程里,应用重启缓存就没了,而且内存大小受限于单个实例。于是独立缓存层出现了,Redis 就是这类组件里的典型代表。

Redis 把数据放在内存里,通过网络端口对外提供服务,所有应用实例共享同一份缓存数据。它基于事件循环处理命令,避免了传统多线程并发带来的锁竞争,单线程模型让性能极其稳定。在普通服务器上,Redis 单实例轻松跑到十万级 QPS,这足够应付绝大多数业务场景。记住这个定位:Redis 首先是内存数据库,其次才是缓存。它原生支持丰富的数据结构、持久化、过期策略和集群方案,决定了它不是一个"用完即丢"的工具。

1.2 五大基础数据结构,每个都对应一个使用场景

很多教程上来就列 Redis 的五种数据结构:String、Hash、List、Set、ZSet。听起来简单,但你在实际项目里能不能用对,就不一样了。我用一个电商系统的例子来说明。

String 是最常用的,可以存缓存对象序列化后的字符串,也可以用来做计数器。比如统计商品访问量,直接INCR product:view:1001,一个命令搞定,天然支持原子操作,不用自己加锁。验证码、分布式锁也都是基于 String 做的。

Hash 适合存对象字段。比如用户信息,用HSET user:1001 name "张三" age 18,之后HGET user:1001 name可以单独取某个字段,不用把整个用户信息序列化成 JSON 再反序列化。这在更新某个字段时特别省流量。

List 可以当队列用。LPUSH往左推,BRPOP阻塞弹出,配合起来可以实现一个简单的异步任务队列。虽然比不上专业的消息队列,但在轻量级场景下完全够用。

Set 是无序去重集合,适合做标签、好友关系、抽奖去重。SINTER可以求两个 Set 的交集,比如"关注了 A 也关注了 B"的用户就是交集运算。

ZSet 是有序集合,每个成员带一个 score,按 score 排序。排行榜是最经典的应用,ZADD rank 100 user1、ZREVRANGE rank 0 9就能取出前十名。还能用 score 存时间戳,实现延时任务。

除了这五种,Redis 还提供了 Bitmap、HyperLogLog、Geo 等扩展结构。Bitmap 可以做签到记录,HyperLogLog 做 UV 统计,Geo 做附近的人。基础结构的选择直接决定了代码的复杂度和运行效率,这部分的功夫要花在平时。

2. 基础原理不能只会用:过期、持久化与淘汰策略

2.1 过期删除是"懒惰"加"定期"两条腿走路

给 key 设置过期时间,几乎是每个 Redis 使用者都会的操作。比如验证码 5 分钟有效,缓存 10 分钟过期。但你知道 Redis 是怎么把过期 key 清理掉的吗?很多人以为到点就删,其实不是。

Redis 的过期删除策略是惰性删除加定期删除结合。惰性删除很好理解:当你访问一个 key 的时候,Redis 会检查它是否设置了过期时间,如果过期了就直接删除并返回 nil。这样做避免了在每个 key 上挂定时器带来的性能损耗。但问题也很明显,如果一个过期 key 一直没人访问,它就永远赖在内存里,占着空间不释放。

所以 Redis 还有定期删除:每隔一段时间,从设置了过期时间的 key 里随机抽取一批,检查是否过期,过期就删。这个操作不是全量扫描,而是分批次抽样,避免阻塞事件循环。两个策略配合在一起,既保证了一点就删的及时性,又不会因为清理操作拖垮性能。

这里有一个需要特别注意的实战细节:如果你给大量 key 设置了相同的过期时间,它们会在同一时刻集中过期,产生瞬间的删除压力,可能导致 Redis 卡顿。更常见的情况是,缓存同时失效后所有请求同时落到数据库,形成缓存雪崩。我在项目里习惯给 TTL 加一个随机偏移,比如基础过期时间 600 秒,再加 0 到 60 秒的随机值,能显著缓解集中过期的问题。

2.2 RDB、AOF 和混合持久化,怎么选才不丢数据

Redis 是内存数据库,一旦进程退出,内存中的全部数据都会消失。为了在重启后恢复数据,Redis 提供了持久化机制。这里有两个选择:RDB 和 AOF。

RDB 是定期把内存中的全量数据生成一份快照,保存到磁盘的二进制文件里。它的优点是恢复速度快、文件紧凑,适合做备份。缺点是快照之间有窗口期,如果 Redis 在这中间宕机,这部分数据就丢了。RDB 生成快照时通过 fork 子进程完成,父进程继续服务请求,一般不会阻塞主线程,但如果数据量特别大,fork 本身也会有短暂停顿,这点要心里有数。

AOF 则是把每次写操作以日志形式追加到文件里。Redis 重启时通过重放日志恢复数据。AOF 提供了三种同步策略:always 每写必同步,数据最安全但性能最差;everysec 每秒同步一次,性能和数据安全性相对平衡;no 交给操作系统决定时机,性能最好但可能丢失较多数据。生产环境一般推荐 everysec,极端情况下最多丢一秒的写操作,大多数业务可以接受。

从 Redis 4.0 开始提供了混合持久化:RDB 快照加上 AOF 增量日志。重启时先加载 RDB,再重放 AOF 的增量部分,兼顾了恢复速度和数据的完整性。如果业务数据丢不起,建议开启 AOF everysec,同时保留 RDB 文件做定期备份。如果纯当缓存且能容忍丢失,甚至可以不开启持久化,但至少要在测试环境验证重启后数据丢失对业务的影响。

2.3 内存淘汰策略:别让 Redis 因为 OOM 罢工

Redis 的内存是有限资源,你不停往里面写数据,总会达到maxmemory上限。默认配置下,Redis 在内存达到上限后会拒绝写请求,报OOM command not allowed when used memory > 'maxmemory'。缓存场景下这是不可接受的,因为业务高峰可能写入更多缓存数据。

正确的做法是配置内存淘汰策略。maxmemory-policy可以设置以下几种:noeviction不淘汰,写入失败;allkeys-lru在所有 key 里按最近最少使用淘汰;volatile-lru只在设置了过期时间的 key 里按 LRU 淘汰;allkeys-lfu和volatile-lfu则是按访问频率淘汰;另外还有allkeys-random和volatile-random随机淘汰。

最常用的是allkeys-lru或allkeys-lfu。LRU 适合数据访问相对均匀的场景,LFU 适合有明显的热点数据变化、需要淘汰冷门数据的场景。我举个例子,一个新闻应用,突发热点暂时没有规律,用 LRU 比较合适;一个推荐系统,用户兴趣会偏移,昨天热门今天冷门的条目,用 LFU 能更快淘汰。

这里提醒一点:如果业务数据切不可丢失,不要把含有重要状态的 key 和缓存 key 混在同一个 Redis 实例里。重要数据应该单独部署并开启持久化,缓存实例专门负责可丢失数据。把两者混在一起,淘汰策略可能会误删重要数据,这是我在项目里踩过的教训。

3. 高并发下怎么"正确地"使用 Redis

3.1 缓存读写主流程:先查缓存,回填 DB,再设置 TTL

提到缓存,最简单的代码就是先查 Redis,没查到再查数据库。但很多新手忽略了几个细节。我直接给出一段通用的伪代码,使用 Python 的 redis-py 客户端演示。

import redis import json r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True) TTL = 600 def get_product(product_id): cache_key = f"product:{product_id}" data = r.get(cache_key) if data is not None: return json.loads(data) # 这里接入数据库查询 product = db_query_product(product_id) if product is None: # 防止缓存穿透,把空结果也缓存一下 r.set(cache_key, json.dumps(None), ex=60) return None # 回填缓存 r.set(cache_key, json.dumps(product), ex=TTL) return product

这段代码有四个要点。第一,缓存读取必须设置超时时间,socket_timeout、connect_timeout都要配,不能让客户端无限等 Redis。第二,回填时要设置 TTL,避免缓存永不失效。第三,对于数据库不存在的 key,也要缓存空值,但要设置一个较短的过期时间,比如 60 秒,否则恶意请求制造大量不存在的 ID 会直接打穿缓存。第四,建议在更新操作时直接删除缓存而不是更新缓存。因为更新缓存需要保证数据格式一致,而且多次更新会导致缓存被反复写,删除缓存后下次读取时重新回填,更简单也更安全。

3.2 连接池、超时与重试:稳定性的三个关键参数

使用 Redis 客户端时,连接池的配置直接决定了并发上限。不少生产事故不是 Redis 本身出了问题,而是客户端连接都在排队等获取连接,应用线程被撑爆。连接池大小要结合业务 QPS 和 Redis 单实例能力来设置。max_connections过小会排队,过大会导致 Redis 维护大量连接,内存和 CPU 都会有额外开销。通常连接池大小在几十到几百之间,如果业务量更大,优先考虑扩容或分片,而不是无限调大连接池。

超时配置同样重要。Redis 正常响应是亚毫秒级,如果网络抖动导致请求阻塞,客户端必须快速失败。一般的做法是设置连接超时 2 秒,读写超时 1 到 3 秒。重试策略要保守,默认不重试或最多重试一次,否则 Redis 短暂不可用时,重试请求会加重故障。

有一个安全细节容易被忽略:如果你在缓存中存放的是大对象,序列化和反序列化的耗时可能超过 Redis 本身的查询耗时。比如一个 5MB 的 JSON 字符串,读出来反序列化要好几十毫秒,这会让缓存优势荡然无存。遇到这种场景,应该把大对象拆分为多个 hash 字段,或者用压缩算法压缩后再存入 Redis。我遇到过一张商品详情页缓存 4MB,导致接口平均耗时 200ms,压缩后降到了 30ms,效果立竿见影。

3.3 高可用选型:主从、哨兵、Cluster 到底怎么取舍

单机 Redis 再好也扛不住所有风险。硬盘损坏、进程崩溃、机房断电,任何一个故障都可能导致服务不可用甚至数据丢失。Redis 的高可用方案主要有三种:主从复制、哨兵、Cluster。

主从复制是最基础的能力,一个主节点可以挂多个从节点,从节点实时复制主节点的数据。写请求只能走主节点,读请求可以走从节点,这样把读压力分散开。如果你的业务读多写少、可以容忍秒级数据延迟,主从复制就够用。

但主从复制不会自动切换。主节点宕机后,需要人工把某个从节点提升为主节点。于是哨兵模式出现了。哨兵是一个独立进程,监控主从节点的健康状态,当主节点不可达时,自动选出一个从节点提升为主节点,并通知客户端更新连接地址。哨兵模式可以实现自动故障转移,一般部署奇数个哨兵节点,避免脑裂。

Cluster 是 Redis 的分布式集群方案。数据按 slot 分布到多个主节点,每个主节点还可以挂从节点。Cluster 支持自动分片和故障转移,特别适合数据量超过单机内存、或者单点写入能力不够的场景。选型的经验是:数据总量不超过几十 GB、可用性要求高、并发量大,用哨兵模式就够了;数据量达到上百 GB 或者需要水平扩展写能力,再上 Cluster。没必要一上来就搞集群,复杂度会增加非常多。

4. 缓存穿透、击穿、雪崩:识别、预防与排障实录

4.1 三种故障形态,怎么一眼认出

这三个名词被说烂了,但真正遇到时,很多人不能快速判断属于哪一种。我用表格整理出特征,方便对照。

故障类型本质现象典型触发场景数据库表现恢复特征
缓存穿透大量请求查一个不存在的 key,缓存永远无法命中恶意构造不存在的 ID,或业务中正常存在空数据数据库收到大量无意义查询缓存空值后立刻缓解
缓存击穿某一个热点 key 过期瞬间,大量并发请求同时打到数据库热门商品详情、微博热搜条目缓存到期单一 key 的数据库查询并发飙升互斥锁或逻辑过期能缓解
缓存雪崩一大批 key 同时过期或 Redis 实例宕机设置了相同 TTL,或重启时没有预加载缓存整体数据库压力暴涨随机 TTL、多级缓存、集群容灾

这三种故障的表现都包含"数据库压力飙升",但成因不同。穿透是"查不到",击穿是"一个热点突然失效",雪崩是"批量 key 同时失效"。处理手段也完全不同,所以第一步一定是准确识别。

4.2 预防手段:空值缓存、布隆过滤器、互斥锁、过期时间错峰

对于缓存穿透,最简单的办法是缓存空值。如果一个 key 对应的数据在数据库中不存在,也写入缓存,只是 value 为空,TTL 设置短一点,比如 60 秒。这样同一时间的重复请求都直接命中缓存,不会打到数据库。但空值缓存只能挡住"同一个 key"的反复查询,如果攻击者每次生成不同的随机 ID,空值缓存就失效了。这时需要布隆过滤器:启动时把所有可能存在的数据 ID 加载到布隆过滤器里,查询前先判断 key 是否存在,不存在直接返回。布隆过滤器有误判率,但不会漏判,误判只会导致少量请求打到数据库,无伤大雅。

对于缓存击穿,核心思想是"让并发回源的请求只放行一个"。可以用互斥锁实现:缓存未命中时先尝试获取锁,SET lock_key unique_value NX EX 3,拿到锁才查数据库并回填缓存;没拿到锁的请求等待一小段时间后重试读取缓存。另一种做法是逻辑过期:缓存里存一个过期时间戳,读取时发现逻辑过期,先加锁回源,同时老缓存还能继续提供服务,避免请求全部打到数据库。

对于缓存雪崩,除了加随机 TTL,还可以做多级缓存,比如应用本地再放一层 Caffeine 或 Guava Cache,Redis 挂掉时还有本地缓存兜底。更稳妥的方案是使用高可用集群,避免单实例宕机导致的大面积缓存失效。事故演练时我会故意把一台节点停掉,验证系统是否还能正常扛住流量,这种演练比纸上谈兵有用得多。

4.3 现场排查工具与踩坑点

线上排查 Redis 问题,光靠脑子想象是不够的。我常用的三个工具是 INFO 命令、redis-cli --bigkeys 和 SLOWLOG。

redis-cli INFO stats可以看keyspace_hits和keyspace_misses,命中率低说明可能在穿透或缓存使用不合理。INFO memory看内存使用量,used_memory超过 maxmemory 时会导致淘汰或写入失败。INFO clients看客户端连接数,连接数异常升高往往意味着有代码在泄漏连接。

redis-cli --bigkeys会扫描统计大 key,默认以字节数统计最大字符串、最大 Hash 等。大 key 是 Redis 卡顿的头号原因,比如一个 Hash 里有几百万个字段,删除它时会阻塞主线程。处理大 key 的正确姿势是用hscan分批删除,或者用unlink命令异步删除,而不是直接del。

SLOWLOG GET 10能查出慢命令。Redis 是单线程,任何慢命令都会阻塞所有后续请求。常见的慢命令包括KEYS、SMEMBERS、大集合的SORT等。生产环境一定要禁止使用KEYS命令,它会遍历全部 key,阻塞时间不可控。要用SCAN代替,分批迭代。

我踩过的坑还包括:误用FLUSHALL清空了整个 Redis,导致缓存全灭;以及monitor命令开着观察线上流量,结果高负载下 monitor 本身吃掉大量 CPU,把服务拖垮。这些命令在生产环境务必谨慎,需要排查时尽量在低峰期、加超时限制地执行。

5. AI 时代,Redis 正在悄悄换身份

5.1 向量检索能力:让 Redis 变成 RAG 的记忆层

大模型应用火起来之后,检索增强生成(RAG)成了最常见的技术架构。大模型只掌握训练截止前的知识,对于私有文档、最新数据、企业内部资料,无法直接回答。RAG 的流程是先检索相关文档片段,再把片段拼到提示词里交给大模型生成答案。检索这一步需要一个向量数据库,用来存储文本、图片的 embedding 向量并做相似度搜索。

过去大家会把向量数据库单独部署,比如专业的向量检索产品。但 Redis 新版本内置了向量检索能力,基于模块化的搜索功能,支持保存向量字段并建立 HNSW 或 FLAT 索引。HNSW 是一种近似最近邻算法,搜索速度极快,适合大规模向量;FLAT 是暴力全量计算,精确但速度慢,适合小规模数据。

创建向量索引的命令大致是这样的:

FT.CREATE idx_docs ON HASH PREFIX 1 doc: SCHEMA content TEXT embedding VECTOR HNSW 8 TYPE FLOAT32 DIM 768 DISTANCE_METRIC COSINE

之后每个文档以 Hash 结构写入 Redis,其中 embedding 字段保存浮点数组的二进制内容。查询时用FT.SEARCH指定KNN参数,就能返回距离最近的文档。对于千万级别以内的向量规模,Redis 的表现相当能打。而且它可以和原有业务数据存在同一套基础设施里,减少组件数量,对中小团队尤其友好。

5.2 语义缓存:用"意思相近"命中缓存

大模型接口的调用成本和响应延迟都远高于普通 API。用户问"怎么修改密码"和"忘记密码怎么重新设置",语义几乎相同,但如果只按字面完全匹配缓存,这两条请求会当成完全不同的内容处理,白白浪费两次模型调用。语义缓存就是把这种"同义不同形"的请求利用起来。

具体实现分三步:第一步,把用户输入通过向量模型转成 embedding;第二步,用 Redis 的向量搜索查找历史缓存中是否有相似度高于阈值的向量,如果找到了,直接返回对应的历史回答;第三步,如果没找到,调用大模型生成回答,再把"向量、用户输入、回答结果"一起存入 Redis。

用伪代码描述核心逻辑:

def get_ai_answer(user_query): query_vec = embedding_model.encode(user_query) res = search_similar_vectors(query_vec, threshold=0.85) if res: return res["answer"] answer = call_llm(user_query) save_vector(query_vec, user_query, answer) return answer

这个方案听起来很美好,但要注意两个问题。一是 embedding 模型本身有调用成本,如果向量转换接口比大模型还慢还贵,就不划算了。一般要选用轻量级 embedding 模型,并且在本地做向量化。二是相似度阈值需要仔细调。阈值太高,命中率低,缓存发挥不了作用;阈值太低,会把不相关的问题错误命中,给出驴唇不对马嘴的答案。我通常会在测试集上模拟一批真实问题,画出准确率和命中率的变化曲线,选一个平衡点。

5.3 JSON 和全文检索:Redis 的文档能力也不差

传统印象里 Redis 只能存字符串和二进制,拿它存复杂结构总要序列化成 JSON。现在 Redis 新版本原生支持 JSON 数据类型,可以通过JSON.SET写入,并通过 JSONPath 读取和修改内部字段,不需要把整个 JSON 取出来反序列化。

比如存储一个订单对象:

JSON.SET order:1001 $ '{"user": "张三", "total": 299, "items": [{"name": "鼠标", "price": 99}]}' JSON.GET order:1001 $.user JSON.INCRBY order:1001 $.total 50

配合全文检索功能,还可以在 JSON 的字段上创建索引,执行类似数据库全文搜索的操作。比如在商品名称上建索引,搜索包含"无线"的所有商品,用FT.SEARCH返回结果,并且可以按字段排序、分页、高亮。这等于在 Redis 里内置了一款轻量级搜索引擎。

我个人的体会是:不要把 Redis 当成万能数据库去替代专业搜索引擎,它更适合中低复杂度的搜索场景。比如管理后台的小型列表筛选、日志系统里千万级以内的标签过滤、AI 应用里元数据与向量混合检索。这种"一个组件干三件事"的设计,对团队技术栈的简化收益非常明显。团队不需要为了简单搜索再引入额外的搜索引擎,运维成本、机器成本、心智成本同时降下来。

AI 时代的所谓"新特性",本质上是让 Redis 从一个纯缓存工具变成了一个多功能数据基础设施。向量检索让 RAG 落地更轻,语义缓存让模型调用成本大幅下降,JSON 和全文索引让结构化和非结构化数据更好地共存。如果你正处于 AI 应用的起步阶段,与其一开始就搭建一套沉重的数据中台,不如先从 Redis 的这些能力开始验证方案,跑通之后再决定是否扩展组件。这套思路,我建议你亲自在测试环境跑一遍,踩踩坑,比任何文档都管用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进