重构UserService:接口设计、事务与缓存一致性实战
开篇先说个背景吧。做了这么多年 Java 后端UserService 怕是我写过最多的类之一了。几乎每个项目都有用户模块而 UserService 作为用户模块的“心脏”它的设计好坏直接决定了后续开发是如鱼得水还是寸步难行。最近正好在一个中后台项目里重构了用户模块把原来的 UserService 从“能跑就行”的 V1 版本彻底推倒重写成了 V2。这个“版本2 UserService类”看似只是改了个名头实际上从接口设计、异常处理、事务边界到性能细节全部重新捋了一遍。手动重构的过程里踩了不少坑也总结出一些一眼就能看出设计功力的经验。这篇文章就把我这次改造的完整思路和实操过程记录下来包括接口怎么定义、Repository 怎么配合、事务和缓存怎么不打架、单测怎么写得让人安心以及我实际运行中遇到的那些经典问题。很多内容是从实际代码和排查日志里抠出来的不是那种水文。如果你也在设计或者重构 Service 层这篇应该能让你少掉几根头发。1. 设计思路拆解版本2到底改了什么1.1 V1的痛点和重构动机先说 V1 是什么状态。它是典型的“贫血模型 上帝类”所有逻辑都堆在一个实现类里一个类两三千行方法又多又杂。findUserById、findUserByName、findUserByEmail 各写各的本质上都是查用户却重复了三遍。更难受的是Service 里直接操作实体类做业务判断还经常把一个实体对象直接扔给 Controller 层导致 Controller、Service、Mapper 全都在改同一个对象谁都不知道这个对象的状态到底被谁改过。在一个老项目里这样的代码还能勉强跑但一旦接手的人开始加新功能比如接入新的登录方式、加用户冻结逻辑、加第三方账号绑定问题就爆炸了。每次改动都要揣摩“这个方法改一下会不会影响另一个模块”回归测试一拉一堆。最核心的问题是职责不清晰方法没有语义化业务规则散落各处。所以 V2 的第一原则就是让 Service 变薄让接口变清楚让数据流转有边界。不是把代码变多而是把一个上千行的“混沌体”拆成可以单独理解和替换的模块。1.2 接口与实现分离的价值V2 里我严格采用了“接口 实现类”分离的方式一个 UserService 接口定义业务能力一个 UserServiceImpl 承载具体实现。这也是我觉得所有入门 Java 后端的人一开始就要养成的习惯不是说每个类都非得这样搞而是对于核心领域服务接口抽象以后带来的收益真的很大。首先调用方只依赖接口不依赖具体实现。这意味着我可以在不改 Controller 代码的前提下随时替换实现方式。举个实际例子前一阵我们接入了 Redis 缓存用户基础信息最开始是在 UserServiceImpl 里硬编码的后来发现这样 hmm 每次改缓存策略都要重新编译而且单元测试也麻烦。重构之后我把接口定义成独立契约Controller 只管userService.getUserProfile(userId)底层缓存逻辑完全封装在实现类里后续切换本地缓存或者分布式缓存都不需要动接口签名。其次接口也是最好的代码注释。方法名和参数组合表达了业务语义读接口就能知道这个类能干什么不用把所有逻辑看完才知道入口在哪。这也是开发协作时候的隐形效率提升。1.3 技术栈和边界界定V2 的项目基础是 Spring Boot 2.7.x Spring Data JPA数据库用的 MySQL 8.x缓存走了 Redis权限这块接的 Spring Security 做最粗粒度的拦截。这个组合在不同团队里用得非常多所以下面的代码如果你拿到自己项目里只需要把包名和注解调整一下即可。这里特别说一下 Spring Data JPA 和 MyBatis 的选型纠结。其实 V1 是 MyBatisV2 我换到了 JPA。原因是用户模块的查询相对固定但对象关系复杂、更新场景很多JPA 的实体映射和变更追踪机制在写业务代码时效率高不少。当然MyBatis 在复杂查询和大数据量下更灵活但是在一个以用户中心为基座的项目里JPA 搭配Query、EntityGraph已经能覆盖绝大多数场景了。不要把 SQL 能力作为主要评估维度把开发效率和模型表达能力作为选型第一优先。对比维度V1MyBatisV2Spring Data JPASQL 控制力强手写 SQL一般复杂查询需 Query实体验证手动封装自动映射 Bean Validation事务集成需手动管理声明式事务更自然联表查询写 SQL 方便需谨慎设计关系映射防 N1维护成本高XML 文件多低代码即注释其实选型没有绝对的好坏核心是你得知道自己失去了什么。选 JPA 就意味着必须把实体关系设计清楚不然懒加载和 N1 问题能把你折磨到怀疑人生。我这次就在这块吃足了苦头后面具体说。2. UserService V2核心代码与关键实现2.1 接口层定义方法粒度与返回值设计接口设计是整个版本2的灵魂。我最后沉淀出来的UserService接口大概长这样因为我后面还要写其他模块这里不贴完整业务实现只展示最有代表性的片段public interface UserService { /** * 根据用户ID查询用户资料 * param userId 用户ID * return 用户资料对象 */ UserProfileDTO getUserProfile(Long userId); /** * 根据用户名查询用户内部认证用 * param username 用户名 * return 用户实体 */ UserEntity findUserByUsername(String username); /** * 注册新用户返回用户ID * param registerCmd 注册命令对象 * return 新用户ID */ Long registerUser(RegisterUserCommand registerCmd); /** * 更新用户基础资料 * param updateCmd 更新命令对象 */ void updateUserProfile(UpdateUserProfileCommand updateCmd); /** * 冻结用户 * param userId 用户ID * param reason 冻结原因 */ void freezeUser(Long userId, String reason); /** * 分页查询用户列表 * param query 查询对象 * return 分页结果 */ PageResultUserProfileDTO pageQueryUsers(UserQuery query); }这里有三个点值得展开说。第一个是返回值设计。查询用户资料返回的是UserProfileDTO而不是直接返回UserEntity。这个决定很关键。实体是和数据库表一对一映射的它里面包含了密码哈希、手机号、创建时间、更新时间、状态标记等字段如果毫无保留地扔给前端等于主动暴露内部数据结构。DTO 只暴露必要字段且可以自由组合格式它让我在 Controller 层和 Service 层之间建立了一道信息防火墙。第二个是**命令对象Command**的使用。注册用户和更新资料我都没有用单个参数而是定义了一个命令对象。说实话一开始也觉得参数没几个没必要搞对象。但后来发现当用户资料字段从 5 个涨到 15 个的时候registerUser(String username, String password, String email, String phone, String avatar, String nickname, Integer gender, ...)这种签名已经没法读了。命令对象聚合了一组输入方法语义更清楚而且在参数校验、通用处理上都很顺。第三个是返回用户 ID 做注册结果。注册不是查询我不需要把整个刚插入的实体返回出去只告诉调用方“新用户的 ID 是多少”就够了。调用方如果还需要详情再调getUserProfile。职责单一避免一个方法干太多事。2.2 实现类的核心逻辑事务、异常、缓存UserServiceImpl是我最花心思的部分。核心代码骨架我贴在下面Service Slf4j Transactional(readOnly true) public class UserServiceImpl implements UserService { Resource private UserRepository userRepository; Resource private UserProfileRepository profileRepository; Resource private UserEventPublisher eventPublisher; Autowired private PasswordEncoder passwordEncoder; Autowired private RedisTemplateString, String redisTemplate; private static final String USER_CACHE_KEY_PREFIX user:profile:; private static final Duration CACHE_TTL Duration.ofMinutes(30); Override public UserProfileDTO getUserProfile(Long userId) { String cacheKey USER_CACHE_KEY_PREFIX userId; String cached redisTemplate.opsForValue().get(cacheKey); if (StrUtil.isNotBlank(cached)) { return JSONUtil.toBean(cached, UserProfileDTO.class); } UserEntity user userRepository.findById(userId) .orElseThrow(() - new UserNotFoundException(用户不存在: userId)); UserProfileDTO dto convertToDTO(user); redisTemplate.opsForValue().set(cacheKey, JSONUtil.toJsonStr(dto), CACHE_TTL); return dto; } Override Transactional(rollbackFor Exception.class) public Long registerUser(RegisterUserCommand registerCmd) { // 1. 校验用户名唯一性 if (userRepository.existsByUsername(registerCmd.getUsername())) { throw new BizException(用户名已存在); } // 2. 校验邮箱唯一性 if (userRepository.existsByEmail(registerCmd.getEmail())) { throw new BizException(邮箱已被注册); } // 3. 构造实体并保存 UserEntity user new UserEntity(); user.setUsername(registerCmd.getUsername()); // 密码加密存储任何地方都不能存明文 user.setPasswordHash(passwordEncoder.encode(registerCmd.getPassword())); user.setEmail(registerCmd.getEmail()); user.setPhone(registerCmd.getPhone()); user.setUserStatus(UserStatusEnum.ACTIVE); userRepository.save(user); // 4. 发布注册成功事件 eventPublisher.publish(new UserRegisteredEvent(user.getId(), user.getUsername())); return user.getId(); } Override Transactional(rollbackFor Exception.class) public void freezeUser(Long userId, String reason) { UserEntity user userRepository.findById(userId) .orElseThrow(() - new UserNotFoundException(用户不存在: userId)); // 状态机检查已冻结的不重复冻结 if (user.getUserStatus() UserStatusEnum.FROZEN) { throw new BizException(该用户已被冻结); } user.setUserStatus(UserStatusEnum.FROZEN); user.setFreezeReason(reason); userRepository.save(user); // 删除缓存让下次查询重新加载最新状态 redisTemplate.delete(USER_CACHE_KEY_PREFIX userId); } }可能你已经注意到了类头上的Transactional(readOnly true)。这是 Spring 事务声明的一个很实用的细节类级别默认只读事务可以降低非必需写场景的事务开销然后通过在具体写方法上追加Transactional(rollbackFor Exception.class)进行覆盖。Spring 的默认回滚规则是只对 RuntimeException 和 Error 生效对受检异常不会回滚。这个rollbackFor Exception.class是我强烈建议要加上的不然业务里抛了一个自定义受检异常事务居然不回滚数据就处于半提交状态查半天都不知道问题在哪。另外freezeUser方法里有一个很必要的幂等操作如果用户已经处于冻结状态直接抛异常。这个判断本身是内存里的但配合事务和唯一约束之后能保证状态迁移不会重复执行这是状态机设计的一个最基础的应用。缓存这块我的策略是查询走缓存 写操作删缓存Cache Aside Pattern。很多人刚用缓存时喜欢在写入之后立刻更新缓存其实这很容易造成数据不一致尤其是多个线程同时写的时候。我实际项目中基本都是“写操作直接删除缓存”让下一次查询重新加载数据库里的最新值。删除比更新简单也更能保证一致性。缓存 TTL 设了 30 分钟防止缓存和库长期不一致如果业务对实时性要求很高可以缩短 TTL或者用版本号 分布式锁做更细的控制。还有那个事件发布器eventPublisher。这里我选择了 Spring 的事件机制而不是自己硬编码。注册用户成功后后续如发送欢迎短信、初始化用户空间、记录日志等逻辑我都放在监听器里而不是写在 registerUser 里面。这样服务类的主链路保持简单也方便异步化处理。2.3 数据访问层的配合Repository 设计要点作为一个好的 UserRepository并不是简单地 extends JpaRepository 就够了。看一下我实际用到的几个方法public interface UserRepository extends JpaRepositoryUserEntity, Long { boolean existsByUsername(String username); boolean existsByEmail(String email); OptionalUserEntity findByUsername(String username); OptionalUserEntity findByEmail(String email); EntityGraph(attributePaths {roles, profile}) Query(select u from UserEntity u where u.id in :ids) ListUserEntity findUsersWithRelationsByIds(Param(ids) CollectionLong ids); Query(select u from UserEntity u where u.userStatus :status and u.createdAt :startTime) PageUserEntity pageQueryActiveUsers(Param(status) UserStatusEnum status, Param(startTime) LocalDateTime startTime, Pageable pageable); }这里有一个特别要强调的不要在循环里逐条调用 findById。开发新手最容易犯的错误就是拿到一批用户 ID 后for 循环里一个 ID 一次查询等到列表有几十条时SQL 直接多出几十次数据库那叫一个酸爽。正确做法是提供批量查询方法使用IN查询一次拿回来然后在内存中做映射。另一个重点是EntityGraph。JPA 的懒加载机制下如果你要把用户和它的角色、资料一起展示直接遍历时会发生“好多个 SELECT”的情况也就是著名的 N1 问题。EntityGraph(attributePaths {roles, profile})的作用就是告诉持久化层在这一次查询时就把关联对象一起查出来了通过 LEFT JOIN 或者额外的 IN 查询。这是我从 V1 换到 JPA 后最有体感的一个优化点。2.4 DTO/VO/Entity 分层与对象转换V1 时代最混乱的地方就是所有层都在操作实体对象。V2 做了一个明确约定入参一律是 Command 对象出参是 DTO只有 Repository 层才使用 Entity层与层之间禁止直接透传实体。约定虽然简单但很多人执行不到位因为转换代码实在太枯燥了。我的做法是用 MapStruct 自动化转换。MapStruct 在编译期生成转换代码性能和手写 setter 一致且类型不匹配会在编译期就暴露错误比 BeanUtil.copyProperties 这种反射工具可靠得多。Mapper(componentModel spring) public interface UserConvertMapper { UserProfileDTO toProfileDTO(UserEntity user); UserEntity toEntity(RegisterUserCommand registerCmd); }然后在 Service 里注入这个 MapperResource private UserConvertMapper userConvertMapper;调用时直接userConvertMapper.toProfileDTO(user)即可。这里我说句心理话很多老工程师一看到“多了很多 DTO/VO 类”就觉得代码量翻倍了但其实分层的核心价值就是避免极端耦合。你每跨越一层都保留模型边界改动时可以只改局部而不是一个实体改动导致 20 个接口行为全变。越是核心的模块越要敢于做这种“麻烦但正确”的设计。用户在系统中的位置太特殊了用户实体如果被所有人直接操作后面改一个字段名都会引发连锁爆炸。3. V2实操记录从依赖配置到单元测试3.1 项目基础依赖与配置如果你要从零搭这个 V2 项目最省心的方式是直接起一个 Spring Initializr 工程加入下面这些依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdorg.mapstruct/groupId artifactIdmapstruct/artifactId version1.5.5.Final/version /dependency dependency groupIdorg.mapstruct/groupId artifactIdmapstruct-processor/artifactId version1.5.5.Final/version scopeprovided/scope /dependency配置文件里有一点要提醒。Spring Boot 2.7 之后spring.redis.*配置前缀变成了spring.data.redis.*老项目升级的时候最容易忽略。下面是简化后的 yml 配置spring: datasource: url: jdbc:mysql://localhost:3306/user_center?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: none show-sql: true properties: hibernate: format_sql: true dialect: org.hibernate.dialect.MySQL8Dialect data: redis: host: localhost port: 6379 timeout: 3sddl-auto: none是为了防止 JPA 根据实体自动改表结构。生产环境千万不能打开 ddl-auto 的 create 或 update一旦实体写错了字段Hibernate 会直接把线上表结构改了这个代价是灾难性的。3.2 实体设计的关键点UserEntity是支撑整个 V2 服务的关键对象它的设计决定后续查询的复杂度。我贴出核心字段Entity Table(name t_user, indexes { Index(name idx_username, columnList username), Index(name idx_email, columnList email) }) public class UserEntity { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(nullable false, unique true, length 64) private String username; Column(nullable false, unique true, length 128) private String email; Column(nullable false, length 128) private String passwordHash; Column(length 20) private String phone; Enumerated(EnumType.STRING) Column(nullable false, length 20) private UserStatusEnum userStatus; Column(length 255) private String freezeReason; Column(nullable false, updatable false) private LocalDateTime createdAt; Column(nullable false) private LocalDateTime updatedAt; PrePersist public void prePersist() { this.createdAt LocalDateTime.now(); this.updatedAt LocalDateTime.now(); } PreUpdate public void preUpdate() { this.updatedAt LocalDateTime.now(); } }这里有几个细节username 和 email 都加 unique 约束。这是最低成本的防线即使 Service 层校验漏了数据库也会拦截重复数据。不过要注意唯一约束建立了之后代码里要捕获DataIntegrityViolationException并转成业务异常否则前端会看到 500还得去找数据库日志。passwordHash 不是 password。存储用户密码的唯一正确姿势是存储哈希串任何明文密码出现都是在给整个用户库裸奔。密码加密用的是 BCryptPasswordEncoder每次随机加盐同一个密码在不同用户之间哈希值也不同这个是 Spring Security 自带的实现。枚举存储用Enumerated(EnumType.STRING)。虽然默认的 ORDINAL 存数字更省空间但一旦枚举顺序调整历史数据就全错位了。存字符串虽然多几个字节但可读性高不会因为代码调整就崩。审计时间字段用 PrePersist 和 PreUpdate 自动填充。不要靠 Service 手动给时间赋值手填容易漏掉更新场景也会把时间设置散落在各个业务方法里。3.3 业务校验的落地方式V2 里我把参数校验分散到了两个层面命令对象上用 Bean Validation 注解做基础格式校验业务语义校验放在 Service 里。public class RegisterUserCommand { NotBlank(message 用户名不能为空) Size(min 4, max 32, message 用户名长度需要4-32个字符) private String username; NotBlank(message 密码不能为空) Size(min 8, max 64, message 密码长度至少8位) private String password; NotBlank(message 邮箱不能为空) Email(message 邮箱格式不正确) private String email; Pattern(regexp ^1[3-9]\\d{9}$, message 手机号格式不正确) private String phone; }在 Controller 里用Valid触发校验不够的细节逻辑再交给 Service。不过有一点要注意Bean Validation 的 message 是“给接口调用的错误提示”不是给用户看的完整文案所以在前端展示时还要包一层。很多团队不装这一层导致数据库、服务层、前端的错误提示互相矛盾这个是在联调时才明显的。3.4 单元测试与 Mock 方案这个我单独拿出来写因为 V2 最大的改变之一就是让代码本身变可测了。以前 V1 的类里全是依赖 JVM 环境的静态调用拿到单元测试里不知道怎么 mock。现在接口分离 依赖注入之后测试代码清爽很多。我用的测试框架是 JUnit 5 Mockito。核心测试展示一个注册方法的用例ExtendWith(MockitoExtension.class) class UserServiceImplTest { Mock private UserRepository userRepository; Mock private PasswordEncoder passwordEncoder; InjectMocks private UserServiceImpl userService; Test void registerUser_shouldReturnUserId_whenSuccess() { // given boolean exists false; when(userRepository.existsByUsername(alice)).thenReturn(exists); when(userRepository.existsByEmail(aliceexample.com)).thenReturn(exists); when(passwordEncoder.encode(password123)).thenReturn(hashed-value); when(userRepository.save(any(UserEntity.class))) .thenAnswer(invocation - { UserEntity entity invocation.getArgument(0); entity.setId(1001L); return entity; }); RegisterUserCommand cmd new RegisterUserCommand(); cmd.setUsername(alice); cmd.setPassword(password123); cmd.setEmail(aliceexample.com); cmd.setPhone(13812345678); // when Long userId userService.registerUser(cmd); // then assertEquals(1001L, userId); verify(userRepository).save(argThat(entity - entity.getUsername().equals(alice) entity.getPasswordHash().equals(hashed-value) )); } }写测试时最容易踩的坑是忽略MockitoExtension这些细节或者一口气 mock 了五个对象然后代码写得像流水账。我的建议是写核心业务用例先写 happy path再写一个异常路径比如用户名重复会抛 BizException就足以覆盖大部分风险。不要为了覆盖率凑测试要把价值高的那几条用例写好。真实项目里单元测试跑得飞快比集成测试更适合做开发自测。4. 常见问题排查与性能优化实录4.1 N1 查询的经典现场与修复这个坑我记忆太深了。V2 初期我在 getUserProfile 的前一版里写过一段“正常得不得了”的代码先查出用户列表然后在 for 循环里逐个访问每个用户的异常信息因为实体关联默认是 LAZY 加载每访问一个关联对象都会触发一次 SQL页面一打开就多了几十条查询。我排查的起手式是先开启 JPA 的 show-sql发现在一次请求里重复了select * from t_user_profile where user_id ?十几次。当场冷汗就下来了。修复方式就是用前面提到的EntityGraph或者用批量查询 内存映射// 旧代码N1 for (Long id : ids) { UserProfileDTO dto userService.getUserProfile(id); } // 新代码一次IN查询 内存映射 ListUserEntity users userRepository.findUsersWithRelationsByIds(ids); MapLong, UserProfileDTO map users.stream() .collect(Collectors.toMap(UserEntity::getId, userConvertMapper::toProfileDTO));千万记得关联对象是否提前加载要结合业务场景做权衡。若只在特定接口里用那就只在那条查询路径上加EntityGraph别把实体全局改成 EAGER 加载不然每个用户查询都会带上一层巨大关联反而拖垮性能。4.2 事务不生效的元凶有段时间我发现freezeUser执行后用户状态没变。这代码明明有Transactional为什么没回滚排查一圈真正的元凶是方法内部调用。比如我在UserServiceImpl内部写了一个普通方法doFreeze()然后在freezeUser()里直接调用了它并且把Transactional标在doFreeze()上——这就等于一个内部 this 调用的方法Spring 的 AOP 代理根本没有经过事务完全失效。这个坑是老生常谈的但真的很多年还会有人踩。解决方式有几种方法不要内部自调或者把事务逻辑抽到另一个 Bean也可以用AopContext.currentProxy()去调用但这个太 hack我一般不推荐。最优雅的还是单独拆一个内部服务或者直接把Transactional标在外部入口方法上。另外也要检查事务和 synchronized 的配合freezeUser如果并发执行同一个用户的状态在内存里读取都是旧值两个线程都判断“未冻结”然后都执行更新最后状态倒还好但freezeReason可能互相覆盖。如果你要严格的并发控制仅靠 JVM 的锁是不够的应该加数据库层面的乐观锁版本号字段或者唯一性设计。V2 目前对用户模块采用了Version乐观锁字段防止不合理的覆盖。4.3 批量写场景的性能瓶颈另一个非常痛的场景是大批量导入用户。一开始我用userRepository.save(user)一条条插入导入 1 万条简直等到怀疑人生一条 INSERT 一次网络开销快不了。后来改用saveAll批量提交。Spring Data JPA 的saveAll在简单的主键策略下确实有性能提升但它本质上还是逐条生成 INSERT 语句只是把 SQL 累积到一次 flush。如果数据量更大就要考虑 jdbc batchingspring: jpa: properties: hibernate: jdbc: batch_size: 50 order_inserts: true order_updates: trueorder_inserts: true的意义在于 Hibernate 会把相同类型的插入顺序排在一起批量执行效率更高。注意一旦启用了 batching就不能对实体使用IDENTITY主键生成策略因为 Hibernate 为了拿到数据库生成的主键必须立即执行 INSERT批量就失效了。这块可以改成SEQUENCE或者TABLE或者干脆分批次手动 flush。我用saveAll分批处理每次 500 条之后导入 1 万数据从 40 多秒降到了 6 秒左右这还是在没开 jdbc batching 的情况下。有条件的真应该开 batching。4.4 缓存与数据库的一致性实践缓存一致性的核心矛盾是缓存里存的是冗余数据一旦数据库改了缓存要么跟着改要么被删掉。V2 里我统一采用 Cache Aside 模式读的时候先读缓存未命中则读库、回填缓存写的时候直接删缓存。这个模式在项目里经受住了考验。不过“删缓存”也有一个微妙的竞态线程 A 读缓存未命中然后读库拿到旧值线程 B 写库成功并删除缓存线程 A 把旧值回填到缓存。最后缓存里是旧值数据库是新值这一条脏数据要等到 TTL 过期才能恢复。解决办法有很多缓存设置过期时间我在 30 分钟能容忍大部分不一致、写操作后延迟双删、或者引入版本号。多数业务场景下加 TTL 删除就够用了不必过度设计。真正要小心的反而是缓存空值的处理。如果查询一个不存在的用户 ID每次都会打到数据库有人恶意遍历 ID 就能把数据库压垮。我通常会在缓存里存一个特殊标记的空值并设置短 TTL比如 5 分钟这样从源头拦截了穿透。4.5 排查工具与定位思路速查平时我查 UserService 相关问题有一套固定思路列成表格供参考症状查看位置可能的根因处理建议查询变慢数据库慢查询日志缺少索引或者存在 N1EXLPAIN 分析补索引或优化 JPA缓存中数据一直是旧值Redis 日志 代码审计删除缓存失败或未删写操作保证 delete 成功注册偶尔重复数据库唯一键约束并发注册且无锁Service 捕获唯一约束异常转为业务提示事务方法没回滚代码审计内部调用导致代理失效提取独立 Bean 或外部方法批量导入超时日志时间戳未开启 batch配置 jdbc batching 并调整主键策略排查类问题不要一上来就猜先贴出日志再结合 show-sql 和 Redis monitor 判断 SQL 与缓存访问基本能快速收敛。5. 后续演进与个人体会版本2的 UserService 重构完成之后我自己最大的感受是代码不在多而在于变化发生时能不能守住边界。V2 版本从设计上把 Controller 变薄、Service 聚焦业务、Repository 关注数据访问每一层的职责都明确到可以让不同的人独立修改而互不干扰。后面如果要继续演进我想到的方向有三个。第一个是接入领域事件的消息队列化。目前UserRegisteredEvent还是同步发布如果有多个外部系统要订阅用户注册消息同步模型会让注册主链路变慢。改动并不复杂把事件发布逻辑换成向 MQ 发送消息即可接口层不用动。第二个是引入更严格的防重注册机制。现在依赖数据库唯一约束 事务但注册请求如果在网关层没有幂等措施同一个请求重复提交用户体验上还是会收到“用户名已存在”的提示。可以在接口入口做基于 token 的幂等拦截或者用 Redis setnx 做一个注册锁。第三个是针对于查询做 CQRS 拆分。随着用户列表筛选条件变多按状态、按时间、按角色、按积分查询接口会变得非常杂。可以考虑把 Query 从 UserService 里剥离出去专门设计一个 UserQueryService 给读模型服务这样写模型和读模型互不影响各自有自己的优化手段。但这种拆分是很后期的演进如果业务规模没到那个程度强行分反而增加维护成本。文章最后再说一个我在实际运行中领悟到的小经验重构完成之后不要把旧代码直接删掉留一个过渡期通过日志或者开关的方式把新老两条实现都跑一段时间对比各自的接口响应时间、异常率、调用链路确认没有偏差后再摘掉旧实现。这个习惯帮我避免了不少线上问题。V2 版本的 UserService 现在已经在线上稳定跑了两个多月后续有机会再分享缓存和查询优化的更多细节。如果这篇文章对你有帮助也欢迎在实际项目中遇到问题时回来对照一下很多坑是共通的。祝你的 Service 层越写越薄代码越写越清楚。