--- 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-业务讲解-用户登录和退出流程解析]]