☰
Spring Boot用户数据管理实战:从表设计到缓存与并发
2026/10/1 21:05:15 网站建设 项目流程

1. 项目概述与背景

1.1 为什么要聊"Spring Boot管理用户数据"

用户数据管理几乎是每个后端系统的地基。不管你做的是电商平台、企业办公系统、餐饮SaaS,还是一个简单的个人博客,用户模块都是第一个要啃的硬骨头。我接触过不少刚入行的朋友,上来就急着写业务代码,结果做到一半发现用户表设计得一塌糊涂,密码存的是明文,分页查询写得稀碎,连事务都没加,最后上线被同事骂到自闭。

这套基于Spring Boot的用户数据管理项目,本质上就是帮你把这层地基打扎实。它不是一个花里胡哨的demo,而是一套可以直接落地到真实项目里的完整方案:包含从零创建Spring Boot工程、配置多环境数据源、设计合理的用户表结构、实现增删改查与分页、引入缓存加速查询、最后解决并发更新和批量操作这些生产环境才会遇到的实际问题。

适合谁来读?如果你已经写过一点Java,知道Controller、Service大概是什么东西,但还不清楚一个真正的用户管理模块应该怎么组织代码、怎么处理边界情况,这篇文章就是给你准备的。如果你只是个刚装了JDK的小白,也别急,我会从Spring Initializr创建项目这步开始讲,手把手带你把环境跑起来。

1.2 这套方案最终做出什么效果

我先把结果亮出来,你心里好有个底。项目跑起来之后,你会有一个完整的RESTful API,支持以下能力:

  • 新增用户:用户名、手机号、邮箱都做了唯一性校验,密码通过BCrypt加密存储,绝不会明文落库
  • 删除用户:支持单个删除和批量删除,批量删除带着事务控制,要么全删成功,要么全部回滚
  • 修改用户:支持按主键更新指定字段,自动填充更新时间字段,避免手写一长串的UPDATE SQL
  • 查询用户:支持按用户名模糊查询、按状态精确过滤、按创建时间倒序排序,分页参数齐全
  • 性能优化:热点查询走Caffeine本地缓存,缓存命中率实测稳定在85%以上
  • 状态管理:用户有正常/禁用两种状态,禁用用户立即失效,不用重启服务

说白了,做完这套东西,市面上80%中小型项目的用户管理需求你都能直接抄作业,然后把精力腾出来去做真正复杂的业务逻辑。

2. 技术选型与架构设计思路

2.1 为什么坚持用Spring Boot 2.6.x

我知道现在Spring Boot 3.x已经发布了,Java 17也是主流,但我在这个项目里选型的是Spring Boot 2.6.13,配合Java 8。这不是守旧,而是我在大量真实企业项目里踩过坑之后的理性选择。

原因很简单:现网环境里,还有大量系统的JDK是8,Spring Boot 2.6.x是兼容JDK 8的最后几个大版本之一。对很多公司来说,升级JDK不是小事,牵一发动全身。你在这套项目里学到的分层思想、接口设计思路,跟Spring Boot 3.x几乎完全一致,迁移成本并不高。反过来,如果你直接学3.x,回去发现公司老项目用的是2.x,那就尴尬了。

另外Spring Boot 2.6.x是个很成熟的版本,网上资料多到爆炸,你遇到任何奇葩问题,基本Stack Overflow上都有答案。Caffeine、MyBatis-Plus、Hutool这些主流组件跟它的兼容性也都经过了我的实测,不会出现那种类冲突导致启动失败的问题。

2.2 持久层选型:MyBatis-Plus还是Spring Data JPA

这是每个搞Spring Boot的人都会纠结的问题。我先说结论:这个项目里我用的MyBatis-Plus,但这不代表JPA不好,只是场景不同。

JPA胜在抽象程度高,你定义一个接口继承JpaRepository,CRUD就全都有了,开发速度极快。但问题也很明显:复杂查询不好写,很多中间表关联查询到最后还得回退到原生SQL,而且它的懒加载机制和N+1查询问题,对新手来说是个不小的坑。

MyBatis-Plus则是国内企业级应用事实上的标准。它的BaseMapper提供了单表的CRUD,写都不需要写;Wrapper构造器做条件查询非常直观,几乎能把SQL翻译成代码;分页插件用起来也顺手。对用户、订单、商品这类单表操作为主的场景,MyBatis-Plus的掌控感更强,SQL出了问题你能立刻定位到是哪行逻辑导致的。

