Redis List实现Java分页:解决MySQL深分页性能瓶颈
1. 项目概述:为什么用 Redis List 做数据库分页,而不是直接查 MySQL?
“【Redis】利用 Redis List 实现 Java 数据库分页快速查询”——这个标题一出来,很多刚接触高并发场景的开发者第一反应是:分页不是LIMIT offset, size一句话的事?加个索引不就完了?干嘛绕一圈去 Redis?我带过几届后端实习生,几乎人人都踩过这个坑:在用户中心页、订单列表页、消息流接口里,当offset超过 5 万,哪怕有联合索引,MySQL 的执行计划也悄悄从range退化成index,甚至全表扫描。某次压测中,一个SELECT * FROM order WHERE status=1 ORDER BY create_time DESC LIMIT 100000, 20接口,平均响应时间飙到 2.8 秒,QPS 直接掉到 37。而真正让用户感知卡顿的,从来不是第 1 页,而是翻到第 5000 页时那一次“转圈”。
Redis List 在这里不是替代数据库,而是做结果集快照缓存层。它的核心价值在于:把“动态计算偏移量”的压力,从每次请求都走一遍数据库排序+跳过前 N 条,变成“一次性预计算 + O(1) 索引访问”。List 的底层是双向链表(实际在 Redis 3.2+ 中,小 List 会用压缩列表 ziplist 存储,内存更省),LRANGE key start stop命令的时间复杂度是 O(S+N),S 是起始位置偏移,N 是返回元素个数——注意,它不依赖总长度,只跟你要取的范围有关。这意味着,哪怕你缓存了 100 万条 ID,取第 999980~999999 条,耗时依然稳定在亚毫秒级。这和 MySQL 的OFFSET本质不同:MySQL 必须真实跳过前 999980 行,无论你是否需要它们。
这个方案特别适合三类场景:一是数据变更频率低但读取极高的列表(如商品分类导航、热门文章榜单);二是分页参数固定、用户不会无限制深翻(比如只允许翻到第 100 页);三是业务能接受“最终一致性”——即缓存更新有秒级延迟,但用户感知不到数据错乱。它不适合实时聊天记录这种每秒写入上百条、且必须严格保序的场景,也不适合用户频繁修改状态导致列表顺序剧烈震荡的业务(比如秒杀库存实时刷新)。关键词“Redis List”“Java 分页”“快速查询”背后,其实是对读写分离粒度、缓存更新策略、一致性边界的一次综合权衡。如果你正在为分页慢发愁,又不想上 Elasticsearch 或 ClickHouse 这类重型组件,这个方案就是一把趁手的“瑞士军刀”——轻、快、可控,而且 Java 生态里 Spring Data Redis 几行代码就能跑起来。
2. 整体设计思路与方案选型逻辑
2.1 为什么选 List,而不是 Sorted Set 或 Hash?
刚接触这个需求时,很多人会纠结:Sorted Set 不是天然支持按 score 排序+范围查询吗?Hash 不是能存结构化数据吗?我们来逐一对比:
Sorted Set(ZSET):确实支持
ZRANGEBYSCORE和ZRANGE,能按分数排序并分页。但它有个硬伤:内存开销大。每个 member 都要额外存储一个 double 类型的 score,且内部用跳表实现,内存占用是 List 的 2~3 倍。实测过:存 100 万个 Long 类型 ID,List 占用约 18MB,ZSET 却要 42MB。对于内存敏感的 Redis 实例(比如单机 8GB),这直接限制了可缓存的数据量。另外,ZSET 的ZRANGE虽然快,但如果你的排序规则是“最新创建优先”,那每次插入都要计算System.currentTimeMillis()作为 score,而毫秒级时间戳在高并发下极易重复,还得加随机后缀防碰撞,逻辑变重。Hash:适合存单条记录的详情(如
HSET user:1001 name "张三" age 28),但不支持原生分页。你想取第 1000~1019 条?得先HGETALL全量拉出来再内存分页,这违背了“减少网络传输+降低应用层压力”的初衷。Hash 更适合作为“二级缓存”,配合 List 使用(List 存 ID,Hash 存 ID 对应的完整对象)。List:优势非常清晰:内存最省、API 最直白、语义最贴合。“列表”这个概念本身就在表达“有序序列”,
LPUSH/LRANGE的操作意图一目了然。它的局限也很明确:不能按条件过滤(比如只取 status=1 的数据),不能反向遍历(除非用LREVRANGE,但语义易混淆)。所以我们的设计必须扬长避短——List 只负责存“已筛选、已排序后的 ID 序列”,筛选和排序逻辑下沉到数据库或应用层预处理。
提示:不要试图让 Redis 承担 SQL 的功能。List 就是快递柜里的格子,编号 001、002、003……你往里放什么、按什么顺序放,是上游系统决定的。Redis 只保证你按编号取件时又快又准。
2.2 缓存更新策略:全量重建 vs 增量更新,怎么选?
这是落地时最常被问的问题。答案很实在:看数据变更频率和业务容忍度。
全量重建(推荐新手首选):定时任务(比如每 5 分钟)或监听数据库 binlog,触发一次完整的 SQL 查询,把最新结果集 ID 全量写入 Redis List。优点是逻辑简单、绝对一致、不易出错;缺点是更新期间可能有短暂空白期(List 被清空到写入完成),以及如果数据量极大(比如千万级),一次
LPUSH可能阻塞 Redis 主线程。我们实测过:100 万 ID 全量写入,用LPUSH key id1 id2 ... id1000批量命令,耗时约 320ms;若拆成单条LPUSH,耗时飙升至 8.6 秒。所以必须用 pipeline 批量。增量更新(适合高频更新场景):监听数据变更事件(如新订单创建、文章发布),只
LPUSH新增 ID 到 List 头部,并定期用LTRIM控制长度(如LTRIM key 0 99999保留最新 10 万条)。优点是实时性高、无空白期;缺点是逻辑复杂——要处理删除、修改导致的顺序变化(比如一篇热文被编辑后权重提升,该插到哪儿?),容易出现 ID 重复或漏掉。某次线上事故就是因为没处理好“文章取消置顶”事件,导致 List 里同一 ID 出现两次,分页时数据错乱。
我们团队最终采用的是混合策略:主列表用全量重建(保障基线正确),另起一个轻量级增量队列(用另一个 List)做热点补充。比如首页推荐列表,每 10 分钟全量刷一次,同时对点击率 > 95 分位的文章,实时LPUSH到hot_list,前端分页时优先查hot_list,未命中再 fallback 到主列表。这样既稳又快。
2.3 分页参数设计:为什么不用 offset,而用 cursor?
传统page=500&size=20的方式,在 Redis List 场景下其实埋了雷。问题出在offset的计算上:第 500 页的start = (500-1)*20 = 9990,这没问题;但如果用户中途刷新页面,或者网络抖动导致请求重发,offset值不变,但 List 内容可能已被更新(比如全量重建刚完成),这时取出来的数据就和上次不一致,用户会感觉“列表跳动”。更严重的是,如果用户手动改 URL 把page=500改成page=1000,而 List 总长才 10 万,LRANGE key 19980 19999就会返回空数组,前端展示一片空白,体验极差。
解决方案是游标分页(Cursor-based Pagination):不传page,而传上一页最后一条的 ID(或 List 索引)。例如,第一次请求GET /api/list?size=20,后端返回数据 +next_cursor=10023(即第 20 条的 ID);第二次请求GET /api/list?size=20&cursor=10023,后端用LPOS key 10023找到该 ID 在 List 中的位置pos,然后LRANGE key pos+1 pos+20。这样即使 List 更新,只要cursor对应的 ID 还在,后续数据就连续。当然,LPOS是 Redis 6.0+ 才有的命令,老版本可以用LRANGE key 0 -1全量扫,但性能差;我们选择升级 Redis 版本,因为LPOS的平均时间复杂度是 O(N),但实际测试中,对 100 万数据的 List,查找一个存在的 ID 平均只要 1.2ms。
注意:游标分页要求 List 中的 ID 必须唯一且稳定。如果业务用 UUID 或雪花 ID,天然满足;如果用自增主键,要确保删除数据时 List 里也同步清理(用
LREM key 0 id),否则LPOS会找到已删除的旧 ID。
3. 核心细节解析与 Java 实操要点
3.1 Redis List 结构设计:存 ID 还是存完整 JSON?
这是第一个实操决策点。我们对比了两种方案:
方案 A:List 存 Long 类型 ID(推荐)
LPUSH article_list 1001 1002 1003 ...
优点:内存极致节省(每个 ID 约 8 字节),序列化/反序列化零成本,LRANGE返回的就是List<Long>,Java 侧直接stream().map(id -> articleMapper.selectById(id))查库。缺点:多一次数据库查询,但可通过批量查优化(SELECT * FROM article WHERE id IN (1001,1002,...))。方案 B:List 存 JSON 字符串
LPUSH article_list "{\"id\":1001,\"title\":\"...\"}"
优点:一次取完,免查库;缺点:内存暴涨(JSON 字符串比 Long 多 10~20 倍空间),序列化耗 CPU,且一旦 Article 对象加字段,旧缓存 JSON 就无法反序列化,兼容性差。
我们选了方案 A。理由很实际:数据库查询可以走连接池+预编译,而 Redis 内存是硬成本。实测数据:100 万条记录,方案 A Redis 占用 21MB,方案 B 占用 380MB。后者直接让我们的 4GB Redis 实例告警。更重要的是,方案 A 让缓存和数据库解耦——List 只管顺序,内容由 DB 保证权威性,更新时只需刷新 List,不用碰 JSON 结构。
3.2 Java 代码关键实现:Spring Data Redis 的正确用法
别直接用RedisTemplate.opsForList().range(key, start, end)!这是新手最容易踩的坑。原因有二:一是它返回List<Object>,你需要手动强转,类型不安全;二是它不支持 pipeline 批量操作,高并发下性能差。正确的姿势是:
// 1. 定义专用的 Redis 操作 Bean(避免泛型擦除) @Bean public RedisTemplate<String, Long> longRedisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Long> template = new RedisTemplate<>(); template.setConnectionFactory(factory); template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); // 注意:这里存 Long,用 LongRedisSerializer 更省,但 Spring 默认没提供,我们自定义了一个 return template; } // 2. 核心分页方法(带 pipeline 优化) public List<Article> getArticlePage(String listKey, long cursorId, int size) { RedisCallback<List<Long>> callback = connection -> { // Step 1: 用 pipeline 批量执行,减少网络往返 List<Object> results = connection.eval( // Lua 脚本原子执行:先找 cursor 位置,再取 range "local pos = redis.call('LPOS', KEYS[1], ARGV[1]); " + "if not pos then return {} end; " + "return redis.call('LRANGE', KEYS[1], pos+1, pos+" + size + ")", Collections.singletonList(listKey), Collections.singletonList(String.valueOf(cursorId)) ); // Step 2: 将结果转为 List<Long> return ((List<byte[]>) results).stream() .map(bytes -> Long.parseLong(new String(bytes))) .collect(Collectors.toList()); }; List<Long> ids = longRedisTemplate.execute(callback); // Step 3: 批量查库(MyBatis Plus 示例) if (ids.isEmpty()) return Collections.emptyList(); return articleMapper.selectBatchIds(ids); // 自动拼 IN 查询 }这段代码的关键点:
- Lua 脚本保证原子性:
LPOS和LRANGE在一个脚本里执行,避免中间 List 被修改导致位置错乱; - Pipeline 替代多次 round-trip:虽然这里只用了一次
eval,但如果是多 key 操作,一定要用RedisConnection#pipelined(); - 类型安全:
longRedisTemplate明确泛型,避免运行时 ClassCastException。
3.3 缓存穿透防护:空值怎么存?
如果用户恶意请求cursor=999999999,而这个 ID 根本不在 List 中,LPOS返回 nil,Lua 脚本返回空数组,后端查库IN ( )报错。更糟的是,如果这个 ID 对应的 Article 本身在 DB 里就不存在,就会击穿缓存,大量请求打到 DB。标准解法是缓存空值:当LPOS找不到时,SET cache_miss:article_list:999999999 "null" EX 60,设 60 秒过期。但要注意,cache_misskey 不能滥用,否则 Redis 里全是垃圾 key。我们的做法是:只对cursor参数做布隆过滤器(Bloom Filter)前置校验。用 RedisBloom 模块(BF.ADD/BF.EXISTS),内存占用极小(100 万 ID 只需 2MB),误判率控制在 0.1%。伪代码:
if (!bloomFilter.exists("article_list_bf", String.valueOf(cursorId))) { return Collections.emptyList(); // 直接返回空,不查 Redis 不查 DB } // 否则继续走 LPOS 流程4. 实操过程与核心环节实现
4.1 从零搭建:环境准备与依赖配置
第一步永远是环境。我们用的是 Spring Boot 2.7.x + Redis 7.0(Docker 部署),关键依赖如下:
<!-- pom.xml --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- RedisBloom 模块(需 Redis 服务器加载) --> <dependency> <groupId>com.redislabs</groupId> <artifactId>jredisbloom</artifactId> <version>2.1.0</version> </dependency> <!-- 如果用 MyBatis Plus --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency>Redis 配置application.yml:
spring: redis: host: localhost port: 6379 database: 0 lettuce: pool: max-active: 20 max-idle: 10 min-idle: 0 max-wait: 10000 # 自定义 Redis Template 配置(见上文)Docker 启动 Redis(含 RedisBloom):
docker run -d \ --name redis-bloom \ -p 6379:6379 \ -v /path/to/redis.conf:/usr/local/etc/redis/redis.conf \ redislabs/rebloom:latest \ redis-server /usr/local/etc/redis/redis.conf \ --loadmodule /usr/lib/redis/modules/redisbloom.so注意:RedisBloom 模块必须在启动时通过
--loadmodule加载,不能运行时MODULE LOAD,否则 Spring Boot 启动会报错。我们吃过亏——测试环境忘了加参数,应用启动卡在 Redis 连接,日志里全是Cannot connect to Redis,排查了 2 小时才发现是模块没加载。
4.2 全量构建脚本:如何高效写入百万级 List
核心是批量 + pipeline + 分片。假设我们要把 MySQL 里最新的 100 万篇文章 ID 写入article_list,SQL 是:
SELECT id FROM article WHERE status = 1 AND create_time > '2023-01-01' ORDER BY create_time DESC LIMIT 1000000;Java 侧不能jdbcTemplate.queryForList(sql)一次性拉 100 万条到内存(OOM 风险),要用流式查询(Streaming Result):
// MyBatis Plus 的流式查询(需配置 fetchSize) QueryWrapper<Article> wrapper = new QueryWrapper<>(); wrapper.eq("status", 1) .gt("create_time", "2023-01-01") .orderByDesc("create_time"); // 设置 fetchSize 为 Integer.MIN_VALUE,启用流式 List<Long> ids = articleMapper.selectObjs(wrapper) .stream() .map(obj -> ((BigInteger) obj).longValue()) .limit(1000000) .collect(Collectors.toList()); // 分批写入 Redis(每批 1000 个 ID) int batchSize = 1000; for (int i = 0; i < ids.size(); i += batchSize) { int end = Math.min(i + batchSize, ids.size()); List<Long> batch = ids.subList(i, end); // 使用 pipeline 批量 LPUSH longRedisTemplate.executePipelined((RedisCallback<Object>) connection -> { for (Long id : batch) { connection.lPush("article_list".getBytes(), String.valueOf(id).getBytes()); } return null; }); } // 最后裁剪长度 longRedisTemplate.opsForList().trim("article_list", 0, 999999);实测数据:100 万 ID,分 1000 批,每批 pipeline 1000 次LPUSH,总耗时 1.8 秒。如果不用 pipeline,单条LPUSH,耗时 12.4 秒。这就是为什么强调“批量”——网络 IO 是最大瓶颈。
4.3 游标分页接口实现:从 Controller 到 Service 的完整链路
Controller 层要干净,只做参数校验和 DTO 转换:
@RestController @RequestMapping("/api/article") public class ArticleController { @GetMapping("/list") public ResponseEntity<PageResult<Article>> list( @RequestParam(defaultValue = "0") long cursor, @RequestParam(defaultValue = "20") int size) { // 校验参数 if (size <= 0 || size > 100) { throw new IllegalArgumentException("size must be between 1 and 100"); } if (cursor < 0) { throw new IllegalArgumentException("cursor must be >= 0"); } PageResult<Article> result = articleService.getPage(cursor, size); return ResponseEntity.ok(result); } }Service 层是核心,包含缓存查询、DB 回源、空值处理:
@Service public class ArticleServiceImpl implements ArticleService { @Override public PageResult<Article> getPage(long cursor, int size) { // Step 1: 布隆过滤器快速拦截 if (cursor != 0 && !bloomFilter.exists("article_list_bf", String.valueOf(cursor))) { return PageResult.empty(size); } // Step 2: Lua 脚本原子查询 List<Long> ids = getIdsByCursor("article_list", cursor, size); // Step 3: 批量查库 List<Article> articles; if (ids.isEmpty()) { articles = Collections.emptyList(); } else { articles = articleMapper.selectBatchIds(ids); // Step 4: 补全缺失 ID(应对 DB 删除但 List 未清理的情况) if (articles.size() < ids.size()) { Set<Long> foundIds = articles.stream().map(Article::getId).collect(Collectors.toSet()); List<Long> missingIds = ids.stream() .filter(id -> !foundIds.contains(id)) .collect(Collectors.toList()); // 异步清理 List 中的 missingIds(用 LREM) asyncCleanList("article_list", missingIds); } } // Step 5: 构建分页结果(next_cursor 为最后一条 ID) long nextCursor = articles.isEmpty() ? 0 : articles.get(articles.size() - 1).getId(); return new PageResult<>(articles, size, nextCursor); } private List<Long> getIdsByCursor(String listKey, long cursor, int size) { // Lua 脚本同上,此处省略 } }PageResult是自定义分页 DTO:
public class PageResult<T> { private List<T> data; private int size; private long nextCursor; // 下一页游标 public static <T> PageResult<T> empty(int size) { return new PageResult<>(Collections.emptyList(), size, 0); } // getter/setter... }前端调用示例(JavaScript):
let cursor = 0; async function loadMore() { const res = await fetch(`/api/article/list?cursor=${cursor}&size=20`); const data = await res.json(); renderList(data.data); cursor = data.nextCursor; // 更新游标 if (cursor === 0) { console.log("no more data"); } }4.4 缓存更新自动化:基于 XXL-JOB 的定时重建任务
我们用 XXL-JOB 做分布式任务调度,每天凌晨 2 点触发全量重建:
@XxlJob("articleListRefreshJob") public void execute() throws Exception { XxlJobLogger.log("开始刷新 article_list 缓存..."); // 1. 获取最新 ID 列表(流式查询) List<Long> newIds = articleMapper.getLatestArticleIds(1000000); // 2. 原子性替换:先写新 key,再 rename String tempKey = "article_list_temp_" + System.currentTimeMillis(); writeListToRedis(tempKey, newIds); // 3. 原子 rename(Redis 的 RENAME 是原子的) longRedisTemplate.rename(tempKey, "article_list"); // 4. 更新布隆过滤器 bloomFilter.add("article_list_bf", newIds.stream().map(String::valueOf).toArray(String[]::new)); XxlJobLogger.log("article_list 缓存刷新完成,共 " + newIds.size() + " 条"); }关键技巧:用RENAME替代DEL + LPUSH。DEL之后到LPUSH完成之间,List 是空的,所有请求都会击穿。而RENAME是原子的,新 key 写完瞬间切换,业务无感。我们还给临时 key 加了时间戳后缀,避免并发任务冲突。
5. 常见问题与排查技巧实录
5.1 典型问题速查表
| 问题现象 | 可能原因 | 排查命令 | 解决方案 |
|---|---|---|---|
| 分页返回空数组,但 DB 有数据 | cursorID 不在 List 中,且布隆过滤器误判 | BF.EXISTS article_list_bf 12345 | 检查布隆过滤器容量和误判率,扩容或重建 |
LRANGE返回数据量少于预期 | List 长度不足,或LTRIM裁剪过度 | LLEN article_list | 检查全量构建脚本的LIMIT和LTRIM参数 |
| 接口响应时间突增(>500ms) | Redis 连接池耗尽,或 pipeline 批量过大 | redis-cli info clients | grep connected_clients | 调大max-active,或减小 batch size |
| 同一 ID 在 List 中出现多次 | 增量更新时未去重,或全量构建 SQL 有GROUP BY错误 | LPOS article_list 12345(多次执行) | 在写入前用HashSet去重,或检查 SQL 逻辑 |
应用启动报Cannot connect to Redis | RedisBloom 模块未加载 | redis-cli MODULE LIST | 检查 Docker 启动参数,确认--loadmodule |
5.2 我踩过的三个坑,现在告诉你怎么绕开
坑一:LPOS在 Redis 6.0 以下版本不可用,自己实现性能爆炸
我们最初在 Redis 5.0 上用 Java 侧LRANGE key 0 -1全量拉取再遍历找 cursor,100 万数据平均耗时 380ms。后来发现LINDEX配合二分查找更快:先LLEN得总长,再LINDEX key mid拿中间元素比较,O(log N)。但代码复杂,且不如升级 Redis 一劳永逸。建议:直接升到 Redis 6.0+,LPOS是官方为游标分页量身定制的命令。
坑二:LPUSH批量时内存溢出(OOM)
用jdbcTemplate.queryForList()拉 100 万 ID 到 JVM 内存,GC 频繁。改成流式查询后,又遇到PreparedStatement的fetchSize不生效——因为 MySQL 驱动默认关闭流式,需在 JDBC URL 加参数:?useCursorFetch=true&defaultFetchSize=1000。记住:流式查询 = JDBC 参数 + MyBatis 配置 + 代码里用Stream处理,三者缺一不可。
坑三:布隆过滤器重建时,旧过滤器还在拦截
XXL-JOB 任务执行中,新布隆过滤器还没写完,旧的还在用,导致部分有效cursor被拦截。解决方案是双写 + 原子切换:任务先写新 BF key(article_list_bf_v2),等写完再RENAME article_list_bf_v2 article_list_bf。应用层用RedisTemplate的rename方法,确保原子性。
5.3 性能压测实录:QPS 从 37 到 1200 的跨越
我们用 JMeter 对比了三种方案:
- 方案 A:纯 MySQL
LIMIT offset,size(无索引优化)→ QPS 37,95% 延迟 2800ms - 方案 B:MySQL + 二级索引 → QPS 210,95% 延迟 420ms
- 方案 C:Redis List 游标分页 → QPS 1200,95% 延迟 12ms
压测配置:100 并发线程,循环 1000 次,size=20,cursor随机取 List 中间位置。关键指标:
- Redis CPU 使用率峰值 32%,远低于 70% 预警线;
- MySQL CPU 从 95% 降到 18%,连接数稳定在 25;
- 应用服务器 GC 次数减少 65%(因减少了大 List 对象创建)。
最惊喜的是深分页稳定性:当cursor指向 List 第 90 万条时,方案 C 的 P99 延迟仍是 15ms,而方案 B 已飙升至 1200ms。这验证了核心论点:Redis List 的LRANGE时间复杂度不随总长度增长,是真正的“深分页友好型”。
6. 扩展思考:这个方案还能怎么玩?
做到这一步,你已经掌握了 Redis List 分页的核心。但技术的价值在于延伸。我们团队后续做了三件事,让这个方案不止于“快”,更变得“智能”:
第一,结合 Redis Streams 做变更通知。List 是静态快照,但业务需要知道“什么时候该刷新”。我们用 Redis Streams 当消息总线:当文章发布、编辑、下架时,发一条消息到article_stream,消费端监听后触发 List 的局部更新(比如LREM删除下架 ID,LPUSH插入新热文)。这样全量重建从每天 1 次,变成“按需增量”,缓存新鲜度从小时级提升到秒级。
第二,用 Redis Graph 做关系分页。List 只解决线性列表,但有些场景是“关注的人发布的文章”。我们把用户关注关系存在 Redis Graph 里(GRAPH.QUERY social "MATCH (u:User)-[f:FOLLOWS]->(t:User) WHERE u.id=123 RETURN t.id"),再把查出的t.id作为输入,去article_list分页。Graph 查关系快,List 查内容快,组合起来就是“社交分页”的最优解。
第三,客户端缓存协同。前端 Vue 项目里,我们把article_list的当前长度(LLEN)和最后更新时间(用GETEX list_updated_at存)一起返回。前端用localStorage缓存这个元数据,下次请求前先比对,如果长度和时间没变,直接用本地缓存的 ID 列表发起LRANGE,连网络请求都省了。实测首屏加载速度提升 40%。
这些都不是必需的,但当你把一个基础方案吃透后,自然会看到它能生长的边界。就像种一棵树,List 是主干,其他技术是枝叶——主干扎实了,枝叶才能茂盛。我自己在实际使用中发现,最值得花时间打磨的,从来不是代码本身,而是对业务场景的精准判断:什么时候该用全量,什么时候必须增量;哪些数据能容忍秒级延迟,哪些必须强一致。技术是工具,而工具的价值,永远由使用者的判断力决定。