分类树查询性能优化:从 2 秒到 0.1 秒的技术蜕变之路
在电商系统中,分类树查询是一个基础且高频的功能,然而这个看似简单的功能背后却隐藏着不小的性能挑战。本文将分享我们在实际项目中对分类树查询功能进行五次优化的全过程,看如何将查询耗时从 2 秒缩短至 0.1 秒,为用户提供更流畅的体验。
一、初始版本:从数据库直接查询
我们的项目采用 Spring Boot 框架,前端使用 Thymeleaf 模板引擎进行动态渲染。在项目初期,为了快速开发功能,分类树查询接口直接从数据库中查询分类数据,组装成分类树后返回给前端。
这种简单直接的方式虽然快速实现了功能,但随着分类数据的不断增加,性能问题很快暴露出来。在开发环境中,当分类数量较多时,接口响应时间逐渐变长,甚至达到了 2 秒,严重影响了用户体验。
二、第一次优化:引入 Redis 缓存
面对性能瓶颈,我们首先想到的是添加 Redis 缓存。优化后的流程如下:
- 用户访问接口获取分类树时,先从 Redis 中查询数据。
- 如果 Redis 中有数据,直接返回。
- 如果 Redis 中没有数据,从数据库中查询数据,拼接成分类树返回。
- 将从数据库中查到的分类树数据保存到 Redis 中,设置过期时间为 5 分钟。
通过这种方式,大部分请求可以直接从 Redis 中获取数据,减少了数据库的访问压力。经过测试,开发环境的接口响应时间得到了明显改善,联调和自测顺利完成。
三、第二次优化:异步定期更新缓存
将功能部署到测试环境后,初期测试没有发现问题,但随着测试的深入,隔一段时间就会出现首页访问很慢的情况。分析发现,当 Redis 缓存过期时,大量请求同时访问数据库,导致数据库压力过大,从而影响了性能。
为了解决这个问题,我们决定使用 Job 定期异步更新分类树到 Redis 中。具体优化措施如下:
- 增加一个 Job,每隔 5 分钟执行一次,从数据库中查询分类数据,封装成分类树,更新到 Redis 缓存中。
- 保留原来的分类树同步写入 Redis 的逻辑,以防止 Redis 突然挂掉。
- 将 Redis 的过期时间改为永久。
这次优化后,测试环境再也没有出现分类树查询的性能问题。
四、第三次优化:添加内存缓存
在网站即将上线前,我们对首页进行了压力测试,发现最大 QPS 只有 100 多,性能瓶颈依然存在。经过分析,我们发现每次都从 Redis 获取分类树是导致性能问题的主要原因。
于是,我们决定添加内存缓存。考虑到分类数据更新频率较低,即使不同服务器节点的内存缓存数据存在短暂的不一致,也不会对用户造成太大影响,因此选择使用 Spring 推荐的 Caffeine 作为内存缓存。优化后的流程如下:
- 用户访问接口时,先从本地内存缓存中查询分类树数据。
- 如果本地缓存有数据,直接返回。
- 如果本地缓存没有数据,从 Redis 中查询数据。
- 如果 Redis 中有数据,将数据更新到本地缓存中,然后返回。
- 如果 Redis 中也没有数据(说明 Redis 挂了),从数据库中查询数据,更新到 Redis 中,然后更新到本地缓存中,返回数据。
- 设置本地缓存的过期时间为 5 分钟,以便获取新的数据。
这次优化效果显著,再次进行压力测试时,QPS 提升到了 500 多,满足了上线要求。
五、第四次优化:开启 GZip 压缩
使用了很长一段时间都没有出现问题。但两年后的一天,有用户反馈网站首页有点慢。经过排查,我们发现分类树的数据已经增加到了上万个,一次性返回的数据量太大,导致网络传输耗时较长。
针对这个问题,我们想到了开启 Nginx 的 GZip 功能,让数据在传输之前先进行压缩。之前调用接口返回的分类树数据大小为 1MB,开启 GZip 压缩后,数据大小缩小到了 100KB,一下子缩小了 10 倍,性能得到了明显提升。
六、第五次优化:优化 Redis 存储
在一次 Redis 大 key 排查中,分类树数据被揪了出来。原来,我们一直使用简单的 key/value 结构在 Redis 中保存分类树数据,随着分类数量的增加,这个 value 变得越来越大,成为了 Redis 中的大 key,影响了 Redis 的性能。
为了解决这个问题,我们从以下几个方面进行了优化:
- 数据瘦身:只保存需要用到的字段,去除了如 inDate、inUserId 和 inUserName 等不必要的字段。
- 修改字段名称:在 JSON 序列化时,将字段名称改为简短的名称,减少数据量。例如,将 id 改为 i,name 改为 n 等。
- 数据压缩:使用 GZip 工具类将 JSON 字符串压缩成 byte 数组,然后保存到 Redis 中。获取数据时,再将 byte 数组解压并转换成 JSON 字符串。
例如
@AllArgsConstructor
@Data
public class Category {private Long id;private String name;private Long parentId;private Date inDate;private Long inUserId;private String inUserName;private List<Category> children;
}
例如
@AllArgsConstructor
@Data
public class Category {/*** 分类编号*/@JsonProperty("i")private Long id;/*** 分类层级*/@JsonProperty("l")private Integer level;/*** 分类名称*/@JsonProperty("n")private String name;/*** 父分类编号*/@JsonProperty("p")private Long parentId;/*** 子分类列表*/@JsonProperty("c")private List<Category> children;
}
经过这些优化,保存到 Redis 中的分类树数据大小减少了 10 倍,成功解决了 Redis 的大 key 问题。
七、优化成果总结
通过这五次优化,分类树查询的性能得到了显著提升:
- 初始版本:响应时间约 2 秒
- 最终版本:响应时间约 0.1 秒
- QPS 从 100 多提升到 500 多
- 数据传输量从 1MB 减少到 100KB 左右
- Redis 中的数据存储量减少了 10 倍
八、经验启示
- 性能优化是一个持续的过程:随着业务的发展和数据量的增加,原来的优化措施可能会逐渐失效,需要持续关注性能问题并进行优化。
- 缓存策略的选择很重要:根据数据的特点和业务需求,选择合适的缓存策略,如 Redis 缓存、内存缓存等,并合理设置缓存过期时间。
- 数据压缩不容忽视:在数据传输和存储过程中,合理使用数据压缩技术可以有效减少数据量,提高性能。
- 数据库操作要谨慎:数据库是系统的瓶颈之一,应尽量减少对数据库的访问,避免大量并发请求同时访问数据库。
- 代码优化细节决定成败:在实际开发中,一些看似微小的优化,如字段名称的简化、不必要字段的去除等,累积起来也能带来显著的性能提升。