---
title: "07-业务讲解-用户注册-使用组合模式处理复杂的验证功能"
created: 2025-12-11
aliases:
- 业务讲解-用户注册-使用组合模式处理复杂的验证功能
tags:
- 项目
---
# 业务讲解-用户注册-使用组合模式处理复杂的验证功能
## 思考
对于并发量不高的普通项目来说,注册用户的逻辑很简单,先验证参数,然后判断库中是否已经有此用户,如果没有进行添加用户即可。看似岁月静好
## 高并发下的问题
但对于大麦这种在一瞬间进行抢票的高并发来说,注册用户的过程中,考虑的问题就需要很多了。
首先就是数据库表中的数据很多的话,就要考虑进行分库分表操作,关于此问题的详细介绍,可查看用户的分库分表相关文档
还有情况,有大量的用户其实是第一次购买,用户买票前要进行注册的操作,那么这种注册的操作当并发量高时就会产生很多的问题。比如一般都是将用户信息会放到缓存中保存一份,用来降低数据库的压力,但是如果用户注册操作的话,缓存中是不存在用户信息的,那么请求最终还是会落到数据库上,这就是经典的缓存穿透问题,关于此问题的详细介绍,可查看相关文档
接下来我们正式的开始分析用户注册的详细过程
## 讲解
模块:damai-user-service
```xml
com.example
damai-user-service
${revision}
```
```java
@Transactional(rollbackFor = Exception.class)
@ServiceLock(lockType= LockType.Write,name = REGISTER_USER_LOCK,keys = {"#userRegisterDto.mobile"})
public void register(UserRegisterDto userRegisterDto) {
//参数验证业务
compositeContainer.execute(CompositeCheckType.USER_REGISTER_CHECK.getValue(),userRegisterDto);
//用户表添加
User user = new User();
BeanUtils.copyProperties(userRegisterDto,user);
user.setId(uidGenerator.getUid());
userMapper.insert(user);
//用户手机表添加
UserMobile userMobile = new UserMobile();
userMobile.setId(uidGenerator.getUid());
userMobile.setUserId(user.getId());
userMobile.setMobile(userRegisterDto.getMobile());
userMobileMapper.insert(userMobile);
bloomFilterHandler.add(userMobile.getMobile());
}
```
### 流程
- 开启事务
- 加分布式锁防止并发问题
- 使用组合模式进行参数验证
- 向用户表中添加数据
- 向用户手机表中添加数据
- 向布隆过滤器中添加数据
### 参数验证业务
参数验证的问题
参数验证是相当常见的,对于字段的必填和格式限制等,我们可以直接使用javax.validation来进行验证,本项目也是引入了此框架,较少了人为的验证工作,
但是对于验证业务来说,还是需要我们自己来开发的,而有些验证的逻辑是多个接口都需要的,另外当验证业务的逻辑变得复杂了后,这些验证业务的逻辑还存在的父子和执行顺序的关系,为了解决复用和结构性的问题,本人利用了组合模式和算法来创建出树形结构,来解决此问题,关于此问题的详细介绍,可查看相关文档
[组件讲解-利用组合模式打造强大验证功能,轻松应对复杂验证需求](https://www.yuque.com/u22210564/ykdrdh/baixnwbm4fhze9w7)
通过阅读组合模式实现复杂的验证功能文档后,我们知道了验证功能的树结构是如何构建的了,下面我们就来分析下,用户注册验证逻辑的树结构构建过程
```java
public enum CompositeCheckType {
/**
* 组合模式类型
* */
USER_REGISTER_CHECK(1,"user_register_check","用户注册")
}
```
用户注册验证逻辑的类型为USER\_REGISTER\_CHECK,验证的逻辑有
- 验证每秒用户注册请求是否超过最大限制
- 验证验证码是否正确
- 验证是否已注册用户
思考
我们思考一个问题,这三个验证逻辑,都是USER\_REGISTER\_CHECK类型,也就是说这三个验证类都要实现AbstractComposite的type()方法,类型都是相同的,如果以后再加一个验证逻辑的话,那么还要再实现一遍,这是不是就产生冗余了呢?
为此,为了解决此问题,我们可以再设计一个抽象层,将相同类型的type方法抽象出来,其余的方法仍旧由验证实现类来实现
com.damai.service.composite.register.AbstractUserRegisterCheckHandler
```java
/**
* @program: 极度真实还原大麦网高并发实战项目。 添加 阿宽不是程序员 微信,添加时备注 damai 来获取项目的完整资料
* @description: 用户注册验证基类,用户注册的相关验证逻辑继承此类
* @author: 阿宽不是程序员
**/
public abstract class AbstractUserRegisterCheckHandler extends AbstractComposite {
@Override
public String type() {
return CompositeCheckType.USER_REGISTER_CHECK.getValue();
}
}
```
AbstractUserRegisterCheckHandler作为抽象类,集成了AbstractComposite验证接口,将type()进行了实现,类型为USER\_REGISTER\_CHECK,以后所有的用户注册的验证逻辑,只需继承AbstractUserRegisterCheckHandler即可
目前用户注册的验证功能有三种, UserRegisterVerifyCaptcha 、 UserRegisterCountCheckHandler 、UserExistCheckHandler都继承了AbstractUserRegisterCheckHandler
这三种验证功能的具体验证逻辑稍后再详细介绍,我们先看来这三个验证功能的层级关系和执行顺序,直接用流程图来体现
![[image-4f24414f.webp]]
通过流程图可以看到用户注册验证的树结构,以及彼此之间的层级关系
- 第一层只有一个节点 UserRegisterVerifyCaptcha 其子节点list 有2个
- 第二层有两个节点 UserRegisterCountCheckHandler ,UserExistCheckHandler,父节点为 UserRegisterCountCheckHandler ,都没有子节点
执行的顺序依次为UserRegisterVerifyCaptcha -> UserRegisterCountCheckHandler -> UserExistCheckHandler
接下来我们详细的介绍这三个验证逻辑的执行过程
### 流程
UserRegisterVerifyCaptcha
```java
/**
* @program: 极度真实还原大麦网高并发实战项目。 添加 阿宽不是程序员 微信,添加时备注 damai 来获取项目的完整资料
* @description: 用户注册检查
* @author: 阿宽不是程序员
**/
@Component
public class UserRegisterVerifyCaptcha extends AbstractUserRegisterCheckHandler {
@Autowired
private CaptchaHandle captchaHandle;
@Autowired
private RedisCache redisCache;
/**
* 验证验证码是否正确
* */
@Override
protected void execute(UserRegisterDto param) {
//验证密码
String password = param.getPassword();
String confirmPassword = param.getConfirmPassword();
if (!password.equals(confirmPassword)) {
throw new DaMaiFrameException(BaseCode.TWO_PASSWORDS_DIFFERENT);
}
String verifyCaptcha = redisCache.get(RedisKeyBuild.createRedisKey(RedisKeyManage.VERIFY_CAPTCHA_ID,param.getCaptchaId()), String.class);
if (StringUtil.isEmpty(verifyCaptcha)) {
throw new DaMaiFrameException(BaseCode.VERIFY_CAPTCHA_ID_NOT_EXIST);
}
if (VerifyCaptcha.YES.getValue().equals(verifyCaptcha)) {
if (StringUtil.isEmpty(param.getCaptchaType())) {
throw new DaMaiFrameException(BaseCode.CAPTCHA_TYPE_EMPTY);
}
if (StringUtil.isEmpty(param.getPointJson())) {
throw new DaMaiFrameException(BaseCode.POINT_JSON_EMPTY);
}
if (StringUtil.isEmpty(param.getToken())) {
throw new DaMaiFrameException(BaseCode.CAPTCHA_TOKEN_JSON_EMPTY);
}
CaptchaVO captchaVO = new CaptchaVO();
captchaVO.setCaptchaType(param.getCaptchaType());
captchaVO.setPointJson(param.getPointJson());
captchaVO.setToken(param.getToken());
ResponseModel responseModel = captchaHandle.checkCaptcha(captchaVO);
if (!responseModel.isSuccess()) {
throw new DaMaiFrameException(responseModel.getRepCode(),responseModel.getRepMsg());
}
}
}
@Override
public Integer executeParentOrder() {
return 0;
}
@Override
public Integer executeTier() {
return 1;
}
@Override
public Integer executeOrder() {
return 1;
}
}
```
- 通过验证码id从redis获取验证标识 yes或者no,以此来判断是否要做验证码的验证逻辑
- 如果没有从redis中获取到数据,那么直接抛出异常,不再将用户注册逻辑执行
- 如果从redis中获取的数据是yes,说明要进行验证码逻辑的验证,执行验证码是否正确
- 如果验证不通过,则抛出异常
通过分析能够看出,其实UserRegisterVerifyCaptcha的验证逻辑,就是判断是否需要进行验证码的验证,如果需要,则执行验证逻辑
前置条件-图形验证码的介绍
关于图形验证码到底是什么?以及是如何生成的?请先跳转到相关文档进行查看,然后再继续学习此文档
图形验证码的介绍、作用以及使用 可以跳转到相应的文档查看
关于图形验证码在用户注册业务流程中是如何生成的,可以跳转到相应的文档查看
到这里说明小伙伴对验证码已经有了了解了,下面来分析校验验证码操作
captchaHandle.checkCaptcha
```java
public ResponseModel checkCaptcha(CaptchaVO captchaVO) {
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
assert requestAttributes != null;
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
captchaVO.setBrowserInfo(RemoteUtil.getRemoteId(request));
return captchaService.verification(captchaVO);
}
```
到这里就是调用验证码组件的api了,captchaService就是AJ-Captcha提供的api,如果小伙伴想对AJ-Captcha项目也有兴趣,可留言,本人会在后续计划安排
如果校验验证码通过的话,那么进行下一个用户注册验证的逻辑
UserRegisterCountCheckHandler
```java
/**
* @program: 极度真实还原大麦网高并发实战项目。 添加 阿宽不是程序员 微信,添加时备注 damai 来获取项目的完整资料
* @description: 用户注册请求数检查
* @author: 阿宽不是程序员
**/
@Component
public class UserRegisterCountCheckHandler extends AbstractUserRegisterCheckHandler {
@Autowired
private RequestCounter requestCounter;
/**
* 验证每秒用户注册请求是否超过最大限制
* */
@Override
protected void execute(final UserRegisterDto param) {
boolean result = requestCounter.onRequest();
if (result) {
throw new DaMaiFrameException(BaseCode.USER_REGISTER_FREQUENCY);
}
}
@Override
public Integer executeParentOrder() {
return 1;
}
@Override
public Integer executeTier() {
return 2;
}
@Override
public Integer executeOrder() {
return 1;
}
}
```
此验证逻辑是通过调用requestCounter计数器,判断当前计算出的每秒的请求数是否大于了配置的最大值,如果大于了,则直接返回异常信息给前端,提示用户注册频繁
```json
{
"code":"40013",
"message":"用户注册频繁",
"data":null
}
```
RequestCounter
```java
/**
* @program: 极度真实还原大麦网高并发实战项目。 添加 阿宽不是程序员 微信,添加时备注 damai 来获取项目的完整资料
* @description: 计数器
* @author: 阿宽不是程序员
**/
@Component
public class RequestCounter {
private final AtomicInteger count = new AtomicInteger(0);
private final AtomicLong lastResetTime = new AtomicLong(System.currentTimeMillis());
@Value("${request_count_threshold:1000}")
private int maxRequestsPerSecond = 1000;
public synchronized boolean onRequest() {
long currentTime = System.currentTimeMillis();
// 如果当前时间和上次重置时间差超过1秒
long differenceValue = 1000;
if (currentTime - lastResetTime.get() >= differenceValue) {
// 重置计数器
count.set(0);
// 更新重置时间
lastResetTime.set(currentTime);
}
if (count.incrementAndGet() > maxRequestsPerSecond) {
log.warn("请求超过每秒{}次限制",maxRequestsPerSecond);
// 超过限制后重置计数器
count.set(0);
// 更新重置时间
lastResetTime.set(System.currentTimeMillis());
return true;
}
return false;
}
}
```
接下来介绍RequestCounter计数器是怎么计算的
- 将整个方法加上synchronized锁,防止并发问题
- 得到当前的时间戳
- 如果当前时间戳和上次更新的时间戳相差大于了1秒,说明这次请求和上次请求相差在1秒以上,那么直接将count当前计数值归0,并将最新的更新时间更新为当前时间戳
●
如果count当前计数值大于了maxRequestsPerSecond最大值限制,那么将count当前计数值归0,并将最新的更新时间更新为当前时间戳,返回结果true,说明确实是超过了限制
●
如果以上请求都没有反正,说明没有超过最大限制,将结果返回false
这里的maxRequestsPerSecond是可以通过request\_count\_threshold进行配置的,默认是1000,@Value注解修饰的变量,在Apollo和Nacos的配置中心都是支持热加载的,并不需要将服务重启,这样可以根据业务的并发量灵活的进行修改限制
但是要注意,这个计数器只是计算当前本地JVM的请求数,但生产中的用户服务可能会部署多台实例,也就是说请求数会被分发到不同的实例上,比如:
![[image-fcde323e.png]]
比如在一秒内的用户注册请求有300个,用户服务的实例有3台,那么请求数经过Gateway网关后,通过Ribbon的负责均衡策略(默认为轮训),也就是这300的请求会被平均分配到这3台实例上,每台实例的接收到的请求数为100,每个计数器计算的就是每台实例自己的最大限制数,如果计数器最大限制为100,那么就触发了限制,直接将限制信息返回,如果计数器最大限制为200,那么就不会触发限制,用户注册的逻辑会继续执行
这时有人会提出疑问,为什么不用 是否校验验证码接口里的逻辑 用lua + redis做计数器呢,还是设置成最大阈值限制为300,每台实例还是依靠lua + redis计数器进行计算,这样就是直接当前所有的请求数了,清晰还直观,多好啊。
首先说,这么做确实是可以的,直接用redis计算确实更加简单,不需要考虑被多台实例平分后的问题。但要记住,本人设计的大麦网项目是始终围绕高并发的问题而思考的,在设计方案的时候要时时刻刻考虑效率的问题,redis的效率确实是很高,但是再高,它也是有网络请求损耗的,也是要占用redis的执行的,所以我们要尽可能设计高效率的执行。
(那为什么刚才是否校验验证码接口里的逻辑使用redis呢? 是否校验验证码是在每台实例上都要检查的,而不是看本地的。举个例子,现在对于全部实例的注册逻辑是1s内10个注册请求出一个校验验证码逻辑,如果要校验验证码,那就必须把“要校验的标志”放在redis中。如果像这里这样做的话,每台实例只看自己的计数器来确定要不要校验然后放在redis中,负载均衡策略选择轮询的状况下,就是1s内n\*10个校验验证码逻辑出n个校验验证码逻辑(这还要在请求密集的前提下)要不然各个实例还有可能计数器状态不一致,就做不到你想要去规定的效果了,是否校验验证码的规则变得随机。这里采用本地是因为它比较关注一台实例的请求处理速率,就算loadbalancer不用轮询说实话感觉问题也不大。而是否校验验证码更注重全部实例共享的逻辑,所以在想要确定是否校验验证码的时候相关信息(计数器)必须共享,否则你原先设下的规则就会变得失效。也就是说改成本地能用,但不太听话。我是这样理解的)
UserRegisterCountCheckHandler作为验证码通过后,下一步的验证,本质是防止缓存穿透的最后一步的限制了,所以要充分考虑健壮性和高效性,所以用jvm本地锁的策略来实现,不借助redis这种第三方工具。
不要以为加了synchronized锁,觉得效率会很低,synchronized在经过jdk1.5版本后的优化,其效率是非常之快的,而且加锁的方法内部只是有判断、赋值变量的操作,没有网络请求、数据库这些耗时的操作,所以执行的效率是非常高的,而且只是用在用户注册这一个功能上,所以不要以为加了锁就担心,该用的时候就要用
让我们回到UserRegisterCountCheckHandler中,当限制没有超过计数器的话,说明此验证是通过的,那么开始执行下一个验证
UserExistCheckHandler
```java
@Component
public class UserExistCheckHandler extends AbstractUserRegisterCheckHandler {
@Autowired
private UserService userService;
/**
* 验证是否已注册用户
* */
@Override
public void execute(final UserRegisterDto userRegisterDto) {
userService.doExist(userRegisterDto.getMobile());
}
@Override
public Integer executeParentOrder() {
return 1;
}
@Override
public Integer executeTier() {
return 2;
}
@Override
public Integer executeOrder() {
return 2;
}
}
```
```java
public void doExist(String mobile){
boolean contains = bloomFilterHandler.contains(mobile);
if (contains) {
LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery(UserMobile.class)
.eq(UserMobile::getMobile, mobile);
UserMobile userMobile = userMobileMapper.selectOne(queryWrapper);
if (Objects.nonNull(userMobile)) {
throw new DaMaiFrameException(BaseCode.USER_EXIST);
}
}
}
```
到这里就是真正的验证用户是否存在的逻辑了,先通过bloomFilterHandler布隆过滤器判断,如果布隆过滤器判断存在的话,为了防止布隆过滤器的误判,再从数据库中判断是否存在。
### 布隆过滤器的特点
- 如果元素判断存在,由于存在误判,这个元素不一定真正存在
- 如果元素判断不存在,那么这个元素一定不存在
布隆过滤器是现在面试爱问的知识点,关于布隆过滤器详细介绍原理和大麦网中布隆过滤器的使用,可跳转到相应的文档查看
(1)理解两层防护区别?
Redis+Lua 做「全局秒级阈值」
统计范围:集群所有实例。
目的:如果短时间内(1 秒内)整个系统被刷成千上万条注册请求,哪怕分布到 N 台机器,每台都不会漏算。
触发:每个请求都会先去 Redis,用 Lua 脚本做原子 INCR/EXPIRE,跨实例累加,当全局速率超过阈值时,就告诉业务“必须要验证码”。
好处:真正做到“集群级限流”。
JVM 本地计数器做「单机防穿透」
统计范围:当前这台应用实例。
目的:在每台实例上,用最便宜的 synchronized 计数拦截能力之外流量,避免再打 DB。
触发:真正要写用户表前:
先用 synchronized+两个 long 字段做秒级计数,本地秒内限流。
BloomFilter过滤加 + 数据库用户判断拦截错误请求
好处:零网络开销、纯内存、极低延迟;又能防止极端的“DB 操作”带来的压力。
(2)为什么要「双层防护」?
性能与成本权衡
全局判断一次:保证高并发下,验证码必须打在最前面,节省后续所有业务逻辑的无效执行。
本地再打一次: 本地计数的目的,是在单机层面再“降一截”——保证即便有热点实例被流量打满,也只会在本机用内存操作快速拒绝,多余请求不会继续打 Redis 或者后端 DB,从而提升整体的系统稳定性和可用性
职责分离
Redis+Lua:解决「跨实例流量暴增」的问题。
本地计数:解决「单实例写库崩溃」的问题。
极致效率
先全局(Redis+Lua):跨实例地判断每秒请求是否刷爆阈值,用来决定“需不需要图形验证码”。
再本地(内存计数):真正走注册逻辑前,先在本机做一次轻量计数拦截,绝大多数恶意/洪峰请求在此挡下;
最后查库:只有通过本地限流的请求,才会进入布隆过滤器+数据库检查,保证功能正确性后进行写库注册。
---
**企业级项目导航**:⬅️ [[06-业务讲解-用户注册-如何巧妙应对缓存穿透|06-业务讲解-用户注册-如何巧妙应对缓存穿透]] | 07-业务讲解-用户注册-使用组合模式处理复杂的验证功能 | ➡️ [[08-业务讲解-用户登录和退出流程解析|08-业务讲解-用户登录和退出流程解析]]