
“版本6 UserService类”——看到这个标题我第一反应是这哥们儿终于把Service层写明白了。后端开发里UserService大概是每个Java程序员写得最多、也最容易被写烂的一个类。从第一个项目里的“万能胶水类”到后来慢慢懂得拆接口、管事务、控缓存一个UserService能活到第6个版本绝对不是简单的迭代而是对整个业务架构认知的一次次推倒重来。这篇我就拿我自己项目中UserService从V1到V6的演进过程来说重点讲V6这一版的设计思路、核心实现和那些不踩一遍根本记不住的坑。先说清楚这篇内容适合谁如果你正在写Service层但总觉得职责模糊、方法越加越多如果你被事务失效、缓存一致性、DTO乱传这类问题折磨过如果你刚接手一个老项目准备重构Service层——这篇内容应该能给你一些可以落地的参考。我会把V6版的接口设计、参数约束、事务与缓存方案、异常体系全部拆开讲代码都是Spring Boot MyBatis-Plus这套最常见的组合跑过、上线过、也回滚过。1. 版本6的UserService到底在解决什么问题1.1 一个Service类的演进史先看看V1到V5都发生了什么不然你没法理解V6为什么要这么设计。V1时期UserService大概就是把Controller里的JDBC代码搬了个家方法名是saveUser、getUserById这种参数全是实体类Controller直接new一个UserDTO往Service里丢。这个阶段谈不上设计能跑就行。V2时期开始有点分层意识了知道Controller不该碰Mapper但Service里开始疯狂堆方法。注册、登录、改密、绑手机、换头像、查列表、导出Excel、统计用户数……全塞在一个类里最夸张的时候一个UserService有四十多个public方法类文件两千多行。这已经不是Service了是个垃圾收纳箱。V3到V5是逐步做减法的过程。V3引入接口与实现分离UserService变成接口UserServiceImpl做实现V4开始把输入输出对象从Entity里剥出来引入DTO和VOV5补上了统一异常体系和参数校验。每一版都在修上一版的毛病但直到V6我才真正把“Service层到底该干什么”这个问题想清楚。1.2 版本6的核心定位与职责边界V6解决的核心问题是Service层的职责切分。我先说结论Service层不该是业务逻辑的最终归宿它应该是一个编排层。真正不可变的业务规则放在领域对象或独立的业务组件里Service负责把流程串起来管好事务、缓存、权限和外部依赖的协作。在V6里我给UserService立了五条规矩一个public方法只做一件事方法名必须表达业务意图不能叫doSomething不允许Entity作为方法入参或出参Entity是数据层的形状不是业务层的形状所有跨表操作必须声明事务边界本地方法调用不产生新事务缓存逻辑不裸写在业务代码里统一走缓存组件接口业务异常必须抛自定义异常绝不向上透传SQLException这类底层异常这五条规矩听起来简单但每条背后都有一段血泪史。比如第二条早期版本里Service方法直接返回User实体Controller拿到后序列化成JSONpassword字段泄露到前端去了那真是线上事故级别的教训。所以V6里我宁可多写两个转换方法也要把边界卡死。2. 核心方法设计与参数取舍2.1 接口设计方法粒度怎么拆V6的UserService接口方法比V2少了一大半但覆盖的业务场景一点没少。核心方法就这些public interface UserService { UserVO register(RegisterCommand command); LoginResult login(LoginCommand command); void logout(Long userId); UserVO updateProfile(UpdateProfileCommand command); void changePassword(ChangePasswordCommand command); void bindPhone(BindPhoneCommand command); UserVO getUserById(Long userId); PageResultUserVO pageQuery(UserPageQuery query); void disableUser(Long userId, String operator); void enableUser(Long userId, String operator); }注意这里有两个关键变化。第一输入参数从单个散参变成了Command对象。以前的方法签名是updateProfile(Long userId, String nickname, String avatar, Integer gender, String bio)六七个参数排在那里调用方一不小心就传错位置。V6统一改成命令对象参数内聚扩展也方便——加字段不需要改方法签名只要给Command加属性。第二写操作不返回业务数据就不返回但涉及状态变更的一定要返回结果。register返回UserVO是为了让前端拿到新用户信息直接进主页updateProfile返回最新UserVO是为了让前端刷新展示changePassword返回void密码改完不需要给前端任何东西。方法粒度上我坚持一个原则能拆的查询和操作不要合并。比如“登录并返回用户信息”和“登录并返回菜单权限”这种需求不要做成loginWithPermission这种带开关参数的方法那样方法会越来越臃肿。拆成login()返回token和用户基本信息权限菜单单独走权限查询接口各自缓存策略不同、变更频率不同强行合并只会让缓存失效逻辑变得一团糟。2.2 DTO/VO/Entity三层模型V6最狠的一个改动是从代码层面禁止Entity越过Service边界。先明确三个概念Entity对应数据库表结构字段和表列一一映射只存在于Mapper层和Service内部的持久化操作中Command/DTO入参对象承载客户端传入的数据可以包含Entity没有的字段比如确认密码、验证码VO出参对象给前端展示用字段经过裁剪、格式化、脱敏绝不包含敏感字段我在V6里加了一个很简单的强制约定UserServiceImpl内部统一用User实体做持久化操作但public方法入口接收Command出口返回VO。转换工作放在一个独立的UserAssembler类里不散落在业务代码中。Component public class UserAssembler { public User toEntity(RegisterCommand command) { User user new User(); user.setUsername(command.getUsername()); user.setNickname(command.getNickname()); user.setPhone(command.getPhone()); // 密码加密在领域服务里做这里不碰明文密码 return user; } public UserVO toVO(User user) { UserVO vo new UserVO(); vo.setUserId(user.getId()); vo.setUsername(user.getUsername()); vo.setNickname(user.getNickname()); vo.setPhone(PhoneMasker.mask(user.getPhone())); vo.setAvatarUrl(user.getAvatarUrl()); vo.setStatus(user.getStatus()); vo.setCreatedAt(user.getCreatedAt()); return vo; } }这样做的直接好处就是敏感字段的泄露面被降到最低。User实体里即使有password、salt这些字段只要不通过Assembler转成VO就不可能出现在接口响应里。我在代码评审时反复强调一个检查点“Controller层不要出现User实体类型”一旦出现就说明有人绕过Assembler了直接打回。2.3 校验与异常处理的边界V6的校验策略是“Controller做基础校验Service做业务校验双层配合”。Controller层用Jakarta Validation注解做格式校验比如NotBlank、Email、Pattern这些是防小人的防止脏数据进入业务层。Service层做的是业务规则校验比如注册时校验用户名是否已存在、手机号是否被绑定、账号是否被禁用。这两类校验不能混在一起Controller的校验失败应该抛MethodArgumentNotValidException由全局异常处理器统一转成400Service的业务校验失败抛的是自定义BizException带错误码和提示信息。异常体系V6长这样public class BizException extends RuntimeException { private final String errorCode; private final String message; public BizException(String errorCode, String message) { super(message); this.errorCode errorCode; this.message message; } // 省略getter }用法就是Service里校验不通过直接throw new BizException(USER_PHONE_BOUND, 该手机号已绑定其他账号)。全局异常处理器捕获BizException后统一返回{ code: USER_PHONE_BOUND, message: 该手机号已绑定其他账号 }给前端。好过以前那种返回一个裸的false或null让前端自己猜错误原因也避免了把SQLIntegrityConstraintViolationException这种底层异常原样甩给前端。我特别想强调一点Service层不要catch异常后再返回一个结果对象作为“失败的表示”。有些代码风格是返回Result对象里面带success标记Service里try-catch后把异常信息塞进Result里返回。V5之前我这么干过结果是调用方每调一个方法都要先判success再拿数据业务代码里全是if (result.isSuccess())异常栈也被吞了线上排查极其痛苦。V6全面改回异常机制代码清爽了一大截。3. 事务、缓存与并发控制的实操实现3.1 事务边界怎么划事务这件事方法论一句话就讲完一个业务用例一个事务。但是落地的时候坑特别多。V6里我用Transactional注解但有三个使用纪律第一个纪律事务方法必须是public且不能是自调用。Spring的声明式事务是基于AOP代理实现的同一个类里方法调用另一个带Transactional的方法代理不生效事务直接废掉。我在register()里调用内部的insertUser()如果insertUser上有Transactional注解看起来没问题但实际根本没有事务包裹。V6的处理方式是事务注解只放在public业务方法上内部private方法一律不标注事务从机制上避免自调用陷阱。第二个纪律事务方法里不做远程调用和耗时操作。在事务里发短信、调第三方接口、做文件上传这是典型的屠龙之术——事务迟迟不提交数据库连接被长时间占用连接池一满整个系统雪崩。V6的register流程把发短信、送优惠券这些动作全部放到事务提交后的事件监听器里处理。第三个纪律事务里update数据要避开大事务。批量更新用户状态时不要循环几千次update一次update带IN条件批量执行。MyBatis-Plus的updateBatchById在这种场景下效率堪忧我实测过5000条数据用循环单条update耗时是批量update的8倍以上连接占用时间长了锁竞争也上来了。事务提交后的事件监听器V6的写法是这样的Service public class UserServiceImpl implements UserService { Transactional(rollbackFor Exception.class) Override public UserVO register(RegisterCommand command) { // 校验用户名唯一性 // 加密密码并入库 User user assembleUser(command); userMapper.insert(user); // 发布注册完成事件事务提交后才执行 applicationEventPublisher.publishEvent(new UserRegisteredEvent(user.getId())); return userAssembler.toVO(user); } }配合TransactionalEventListener(phase TransactionPhase.AFTER_COMMIT)监听事务提交事件把欢迎短信、初始化默认配置这些写进监听器里。这样既保证了主流程的一致性又不会拖慢事务。3.2 缓存策略与一致性用户信息的读取频率远高于写入频率V6给getUserById加了缓存用的是Cache Aside模式读的时候先查缓存缓存没有就查库并回填写的时候先更新数据库再删除缓存。缓存key的设计我踩过一个很典型的坑。早期用的key是user: userId线上跑着没毛病但后来业务加了一个查询维度——“根据手机号查用户”我又加了一条user:phone: phone的缓存。问题来了用户改了手机号我删了user:phone:old却忘了同步处理user:id:xxx的缓存里冗余的手机号信息导致有段时间部分用户登录后显示的仍是旧手机号。V6的解法是User缓存里只存用户基本信息手机号这类可变更且需要单独索引的字段不放进id维度的缓存里手机号查询走独立缓存更新手机号时同时删除两个key并且用消息通知缓存组件统一失效。public UserVO getUserById(Long userId) { String cacheKey CacheKeyBuilder.userInfo(userId); UserVO cached cacheService.get(cacheKey, UserVO.class); if (cached ! null) { return cached; } User user userMapper.selectById(userId); if (user null) { throw new BizException(USER_NOT_FOUND, 用户不存在); } UserVO vo userAssembler.toVO(user); // 设置过期时间防止缓存永久占用 cacheService.set(cacheKey, vo, Duration.ofMinutes(30)); return vo; }缓存这部分的另一个原则是写操作后直接删缓存而不是更新缓存。更新缓存听起来省一次查询但容易出现并发写导致缓存数据和数据库不一致删缓存代价低、实现简单下次读取时自然回填。3.3 并发场景下的用户状态变更用户状态变更禁用、启用、封号看起来就是个update语句但并发场景下容易出问题。两个管理员同时操作同一个用户一个禁用、一个启用后提交的覆盖先提交的最终状态取决于谁最后写库这显然不合理。V6给用户表加了一个version字段做乐观锁。MyBatis-Plus自带乐观锁插件只需要在实体上标注Version注解update时插件会自动带上version条件Version private Integer version;实现的效果是update user set status ?, version version 1 where id ? and version ?。如果version不匹配影响行数为0说明这期间数据被别人改过了业务层捕获这个结果后抛出“操作冲突请刷新后重试”的异常。这里要特别提醒一点乐观锁不适用于高频写冲突的场景。如果用户表每天有几十万次状态修改乐观锁会带来大量重试不如直接引入分布式锁或者队列串行化。我们业务里用户状态变更频率很低乐观锁完全够用。V6设计时我对并发方案的选择标准很简单读取多、写入少、冲突概率低用乐观锁写入频繁、冲突概率高用悲观锁或分布式锁。4. 踩过的坑与排查实录4.1 密码字段序列化泄漏这个事故我记到现在。早期版本User实体直接被Controller返回Jackson把实体转JSON时把所有字段全序列化了包括password和salt。前端网络请求的Response里密码hash直接暴露虽然hash不可逆但给撞库攻击提供了素材。排查时发现是接口联调阶段前端偶然看到返回报文里有password字段反馈过来才意识到。修复方案除了前面说的DTO/VO隔离之外我还给实体字段加了JsonIgnore兜底双保险。现在Code Review时看到任何Mapper返回的实体出现在Controller层直接打回这条红线不容置疑。4.2 事务自调用失效这个坑是群里一个同事踩的现象是注册用户时插入主表成功、插入扩展表失败但主表数据居然没回滚。查了半天发现register()方法调用的是同一个类里的insertUserDetail()方法而Transactional注解恰好标在insertUserDetail上。因为Spring AOP走的是代理对象内部自调用this.insertUserDetail()根本不经过代理事务注解完全没生效。V6的应对手段有两个一是事务注解只放在public入口方法上内部方法不带事务二是如果确实需要内部方法独立事务比如记录操作日志主事务失败日志也要落库把该方法拆到另一个独立的Service类里通过注入代理对象实现跨类调用。4.3 缓存穿透与雪崩用户查询缓存这块V6处理过一个缓存穿透问题。攻击者频繁查询一个不存在的userId缓存里没有数据库也没有每次都打到数据库DB压力直线上升。解决方式是缓存空值查库没查到就往缓存里写一个特殊的空对象过期时间设短一点比如5分钟后续相同请求直接命中空缓存不再穿透到数据库。缓存雪崩的防护则是给缓存过期时间加随机扰动。原来所有用户缓存都是30分钟过期如果同一时间有大量key一起过期回源请求会瞬间压垮数据库。V6的做法是过期时间在25到35分钟之间随机避免大面积同时过期。4.4 大列表查询与分页早期getUserList这个方法的实现方式是查出全表再内存里分页用户量过万之后接口越来越慢最后查出是MyBatis-Plus的page查询在表数据量大的情况下深分页偏移量巨大limit 100000, 20这种SQL会全表扫到第10万行再截取性能极差。V6的方案是按业务场景区分后台管理端的分页查询用常规page但限制最大偏移量超深分页场景改用游标式查询基于上次查询的最大id或时间戳C端场景的用户搜索直接走Elasticsearch不再查MySQL。其实大部分“用户列表”需求根本不需要用户自己全量翻页加上搜索条件之后结果集本身就小得多。5. 版本演进路线与后续还能怎么扩展5.1 从版本1到版本6的关键节点回顾回头看V1到V6每个版本解决的核心矛盾都不一样版本核心问题关键改进V1代码全堆在Controller抽出Service层但只是代码搬运V2Service变成上帝类方法爆炸按业务模块拆分但Entity仍到处传V3实现细节无法替换引入接口与实现分离V4参数混乱、返回结构不稳定引入DTO/VOEntity不再出ServiceV5异常处理混乱错误信息不可读统一异常体系、全局异常处理器V6事务/缓存/并发缺乏规范明确职责边界补充事务、缓存、乐观锁如果你正准备重构自己的Service层我建议直接跳到V4的起点开始做先做DTO/VO隔离再补统一异常最后再动事务和缓存。步子太大容易把业务改崩。5.2 后续还能怎么演进V6不是终点。按照当前的业务体量下一步我在考虑两件事。第一是引入防腐层隔离外部依赖。现在UserServiceImpl里直接调用了短信服务、对象存储服务的客户端这些外部依赖一旦变更Service代码就得跟着改。后面计划在Service和外部依赖之间加一层防腐接口把外部Sdk调用收敛到独立的adapter包里Service只依赖接口不依赖实现。第二是评估CQRS模式的引入。读操作和写操作的频率、数据形态差异越来越大读操作走缓存、走ES写操作走MySQL如果继续共用一个UserService方法会越来越别扭。更合理的形态是把查询方法拆到独立的UserQueryService里UserService专注命令和事务。这块我还在权衡毕竟拆分是有成本的但如果读接口的调用量继续涨拆分是迟早的事。最后分享一个我写Service层这些年的体会别迷信设计模式先管住边界。V6这一版没用什么高深的设计模式就是老老实实把Entity关在数据层、把事务边界划清楚、把异常说人话、把缓存失效逻辑统一收口。就这几件“笨事”做扎实了Service层的代码质量就已经超过绝大多数项目了。你手里的UserService如果还在V2甚至V1的状态别慌照着V4、V5、V6的路一步步收拾每一步都能看到立竿见影的改善。