Spring Boot + Sa-Token 实时聊天系统:用户注册流程源码深度剖析
本文以微狗实时聊天项目为例,从一行注册接口出发,逐层拆解 Controller → Service → Mapper 的完整调用链路,挖掘其中值得学习的 4 个技术亮点。
一、项目背景
微狗是一个基于 Spring Boot + Netty 的实时聊天网站,支持私聊、群聊、文件上传、第三方登录等功能。项目采用经典的三层架构,注册流程虽看似简单,但细节中藏着几个容易忽略的工程实践。
二、请求入口:Controller 层
java
// UserController.java
@PostMapping("/register")
@ApiOperation(value = "用户注册")
public BaseResponse<Long> userRegister(@RequestBody UserRegisterRequest userRegisterRequest) {
if (userRegisterRequest == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
String userAccount = userRegisterRequest.getUserAccount();
String userPassword = userRegisterRequest.getUserPassword();
String checkPassword = userRegisterRequest.getCheckPassword();
if (StringUtils.isAnyBlank(userAccount, userPassword, checkPassword)) {
return null;
}
long result = userService.userRegister(userAccount, userPassword, checkPassword);
return ResultUtils.success(result);
}
Controller 的职责很清晰:接收请求 → 提取参数 → 调用 Service → 包装响应。这里只做最基本的空值校验,把真正的业务逻辑全部下放到 Service 层,保持 Controller 的轻薄。
【亮点一:参数校验的双层防御】
Controller 层做了粗粒度的空值检查(防止明显的恶意请求直接打到数据库),Service 层再做细粒度的长度、重复性校验。这种"粗筛 + 精筛"的模式,既避免无意义的数据库查询,又保证业务规则的完整覆盖。
很多项目只在 Controller 做一次校验就完事,一旦有人绕过 Controller(比如内部调用 Service),就会出现数据不一致。
三、核心逻辑:Service 层
java
// UserServiceImpl.java
@Override
public long userRegister(String userAccount, String userPassword, String checkPassword) {
// ① 参数校验
if (StringUtils.isAnyBlank(userAccount, userPassword, checkPassword)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数为空");
}
if (userAccount.length() < 4) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户账号过短");
}
if (userPassword.length() < 8 || checkPassword.length() < 8) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户密码过短");
}
if (!userPassword.equals(checkPassword)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "两次输入的密码不一致");
}
// ② synchronized 锁------防止并发注册同一账号
synchronized (userAccount.intern()) {
// ③ 查重
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("userAccount", userAccount);
long count = this.baseMapper.selectCount(queryWrapper);
if (count > 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "账号重复");
}
// ④ MD5 + SALT 加密
String encryptPassword = DigestUtils.md5DigestAsHex((SALT + userPassword).getBytes());
// ⑤ 保存用户
User user = new User();
user.setUserAccount(userAccount);
user.setUserPassword(encryptPassword);
user.setUserAvatar(DEFAULT_AVATAR);
user.setUserName(DEFAULT_NICKNAME + System.currentTimeMillis());
boolean saveResult = this.save(user);
if (!saveResult) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "注册失败,数据库错误");
}
// ⑥ 新用户自动加入系统群聊
Long userId = user.getId();
UserRoomRelate userRoomRelate = new UserRoomRelate();
userRoomRelate.setRoomId(SYSTEM_ROOM_ID);
userRoomRelate.setUserId(userId);
userRoomRelateService.save(userRoomRelate);
return userId;
}
}
这段代码只有 50 行左右,但藏着 4 个值得深挖的技术亮点。
四、亮点二:synchronized (userAccount.intern()) --- 用字符串常量池做并发锁
这是整段代码最值得讨论的一行:
java
synchronized (userAccount.intern()) {
为什么需要锁?
注册流程的"查重 → 插入"是一个先查后写的非原子操作 。假设两个线程同时用账号 zhangsan 注册:
时间线:
线程A: selectCount → count=0 → 准备insert
线程B: selectCount → count=0 → 准备insert ← 此时A还没insert,B查到的也是0
线程A: insert 成功
线程B: insert 成功 ← 重复账号被写入了!
加锁后,同一账号的注册请求会串行执行,杜绝并发重复。
为什么用 intern()?
String.intern() 是 JDK 的原生方法,它会将字符串放入 JVM 的字符串常量池,相同内容的字符串返回同一个对象引用。
java
"zhangsan".intern() == "zhangsan".intern() // true,指向常量池同一个对象
"zhangsan1".intern() != "zhangsan2".intern() // 不同账号,不同锁对象
这意味着:
- 同账号的注册请求 → 锁同一个对象 → 串行执行
- 不同账号的注册请求 → 锁不同对象 → 并行执行
比 synchronized(this) 细细得多------后者会让所有注册请求排队,而 intern() 只阻塞同账号的请求。
这个方案的局限
| 问题 | 说明 |
|---|---|
| JVM 常量池大小有限 | 大量账号注册会导致常量池膨胀,在高并发场景下可能触发 GC 问题 |
| 单机有效 | 分布式部署时,两台机器的 JVM 常量池互不共享,锁失效 |
| JDK 7+ 常量池移到堆 | 不再是 PermGen 溢出,但仍然占用堆内存 |
改进建议 :生产环境应使用 分布式锁 (Redis
SETNX或 Redisson),本项目作为学习项目,单机锁够用且足够直观。
五、亮点三:先查重再插入 --- 防止脏数据的经典模式
java
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("userAccount", userAccount);
long count = this.baseMapper.selectCount(queryWrapper);
if (count > 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "账号重复");
}
配合 synchronized 锁,这段查重逻辑确保了:
- 单线程内:查到 count > 0 说明账号已存在,直接拒绝
- 多线程内:锁保证了同一账号不会被两个线程同时查到 count=0
更优方案 :在数据库层面给
user_account字段加 唯一索引,即使代码层没防住,数据库也会拒绝重复插入。这是"代码防御 + 数据库兜底"的双保险策略。
六、亮点四:MD5 + 固定盐值加密 --- 密码存储的基本功
java
String encryptPassword = DigestUtils.md5DigestAsHex((SALT + userPassword).getBytes());
其中 SALT 定义在 SystemConstants 中:
java
public interface SystemConstants {
String SALT = "cong";
}
加密流程
原始密码: 12345678
加盐拼接: cong12345678
MD5结果: e10adc3949ba59abbe56e057f20f883e...(实际值)
加盐的目的:防止彩虹表攻击。如果直接 MD5 存密码,攻击者拿常用密码的 MD5 对照表就能反推。加了盐之后,即使两个用户密码相同,存储的密文也不同。
当前方案的不足
| 问题 | 说明 |
|---|---|
| 固定盐值 | 所有用户用同一个盐,同一密码的加密结果还是一样的 |
| MD5 已不够安全 | MD5 已被证明存在碰撞漏洞,不应用于密码加密 |
生产建议 :使用 BCrypt 或 SCrypt ,它们自带随机盐、可调强度,Spring Security 的
BCryptPasswordEncoder就很好用:
java
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
String encryptPassword = encoder.encode(userPassword);
// 验证时
encoder.matches(rawPassword, storedPassword)
七、亮点五:注册即入群 --- 用业务联动提升用户体验
java
// ⑥ 新用户自动加入系统群聊
Long userId = user.getId();
UserRoomRelate userRoomRelate = new UserRoomRelate();
userRoomRelate.setRoomId(SYSTEM_ROOM_ID); // 系统群聊ID=1
userRoomRelate.setUserId(userId);
userRoomRelateService.save(userRoomRelate);
这个细节很多人不会注意到:注册不只是创建用户记录,还自动把用户拉入系统群聊。
SYSTEM_ROOM_ID = 1 是一个全局群聊房间,所有注册用户都会被加入。这样的设计有几个好处:
- 新用户不空:注册后立刻能看到系统群聊的消息,不会面对一个空白的聊天界面
- 降低流失率:用户第一天就能互动,留存率比"空房间"高得多
- 系统公告渠道:运营可以通过系统群聊发通知、活动等
实现方式
UserRoomRelate 是用户和房间的关联表:
java
// UserRoomRelate.java
@TableName(value = "user_room_relate")
public class UserRoomRelate {
private Long id;
private Long roomId; // 房间ID
private Long userId; // 用户ID
private Date createTime;
private Date updateTime;
}
注册成功后,往这张表插入一条记录,用户就自动拥有了系统群聊的成员身份。
八、完整调用链路图
前端 POST /api/user/register
│
↓
UserRegisterRequest (DTO)
├── userAccount
├── userPassword
└── checkPassword
│
↓
UserController.userRegister()
├── 粗粒度空值校验
└── 调用 userService.userRegister()
│
↓
UserServiceImpl.userRegister()
├── ① 细粒度参数校验(长度、一致性)
├── ② synchronized(userAccount.intern()) 加锁
├── ③ UserMapper.selectCount() 查重
├── ④ MD5+SALT 加密密码
├── ⑤ UserMapper.save() 保存用户
│ └── UserConstant.DEFAULT_AVATAR 默认头像
│ └── UserConstant.DEFAULT_NICKNAME 默认昵称+时间戳
├── ⑥ UserRoomRelateService.save() 加入系统群聊
│ └── SystemConstants.SYSTEM_ROOM_ID 系统群聊ID
└── 返回 userId
│
↓
BaseResponse<Long> → 前端收到用户ID
九、涉及的所有类
| 类名 | 包路径 | 作用 |
|---|---|---|
UserRegisterRequest |
model/dto/user/ |
注册请求体,接收前端参数 |
UserController |
controller/ |
REST 接口入口,路由 /user/register |
UserService |
service/ |
用户服务接口,定义业务方法签名 |
UserServiceImpl |
service/impl/ |
用户服务实现,注册的核心逻辑全在这里 |
UserMapper |
mapper/ |
MyBatis-Plus Mapper,操作 user 表 |
User |
model/entity/ |
用户实体类,映射 user 表字段 |
UserRoomRelate |
model/entity/ |
用户-房间关联实体 |
UserRoomRelateService |
service/ |
用户-房间关联服务 |
SystemConstants |
constant/ |
SALT 盐值、SYSTEM_ROOM_ID |
UserConstant |
constant/ |
默认头像、默认昵称、角色常量 |
ErrorCode |
common/ |
错误码枚举(PARAMS_ERROR、SYSTEM_ERROR 等) |
BusinessException |
exception/ |
自定义业务异常,统一错误处理 |
BaseResponse |
common/ |
统一响应包装类 |
ResultUtils |
common/ |
响应工具类,快速构建 success/error |
十、总结
一个注册接口,50 行核心代码,4 个技术亮点:
| 亮点 | 做法 | 学习价值 | 生产改进方向 |
|---|---|---|---|
| 并发防重 | synchronized(userAccount.intern()) |
字符串常量池锁,精细粒度 | → Redis 分布式锁 |
| 查重防脏 | 先 selectCount 再 insert | 先查后写+锁的组合 | → 数据库唯一索引兜底 |
| 密码加密 | MD5 + 固定盐值 | 加盐是基本功 | → BCrypt 随机盐 |
| 注册入群 | save + UserRoomRelate | 业务联动,用户体验优先 | → 事件驱动解耦 |
注册流程是 IM 系统的第一个用户触点,写得扎实,后面的登录、聊天才有基础。希望本文对你在做类似项目时有参考价值。
项目地址 :https://github.com/lhccong/we-go(后端)
技术栈:Spring Boot 2.7.2 + MyBatis-Plus + Sa-Token + Netty + Redis