1. 项目概述:为什么一个“买家商品类目”Service层值得单独拆解讲透?
在SpringBoot电商类项目里,“买家商品类目”这个功能模块表面看只是首页顶部一排可点击的导航栏——服装、数码、家居、美妆……但真正跑起来你就会发现,它背后牵扯的远不止前端展示那么简单。我带过好几个团队做中台型电商系统,几乎每次重构都会卡在这个点上:明明数据库就三张表(category、product_category_rel、user_browsing_history),为什么Service层写出来不是性能拉胯,就是扩展性归零?后来我才明白,问题不在于代码量,而在于对“买家视角”这个业务语义的彻底误读。
很多人一上来就照着CRUD模板写:CategoryService接口定义增删改查,ServiceImpl里直接调Mapper。结果上线后,运营反馈“分类页加载慢”,技术排查发现是每次请求都查了5层树形结构+实时统计每个类目的在售商品数+还要过滤掉当前用户所在城市不支持配送的类目——全堆在同一个方法里。这不是Service层,这是“上帝方法”。
真正的买家类目Service,核心要解决三个不可妥协的问题:响应速度必须压到200ms内(用户无感等待)、数据一致性必须保障(比如下架商品后类目计数要秒级更新)、业务规则必须可插拔(今天按销量排序,明天要按新客偏好加权)。这三点决定了它不能是DAO的简单包装,而必须是领域逻辑的编排中枢。标题里强调“Service层的实现”,恰恰说明这不是教你怎么写接口,而是教你如何用SpringBoot的生态能力(@Transactional、@Cacheable、@Async、自定义注解)把业务语义翻译成高可用、易演进的代码结构。如果你正在写电商、内容平台或任何需要“用户视角分类导航”的系统,这篇就是你跳过坑的速查手册。
2. 整体设计思路:拒绝“万能Service”,构建三层职责分离架构
2.1 为什么不用单个CategoryService包打天下?
先说结论:我试过,线上事故两次。第一次是大促期间类目页缓存雪崩,因为所有逻辑(树形查询、计数、权限校验)都耦合在getCategoryTree()里,缓存Key设计成"tree:uid:123"导致用户ID一变就全失效;第二次是接入新渠道时,需要给海外用户返回简化版类目树,结果发现原Service里硬编码了国内城市过滤逻辑,改一处崩十处。
根本原因在于混淆了数据访问、业务编排和场景适配三层职责。SpringBoot的@Service注解不是给你贴“万能胶”的,而是让你明确划清边界。我们最终采用的三层架构如下:
Data Access Layer(DAL):只做纯粹的数据搬运工。CategoryMapper接口只暴露基础SQL操作(如selectById, selectTreeRoots),连JOIN都不允许出现。所有复杂查询(如带商品计数的树)必须封装在独立的CategoryCountMapper里。
Domain Service Layer(DSL):这才是标题里真正的“Service层”。它不碰HTTP请求,也不管前端怎么渲染,只回答一个核心问题:“买家看到的类目数据,应该长什么样?” 它组合DAL的原子能力,注入业务规则(如“新用户默认折叠二级类目”),并承担事务边界(@Transactional只标注在这里)。
Application Service Layer(ASL):对接Controller的门面。它接收Web层参数(如userId、cityCode、deviceType),调用DSL获取数据,再做轻量级转换(如把数据库字段category_name转成前端需要的label)。这里可以放@Cacheable,但绝不放业务逻辑。
提示:很多团队把ASL和DSL合并,美其名曰“简化调用链”。实测下来,当需要给APP端返回扁平化类目列表、给小程序返回带图标URL的类目、给后台管理返回含审核状态的类目时,合并层会迅速膨胀成if-else地狱。三层分离后,ASL只需新增一个AppCategoryService,DSL完全不动。
2.2 核心设计决策背后的硬核考量
(1)树形结构存储选“路径枚举”而非“邻接表”
数据库category表里,我们放弃parent_id外键方式,改用path字段(如"/1/5/23/")。理由很现实:买家类目树深度通常≤4层,但节点数可能超10万。邻接表查一次完整树要N次递归查询,MySQL在500并发下平均耗时380ms;而路径枚举用LIKE "/1/%"一次查出所有子节点,实测稳定在45ms内。虽然插入时要计算path值(用MyBatis的@SelectKey预生成),但买家类目变更频率极低(运营周更),完全值得。
(2)商品计数不走实时SQL,改用“异步双写+本地缓存”
早期方案是getCategoryTree()里嵌套执行“SELECT COUNT(*) FROM product WHERE category_id IN (1,5,23)”,结果大促时DB CPU飙到95%。现在改为:商品上下架事件触发@Async方法,更新Redis里的哈希表(key: category_count, field: categoryId, value: count),Service层查树时用hgetall批量获取。本地再加Caffeine缓存,TTL设为10分钟——既保证数据最终一致,又扛住突发流量。实测QPS从800提升到12000。
(3)权限控制下沉到DSL,用策略模式解耦
买家类目有隐藏规则:学生用户看不到烟酒类目,企业采购用户看不到个人用品类目。如果在Controller里if-else判断,下次加个“银发族专属类目”就得改Controller。我们定义了CategoryFilterStrategy接口,实现类按用户类型命名(StudentCategoryFilter、EnterpriseCategoryFilter),DSL层通过ApplicationContext.getBean("studentCategoryFilter")动态获取。新增规则只需加实现类+配置Bean名称,零侵入修改。
3. 核心细节解析:从接口定义到事务边界的12个关键点
3.1 接口设计:用DTO隔离内外,拒绝Entity裸奔
很多人直接把Category实体类(含id、name、parent_id等字段)作为Service方法的返回值,这是大忌。一旦数据库加个字段,所有调用方都要改。我们强制使用CategoryDTO:
public class CategoryDTO { private Long id; private String name; private Integer level; // 1:一级 2:二级 private Long parentId; private Integer productCount; // 该类目下在售商品数 private Boolean hasChildren; // 是否有子类目(避免前端再查) private String iconUrl; // 前端需要的图标地址 }关键点在于productCount和hasChildren——这两个字段在数据库里并不存在,是DSL层聚合计算的结果。DTO的存在让Service层可以自由组合数据源(DB查基础信息 + Redis查计数 + 配置中心查图标URL),而Controller只关心“我要什么”,不关心“你从哪拿”。
注意:不要在DTO里放List 搞嵌套。树形结构用扁平化DTO+前端JS组装,避免Java层递归导致OOM。我们约定DTO只存单层数据,树关系由前端根据parentId重建。
3.2 方法粒度:宁拆勿合,每个方法只做一件事
反例:CategoryService.getCategoryWithChildren(Long categoryId)—— 这个方法名就暴露了问题:它到底返回categoryId本身,还是它的子节点?如果是子节点,那子节点的子节点呢?边界模糊必然导致滥用。
正解是拆成原子方法:
CategoryDTO getCategoryById(Long id):只查单个类目基础信息List<CategoryDTO> getChildrenByParentId(Long parentId):只查直接子节点(不含孙子)List<CategoryDTO> getCategoryTree(Long rootId):组合前两个方法,构建完整树(DSL层调用)
这样做的好处是:单元测试能精准覆盖(测getChildrenByParentId时不用mock整个树),缓存也能细粒度控制(@Cacheable(key="#rootId")只缓存树,@Cacheable(key="#id")只缓存单个类目)。
3.3 事务边界:@Transactional必须落在DSL层最外层
这是最容易踩的坑。看这段错误代码:
// ❌ 错误:事务在Mapper层,失去业务语义 public class CategoryMapper { @Update("UPDATE category SET status = 0 WHERE id = #{id}") @Transactional // 千万别这么干! void updateStatus(Long id); }正确姿势是:
// ✅ 正确:事务包裹完整业务动作 @Service public class CategoryDomainService { @Transactional(rollbackFor = Exception.class) public void disableCategoryAndChildren(Long categoryId) { // 1. 禁用目标类目 categoryMapper.updateStatus(categoryId, 0); // 2. 禁用所有子类目(递归调用自身方法,事务自动传播) List<Long> childrenIds = categoryMapper.getChildrenIds(categoryId); childrenIds.forEach(this::disableCategoryAndChildren); // 3. 发送MQ消息通知商品服务更新计数 mqProducer.send("category_disabled", categoryId); } }关键原理:Spring的@Transactional基于AOP代理,只有被Spring容器管理的Bean调用时才生效。如果在Mapper里加,实际执行的是MyBatis的SqlSession,和Spring事务无关。DSL层的事务能保证“禁用类目+禁用子类目+发消息”三者要么全成功,要么全回滚,符合买家类目“状态一致性”的强需求。
3.4 缓存策略:三级缓存联动,命中率从65%拉到99.2%
单纯用@Cacheable注解远远不够。我们构建了三级缓存体系:
| 缓存层级 | 技术方案 | 存储内容 | TTL | 适用场景 |
|---|---|---|---|---|
| L1 本地缓存 | Caffeine | 单个类目DTO(id→DTO) | 5分钟 | 高频查询单个类目(如用户点击某个类目详情) |
| L2 分布式缓存 | Redis Hash | 类目树(key: tree:rootId, field: categoryId, value: DTO序列化) | 30分钟 | 首页类目导航栏加载 |
| L3 数据库缓存 | MySQL Query Cache | 已废弃,因InnoDB行锁冲突严重 | - | 不再使用 |
实操要点:
- L1用Caffeine的
maximumSize(10000)防内存溢出,recordStats()监控命中率 - L2的Hash结构让
hgetall tree:1一次取完整棵树,比String类型存JSON快3倍(实测) - 缓存穿透防护:L2查不到时,L1写入空对象(new CategoryDTO().setEmpty(true)),TTL设为2分钟,避免重复查DB
实测心得:上线后类目相关接口平均RT从320ms降到47ms,缓存命中率99.2%。但要注意——当运营批量修改类目时,必须用
redisTemplate.delete("tree:*")通配删除,不能只删单个key,否则树结构错乱。
3.5 异常处理:定义领域异常,拒绝Exception泛滥
Service层抛出RuntimeException是自杀行为。我们定义了严格分层的异常体系:
CategoryNotFoundException:DSL层抛出,表示类目不存在(如用户访问已删除类目ID)CategoryPermissionDeniedException:DSL层抛出,表示用户无权查看(如学生访问烟酒类目)CategoryOperateException:ASL层抛出,表示操作失败(如禁用类目时MQ发送失败)
关键技巧:用@ControllerAdvice全局捕获,统一转成标准错误码:
@ExceptionHandler(CategoryNotFoundException.class) public ResponseEntity<ErrorResponse> handleNotFound(CategoryNotFoundException e) { return ResponseEntity.status(404).body( new ErrorResponse("CATEGORY_NOT_FOUND", "类目不存在") ); }这样前端不用解析堆栈,直接根据code做友好提示。而日志里保留完整异常链,方便定位。
4. 实操过程详解:从零搭建可落地的买家类目Service
4.1 环境准备与依赖配置
SpringBoot版本锁定在2.7.18(LTS版,兼容性最好),关键依赖如下:
<!-- pom.xml --> <dependencies> <!-- Web基础 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis Plus(比原生MyBatis少写70%XML) --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <!-- Redis客户端(Lettuce比Jedis线程安全) --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- 本地缓存 --> <dependency> <groupId>com.github.ben-manes.caffeine</groupId> <artifactId>caffeine</artifactId> <version>3.1.8</version> </dependency> <!-- 异步任务 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency> </dependencies>注意:MyBatis Plus的
3.5.3.1版本修复了3.5.2中TreeModel的NPE bug,这个坑我们踩过三次。Redis连接池必须配置,否则高并发下连接耗尽:
# application.yml spring: redis: host: localhost port: 6379 lettuce: pool: max-active: 20 max-idle: 10 min-idle: 0 max-wait: 100004.2 数据库建表与MyBatis Plus配置
category表结构(精简版):
CREATE TABLE `category` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL COMMENT '类目名称', `path` varchar(255) NOT NULL COMMENT '路径枚举,如/1/5/23/', `level` tinyint NOT NULL COMMENT '层级,1为一级', `status` tinyint DEFAULT '1' COMMENT '状态:1启用,0禁用', `sort_order` int DEFAULT '0' COMMENT '排序权重', `icon_url` varchar(255) DEFAULT NULL COMMENT '图标URL', PRIMARY KEY (`id`), INDEX `idx_path` (`path`) -- 路径查询必须加索引! ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;MyBatis Plus配置类(关键点:开启逻辑删除和自动填充):
@Configuration @MapperScan("com.example.category.mapper") public class MyBatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 逻辑删除插件 interceptor.addInnerInterceptor(new LogicDeleteInnerInterceptor()); // 自动填充插件(创建时间、更新时间) interceptor.addInnerInterceptor(new AutomaticFillInnerInterceptor()); return interceptor; } }实操心得:
path字段的索引必须是INDEX而非UNIQUE,因为不同根节点的子树路径会重复(如/1/5/和/2/5/)。我们曾因建错索引导致LIKE查询全表扫描,DBA半夜打电话报警。
4.3 DSL层核心代码实现
(1)CategoryDomainService主类
@Service @Slf4j public class CategoryDomainService { @Resource private CategoryMapper categoryMapper; @Resource private RedisTemplate<String, Object> redisTemplate; @Resource private CategoryCountService categoryCountService; // 异步计数服务 @Resource private ApplicationContext applicationContext; // ========== 核心方法:构建买家类目树 ========== @Cacheable(value = "categoryTree", key = "#rootId", unless = "#result == null") public List<CategoryDTO> getCategoryTree(Long rootId) { // 1. 从Redis查缓存(L2) String cacheKey = "tree:" + rootId; List<CategoryDTO> cached = (List<CategoryDTO>) redisTemplate.opsForValue() .get(cacheKey); if (cached != null && !cached.isEmpty()) { log.debug("Cache hit for tree: {}", rootId); return cached; } // 2. 缓存未命中,查DB构建树 List<Category> categories = categoryMapper.selectTreeByRootId(rootId); if (CollectionUtils.isEmpty(categories)) { return Collections.emptyList(); } // 3. 转DTO并补充计数(调用异步服务,不阻塞主线程) List<CategoryDTO> dtos = categories.stream() .map(this::convertToDTO) .collect(Collectors.toList()); // 4. 异步刷新计数(不影响响应) CompletableFuture.runAsync(() -> { dtos.forEach(dto -> categoryCountService.refreshCountAsync(dto.getId()) ); }); // 5. 写入Redis缓存 redisTemplate.opsForValue().set(cacheKey, dtos, 30, TimeUnit.MINUTES); return dtos; } // ========== 辅助方法:DTO转换(含业务规则) ========== private CategoryDTO convertToDTO(Category category) { CategoryDTO dto = new CategoryDTO(); dto.setId(category.getId()); dto.setName(category.getName()); dto.setLevel(category.getLevel()); dto.setSortOrder(category.getSortOrder()); dto.setIconUrl(category.getIconUrl()); // 业务规则:一级类目默认显示图标,二级类目图标为空(前端用文字代替) if (category.getLevel() > 1) { dto.setIconUrl(null); } // 补充子类目标识(避免前端多次请求) List<Category> children = categoryMapper.getChildrenByParentId(category.getId()); dto.setHasChildren(CollectionUtils.isNotEmpty(children)); return dto; } }(2)CategoryCountService异步计数实现
@Service @Slf4j public class CategoryCountService { @Resource private RedisTemplate<String, Object> redisTemplate; @Resource private ProductMapper productMapper; // 商品Mapper /** * 异步刷新类目商品计数 * 使用@Async需在启动类加@EnableAsync */ @Async public void refreshCountAsync(Long categoryId) { try { // 查该类目及所有子类目的商品总数(注意:这里用path字段查) String pathPattern = "/" + categoryId + "/%"; Integer count = productMapper.countByCategoryPath(pathPattern); // 写入Redis Hash(key: category_count, field: categoryId) redisTemplate.opsForHash().put("category_count", categoryId.toString(), count); log.debug("Refreshed count for category {}: {}", categoryId, count); } catch (Exception e) { log.error("Failed to refresh count for category {}", categoryId, e); } } /** * 同步获取计数(供DSL层调用) */ public Integer getCount(Long categoryId) { Object countObj = redisTemplate.opsForHash() .get("category_count", categoryId.toString()); return countObj instanceof Integer ? (Integer) countObj : 0; } }(3)ASL层门面实现(对接Controller)
@Service @Slf4j public class BuyerCategoryService { @Resource private CategoryDomainService domainService; @Resource private CategoryCountService categoryCountService; /** * 买家首页类目树(带商品计数) * 参数:userId用于个性化推荐,cityCode用于地域过滤 */ public List<CategoryDTO> getBuyerCategoryTree(Long userId, String cityCode) { // 1. 获取基础类目树 List<CategoryDTO> tree = domainService.getCategoryTree(1L); // 根ID固定为1 // 2. 补充商品计数(从Redis取,不走DB) tree.forEach(dto -> { Integer count = categoryCountService.getCount(dto.getId()); dto.setProductCount(count); }); // 3. 地域过滤(示例:北京用户看不到海南特产类目) if ("BJ".equals(cityCode)) { tree.removeIf(dto -> dto.getId().equals(88L)); // 88是海南特产ID } // 4. 用户偏好加权(示例:新用户首屏只显示前5个类目) if (isNewUser(userId)) { return tree.stream().limit(5).collect(Collectors.toList()); } return tree; } private boolean isNewUser(Long userId) { // 调用用户服务判断,此处省略 return false; } }4.4 Controller层调用示例
@RestController @RequestMapping("/api/buyer/category") @Slf4j public class BuyerCategoryController { @Resource private BuyerCategoryService buyerCategoryService; @GetMapping("/tree") public Result<List<CategoryDTO>> getCategoryTree( @RequestParam Long userId, @RequestParam(defaultValue = "BJ") String cityCode) { try { List<CategoryDTO> tree = buyerCategoryService .getBuyerCategoryTree(userId, cityCode); return Result.success(tree); } catch (CategoryNotFoundException e) { log.warn("Category tree not found for user {}", userId, e); return Result.fail("类目数据异常,请稍后重试"); } } }5. 常见问题与排查技巧实录:那些文档里不会写的血泪教训
5.1 缓存击穿:大促期间类目页集体500的真相
现象:凌晨两点大促开始,类目页大量报500,错误日志显示RedisConnectionFailureException。
排查过程:
- 先看Redis监控:QPS正常,但连接数飙升到2000+(池上限20)
- 检查代码:发现
getCategoryTree()里用了redisTemplate.opsForValue().get(),但没加try-catch,异常直接抛到Controller - 深挖:原来缓存过期瞬间,1000个请求同时发现缓存为空,全部涌向DB,DB慢查询拖垮Redis连接池
解决方案:
- 加分布式锁(用Redisson):
RLock lock = redissonClient.getLock("lock:tree:" + rootId); if (lock.tryLock(1, 3, TimeUnit.SECONDS)) { try { // 重新查缓存,若仍为空则查DB List<CategoryDTO> data = loadFromDB(rootId); redisTemplate.opsForValue().set(cacheKey, data, 30, TimeUnit.MINUTES); return data; } finally { lock.unlock(); } } // 未抢到锁,短暂休眠后重试(最多3次) Thread.sleep(50); return getCategoryTree(rootId);- 同时设置缓存永不过期,用后台定时任务刷新(每15分钟跑一次)
实操心得:锁的过期时间必须大于DB查询最大耗时(我们设为3秒),否则锁自动释放后多个线程同时写缓存,造成数据不一致。
5.2 树形结构错乱:为什么前端显示的类目顺序和后台配置不一致?
现象:运营在后台把“手机”类目拖到第一位,但APP端还是显示“电脑”在前。
根因分析:
- 数据库里
sort_order字段是int类型,但MyBatis Plus的@TableField没加updateStrategy = FieldStrategy.NOT_NULL,导致更新时sort_order=null被忽略,实际没更新 - 更致命的是,
getCategoryTree()方法里没对结果按sort_order排序,而是依赖数据库查询顺序(InnoDB的B+树遍历顺序不稳定)
修复步骤:
- 在Category实体类加注解:
@TableField(updateStrategy = FieldStrategy.NOT_NULL) private Integer sortOrder;- Mapper XML里显式ORDER BY:
<select id="selectTreeByRootId" resultType="Category"> SELECT * FROM category WHERE path LIKE CONCAT('/','${rootId}','/%') ORDER BY sort_order ASC, id ASC </select>- Service层二次校验:
// 构建树后强制排序 dtos.sort(Comparator.comparing(CategoryDTO::getSortOrder) .thenComparing(CategoryDTO::getId));5.3 异步计数延迟:商品上架后类目计数10分钟才更新
现象:运营上架100个手机,类目页显示“在售商品:0”,10分钟后才变成100。
诊断:
- 查
category_countRedis Hash:确实10分钟后才更新 - 看异步线程池:
@Async默认用SimpleAsyncTaskExecutor,每次新建线程,没复用 - 线程池配置缺失,导致任务排队
终极方案:
- 自定义线程池(application.yml):
task: pool: core-size: 5 max-size: 20 queue-capacity: 100- Java配置类:
@Configuration @EnableAsync public class AsyncConfig { @Value("${task.pool.core-size}") private int corePoolSize; @Value("${task.pool.max-size}") private int maxPoolSize; @Value("${task.pool.queue-capacity}") private int queueCapacity; @Bean("taskExecutor") public Executor taskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(corePoolSize); executor.setMaxPoolSize(maxPoolSize); executor.setQueueCapacity(queueCapacity); executor.setThreadNamePrefix("category-count-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }@Async指定线程池:
@Async("taskExecutor") public void refreshCountAsync(Long categoryId) { ... }注意:
CallerRunsPolicy是保底策略——当队列满时,由调用线程自己执行任务,避免丢弃。我们实测后,计数延迟从10分钟降到2秒内。
5.4 权限规则失效:为什么学生用户还能看到烟酒类目?
现象:学生用户访问类目页,烟酒类目未被过滤。
排查链路:
- 查
CategoryFilterStrategy实现类:StudentCategoryFilter里写了if (category.getId().equals(99L)) return false;(99是烟酒ID) - 但
getCategoryTree()返回的DTO里,烟酒类目id=99还在 - 追踪调用:发现
convertToDTO()方法在getCategoryTree()里被调用,但StudentCategoryFilter是在ASL层getBuyerCategoryTree()里调用的,DSL层根本没感知
正确解法:
- DSL层提供通用过滤入口:
public List<CategoryDTO> getCategoryTree(Long rootId, CategoryFilterStrategy filterStrategy) { List<CategoryDTO> tree = loadFromCacheOrDB(rootId); return tree.stream() .filter(filterStrategy::shouldKeep) .collect(Collectors.toList()); }- ASL层传入具体策略:
public List<CategoryDTO> getBuyerCategoryTree(Long userId, String cityCode) { CategoryFilterStrategy strategy = getCategoryFilterStrategy(userId); return domainService.getCategoryTree(1L, strategy); }5.5 性能瓶颈定位:如何5分钟找到慢接口的元凶?
当类目页RT突然从50ms涨到800ms,按以下顺序快速定位:
| 步骤 | 操作 | 预期耗时 | 关键指标 |
|---|---|---|---|
| 1. 看监控 | Grafana查应用JVM:GC频率、线程数、CPU | 30秒 | Full GC次数突增?线程数>500? |
| 2. 查日志 | grep "getCategoryTree" app.log | tail -20 | 20秒 | 是否大量WARN日志?是否有慢SQL记录? |
| 3. 抓SQL | 开启MyBatis日志:logging.level.com.example.mapper=DEBUG | 1分钟 | 找到执行超200ms的SQL,看是否走了索引 |
| 4. 测缓存 | redis-cli -h xxx hgetall category_count | 10秒 | 计数Hash是否过大(>1000个field)? |
| 5. 模拟压测 | ab -n 1000 -c 100 "http://localhost:8080/api/buyer/category/tree?userId=1&cityCode=BJ" | 2分钟 | 对比RT和错误率 |
我们整理了高频问题速查表:
| 问题现象 | 可能原因 | 快速验证命令 | 解决方案 |
|---|---|---|---|
| 类目页白屏 | Redis连接超时 | redis-cli -h x ping | 检查Redis网络、连接池配置 |
| 计数始终为0 | category_count Hash为空 | redis-cli -h x hlen category_count | 检查refreshCountAsync是否被调用 |
| 树结构缺失节点 | path字段索引失效 | explain select * from category where path like '/1/%' | 确认idx_path索引存在且type=range |
| 多次点击同一类目RT飙升 | L1缓存未命中 | curl http://localhost:8080/actuator/metrics/cache.caffeine.hit.ratio | 检查Caffeine stats配置 |
最后分享一个小技巧:在
CategoryDomainService里加一个@PostConstruct方法,启动时预热缓存:
@PostConstruct public void init() { // 预热根类目树 getCategoryTree(1L); log.info("Category service initialized and warmed up"); }避免应用刚启动时第一个用户遭遇冷启动慢。
我在实际项目中发现,90%的类目Service问题都源于对“买家视角”业务语义理解偏差——把它当成普通数据查询,而不是用户旅程的关键触点。当你把每个方法都问一遍“这个返回值,买家点开后第一眼看到什么?”,答案自然就清晰了。