ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot集成Redis实现图书分页缓存:ZSet实战指南

SpringBoot集成Redis实现图书分页缓存:ZSet实战指南 做图书购买系统的时候图书列表分页和Redis缓存几乎是绕不开的两个词。我这次在SpringBoot项目里把图书数据塞进Redis并且让前端以分页的形式展示出来踩了不少坑——从Redis安装配置、key序列化乱码到MyBatis-Plus分页插件突然失效、Redis非分页缓冲池内存暴涨一路排查下来对配置到前后端交互这条完整链路有了很深的体会。这篇文章把整条实现路径完整梳理一遍包括数据结构的选型逻辑、缓存预热方案、ZSet分页的核心命令与代码实现、接口封装以及Vue前端对接分页组件的全过程。适合正在做SpringBoot购物类项目、想用Redis做列表缓存分页、或者被Redis乱码、分页失效这类问题折磨过的同学参考。1. 为什么要用Redis做图书分页业务场景与选型思考1.1 图书购买系统里列表查询到底有多痛图书购买系统虽然比不上一线电商平台那种动不动百万并发的规模但图书列表页依然是用户访问最密集的入口。用户打开首页要看热门书籍点进分类要看某个分类下的书籍列表搜索关键词要看搜索结果页——几乎每一次操作都会触达图书列表的查询。最开始我直接用MySQL分页LIMIT offset, size语法简单、开发效率也高。但数据量上来之后问题就出现了图书表几十万条记录只是起步加上多条件筛选分类、出版社、价格区间、评分排序SQL会变得非常复杂。每次用户翻页都要完整走一遍索引扫描和排序数据库的连接数被频繁打满高峰期接口响应从几十毫秒飙升到两三秒。更重要的是图书列表有一个特点热门榜单、新品推荐、分类列表这些数据的实时变化并不频繁。一本书上架后它的书名、作者、价格、封面可能一个月都不会变只有销量和评分会波动。这种读多写少、短暂延迟可以接受的数据非常适合放到Redis里做缓存。1.2 为什么缓存分页必须一起设计很多人的第一反应是我只要给MySQL加一层缓存把查询结果缓存住不就好了比如用商品ID作为key缓存单个图书信息分页还是走MySQL——这种方案能缓解一部分压力但治标不治本。因为分页查询的SQL组合太多第1页、第2页、第5页按销量排、按价格排、按上架时间排……如果以整个分页请求URL作为缓存key那同一份图书数据会被缓存成几十种不同的key缓存命中率很低数据一致性维护也很痛苦。而且当图书的销量变化时所有涉及这个图书的分页缓存全部要失效缓存重建的瞬间DB压力又上来了。所以我在这次项目里换了一个思路把经过排序的图书ID集合直接放进Redis分页读取在Redis中完成MySQL只负责全量数据的存取和更新。这样列表页的每次翻页都是一次内存操作速度极快而且所有分页共用同一份缓存数据不会有同一份数据在多个缓存key里不一致的问题。1.3 Redis里适合做分页的数据结构选型对比Redis提供了丰富的数据结构但并不是每一种都适合做分页。我在做技术选型的时候把几种常用方案放在一起对比了一轮。数据结构分页实现方式优点缺点是否适合图书列表分页String拼接JSON字符串整体存取简单粗暴无法部分读取更新成本高否ListLRANGE key start stop分页效率高支持倒序只能按插入顺序无法按业务字段排序部分适合HashHSCAN配合手动计算适合存储实体字段分页需要借助游标逻辑复杂否SetSSCAN去重无序分页无意义否ZSetZRANGE / ZREVRANGE key start stop按score排序天然支持分页需要合理设计score非常适合最终我选择了ZSet。原因是图书列表几乎都存在按某个指标排序的需求热门图书按销量排、最新上架按时间排、价格区间按价格排这些都可以映射到ZSet的score上。ZSet底层使用跳跃表实现ZRANGE按排名区间取数据的时间复杂度是O(log N M)即使集合里有几十万条记录取一页数据也很快。2. 环境与项目搭建Redis安装、SpringBoot基础配置、序列化策略2.1 本机Redis安装与可视化客户端选择我在Windows开发机上做本地调试所以先用的是Windows版本Redis。Redis官方其实不直接提供Windows安装包我用的开源移植版本版本号选的是稳定分支。解压后直接运行redis-server.exe即可启动默认端口6379。生产环境用的是Linux服务器上的Redis 6.x安装方式是直接下载官方源码编译配置好requirepass设置访问密码同时把daemonize yes打开让Redis后台运行。这里提醒一句本地开发建议Redis不设密码或设一个简单的但生产环境必须设置强密码并且建议绑定内网IP而不是0.0.0.0。我日常工作用的可视化客户端是Another Redis Desktop Manager方便查看key列表、查看某个zset的全部成员和score、执行Redis命令。排查序列化乱码问题、验证分页数据是否正确它帮了大忙。纯命令行也可以但可视化工具在调试阶段效率高得多。2.2 引入SpringBoot依赖与核心配置项目基于SpringBoot 2.7.14Java版本用的8。在pom.xml中加入Redis相关依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-pool2/artifactId /dependency第一个依赖提供了Spring Data Redis的封装包含Lettuce连接工厂和RedisTemplate等核心类。第二个依赖是连接池生产环境高并发下强烈建议加上否则每个操作都新建连接性能会很难看。然后配置application.ymlspring: redis: host: 127.0.0.1 port: 6379 password: 123456 database: 0 timeout: 5000ms lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2 max-wait: 3000ms这里有个容易被忽略的点SpringBoot 2.x默认使用Lettuce作为Redis客户端而不是Jedis。Lettuce基于Netty连接复用能力更强多线程环境下表现更好所以默认选它是有道理的。如果你之前用过Jedis不用刻意换回来Lettuce在大多数场景下更优。database: 0表示使用默认的0号库。如果你一个Redis实例同时服务多个业务建议为不同业务分配不同的database避免key互相干扰。图书分页相关的缓存我单独放在了2号库防止和会话缓存、验证码缓存混在一起。2.3 自定义RedisTemplate序列化策略这步不能省Spring Data Redis在自动配置时默认使用JdkSerializationRedisSerializer对value进行序列化。这在纯Java环境里没问题但一旦配合可视化工具查看或者让其他语言的服务读取数据就会遇到一串转义字符可读性极差而且序列化后的数据体积很大浪费内存。我在项目里自定义了一个RedisTemplatekey使用StringRedisSerializervalue使用GenericJackson2JsonRedisSerializerConfiguration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); StringRedisSerializer keySerializer new StringRedisSerializer(); GenericJackson2JsonRedisSerializer valueSerializer new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(keySerializer); template.setHashKeySerializer(keySerializer); template.setValueSerializer(valueSerializer); template.setHashValueSerializer(valueSerializer); template.afterPropertiesSet(); return template; } }这步处理直接影响后续分页数据的可读性。否则你在Redis里看到的key是\xac\xed\x00\x05t\x00...这样的二进制乱码看到zset里的member也是乱码排查数据对错会非常崩溃。GenericJackson2JsonRedisSerializer会把对象序列化成JSON字符串同时携带class类型信息反序列化时能还原成原来的Java对象。这里有一个注意点被序列化的对象必须有无参构造函数否则反序列化会报错。3. 图书数据入缓存缓存预热方案与数据结构设计3.1 图书数据模型与Redis键设计图书实体类如下public class Book { private Long id; private String title; private String author; private Double price; private Integer sales; private Long publishTime; private String category; private String coverUrl; // 省略getter/setter }在Redis里我把每本图书的详情存为Hash结构但是参与排序和分页的是ZSet。举一个最常用的场景——热门图书榜键book:rank:sales score图书销量 member图书ID用Redis命令查看就是这个效果ZREVRANGE book:rank:sales 0 9 WITHSCORES类似地如果要做按上架时间最新的分页就可以设计另一个键键book:rank:latest score图书上架时间戳 member图书ID设计键名时用冒号分层业务模块:功能:排序维度这样既清晰又方便统一清理。可视化工具里看的时候也能按前缀搜索。3.2 缓存预热别让第一个用户成为受害者如果缓存是空的第一个用户访问热门图书列表时程序需要去MySQL查数据再写入Redis这个过程耗时几百毫秒用户看到的就是一个白屏转圈。这就是缓存击穿的一种体现。所以我在项目启动并完成数据库初始化后主动做了一次缓存预热。利用ApplicationRunner接口在SpringBoot启动完成后自动执行Component public class BookCacheWarmer implements ApplicationRunner { Autowired private BookService bookService; Override public void run(ApplicationArguments args) { log.info(开始预热图书排行榜缓存...); bookService.rebuildRankCache(sales); bookService.rebuildRankCache(latest); log.info(图书排行榜缓存预热完成); } }预热的核心逻辑是从MySQL查出全量参与排序的图书按排序维度写入对应的ZSet。每次预热之前先删除旧的key再重新构建保证幂等防止重复数据累积public void rebuildRankCache(String dimension) { String key getRankKey(dimension); stringRedisTemplate.delete(key); ListBook books bookMapper.selectList(null); for (Book book : books) { double score getScoreByDimension(book, dimension); stringRedisTemplate.opsForZSet().add(key, String.valueOf(book.getId()), score); } }如果图书量特别大比如上百万单线程预热会很慢可以考虑用SCAN分批读取MySQL再用pipeline批量写入Redis。本次项目图书量在10万级别单线程全量写入大约两三秒完全能接受。3.3 为什么坚持用ZSet而不是List很多人会问List的LRANGE也能分页为什么我用ZSet我来实际对比一下。List按插入顺序存储如果图书上架时我们用LPUSH把新书插到头部那最新上架这个排序确实可以用List实现。但按销量排序就麻烦了一本书销量增长后它在List中的位置不会自动变化必须把元素删掉重新插入这个操作在List里是O(N)级别的。ZSet则天然支持排序。score就是排序依据销量变了只需要更新scoreRedis底层跳跃表会自动维护顺序。这样无论是新增图书、还是已有图书的销量/价格变化我们只需要做一次ZADD它就会落到正确的位置上分页查询永远拿到最新顺序。这一点的工程价值非常大。另外ZSet还支持范围查询ZCOUNT可以统计某个score区间有多少本书ZRANGEBYSCORE可以取某个score区间内的数据。做价格100到200元的图书分页这类筛选ZSet几乎就是为这种场景准备的。4. Redis分页查询核心实现从命令到Spring Data Redis代码4.1 读懂ZSet分页命令的语义先用Redis命令把ZSet分页的核心语义搞清楚。以按销量排行为例ZREVRANGE book:rank:sales 0 9 WITHSCORES这条命令的意思是从book:rank:sales这个有序集合中按score从大到小排序取排名从0到9的数据即第1页的10条。注意ZSet的排名是从0开始计算的这和Java数组下标一样。ZREVRANGE是倒序取score从大到小ZRANGE是正序取score从小到大。它和MySQL分页的对应关系是MySQLRedis ZSetORDER BY sales DESC LIMIT offset, sizeZREVRANGE key offset offsetsize-1ORDER BY price ASC LIMIT offset, sizeZRANGE key offset offsetsize-1COUNT(*)ZCARD key要判断是否有下一页Redis没有直接提供这样的方法一般是用ZCARD获取总数量然后计算offset size total是否成立。4.2 基于ZSetOperations的Service层实现在SpringBoot中我封装了一个分页查询方法。先放分页结果的统一返回类public class PageResultT { private ListT list; private Long total; private Integer pageNum; private Integer pageSize; private Boolean hasNext; // 构造方法和getter/setter省略 }核心Service实现如下Service public class BookRankService { Autowired private StringRedisTemplate stringRedisTemplate; Autowired private BookMapper bookMapper; private static final String RANK_KEY book:rank:sales; public PageResultBookVO getHotBooks(int pageNum, int pageSize) { // 页码从1开始转换为Redis的start下标 long start (long) (pageNum - 1) * pageSize; long end start pageSize - 1; // 分页取member图书ID列表 SetString bookIds stringRedisTemplate.opsForZSet() .reverseRange(RANK_KEY, start, end); // 获取总数判断是否有下一页 Long total stringRedisTemplate.opsForZSet().zCard(RANK_KEY); boolean hasNext end total - 1; ListBookVO books new ArrayList(); if (bookIds ! null !bookIds.isEmpty()) { for (String bookId : bookIds) { Book book bookMapper.selectById(bookId); if (book ! null) { books.add(convertToVO(book)); } } } PageResultBookVO result new PageResult(); result.setList(books); result.setTotal(total); result.setPageNum(pageNum); result.setPageSize(pageSize); result.setHasNext(hasNext); return result; } }这里有个细节从Redis取回的member是图书ID字符串拿到ID之后还是要回MySQL查详情。小题大做不是的这是典型的索引与数据分离思路ZSet中只存ID不存完整图书信息控制内存占用ID从数据库批量查出详情后还能走MySQL的主键索引一次查询极快。如果一本书删除后没有及时清理Redis中的member这里bookMapper.selectById(bookId)可能返回null所以要做空值判断否则前端分页列表里会出现空位。4.3 大offset优化从跳页到游标上面这种实现有一个隐性瓶颈当用户不断往后翻页比如翻到第100页offset990时ZSet内部的跳跃表需要从链表头开始遍历跳过前990个元素才能取到目标区间。数据量小无所谓但数据量达到百万级时深分页的延迟会明显上升。这跟MySQL深分页LIMIT 1000000, 20慢是同一个道理。图书购买系统的榜单查询通常不会有用户真的翻到几百页但如果你的业务后续扩展到全部图书分页浏览这种场景可以考虑把分页模式从跳页改成游标上次请求返回的最后一本书的score值下一页就用ZREVRANGEBYSCORE按score区间继续往后取代码思路示意// 上一页最后一条的score作为游标 Double lastScore getLastScoreFromPrevPage(); SetString nextPage stringRedisTemplate.opsForZSet() .reverseRangeByScore(RANK_KEY, 0, lastScore - 1, 0, pageSize);游标方式只能一页一页往下走不能随意跳页但它的性能不随页数增加而退化。图书榜单这种场景我给前端同时提供pageNum/pageSize跳页模式和cursor滚动模式根据业务需要二选一。5. 接口层与前端交互返回结构设计、Vue分页组件对接5.1 统一返回结构与RESTful接口设计后端接口不是直接把PageResult丢给前端就行通常外面还要包一层统一的返回结构让前端可以统一处理成功、失败、未登录等状态码。我设计的统一返回体public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.setCode(code); result.setMessage(message); return result; } }Controller层接口RestController RequestMapping(/api/books) public class BookController { Autowired private BookRankService bookRankService; GetMapping(/hot) public ResultPageResultBookVO getHotBooks( RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize) { if (pageNum 1) { pageNum 1; } if (pageSize 1 || pageSize 50) { pageSize 10; } return Result.success(bookRankService.getHotBooks(pageNum, pageSize)); } }接口设计为GET /api/books/hot?pageNum1pageSize10。注意参数校验页码最小是1每页条数限制在50以内。如果不做限制有人传pageSize100000虽然Redis不会崩但返回的数据传输会给网络带宽造成压力前端渲染也会卡死。5.2 前端Vue Element UI分页组件对接前端我用的Vue 2 Element UI分页组件是el-pagination。模板部分template div classbook-list el-table :databookList v-loadingloading el-table-column proptitle label书名/el-table-column el-table-column propauthor label作者/el-table-column el-table-column propprice label价格/el-table-column el-table-column propsales label销量/el-table-column /el-table el-pagination background layouttotal, prev, pager, next :totaltotal :page-sizepageSize :current-page.syncpageNum current-changehandlePageChange /el-pagination /div /template对应的逻辑部分重点在于请求封装和竞态处理export default { data() { return { bookList: [], total: 0, pageNum: 1, pageSize: 10, loading: false, requestSeq: 0 }; }, mounted() { this.fetchBooks(); }, methods: { async fetchBooks() { // 用一个自增序列号防止响应乱序覆盖 const currentSeq this.requestSeq; this.loading true; try { const res await axios.get(/api/books/hot, { params: { pageNum: this.pageNum, pageSize: this.pageSize } }); if (currentSeq ! this.requestSeq) { return; } if (res.data.code 200) { this.bookList res.data.data.list; this.total res.data.data.total; } } finally { this.loading false; } }, handlePageChange() { this.fetchBooks(); } } };这里有一个实战中很容易踩的坑快速翻页时的响应竞态。用户在第1页还没加载完的时候迅速翻到第2页如果第1页的响应比第2页晚到前端会把旧数据展示在当前页面。我用了一个requestSeq自增序号回调时判断当前请求是否还是最新一次不是就直接丢弃这样能保证展示的一定是最后一次请求的结果。5.3 联调阶段的几个小经验联调时最容易出问题的不是接口本身而是参数名约定。后端我用了pageNum/pageSize前端必须完全一致否则会收到默认值用户翻页没有反应。另外建议后端接口无论成功失败都返回HTTP 200并在body中携带业务code只在系统级异常如网络断开、服务宕机时才返回HTTP 500。这样前端的axios拦截器可以统一处理业务错误提示而不是每个请求都去解析HTTP状态码。分页组件的:current-page.sync修饰符要和current-change事件配合好——current-page是当前页码current-change在用户点击页码时触发两个必须绑定同一个数据否则会出现点了第5页但地址栏参数还是第1页的诡异现象。6. 实战中踩过的坑分页失效、序列化乱码、缓冲池占用6.1 MyBatis-Plus分页插件失效的根因做图书后台管理的时候我用MyBatis-Plus作为ORM框架。写第一个分页查询时就遇到了分页插件完全没生效的问题——Page参数传了但SQL打印出来没有LIMIT接口把全表数据都返回了。排查过程是这样的第一步看依赖确认引入了mybatis-plus-boot-starter。没问题。第二步看配置分页插件需要自己声明一个MybatisPlusInterceptor的Bean加上PaginationInnerInterceptor注意是PaginationInnerInterceptor不是PaginationInterceptor后者在MyBatis-Plus 3.5.0以上版本已经废弃。我一开始写的是废弃的那个类自然没生效。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }第三步检查配置类是否被SpringBoot扫描到。我一开始把MybatisPlusConfig放在子包里面没有加MapperScan导致整个配置类没有加载。加上Configuration并且保证它在主启动类的扫描范围内之后分页才正常。这个坑其实和Redis没有直接关系但它提醒了我一件事当你的项目同时用了MyBatis-Plus的分页和Redis的分页时要明确每一层分页发生在哪。我的设计是后台管理系统里图书维护用MyBatis-Plus分页查数据库前台商城列表展示走Redis ZSet分页。两者职责分离不要混在一个接口里否则分页逻辑会互相干扰。6.2 Redis键名出现乱码序列化器没配对用Spring Data Redis写入ZSet之后我在可视化工具里看到的key是这样的\xac\xed\x00\x05t\x00\x0fbook:rank:salesmember显示也是乱码。这个问题的根源很明确我使用了默认的RedisTemplatevalue序列化器是JdkSerializationRedisSerializer它在序列化字符串时会在前面加一段Java序列化的二进制头。解决方法就是前面第2.3节写的那样自定义RedisTemplate统一使用StringRedisSerializer处理key。如果你已经写入了乱码数据记得先删除旧key再重建否则新旧数据会混在一起。排查乱码问题时有一个技巧可以用TYPE book:rank:sales命令查看key的类型再用ZRANGE book:rank:sales 0 5直接看存储的原始内容。如果输出是二进制乱码而不是可读的图书ID那基本可以断定是序列化器配置问题不用再怀疑业务代码。6.3 Redis非分页缓冲池占用很高的问题项目运行一段时间后我发现Redis进程的内存占用增长得很快用redis-cli INFO memory查看used_memory_dataset不算特别大但used_memory_rss进程占用的物理内存很高同时日志里有大量内存碎片告警。再执行redis-cli --bigkeys扫描发现存在几个超大key其中一个ZSet挂了十几万条图书ID数据。正常情况下不行——如果所有图书都在一个key下单key过大写入和读取都会变慢内存碎片率升高最终导致maxmemory触发后出现淘汰抖动。解决方案有两个方向一是给Redis设置内存淘汰策略。在redis.conf中配置maxmemory 512mb maxmemory-policy allkeys-lruallkeys-lru表示所有key按LRU最近最少使用淘汰。图书榜单缓存即使被淘汰了下一次请求时还能从MySQL重新预热所以这种非强一致的数据用LRU策略很合适。二是把单个大ZSet拆分成多个小ZSet。按图书分类拆分热门榜拆成文学类ZSet科技类ZSet少儿类ZSet每个分类的ZSet只维护本分类的图书ID。这样每个key的数据量可控内存碎片问题明显缓解而且还能顺便支持某个分类下按销量分页的需求。6.4 缓存与数据库的一致性提醒Redis分页缓存最容易被忽视的就是数据一致性。图书下架了但ZSet里还留着它的ID图书改价格了但按价格排行的ZSet里它的score还是旧价格——这些都会导致前端展示错误的数据。我的做法是所有图书的写操作新增、修改、删除在事务提交之后同步更新Redis中受影响的ZSet新增图书ZADD加入对应榜单score按初始值如销量为0、上架时间为当前时间戳修改销量/价格ZADD更新对应榜单的score下架图书ZREM从所有榜单中移除该图书ID这套逻辑直接放在Service层保证更新MySQL和更新Redis在一个方法内完成。对于极端并发场景有人会用延迟双删策略先删除缓存再更新数据库过几百毫秒后再删除一次缓存防止缓存里写入脏数据。但在图书这种低并发写场景下同步更新已经够用不需要引入额外的消息队列来处理。最后的实操总结先把数据结构想清楚再动手整个系统完整跑通之后我最深的体会就是一个词结构先行。图书购买系统里列表分页看似简单其实牵扯到缓存选型、Redis数据结构、序列化配置、接口设计、前端联调、一致性维护整整一条链路。如果一开始就把这些问题考虑清楚后面的开发就是一马平川如果跟我一样边做边踩坑后面要付出的debug成本绝对是翻倍的。再分享一个小技巧在动手写代码之前先用redis-cli把核心命令手动敲一遍确认数据写入、分页读取、排序变化都符合预期再写Java代码。Redis的命令是理解Spring Data Redis封装逻辑的钥匙命令行玩明白了ZSetOperations那些方法一看就知道是什么意思。
返回列表