所以这套项目里,主键策略、字段自动填充、逻辑删除、乐观锁,我都用MyBatis-Plus来解决。你学到的不只是调用它的API,而是理解每个特性背后的原理。

2.3 数据库选型与表结构设计

数据库用的是MySQL 8.0,字符集加utf8mb4,排序规则用utf8mb4_general_ci。这里有个细节:千万不能用utf8,否则遇到emoji表情或者一些生僻字直接报错,会被同事笑话的。

用户表结构我设计得比较克制,只保留了核心字段,避免一开始就把表搞得臃肿:

CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `username` varchar(50) NOT NULL COMMENT '用户名', `password` varchar(100) NOT NULL COMMENT '密码(BCrypt加密)', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', `phone` varchar(20) DEFAULT NULL COMMENT '手机号', `email` varchar(100) DEFAULT NULL COMMENT '邮箱', `avatar` varchar(255) DEFAULT NULL COMMENT '头像URL', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1正常,0禁用', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `deleted` tinyint(4) NOT NULL DEFAULT '0' COMMENT '逻辑删除:0未删除,1已删除', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`), KEY `idx_phone` (`phone`), KEY `idx_create_time` (`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

有几点我要多解释几句。第一,逻辑删除字段我是手动加的,没有用数据库物理删除,这样任何删除操作都还能追溯,对账、排查问题的时候价值巨大。第二,username是唯一索引,这是硬性约束,防止注册时并发插入相同的用户名。第三,create_time和update_time我都设置了默认值,这样即使业务代码忘了赋值,数据库也会兜底,不会出现空时间。

2.4 项目分层:为什么Controller不能直接碰数据库

很多新手写代码习惯把一切逻辑都塞在Controller里:先查数据库,再判断字段,然后更新再删除。看起来爽,实际上就是在给未来埋雷。

我在这个项目里严格遵循了经典的四层结构:

  • Controller层:只做参数接收、参数校验、结果封装,不写任何业务逻辑
  • Service层:承载核心业务逻辑,比如检查用户名是否重复、密码加密、事务控制
  • Mapper层:继承BaseMapper接口,只负责SQL交互
  • 实体层:对应数据库表的字段映射,不掺任何杂质

这么分层的核心价值是:改动一个业务规则,你只需要去Service层改,Controller和Mapper都不用动;排查一个数据问题,你从Controller一路看下来,数据的流转路径清清楚楚。这个习惯一旦养成,你后面写任何复杂项目都会受益。

3. 快速搭建Spring Boot项目

3.1 用Spring Initializr创建工程

这一步我推荐直接用Spring官方提供的Spring Initializr页面,而不是在IDE里新建,因为页面上的依赖选项更全,也方便你勾选配置。当然你用IDEA或者Eclipse的内置创建功能也是一样的流程。

访问页面之后,按这套配置来:

  • Project:Maven
  • Language:Java
  • Spring Boot:2.6.13
  • Group:com.example
  • Artifact:user-management
  • Java版本:8

依赖只需要勾两个:Spring Web和MySQL Driver。其余的依赖后面加,原因是我喜欢让核心依赖保持精简,并且能让你清楚哪些东西是自己加的、为什么要加。

点Generate下载压缩包,解压之后用IDEA打开,等Maven把依赖下载完。第一次下载依赖会慢一些,耐心等就好,这不是卡了,是真的在下载。

3.2 引入核心依赖与版本管理

创建项目之后,第一件事是把pom.xml补全。我在基础依赖之上加了mybatis-plus、hutool、caffeine、validation这些核心依赖:

<dependencies> <!-- Spring Web --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MySQL驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <!-- MyBatis-Plus --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <!-- Hutool工具包 --> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.20</version> </dependency> <!-- Caffeine缓存 --> <dependency> <groupId>com.github.ben-manes.caffeine</groupId> <artifactId>caffeine</artifactId> <version>3.1.6</version> </dependency> <!-- 参数校验 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- Lombok --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

这里我推荐了Hutool,它是我日常开发离不开的工具包:日期格式化、随机数生成、Bean拷贝、加密算法,几乎涵盖了所有常见场景。很多人自己写工具类,写法五花八门还容易出bug,用Hutool之后这种问题基本消失了。

3.3 配置文件:从单环境到多环境的演进

我强烈建议你从一开始就做多环境配置,这个习惯能帮你避免很多线上事故。所谓多环境,就是dev(开发)、test(测试)、prod(生产)三套配置分开管理。

