一、注册链路概览
注册是几乎所有业务系统的入口,看似简单,却暗藏诸多并发与一致性问题。本文以一个真实的用户注册链路为例,逐层剖析其设计思路与实现细节。
完整链路流程:
前端请求 → Controller接收 → 责任链校验(3个handler) → 分布式锁(Redis) → 插入用户表 → 插入手机号关联表 → 唯一索引兜底 → 布隆过滤器标记
以下逐层展开。
二、Controller:接收请求
接收前端返回的JSON
@PostMapping("/user-service/register") public Result<UserRegisterRespDTO> register(@RequestBody @Valid UserRegisterReqDTO requestParam) { return Results.success(userLoginService.register(requestParam)); }解析:
- @PostMapping:非幂等操作,注册本身不可重复执行
- Result<T> :统一响应包装,所有接口返回格式一致
- @Valid:触发 JSR-303 参数校验,如非空、格式等
- UserRegisterReqDTO:前端传参的数据传输对象,与数据库实体解耦
三、责任链模式:校验逻辑的解耦与编排
3.1为什么需要责任链?
注册校验通常包含多项规则:参数非空、用户名唯一性、证件号注销次数限制等。如果全部写在 Service 里,代码会变得臃肿且难以扩展。
使用责任链的好处:
每个校验规则独立封装为 Handler
新增/删除/调整顺序只需增删 Handler 或调整 @Order
单一职责,便于单元测试
3.2调度器与 Mark 机制
基于AbstractChainContext(自定义的链式调度器)来管理所有 Handler。
Mark是什么?
Mark 是一个字符串标识,用于将 Handler 划分到不同的业务小组。同一个 Mark 的 Handler 会在同一条链上按顺序执行。
public interface UserRegisterCreateChainFilter<T> extends AbstractChainHandler<T> { @Override default String mark() { return UserChainMarkEnum.USER_REGISTER_FILTER.name(); // "USER_REGISTER_FILTER" } }类比:3 个安检员佩戴同色臂章,都隶属于「注册安检组」,共同完成一套安检流程。
3.3三条校验规则
调度器会自动扫描所有实现了USER_REGISTER_FILTER标记的 Handler,按getOrder()升序执行:
| Order | Handler | 职责 |
|---|---|---|
| 0 | 参数非空校验 | 拦截 null 或空字符串 |
| 1 | 用户名唯一性校验 | 布隆过滤器快速判断(详见后文) |
| 2 | 证件号注销次数校验 | 同一证件号注销超过 5 次则拦截 |
核心机制: 任一 Handler 抛出异常,后续 Handler 不再执行,异常直接上抛,由 Web 层统一处理返回前端。
四、分布式锁:跨实例的并发控制
防止重复注册的关键
4.1 问题背景:为什么本地锁不够用?
先明确一个概念:实例 = 一个正在运行的服务进程。
假设 user-service 部署了 3 个实例(3 台服务器),前端请求经过网关随机分发:
用户A请求注册"张三" → 网关 → 实例1(放行)
用户B请求注册"张三" → 网关 → 实例2(放行) ← 同时发生,产生重复注册!
本地锁(synchronized / ReentrantLock)的作用范围:
| 锁类型 | 作用范围 | 类比 |
|---|---|---|
synchronized | 单个 JVM 进程 | 一栋楼的保安 |
ReentrantLock | 单个 JVM 进程 | 更高级的保安,能排队/超时,但仍只守一栋楼 |
在微服务多实例部署下,必须使用分布式锁。
4.2 基于 Redisson 的分布式锁实现
// 1. 创建锁(Key 为用户名) RLock lock = redissonClient.getLock(LOCK_USER_REGISTER + requestParam.getUsername()); // 2. 尝试抢锁(非阻塞) boolean tryLock = lock.tryLock(); if (!tryLock) { throw new ServiceException(HAS_USERNAME_NOTNULL); // 没抢到 → 用户名已被他人占用 } try { // 3. 执行业务逻辑(插入数据库等) } finally { // 4. 释放锁 lock.unlock(); }关键点:
锁存储在 Redis 中,所有实例共享同一个 Redis,因此能跨实例互斥底层使用 Redis 原子命令 SET NX EX,由 Redisson 封装锁的粒度精确到用户名,不同用户互不影响
项目中的锁 Key 定义:
//用户注册锁,Key Prefix + 用户名 public static final String LOCK_USER_REGISTER = "index12306-user-service:lock:user-register:"; //用户注销锁,Key Prefix + 用户名 public static final String USER_DELETION = "index12306-user-service:user-deletion:"; //用户注册可复用用户名分片,Key Prefix + Idx public static final String USER_REGISTER_REUSE_SHARDING = "index12306-user-service:user-reuse:"; //用户乘车人列表,Key Prefix + 用户名 public static final String USER_PASSENGER_LIST = "index12306-user-service:user-passenger-list:";进阶思考:tryLock() 无参调用默认等待时间 30 秒(Redisson 看门狗机制)。如果业务执行超时,看门狗会自动续期,避免锁提前释放。这一机制值得深入理解。
五、数据库插入:对象转换与唯一索引兜底
5.1 DTO → DO 转换
前端传参是 UserRegisterReqDTO,数据库操作需要 UserDO。
int inserted = userMapper.insert(BeanUtil.convert(requestParam, UserDO.class));为什么不能直接用 DTO?
@TableName("t_user") // UserDO 类上的注解 public class UserDO { // 字段映射... }MyBatis-Plus 通过 @TableName 和字段注解识别表名与列映射,必须传入 UserDO 类型。BeanUtil.convert() 负责按字段名复制属性值。
5.2 唯一索引:最后的防线
用户表插入成功后,还需往 t_user_phone 表插入手机号关联记录:
try { userPhoneMapper.insert(userPhoneDO); } catch (DuplicateKeyException dke) { throw new ServiceException(PHONE_REGISTERED); }双保险机制:
分布式锁(前置拦截)→ 唯一索引(兜底保障)
即使分布式锁出现极端情况(如锁过期、Redis 宕机),数据库唯一索引仍能保证数据一致性,将重复插入转化为业务异常返回前端。
注意事项:在 @Transactional 事务中捕获 DuplicateKeyException 后抛出业务异常,需确保事务正确回滚,避免用户表已插入而手机号表插入失败的不一致状态。
六、布隆过滤器:防止缓存穿透
6.1 使用场景
用户名唯一性校验是注册链路的高频查询。如果每次都查库,在恶意流量下可能造成数据库压力过大。
解决方案:使用布隆过滤器(Bloom Filter)作为前置快速判断。
6.2 核心逻辑
// 注册前:快速判断用户名是否"可能存在" // 注意:布隆过滤器说"不存在"则一定不存在;说"存在"则可能存在(有误判率) if (bloomFilter.contains(username)) { // 触发二次查库确认,避免误判拦截真实新用户 } // 注册成功后:标记该用户名已存在 userRegisterCachePenetrationBloomFilter.add(username);6.3 重要特性
| 特性 | 说明 |
|---|---|
| 判断结果 | 「不存在」→ 一定不存在;「存在」→ 可能存在 |
| 支持删除 | 不支持,因此只增不减 |
| 使用原则 | 注册成功后必须 add,否则下次查询会误判为不存在 |
| 误判处理 | 命中后需查库二次确认,不能直接拦截 |
6.4 缺陷与应对
布隆过滤器不支持删除,用户注销后无法清除标记。项目中的应对方案是:
// 用户名复用分片锁,用于处理注销后用户名的重新注册 public static final String USER_REGISTER_REUSE_SHARDING = "user-service:user-reuse:";通过分片锁控制用户名复用的并发安全,同时配合业务表记录注销状态,形成完整的用户名生命周期管理。
七、异常的统一处理(补充)
整个链路中抛出的异常(校验异常、锁异常、数据库异常)不会直接暴露给前端。项目通过全局异常处理器统一拦截:
@ControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(ServiceException.class) public Result<Void> handleServiceException(ServiceException e) { return Results.failure(e.getCode(), e.getMessage()); } }这样保证了所有业务异常返回统一的 JSON 格式,前端只需要按 Result 结构解析即可。
八、总结与思考
8.1 链路设计亮点
| 层次 | 设计 | 作用 |
|---|---|---|
| 校验层 | 责任链模式 | 解耦、可扩展、顺序可控 |
| 并发层 | Redisson 分布式锁 | 跨实例互斥,防止重复注册 |
| 数据层 | 数据库唯一索引 | 兜底保障,数据最终一致性 |
| 性能层 | 布隆过滤器 | 拦截绝大部分无效查询,防缓存穿透 |
8.2 值得进一步思考的问题
布隆过滤器误判率如何调优?可根据用户规模设置合适的容量和哈希函数数量,在内存与准确性之间取得平衡。
锁超时与看门狗:如果注册逻辑因网络抖动执行超时,Redisson 的看门狗机制如何自动续期?何时需要手动配置
leaseTime?注销用户名的复用:布隆过滤器不支持删除,注销后的用户名如何安全地重新开放注册?这需要结合业务状态和分片锁共同设计。
极端高并发下的优化:Redis 分布式锁在超高并发下是否成为瓶颈?是否可引入分段锁或先尝试布隆过滤器拦截大部分请求?