分页缓存不能直接缓存 OFFSET + LIMIT 查询结果,因偏移量变化导致缓存键不连续、数据重叠、一致性差,且排序字段重复时 OFFSET 本身不稳定;应改用游标式分页,以唯一标识(如 ID 或时间戳)为缓存键,并统一序列化、防穿透雪崩。

分页缓存为什么不能直接缓存 OFFSET + LIMIT 查询结果
因为分页偏移量变化时,缓存键不连续,导致大量缓存冗余甚至击穿。比如 page=1 和 page=2 的数据可能有重叠(新增/删除行后),但缓存里却是两份独立快照,既浪费空间,又无法保证一致性。
更关键的是:数据库排序字段若存在重复值(如多个记录 CreatedTime 相同),OFFSET 分页本身就不稳定 —— 同一查询多次执行可能返回不同行,缓存只会放大这个问题。
- 用
OFFSET缓存页码 → 缓存键难设计、易失效、更新成本高 - 真实业务中数据增删频繁 →
page=5的“第一页”可能在下次请求时已不是原来那批数据 - Redis 里存大量小 key(如
user:list:page:1、user:list:page:2)→ 内存碎片+淘汰压力大
Redis 中该缓存什么:游标式分页 + 范围键设计
把“页码”换成“最后一条记录的唯一标识”,比如 Id 或复合游标 CreatedTime:Id。缓存结构变成:user:list:cursor:15234,表示“从 Id=15234 之后取 20 条”。这样每页缓存键可预测、可复用、可续查。
实际操作中建议用 SortedSet 存原始数据(按时间或 ID 排序),再用 GETRANGE 类似逻辑分段读取;或者用 String 缓存序列化后的 List<T>,但必须带版本号或时间戳校验。
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
- 缓存 key 示例:
user:list:v2:since:1672531200000(按时间戳分段) - 避免用
page当 key 的一部分,改用cursor、min_id、max_created_time - 写入缓存前加
EXPIRE,且过期时间要略大于数据库慢查询阈值(比如设为 90 秒,而非 30 秒) - 如果用
StackExchange.Redis,记得调用Database.StringSetAsync(key, value, expiry)显式传入TimeSpan
StackExchange.Redis 序列化踩坑:不要依赖默认 JsonSerializer
默认序列化器(Newtonsoft.Json 或 System.Text.Json)对 DateTime、decimal、空集合等处理不一致,尤其跨服务部署时容易反序列化失败,报错类似:Cannot deserialize the current JSON array 或 Invalid cast from 'Int64' to 'DateTime'。
更隐蔽的问题是:某些实体含 [JsonIgnore] 或 [DataMember],而 Redis 客户端没走统一序列化管道,导致缓存内容缺失关键字段,分页时数据错位。
- 统一使用
System.Text.Json并配置JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase - 显式指定
JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - 对
DateTime字段,强制用DateTimeKind.Utc入库和序列化,避免时区混淆 - 缓存前先本地
JsonSerializer.Serialize一次验证能否 round-trip,别等从 Redis 取出来才崩
缓存穿透与雪崩:分页场景下最容易被忽略的两个点
用户猛刷 page=9999 或构造超大 cursor 值,后端直接透传到 DB 查空结果,缓存里还存了个空集合 —— 下次请求照样打库。这是典型的穿透;而大量分页 key 同时过期(比如凌晨批量刷新),所有请求瞬间涌向数据库,就是雪崩。
分页场景特殊在于:“无效页”比普通接口更多见,且前端常自动预加载下一页,导致无效请求密度更高。
- 对空结果也缓存,但用短过期时间(如 2 秒)+ 单独 key 后缀(
:empty),防止污染正常数据 - 用布隆过滤器(
BloomFilter)预判游标是否可能有效,但注意它只适合“ID 类型游标”,时间范围类不适用 - 给分页缓存加随机过期偏移(
expiry.AddSeconds(new Random().Next(0, 30))),避免集体失效 - 数据库查不到时,检查游标是否明显越界(如
id > MAX(Id) FROM table),直接返回空并跳过缓存写入

