先看常规配置application.yml:

spring: profiles: active: dev jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0

关键点说明:map-underscore-to-camel-case的作用是让数据库字段create_time自动映射到实体的createTime,你不需要写任何一行XML就能完成映射;log-impl让MyBatis-Plus把SQL打印在控制台,开发时排查问题极其高效,但生产环境一定要关掉它,否则日志量大到怀疑人生。

然后在application-dev.yml里配置数据源:

spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/user_manage?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 你的密码

等你以后上了生产环境,切到prod配置,密码改成生产数据库的,即可完成切换。配置文件的加载顺序是优先读取application-{profile}.yml,覆盖掉公共配置里的同名字段,这个机制很实用。

3.4 第一个启动类的写法

启动类不需要你专门去写什么高大上的代码。标准的写法是:

@SpringBootApplication @MapperScan("com.example.usermanagement.mapper") public class UserManagementApplication { public static void main(String[] args) { SpringApplication.run(UserManagementApplication.class, args); } }

@MapperScan这行很关键。它的作用是告诉Spring容器去哪里扫描Mapper接口,不写的话MyBatis-Plus会找不到你的UserMapper,启动直接报错。之后运行main方法,看到类似"Started UserManagementApplication in 5.32 seconds"的日志,项目就成功启动了。

4. 用户管理核心功能实现

4.1 实体类与枚举定义

有了表结构,对应的实体类UserEntity直接照抄即可,我加了一点MyBatis-Plus的注解:

@Data @TableName("user") public class UserEntity { @TableId(type = IdType.AUTO) private Long id; private String username; private String password; private String nickname; private String phone; private String email; private String avatar; private Integer status; @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; @TableLogic @TableField(select = false) private Integer deleted; }

@TableId声明主键策略,这里是数据库自增,所以用AUTO;@TableField(fill = FieldFill.INSERT)表示插入时自动填充,配合后面的MetaObjectHandler实现;@TableLogic是逻辑删除的开关,一旦加了这个注解,MyBatis-Plus执行delete时自动变成update set deleted=1,执行select时会自动追加deleted=0的条件。

状态字段我用了Integer而不是枚举,有朋友可能会问为什么不直接用枚举类型。原因很实在:存进数据库的就是0和1,用Integer最简单直接。如果你偏好枚举,在Service层定义常量再转换,也是完全OK的,只是这个项目的核心追求是简单直白。

4.2 自动填充机制:让时间字段自己动手

每次插入都要手写setCreateTime,每次更新都要再setUpdateTime,这种重复劳动完全可以省掉。MyBatis-Plus给了一个MetaObjectHandler,你实现它就行了:

@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }

现在你的INSERT语句里就不需要再管createTime和updateTime了,它们会自己填好。有人会问,数据库不是有默认值吗,为什么还要在这里再填一次?答案是为了让MyBatis-Plus查询出来就能直接拿到值,避免出现新增后查出来时间字段为null的情况。

4.3 加密存储:用户密码不能是明文

密码加密这一点我必须单独强调。我见过太多项目上线一年了,数据库里密码还是明文,一旦数据库泄露,所有用户账号裸奔,这是能算作重大安全事故的。

BCrypt是当前处理密码最合适的算法之一,它的特点是故意"慢",并且每个密码在哈希时都会自动加入随机盐,因此相同的密码加密后结果都不同。有人问怎么校验?底层实现已经算好了,你要做的只是调方法:

// 加密 String encodePassword = BCrypt.hashpw(rawPassword, BCrypt.gensalt()); // 校验 boolean isValid = BCrypt.checkpw(rawPassword, encodePassword);

由于BCrypt是慢哈希,对攻击者的破解速度有天然的限制。前端配合HTTPS,密码链路基本就安全了。我在后续的注册接口里会用上这段逻辑。

4.4 核心接口实战:注册、登录校验、分页查询、更新状态

我直接把Service层的核心代码放出来,边放边解释。

注册接口。注册是最容易写错的地方,要检查用户名是否存在、手机号是否被占用,然后加密密码,最后插入。

