做了几年后端,手头业务系统换了一茬又一茬,但几乎每一个项目的第一步,都是先把“用户”这块地基打好。用户注册、信息维护、状态管理、登录权限,这套东西看起来简单,真正要做得稳、做得可扩展、经得住线上流量和频繁需求变更的考验,还是有不少门道。Spring Boot 作为当下 Java 后端最主流的框架,把用户数据管理这套标准能力落地成工程化代码,是我认为每个做业务开发的都应该完整体验一遍的路径。
这篇内容就是我基于 Spring Boot 从零实现一个用户数据管理模块的完整记录。从项目初始化、分层设计,到安全认证、缓存加速、日志排查,再到微服务场景下的协议选择,都会拆开讲清楚。适合同刚学完 Java 基础想接触真实业务的同学,也适合已经有工作经验但想把用户模块做得更规范的后端开发者。我会把每一步的思路和踩坑都写出来,尽量不只是贴代码,而是讲明白"为什么这么做"。
1. 项目定位与整体技术设计
1.1 为什么单拿"用户数据管理"说事
很多人觉得用户管理不就是一张表做增删改查,有什么好研究的?但你把视角放大到整个业务系统里看,用户数据是所有业务的锚点。
我见过一个餐饮 SaaS 项目,用户体系里既有门店老板,又有店员,还有接入了 AI 点餐助手的终端消费者。三类人的字段属性不同,权限边界不同,连登录方式都不一样。如果一开始就把用户表设计成单一大宽表,后面每加一种角色就要加字段、加判断逻辑,代码很快就会被 if else 塞满。相反,如果一开始就按"基础账号+角色扩展"的思路设计,后面接什么业态都稳得住。
所以单拿用户管理出来说,不是因为它简单,而是因为它是检验工程能力的试金石。一个用户模块写得清不清楚,直接决定了后面所有业务模块开发时是顺畅还是踩坑。做管理系统也好,做开放平台也罢,用户数据的建模、存储、访问控制、性能优化,这些问题都是通用的。
Spring Boot 在这里的价值,是它把 Web 开发里大量重复的配置工作给收掉了。过去 SSH 年代,光搭一个能跑的环境就要折腾 datasource、事务、视图解析器好几天。现在 Spring Boot 靠自动配置加起步依赖,一个内嵌容器直接打包运行。我们要做的就是专注在业务逻辑本身,把用户数据的读写、校验、安全、审计这些事做扎实。
1.2 技术选型背后:Spring、Spring Boot、Spring Cloud 的关系
新人经常把 Spring、Spring Boot、Spring Cloud 三个词混在一起。我打个比方:Spring 是工具库,提供依赖注入、AOP、事务管理这些核心能力,是地基;Spring Boot 是精装房,把工具库组装成可以直接拎包入住的框架,内置了 Web 服务器、自动配置、健康检查;Spring Cloud 是小区物业,解决微服务架构下多个服务之间的注册发现、配置管理、熔断降级这些协同问题。
做用户数据管理这个项目,核心用 Spring Boot,思考的是单应用内的模块化;但如果公司把用户中心独立成一个服务,就要考虑 Spring Cloud 那套服务治理的东西。不过不管哪种形态,底层对用户数据的操作逻辑是一致的。
版本选择上,我用的是 Spring Boot 3.x。它要求 JDK 17 及以上,最大的变化是 Jakarta EE 命名空间的迁移,比如javax.servlet变成了jakarta.servlet。如果你所在团队还在用 Spring Boot 2.7 和 JDK 8,那不用急着升,但新项目确实可以评估 3.x 的收益:启动更快、GraalVM 原生镜像支持更好、安全框架的配置模型更简洁。这次项目里我用到的安全配置就是基于 Spring Boot 3 的写法,后面会专门展开。
项目结构我采用经典的四层模型:Controller 负责接口路由和参数绑定,Service 承载业务规则和事务边界,Repository 做数据持久化,Entity 映射表结构。再加上 DTO 做数据出入参隔离,防止实体直接暴露给前端。这个分层看起来老套,但老套意味着成熟,团队里任何一个后端进来都能快速上手,这是我最看重的一点。
1.3 用户模块的功能边界划定
动手之前先把用户数据管理的功能范围定清楚,不然写着写着就容易失控。
我这次划的核心功能有五个:用户注册与资料维护、用户查询与分页列表、用户状态管理(启用/禁用/删除)、登录认证与密码加密、操作日志记录。扩展增强部分是本地缓存加速热点查询、WebSocket 推送用户状态变更通知、gRPC 接口供其他微服务调用用户基础信息。这其实也对应了很多实际业务的演进路径:先把基础的 CRUD 和认证跑通,再根据性能需求和架构演进逐步加缓存、加通信协议。
需求边界划定之后,数据库表设计就顺理成章了。
2. 从零搭建项目与核心模块实现
2.1 第一个 Spring Boot 项目的快速创建
现在的项目脚手架比早年省事太多。直接用 Spring Initializr 生成基础工程,选 Java 17、Spring Boot 3.2.x,依赖勾选 Spring Web、Spring Data JPA、Spring Security、Validation、H2(本地调试用),生产切到 MySQL 即可。
生成后的目录结构里,我习惯把代码按功能分包而不是按技术层分包。比如用户管理相关的代码放在user包下,里面再分controller、service、repository、entity、dto。这样改动用户功能时,基本只在这个包内活动,不会牵扯到其他模块,也方便以后做模块拆分时直接搬走。
配置文件我用 YAML 格式。有人说 properties 够用了,但 YAML 的缩进结构天然适合表达多层配置,比如 spring、spring.datasource、spring.jpa 这种层级关系,读起来一目了然。而且 Spring Boot 支持在一个 YAML 里用---分割多环境配置块,开发生产和测试环境的参数可以放在同一个文件里管理,省掉多个 profile 文件来回切换的麻烦。
spring: application: name: user-center datasource: url: jdbc:mysql://localhost:3306/user_center?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root hikari: maximum-pool-size: 20 minimum-idle: 5 jpa: hibernate: ddl-auto: validate show-sql: false cache: type: caffeine这里有个细节值得注意:ddl-auto我设置为validate而不是update。用update虽然省事,但生产环境一旦表结构被框架自动改了,出问题很难追溯。validate模式启动时会校验实体和表结构是否一致,不一致直接报错,逼着开发用正式的迁移脚本去改表结构。配合 Flyway 管理数据库版本,这算是比较稳妥的组合。
2.2 用户实体设计与表结构要点
用户表的设计,我踩过几次坑之后固定下来了一套字段模板。
主键我采用数据库自增 Long 类型,而不是用 UUID 字符串。自增主键对 InnoDB 的聚簇索引友好,写入性能好,而且 URL 路径传参短。UUID 适合在分布式场景做全局唯一标识,但我更倾向于把这个需求放在业务唯一标识字段上解决,比如用户编号(user_no)单独生成一个,主键仍然用自增,兼得性能与全局唯一性。
password 字段存的是 BCrypt 加密后的哈希值,长度设置 60 到 64 就够。我在早期项目里把密码字段设置为 VARCHAR(100),纯粹是浪费空间,而且字段太长会影响索引效率。BCrypt 加密的固定长度是 60 个字符,设置成 VARCHAR(64) 已经留了余量。email 和 phone 这类字段要注意唯一索引的设置。但这里有个坑:如果用户允许不填邮箱,空字符串和 NULL 值在唯一索引中的表现不同,多个 NULL 值可以共存,但多个空字符串会冲突。这就要求在应用层统一处理,转成 NULL 存储。
@Entity @Table(name = "sys_user", indexes = { @Index(name = "uk_username", columnList = "username", unique = true), @Index(name = "idx_status", columnList = "status") }) public class UserEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false, length = 32) private String username; @Column(nullable = false, length = 64) private String password; @Column(length = 64) private String email; @Column(length = 20) private String phone; @Column(nullable = false) private Integer status; @Column(nullable = false, updatable = false) private LocalDateTime createTime; @Column(nullable = false) private LocalDateTime updateTime; }状态字段我用 Integer 而不是 Boolean,因为用户常见状态不止启用和禁用,后面还可能加"待激活""已锁定"这些中间状态。Boolean 的表达力在业务场景里往往不够用。createTime 设置updatable = false,放物理删除之外还加一个逻辑删除标记,用户点删除时只改deleted字段,数据仍然留存,方便审计追溯。
2.3 Repository 到 Controller 的分层落地
Spring Data JPA 的 Repository 接口写法很简洁,但有几个关键点要注意。
继承JpaRepository<T, ID>就自动获得基础的 CRUD 方法,不需要写实现类。但涉及复杂查询时,我建议优先使用@Query注解写 JPQL 或者原生 SQL,而不是靠方法名解析。方法名解析虽方便,但一旦查询条件多了,方法名会变得又臭又长,比如findByStatusAndEmailIsNotNullAndCreateTimeBetween这种,可读性极差。
分页查询用 Spring Data 内置的Pageable机制。前端传页码和页大小,后端构造PageRequest.of(page, size, Sort.by("createTime").descending()),返回Page<UserEntity>即可。这里我踩过的一个坑是页码从 0 开始。前端习惯从 1 开始传,如果不做转换,第一页数据会一直看不到。我统一在 Controller 层做page - 1的转换,不把前端习惯带进 Repository 层。
Service 层是事务边界所在。所有写操作都要保证事务性,比如用户注册时,不仅要插入用户记录,还要初始化角色关联,甚至发送欢迎通知,多步操作任何一个失败都应该回滚。我在注册方法上直接标注@Transactional(rollbackFor = Exception.class),默认的运行时回滚策略在某些受检异常场景下会失效,显式指定更安全。
Controller 层只做三件事:接收参数、调用 Service、包装返回结果。尽量别在 Controller 里写业务逻辑,比如密码校验、状态判断这些统统下沉到 Service。这么做的原因是 Controller 容易被切面拦截,比如统一日志、统一异常处理,如果里边掺杂业务逻辑,切面作用范围就会失控。
@RestController @RequestMapping("/api/users") public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @PostMapping public Result<Long> createUser(@RequestBody @Valid UserCreateDTO dto) { return Result.success(userService.createUser(dto)); } @GetMapping public Result<Page<UserVO>> pageUsers(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size) { return Result.success(userService.pageUsers(page - 1, size)); } }Result 是统一返回包装类,包含 code、message、data 三个字段。这个习惯建议从一开始就养好。一个项目里有的接口直接返回实体、有的返回 Map、有的返回 null,前后端联调的时候就会非常混乱。虽然这次讲的是用户模块,但它对接的接口风格会为整个项目的接口规范定调。
3. 用户安全认证与依赖注入的细节处理
3.1 Bean 注入控制:构造器注入是默认首选
Spring Boot 里的 Bean 注入方式有三种:字段注入、Setter 注入、构造器注入。很多新手上来就在类成员变量上写@Autowired,IDEA 也会默认提示 warning,这个习惯趁早改掉。
字段注入写起来确实舒服,但有几个问题。第一,依赖关系不直观,类实例化时到底需要哪些依赖,一眼看不出来。第二,无法用final修饰,依赖可能被后续代码修改,破坏了不可变性。第三,测试的时候不能直接 new 出来传参,必须靠 Spring 容器,单元测试变得别扭。构造器注入用final修饰依赖,Spring 会保证所有依赖在构造时全部就绪,测试也能直接传 mock 对象。
但构造器注入也不是没有坑。当类之间的依赖关系出现循环时,比如 A 依赖 B,B 又依赖 A,构造器注入在启动时就会直接报错,而字段注入可能拖到运行时才暴露问题。所以我把"碰到循环依赖报错"当成一个信号,说明设计上有问题,应该重构而不是改注入方式。比如抽出中间层、用事件发布解耦、或者把单向依赖打破。解决循环依赖的正确姿势是调整依赖方向,不是改成懒加载或字段注入。
多个同类 Bean 存在时,可以用@Qualifier指定注入哪个。比如我有两个UserNotifier实现类,一个发短信、一个发邮件,注入时加@Qualifier("smsNotifier")就能精确选择。更优雅的玩法是用@Named注解结合接口按名称自动匹配,但理解成本稍微高一点,团队里还是用 Qualifier 更直白。
@Primary是另一个控制手段。当多个候选 Bean 都不指定 Qualifier 时,Spring 默认选中标记了@Primary的那个。我一般把默认实现的 Bean 标上@Primary作为兜底,具体场景需要替换时再按类型或名称指定。这样既保证了默认行为,又保留了灵活切换的窗口。
3.2 Spring Security:从适配器到 SecurityFilterChain 的迁移
Spring Security 的配置变化是很多从 Spring Boot 2.x 升到 3.x 的人最先撞上的墙。
旧写法是继承WebSecurityConfigurerAdapter,重写configure(HttpSecurity http)方法,这是 Spring Boot 2.x 时代的标准做法。Spring Boot 3 中这个类已经被移除,新写法是用SecurityFilterChain作为 Bean,配合HttpSecurity的建造者风格进行配置。核心思路没有变,都是从"放行哪些路径、拦截哪些路径、走什么认证方式"这三个维度展开。
我在做用户登录认证时的配置如下:/api/auth/login和/api/users/register这两条路径放行,其余/api/**全部要求认证。认证方式用 JWT Token,替换掉传统 Session。无状态认证的好处是后端服务可以水平扩展,因为 Session 数据不用同步到内存或者 Redis,每台机器都能独立校验 Token。
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/login", "/api/users/register").permitAll() .requestMatchers("/api/**").authenticated() .anyRequest().permitAll()) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }这里解释几个非常容易被问到的点。
csrf为什么禁用?因为 JWT 是放在 Authorization Header 里传的,不是靠 Cookie 自动携带,CSRF 攻击的根基就不存在了。但如果完全采用 Session + Cookie 方案,CSRF 保护必须保留。这个决定和前端怎么做事关联的,不是无脑禁。
sessionManagement设置成STATELESS,是通知 Spring 容器不要创建 HttpSession。如果忘记配置,Spring Security 可能一边检查 JWT,一边顺手创建 Session,白白占用服务端内存。
自定义的JwtAuthFilter放在UsernamePasswordAuthenticationFilter之前,保证在认证逻辑执行前先解析 Token。过滤器里做的事情是取 Header 中的 Bearer Token -> 解析用户 ID -> 加载用户信息 -> 设置到 SecurityContextHolder。这里三个注意点:解析失败不要抛异常直接放行,让后续的认证机制判定;Token 过期要返回 401 而不是 500;每次请求都创建 SecurityContext 又要清理干净,防止线程池复用导致用户身份串号。
密码加密这块我用 BCrypt。BCryptPasswordEncoder不能换,原因在于它内置盐值随机生成,并且每次加密结果都不一样,所以用matches(明文, 哈希)校验而不是等值比较。强制所有项目成员不要拿 MD5/SHA 去做密码哈希,这是底线。数据库泄露时,MD5 撞库成本极低,BCrypt 因为计算代价昂贵,暴力破解要难得非常多。
3.3 登录认证中的用户加载链路
实现UserDetailsService接口是 Spring Security 接入用户数据的标准途径。loadUserByUsername方法根据用户名查库,返回UserDetails类型。这里最容易被忽略的细节是异常处理:用户不存在时要抛出UsernameNotFoundException,不能返回 null,否则后面认证过程会空指针。用户被禁用时,UserDetails.isEnabled()需要返回 false,配合DaoAuthenticationProvider的禁用检查,在认证阶段就拦住。
有一个实际项目中的体验是:不要把过多的业务状态往UserDetails里塞。比如积分、等级、会员到期时间这些,查询频繁且字段频繁变动,塞进去会导致每次请求都重新拉取、重新刷新 Token,代价很大。UserDetails 只放最小集:用户ID、用户名、密码哈希、角色列表、启用状态。其他信息需要时再查表。这个边界划清楚,认证链路的性能就基本稳了。
4. 日志、缓存与高性能通信扩展
4.1 日志体系:别再用 System.out.println 定位问题
用户模块上线后,最忙的一天是运营批量导数据出错的时候。没有结构化日志,排查问题就像大海捞针。
Spring Boot 默认使用 Logback 作为日志实现。application.yml里配置好日志级别和输出路径,再加上一个logback-spring.xml做滚动策略和格式定制就够用了。
logging: level: root: info com.example.usercenter: debug org.springframework.security: warn file: name: logs/user-center.log logback: rollingpolicy: max-history: 15 max-file-size: 100MB日志级别按包设置的目的是控制粒度。com.example.usercenter在开发环境开 debug,方便看业务细节;Spring Security 的框架日志设 warn,避免刷屏。生产环境 root 保持 info,如果遇到疑难问题再动态调级,不用重启。
我强烈建议项目从第一天就引入 MDC 机制。MDC 全称 Mapped Diagnostic Context,本质是线程私有的上下文 Map。我在网关或者 Spring 的拦截器里,为每一个请求生成一个 traceId 放到 MDC 中,日志格式里打印%X{traceId},这样一整条请求链路中打出的每行日志都有同一个追踪 ID。查询用户在注册、登录、改密码过程中做了什么操作,一条 traceId 捞出来全部搞定。
用户数据是典型的敏感数据,每次查询和修改都应该有审计日志。我的做法是在 Service 层写一个切面,拦截用户模块所有写操作,记录操作人、操作时间、目标用户 ID、请求参数摘要、操作结果。这里注意不要记录完整密码字段,日志里打全量参数是安全事故高发区。
4.2 Caffeine 本地缓存:加速用户热点数据查询
用户数据的特点是读多写少,尤其用户头像、昵称、基础资料这类信息,可能一分钟被读取几千次,但写入频率极低。把这类数据放数据库里每次都全量查,连接池开销大、响应时间长,放 Caffeine 本地缓存则效果立竿见影。
Spring Boot 集成 Caffeine 比较简单,引入com.github.ben-manes.caffeine:caffeine和spring-boot-starter-cache,然后在配置类里加一个CacheManagerBean。
@Configuration @EnableCaching public class CacheConfig { @Bean public CacheManager cacheManager() { Caffeine<Object, Object> caffeine = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(5, TimeUnit.MINUTES) .recordStats(); return new CaffeineCacheManager("userDetail", "userAuth"); } }expireAfterWrite是写入后过期,适合用户信息这种会主动变更的数据。maximumSize10万条,对单机用户数据来说足够,不设最大条数的话缓存无限增长,会出现内存溢出。
在查询用户详情的 Service 方法上标注@Cacheable(cacheNames = "userDetail", key = "#userId"),第一次查询走数据库,后续查询直接命中缓存。这里最难的一环是缓存一致性。用户修改资料后,必须主动清掉对应缓存。我通常在被修改的写方法上通过@CacheEvict来清理,不用 TTL 傻等它过期。同时给 Caffeine 开启recordStats(),可以通过CaffeineCacheManager拿到命中率指标,如果命中率低,说明缓存的数据和业务访问模式不匹配,需要调整过期策略。
本地缓存并非万能。它是单机内存态,当应用有多个实例部署时,各个实例的缓存不共享,A 实例更新了用户信息,B 实例的缓存还是旧值。这个问题在单节点阶段不影响,但一旦扩容到多实例就必须考虑,要么接受短时不一致,要么换 Redis 做分布式缓存。用户数据这种对一致性要求高的场景,我更建议本地缓存只存低敏感、低变动的数据,敏感数据以分布式缓存为主。
4.3 WebSocket 与 gRPC:用户模块的通信扩展
用户数据变化的实时通知,特别是管理后台的"用户禁用、强制下线"这类操作,天然适合 WebSocket。管理员操作后,服务端可以主动把消息推送到用户端,不用用户轮询接口。Spring Boot 里配置 WebSocket,只需在 YAML 中声明端点,然后实现握手拦截器和消息处理器即可。
以下是 WebSocket 在 YAML 中的配置方式:
spring: websocket: mapping: path: /ws allowed-origins: - http://localhost:8080实际业务中我会用 STOMP 协议做一层消息代理,这样客户端只需要订阅/topic/user/{userId}主题,服务端用SimpMessagingTemplate推送即可,不再手动管理底层 WebSocket Session。需要注意鉴权问题:WebSocket 握手时的 Token 校验容易做漏,如果直接放行,攻击者伪装成管理员接收推送消息,后果很严重。我建议在握手拦截器里统一校验 Token,用ChannelInterceptor做入站消息的二次鉴权,不能信任客户端任何自报身份。
再说 gRPC。为什么要引入 gRPC 而不是继续用 REST?典型场景是用户中心被拆成独立服务后,订单服务查询用户基础信息,每次走 HTTP + JSON 序列化,性能开销大不说,调试起来接口调用链路也不清晰。gRPC 基于 HTTP/2,默认用 Protobuf 二进制序列化,单次调用比 JSON 方式快很多,还有强类型接口定义。Spring Boot 6 的官方支持是spring-boot-starter-grpc,只需要定义好.proto文件,生成 stub,实现服务接口即可。
用户模块里,我把 gRPC 服务的定位放在"对内服务间查询",而非对外暴露。对外接口仍然是 REST 或 GraphQL,保持生态兼容和客户端便利性;对内高强度的读操作,比如订单系统要批量查询用户 ID 对应的昵称和头像,用 gRPC 双向流或者普通接口性能明显更好。
syntax = "proto3"; package usercenter; service UserQueryService { rpc GetUserInfo (UserInfoRequest) returns (UserInfoResponse); } message UserInfoRequest { int64 user_id = 1; } message UserInfoResponse { int64 user_id = 1; string username = 2; string nickname = 3; string avatar = 4; }引入新协议的同时也带来新的复杂度。服务发现你要处理、负载均衡要考虑、链路追踪要兼容 HTTP/2 的传播,如果团队小运维能力弱,这些成本不是零。我个人的判断是:当内部服务间调用量到了一定规模,比如日均千万级,gRPC 的收益足够覆盖成本,那就值得上。
5. 常见问题与排查技巧实录
5.1 典型问题速查表
把实际开发中踩过的坑集中整理成表,遇到问题可以直接对照排查。
| 问题现象 | 根本原因 | 解决思路 |
|---|---|---|
| 启动报循环依赖错误 | 两个 Service 互相构造器注入 | 重构依赖方向,或引入事件解耦 |
| 查询用户列表接口越来越慢 | 缺少复合索引,或全表扫描 | SQL EXPLAIN 分析执行计划,补联合索引 |
| 密码 matches 永远返回 false | 数据库字段长度不够,哈希被截断 | 确认 BCrypt 哈希长度为 60,字段长度大于 60 |
| 登录状态一会有一会没有 | 忽略了 SecurityContext 清理 | 在每个请求结束后调用 SecurityContextHolder.clearContext() |
| JSON 序列化无限递归 | 实体双向关联未加 @JsonIgnore | 或改用 DTO 视图对象返回,不直接序列化实体 |
| 缓存数据更新后仍是旧值 | 更新方法没有清理对应缓存 | 在写方法上标注 @CacheEvict,保证关键路径一致性 |
| 分页查询返回数据错位 | 前端页数从 1 开始,后端 Pageable 从 0 开始 | 在 Controller 统一减一转换 |
| WebSocket 偶发断连 | 没处理心跳保活,代理空闲超时 | 客户端定期发送 ping,服务端配置心跳响应 |
5.2 最值得记录的三个排障场景
第一个是循环依赖。场景是用户 Service 要调用通知 Service,通知 Service 又需要用户信息,两边一写启动直接 failed,报The dependencies of some of the beans form a cycle。我当时第一反应是加@Lazy注解绕过,但绕过不等于解决。后来用一个UserEventPublisher把通知逻辑改造成事件监听:用户注册成功后发布UserRegisteredEvent,通知模块监听这个事件做自己的事,依赖方向变成了单向,循环自然消失。
第二个是 JWT 密钥配置在代码里的问题。早期项目把密钥写死在配置类里,有一次泄漏到代码仓库,被迫全部用户强制下线并重新登录。现在的做法是通过环境变量注入密钥,代码仓库只保留占位符。同时每次生成的 Token 里带一个密码版本号字段,密码修改后版本号更新,所有旧 Token 立即失效,这比单纯依赖过期时间要安全得多。
第三个是慢 SQL 隐藏得很深。用户列表页加了一个模糊查询手机号的接口,本地测试数据少看不出问题,上了生产几万条数据直接超时。EXPLAIN 一看,用了%xxx%前缀模糊查询,索引完全失效。解决方式是把这类模糊查询改成前缀匹配模式,或者干脆引入 Elasticsearch 做更灵活的检索。用户数据管理到后期,搜索需求一定会超出数据库能力边界,提前想好拆分方案可以减少很多临时救火。
5.3 兜底与兜不住:异常处理的两个层次
用户模块里的业务异常,比如用户名重复、邮箱格式错误、用户不存在,这些是预期内的分支流程。我统一用BizException抛出,配合@RestControllerAdvice全局异常处理器,转换成标准错误码返回前端。这类异常不需要打印堆栈,错误信息本身就是要给用户看的。
真正需要关注的是预期外的异常,比如数据库连接池耗尽、外部服务超时。这类异常捕获后应该记录完整堆栈并触发告警。我在全局异常处理器里对这两类做了区分,BizException打 warn 级日志,其他异常打 error 级并带上 traceId,这样排查问题时能把目标范围缩到最小。
UnifiedResult 封装里还有一个细节:错误码不要直接用字符串如 "USER_NOT_FOUND",而要带模块前缀,如 "101001",方便运营人员直接根据错误码定位问题模块。用户管理相关错误码我规划在 101 段,角色权限在 102 段,这样随着业务扩张错误码也不会乱成一锅粥。
5.4 测试:用户数据的防御墙
用户数据是核心资产,方法级别的单元测试和接口级别的集成测试我都写了,重点覆盖三层:密码加密校验、缓存命中与失效、权限边界。
Repository 层的测试用 H2 内存数据库跑真实 SQL,验证分页查询和条件过滤。Service 层测试用 Mockito mock 掉 Repository,专注验证业务规则,比如用户创建时用户名去重检查、状态流转是否合法。Security 层的测试要重点验证路径权限:未登录用户访问/api/users应得到 401,普通用户访问管理员接口应得到 403。
这里容易忽略的是测试代码里的数据污染问题。因为用户是有唯一索引的,测试用例之间如果不清理数据库,重复跑同一条用户名就会报冲突。我在测试基类的@BeforeEach里统一清理用户表,确保每条用例独立运行。这个习惯养成了,后面写任何模块测试都会很顺畅。
6. 项目运行的工程经验补充
6.1 起步依赖的版本兼容性管理
Spring Boot 的版本管理很棒的一点是它聚合了常用第三方库的兼容版本。直接用spring-boot-starter-parent作为 parent,就不需要手动指定一大堆jar版本号。但一旦引用的库不在 Boot 管理范围内,比如某个自研中间件 SDK,版本冲突就会冒出来。我最常见到的是 Jackson 版本被传递依赖覆盖,导致 JSON 序列化行为诡异。排查方法是启动时带上--debug参数,查看依赖树报告,迅速定位版本来源。
mvn dependency:tree -Dincludes=com.fasterxml.jackson.core在 Spring Boot 项目中,尽量复用 Spring 官方管理的 BOM 版本,非必要不手动覆盖。每覆盖一个版本都要有充分的理由和验证记录,不然上线后的兼容性问题非常隐蔽。
6.2 环境隔离与配置管理
开发、测试、生产三套环境的数据库连接、缓存、日志级别都不同。我用 YAML 多文档块组织,通过spring.profiles.active指定当前激活的环境。三套环境四个配置维度存在安全差异:生产环境的数据库密码绝不应放在代码库,用环境变量或者配置中心单独管理。我在启动命令里用--spring.datasource.password=${DB_PASSWORD}的方式注入,别人拉走代码没有环境变量也跑不起来生产配置。
多实例部署时配置修改再热更新,spring-boot-starter-actuator的 refresh 端点配合配置中心可以做到,但那是微服务化之后的事了。在这之前,先保证每个环境的配置源独立、可追溯,就已经能节省很多半夜被叫起来改配置的时间。
6.3 迭代演进:单体用户模块到微服务用户中心
这次项目以单体模块收尾,但我特意在代码设计上预留了微服务化的空间。用户相关的传输对象全部在 API 层用 DTO 隔离,不直接暴露实体;认证逻辑通过独立的AuthService封装,目标是以后拆成独立服务时,Controller 和 Repository 层能跟着业务模块直接搬走。
从单体到微服务,用户数据管理要面对的新问题有几个:会话状态从 Session 改成分布式 Token,用户查询缓存从本地 Caffeine 改成 Redis,跨服务调用从 Feign 改成 gRPC,接口文档从手写 Swagger 改成契约驱动。我用了整整一个专栏的体量思考过渡方案,结论是不要为了微服务而微服务。等用户模块的单体代码已经足够内聚、团队也习惯了清晰的接口边界时,拆分才是一件顺理成章的事。
如果把这次项目的代码和文档沉淀下来,用户中心完全可以作为公司新项目的脚手架模板。它包含了最基础也最关键的工程能力:清晰的代码分层、规范的身份认证、可靠的日志链路、高性能的缓存访问、丰富的排障经验。这个模板也许不能直接解决所有业务问题,但它提供了一个经过验证的起点。下一篇分享,我想把重点放在用户中心之外的话题上,看看互联网系统中其他核心模块是怎样一步步长成复杂体系的。