尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spring Boot用户数据管理实战:从表设计到缓存与并发

Spring Boot用户数据管理实战:从表设计到缓存与并发 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是8Spring 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胜在抽象程度高你定义一个接口继承JpaRepositoryCRUD就全都有了开发速度极快。但问题也很明显复杂查询不好写很多中间表关联查询到最后还得回退到原生SQL而且它的懒加载机制和N1查询问题对新手来说是个不小的坑。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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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的内置创建功能也是一样的流程。访问页面之后按这套配置来ProjectMavenLanguageJavaSpring Boot2.6.13Groupcom.exampleArtifactuser-managementJava版本8依赖只需要勾两个Spring Web和MySQL Driver。其余的依赖后面加原因是我喜欢让核心依赖保持精简并且能让你清楚哪些东西是自己加的、为什么要加。点Generate下载压缩包解压之后用IDEA打开等Maven把依赖下载完。第一次下载依赖会慢一些耐心等就好这不是卡了是真的在下载。3.2 引入核心依赖与版本管理创建项目之后第一件事是把pom.xml补全。我在基础依赖之上加了mybatis-plus、hutool、caffeine、validation这些核心依赖dependencies !-- Spring Web -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- MySQL驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency !-- MyBatis-Plus -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency !-- Hutool工具包 -- dependency groupIdcn.hutool/groupId artifactIdhutool-all/artifactId version5.8.20/version /dependency !-- Caffeine缓存 -- dependency groupIdcom.github.ben-manes.caffeine/groupId artifactIdcaffeine/artifactId version3.1.6/version /dependency !-- 参数校验 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency !-- Lombok -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies这里我推荐了Hutool它是我日常开发离不开的工具包日期格式化、随机数生成、Bean拷贝、加密算法几乎涵盖了所有常见场景。很多人自己写工具类写法五花八门还容易出bug用Hutool之后这种问题基本消失了。3.3 配置文件从单环境到多环境的演进我强烈建议你从一开始就做多环境配置这个习惯能帮你避免很多线上事故。所谓多环境就是dev开发、test测试、prod生产三套配置分开管理。先看常规配置application.ymlspring: profiles: active: dev jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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声明主键策略这里是数据库自增所以用AUTOTableField(fill FieldFill.INSERT)表示插入时自动填充配合后面的MetaObjectHandler实现TableLogic是逻辑删除的开关一旦加了这个注解MyBatis-Plus执行delete时自动变成update set deleted1执行select时会自动追加deleted0的条件。状态字段我用了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. 检查用户名是否已存在 LambdaQueryWrapperUserEntity usernameQuery new LambdaQueryWrapper(); usernameQuery.eq(UserEntity::getUsername, request.getUsername()); if (userMapper.selectCount(usernameQuery) 0) { throw new BizException(用户名已存在); } // 2. 手机号重复校验 if (StrUtil.isNotBlank(request.getPhone())) { LambdaQueryWrapperUserEntity 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 PageResultUserDTO pageQuery(UserQueryRequest request) { PageUserEntity page new Page(request.getPageNum(), request.getPageSize()); LambdaQueryWrapperUserEntity 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); PageUserEntity resultPage userMapper.selectPage(page, queryWrapper); PageResultUserDTO 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 ApiResponseUserDTO register(Validated RequestBody UserRegisterRequest request) { return ApiResponse.success(userService.register(request)); } PostMapping(/page) public ApiResponsePageResultUserDTO pageQuery(RequestBody UserQueryRequest request) { return ApiResponse.success(userService.pageQuery(request)); } PutMapping(/status/{id}) public ApiResponseVoid 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 CacheString, UserEntity userCache Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(30)) .recordStats() .build(); public UserEntity getByUsername(String username) { return userCache.get(username, key - { LambdaQueryWrapperUserEntity 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方法ListUserEntity users userMapper.selectBatchIds(idList);最终生成的SQL是SELECT ... FROM user WHERE id IN (?, ?, ?...)一次就完了。像这种细节对性能的影响在数据量大的时候非常明显。5.4 索引优化怎么设计索引让查询飞起来用户表的索引不能乱加每加一个索引插入、更新数据时就要额外维护有代价的。最适合用户表场景的索引组合是业务场景索引建议说明用户名精确查询uk_username唯一索引登录、注册查重必备手机号精确查询idx_phone普通索引如果业务里常按手机号查用户加这个创建时间范围排序idx_create_time普通索引列表页按时间倒序必备状态过滤不建索引状态值分布太集中索引价值不大覆盖索引比回表快很多这个项目我建议你体会一下只查用户IdNickname时走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的唯一索引依然存在。所以同一个用户名删除后仍占着索引坑位。三种解决方案我按推荐顺序排数据库唯一索引改成联合唯一索引比如(username, deleted)。但注意deleted字段会被MyBatis-Plus默认过滤查询时要拿到原始值才行删除时把用户名改名比如在原用户名后加时间戳让唯一索引不再冲突彻底物理删除用户记录不推荐违反逻辑删除初衷我的个人建议是方案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连接串上加serverTimezoneAsia/ShanghaiSpring Boot配置里设置spring.jackson.time-zoneGMT8JVM启动参数加-Duser.timezoneAsia/Shanghai我建议三管齐下保证万无一失。你在spring.datasource.url里写得serverTimezoneAsia/Shanghai同时在application.yml里再设置jackson的时区基本就不会再踩坑了。6.6 并发注册相同用户名如何保证不重复用户同时点了两次注册两个请求都通过了用户名不存在检查然后都执行insert后插入的会撞唯一索引直接抛异常。我推荐两种处理不依赖查询判断直接捕获数据库的唯一索引冲突异常在异常处理器里翻译成友好提示用户名已存在。这种方式并发下绝对安全数据库是最终防线查询插入之间加分布式锁需要Redis等组件这个方案扩展性更强小项目用方案1就够了因为数据库唯一索引就是天然正确的防重机制。上一节注册接口里第一步的selectCount检查是提升用户体验的辅助手段但不要指望它能扛住并发。7. 项目管理过程中的个人体会做了这么多用户管理项目我有一个越来越深的感受真正有价值的不是CRUD本身而是你对待每一个细节的判断。用户名查重、密码加密、逻辑删除、索引设计、缓存策略每一个模块单独拿出来都很简单但串在一起之后系统的健壮性、可维护性就拉开了档次。在这套代码的迭代过程中我其实反复推翻过自己的实现。第一版只是单纯的增删改查能用但不够好后来加了缓存快了但并发时脑子疼后来加了逻辑删除和唯一索引的权衡才勉强算得上生产可用。这种不断发现自己方案缺陷的过程就是做技术最上瘾的地方。所以当你把这一整套流程亲手敲完、调通、上线再看自己写的模块会自然而然对Spring Boot的代码组织方式和数据边界有更深的理解。这也是我希望通过这篇文章传递给你的东西项目本身不是终点你在这个过程里养成的编码习惯和排查意识才是你接下来做任何系统时最值钱的底子。另外给你一个非常实在的建议把这套东西完成后加上Spring Security做登录认证再配合一个简单的角色权限表就是一个可以直接给真实项目做脚手架的基础工程了。我自己的经验是从用户管理出发一步步扩展出部门、角色、权限这套RBAC体系是理解整个后端权限控制最平滑的学习路径。这一步走完你再回头看那些高大上的框架和复杂的中间件就会从容很多。
返回列表