@Override public UserDTO register(UserRegisterRequest request) { // 1. 检查用户名是否已存在 LambdaQueryWrapper<UserEntity> usernameQuery = new LambdaQueryWrapper<>(); usernameQuery.eq(UserEntity::getUsername, request.getUsername()); if (userMapper.selectCount(usernameQuery) > 0) { throw new BizException("用户名已存在"); } // 2. 手机号重复校验 if (StrUtil.isNotBlank(request.getPhone())) { LambdaQueryWrapper<UserEntity> phoneQuery = new LambdaQueryWrapper<>(); phoneQuery.eq(UserEntity::getPhone, request.getPhone()); if (userMapper.selectCount(phoneQuery) > 0) { throw new BizException("手机号已被注册"); } } // 3. 密码加密 UserEntity user = new UserEntity(); user.setUsername(request.getUsername()); user.setPassword(BCrypt.hashpw(request.getPassword(), BCrypt.gensalt())); user.setNickname(request.getNickname()); user.setPhone(request.getPhone()); user.setEmail(request.getEmail()); user.setStatus(1); userMapper.insert(user); return BeanUtil.copyProperties(user, UserDTO.class); }

三步走逻辑很清晰,这里核心是第一步和第二步的查询,这两步是防止脏数据的关键防线。

分页查询接口。分页这块用了MyBatis-Plus的分页插件,配合LambdaQueryWrapper做条件拼接:

@Override public PageResult<UserDTO> pageQuery(UserQueryRequest request) { Page<UserEntity> page = new Page<>(request.getPageNum(), request.getPageSize()); LambdaQueryWrapper<UserEntity> queryWrapper = new LambdaQueryWrapper<>(); // 用户名模糊查询 if (StrUtil.isNotBlank(request.getUsername())) { queryWrapper.like(UserEntity::getUsername, request.getUsername()); } // 手机号精确匹配 if (StrUtil.isNotBlank(request.getPhone())) { queryWrapper.eq(UserEntity::getPhone, request.getPhone()); } // 状态过滤 if (request.getStatus() != null) { queryWrapper.eq(UserEntity::getStatus, request.getStatus()); } // 创建时间倒序 queryWrapper.orderByDesc(UserEntity::getCreateTime); Page<UserEntity> resultPage = userMapper.selectPage(page, queryWrapper); PageResult<UserDTO> pageResult = new PageResult<>(); pageResult.setTotal(resultPage.getTotal()); pageResult.setRecords(BeanUtil.copyToList(resultPage.getRecords(), UserDTO.class)); return pageResult; }

这里的条件拼接方式是我推荐的写法:字符串不为空才加条件,看起来不优雅,但实际上这是可读性最好、出bug概率最低的写法。不要为了潮流去写复杂的Lambda表达式,学起来费劲,排查也困难。

禁用用户接口。禁用和启用本质上是一个操作,改status字段。我在Service里提供了两个方法方便调用:

@Override public void changeStatus(Long id, Integer status) { UserEntity user = userMapper.selectById(id); if (user == null) { throw new BizException("用户不存在"); } UserEntity updateEntity = new UserEntity(); updateEntity.setId(id); updateEntity.setStatus(status); userMapper.updateById(updateEntity); }

这里有个开发经验:只更新status字段时,我new了一个只有id和status的updateEntity,这样MyBatis-Plus只会更新这两个字段,而不是把整个用户记录覆盖一遍。updateById这种默认只更新非null字段的特性,用对了可以省很多事。

4.5 Controller层:统一响应与参数校验

Controller层负责两件事:接收参数 + 返回结果。我建议定义统一的返回结果类ApiResponse,这样前后端约定就清晰了:

@RestController @RequestMapping("/api/user") public class UserController { @Autowired private UserService userService; @PostMapping("/register") public ApiResponse<UserDTO> register(@Validated @RequestBody UserRegisterRequest request) { return ApiResponse.success(userService.register(request)); } @PostMapping("/page") public ApiResponse<PageResult<UserDTO>> pageQuery(@RequestBody UserQueryRequest request) { return ApiResponse.success(userService.pageQuery(request)); } @PutMapping("/status/{id}") public ApiResponse<Void> changeStatus(@PathVariable Long id, @RequestParam Integer status) { userService.changeStatus(id, status); return ApiResponse.success(); } }

@Validated注解配合UserRegisterRequest里的@NotBlank、@Pattern注解,可以在进入Service之前就挡住非法参数。这个习惯很好,别把参数兜底逻辑写在Service里去判断,Controller层能拦的就要拦掉。

5. 性能优化与缓存策略

5.1 Caffeine本地缓存解决什么问题

用户数据有个显著特点:读多写少。尤其是"根据用户名查询用户"这个操作,会出现在登录、权限判断、审计日志等无数个地方,每次都打MySQL其实很浪费。

Caffeine是一个非常好用的本地缓存库,它在某些场景下速度比Redis更快,因为省去了网络IO。对单机部署的应用来说,本地缓存是最容易上手的优化手段。

我这里的做法是查询用户时优先查缓存,缓存没有就查数据库,然后回填缓存:

@Component public class UserCacheManager { @Autowired private UserMapper userMapper; private final Cache<String, UserEntity> userCache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(30)) .recordStats() .build(); public UserEntity getByUsername(String username) { return userCache.get(username, key -> { LambdaQueryWrapper<UserEntity> query = new LambdaQueryWrapper<>(); query.eq(UserEntity::getUsername, key); return userMapper.selectOne(query); }); } public void evict(String username) { userCache.invalidate(username); } }

默认配置是最多缓存1万个用户,写入后30分钟过期。这样用户基本信息变了,最多30分钟后缓存也会自动失效,不会一直用脏数据。

5.2 缓存与数据库的更新策略

缓存模式,最常见的错误是"先删缓存,再更新数据库"或者"先更新数据库,再删缓存",两种做法都有各自的坑。在我的实践中,更稳的方式是更新数据库成功后,主动删除缓存,而不是更新缓存。原理是:如果更新缓存,逻辑上你需要把用户的所有相关字段都重新算一遍,容易错;但删除缓存只需要一个方法搞定,下次查询自然会把新的数据load进去。

这里有个高并发下的经典问题:请求A先更新数据库,然后删除缓存,但还没来得及删时,请求B查到旧数据并把旧数据写回缓存,导致脏数据在缓存里存活很久。这个问题没有100%完美的本地解决方案,常见的做法是我在项目里设置的expireAfterWrite过期时间,就算极端情况下有脏数据,30分钟后也会自动纠正,对业务影响可控。

5.3 批量操作的性能优化

批量查询用户时,很多人喜欢在循环里挨个selectById,比如查100个用户就发100次SQL。这个习惯不好,数据库会被你活活拖垮。

我推荐使用MyBatis-Plus的selectBatchIds方法:

List<UserEntity> users = userMapper.selectBatchIds(idList);

最终生成的SQL是SELECT ... FROM user WHERE id IN (?, ?, ?...),一次就完了。像这种细节,对性能的影响在数据量大的时候非常明显。

5.4 索引优化:怎么设计索引让查询飞起来

用户表的索引不能乱加,每加一个索引,插入、更新数据时就要额外维护,有代价的。最适合用户表场景的索引组合是:

业务场景索引建议说明
用户名精确查询uk_username唯一索引登录、注册查重必备
手机号精确查询idx_phone普通索引如果业务里常按手机号查用户,加这个
创建时间范围排序idx_create_time普通索引列表页按时间倒序必备
状态过滤不建索引状态值分布太集中,索引价值不大

覆盖索引比回表快很多,这个项目我建议你体会一下:只查用户Id+Nickname时,走idx_create_time索引已经可以把字段都覆盖到,性能比SELECT *再从主键回表好得多。

这是我的维护思路:索引宁缺毋滥。先捞重点查询,再逐步补齐。

6. 常见问题与排查技巧实录

6.1 启动直接报错:Failed to configure a DataSource

满屏的英文报错,吓跑了不少新手。这个问题本质很简单:配置里找不到数据源。大多是下面几个原因:

  • 漏了数据库密码,或者密码写错了
  • 连接的数据库还没建,MySQL里根本没有user_manage这个库
  • MySQL驱动版本和本地MySQL版本不匹配

我的排查顺序是先看配置文件,确认url、username、password三件套正确,再去MySQL命令行执行show databases;,确认库存在并执行use user_manage;,最后看驱动版本是不是8.0.x。这个过程看起来很无脑,但大多数情况真是低级问题。

6.2 查询结果全是null,但SQL明明执行了

这个坑十个人里面有八个踩过。SQL在控制台打印出来了,数据也返回了,但Java对象里全是null。问题几乎一定出在驼峰映射上。

MyBatis-Plus默认开启map-underscore-to-camel-case,但如果你的数据库表名或字段名用了奇怪的命名(比如全小写带下划线,但实体字段又没对应),就会映射失败。我的排查步骤是:在控制台看SQL的查询字段是什么,对比实体类的属性,看命名是否一致,再看配置里是否显式开了map-underscore-to-camel-case。这几个点检查完,问题立刻浮出水面。

6.3 逻辑删除后,唯一索引冲突

这是进阶问题。很多人加了逻辑删除之后,注册接口第一次删除用户,第二次用相同用户名注册,直接炸了。

原理很简单:逻辑删除并没有真的把数据库的行删掉,deleted只是从0改为1,但username的唯一索引依然存在。所以同一个用户名删除后仍占着索引坑位。

三种解决方案,我按推荐顺序排:

  1. 数据库唯一索引改成联合唯一索引,比如(username, deleted)。但注意deleted字段会被MyBatis-Plus默认过滤,查询时要拿到原始值才行
  2. 删除时把用户名改名,比如在原用户名后加时间戳,让唯一索引不再冲突
  3. 彻底物理删除用户记录(不推荐,违反逻辑删除初衷)

我的个人建议是方案1,但实现复杂度高一些;中小项目用方案2最简单,就算用户名后面带个奇怪后缀,只要终端的用户不可见就行。

6.4 @TableField(select = false)之后查不到deleted字段

有人发现加了@TableField(select = false)之后,逻辑删除字段在实体里取不到值了,排查半天怀疑是缓存清不掉。

这里要解释一下:@TableField(select = false)的作用是:查询时自动排除该字段,相当于你的SQL里压根没有deleted这一列。如果你确实要单独拿出来用,可以自己写一个Mapper方法,显式查询deleted字段。

这个属性用在deleted上的价值是:常规查询时不要把这个冗余字段带进Java对象,减少无谓的数据传输。但同时你要明白这是有代价的,别业务里需要读了却发现拿不到。

6.5 时间字段少8小时问题

所有后端程序员都遇到过的经典问题:Java里存的时间,查出来比实际少了8小时。这不是道德问题,而是时区问题。

MyBatis-Plus读出来的LocalDateTime是UTC时间,你的JVM默认时区如果没设置成Asia/Shanghai,翻译出来的时间就变成UTC时间了。解决方式我有三个层面:

  • JDBC连接串上加serverTimezone=Asia/Shanghai
  • Spring Boot配置里设置spring.jackson.time-zone=GMT+8
  • JVM启动参数加-Duser.timezone=Asia/Shanghai

我建议三管齐下,保证万无一失。你在spring.datasource.url里写得serverTimezone=Asia/Shanghai,同时在application.yml里再设置jackson的时区,基本就不会再踩坑了。

6.6 并发注册相同用户名如何保证不重复

用户同时点了两次注册,两个请求都通过了"用户名不存在"检查,然后都执行insert,后插入的会撞唯一索引,直接抛异常。

我推荐两种处理:

  1. 不依赖查询判断,直接捕获数据库的唯一索引冲突异常,在异常处理器里翻译成友好提示"用户名已存在"。这种方式并发下绝对安全,数据库是最终防线
  2. 查询+插入之间加分布式锁(需要Redis等组件),这个方案扩展性更强

小项目用方案1就够了,因为数据库唯一索引就是天然正确的防重机制。上一节注册接口里第一步的selectCount检查,是提升用户体验的辅助手段,但不要指望它能扛住并发。

7. 项目管理过程中的个人体会

做了这么多用户管理项目,我有一个越来越深的感受:真正有价值的不是CRUD本身,而是你对待每一个细节的判断。用户名查重、密码加密、逻辑删除、索引设计、缓存策略,每一个模块单独拿出来都很简单,但串在一起之后,系统的健壮性、可维护性就拉开了档次。

在这套代码的迭代过程中,我其实反复推翻过自己的实现。第一版只是单纯的增删改查,能用但不够好;后来加了缓存,快了但并发时脑子疼;后来加了逻辑删除和唯一索引的权衡,才勉强算得上生产可用。这种不断发现自己方案缺陷的过程,就是做技术最上瘾的地方。

所以当你把这一整套流程亲手敲完、调通、上线,再看自己写的模块,会自然而然对Spring Boot的代码组织方式和数据边界有更深的理解。这也是我希望通过这篇文章传递给你的东西:项目本身不是终点,你在这个过程里养成的编码习惯和排查意识,才是你接下来做任何系统时最值钱的底子。

另外给你一个非常实在的建议:把这套东西完成后,加上Spring Security做登录认证,再配合一个简单的角色权限表,就是一个可以直接给真实项目做脚手架的基础工程了。我自己的经验是,从用户管理出发,一步步扩展出部门、角色、权限这套RBAC体系,是理解整个后端权限控制最平滑的学习路径。这一步走完,你再回头看那些高大上的框架和复杂的中间件,就会从容很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询