--- title: "03-(缓存穿透)用户注册业务深度解析" created: 2025-12-11 aliases: - (缓存穿透)用户注册业务深度解析 tags: - 项目 --- # (缓存穿透)用户注册业务深度解析 ## 视频 ![[image-ea4ce66f.png]] ## **引子:看似简单的注册逻辑** 对于普通项目来说,用户注册的逻辑非常简单: ```text 验证参数 → 查询用户是否存在 → 不存在则添加用户 → 完成 ``` 看似岁月静好,一切都那么自然。但对于高并发系统来说,事情远没有这么简单。 深入分析为什么一个简单的注册功能,需要如此精心的设计。 ## **高并发下的问题** ### **场景还原** 想象一下周杰伦演唱会开票的场景: ![[高并发注册场景还原-f30e9099.jpg]] ### **问题一:数据库压力** 当用户量达到千万级别时,单表已经无法承受,需要进行 **分库分表**。这部分可以参考[[01-用户服务分库分表设计思维全景|用户服务分库分表设计思维全景]]相关文档。 ### **问题二:缓存穿透(核心问题)** 在上一篇文章 [[02-深度解析:用户注册场景下的“缓存穿透”防御战|深度解析:用户注册场景下的“缓存穿透”防御战]]已经做了系统的分析,这里进行简单回顾: ![[注册场景的缓存穿透问题-e29c2f1e.jpg]] **这就是经典的缓存穿透问题**:查询的数据在缓存和数据库中都不存在,导致每次查询都"穿透"缓存,直接查询数据库。 更糟糕的是,在高并发场景下: ![[高并发下的灾难-391f74cc.jpg]] ## **解决方案:多层防御体系** 通过前面的分析,我们知道单一的解决方案都有其局限性: | **方案** | **问题** | | --- | --- | | 缓存空对象 | 每个手机号都不同,空值无法复用 | | 分布式锁 | 串行执行,严重影响并发性能 | | 布隆过滤器 | 存在误判,需要配合其他手段 | **正所谓技术都是围绕业务来服务的**,我们必须从业务角度出发,设计一套多层防御体系。 ### **整体架构设计** ![[用户注册多层防御体系-f6dc12ca.jpg]] ## **代码实现详解** ### **注册入口** ```java @Transactional(rollbackFor = Exception.class) @ServiceLock(lockType = LockType.Write, name = REGISTER_USER_LOCK, keys = {"#userRegisterDto.mobile"}) public void register(UserRegisterDto userRegisterDto) { // 1. 参数验证业务(组合模式,包含多层验证) compositeContainer.execute(CompositeCheckType.USER_REGISTER_CHECK.getValue(), userRegisterDto); // 2. 用户表添加 User user = new User(); BeanUtils.copyProperties(userRegisterDto, user); user.setId(uidGenerator.getUid()); userMapper.insert(user); // 3. 用户手机表添加 UserMobile userMobile = new UserMobile(); userMobile.setId(uidGenerator.getUid()); userMobile.setUserId(user.getId()); userMobile.setMobile(userRegisterDto.getMobile()); userMobileMapper.insert(userMobile); // 4. 将手机号加入布隆过滤器(供后续注册请求判断) bloomFilterHandler.add(userMobile.getMobile()); } ``` **关键点解析**: | **注解/操作** | **作用** | | --- | --- | | `@Transactional` | 保证用户表和手机表的写入原子性 | | `@ServiceLock` | 分布式锁,防止同一手机号并发注册 | | `compositeContainer.execute` | 组合模式执行多层验证 | | `bloomFilterHandler.add` | 注册成功后加入布隆过滤器 | ### **验证逻辑的组合模式设计** 在分析具体验证逻辑之前,我们先来理解一个设计问题。 #### **🤔 问题思考** 参数验证是很常见的需求,但当验证逻辑变得复杂后,会存在以下问题: 1. **复用问题**:有些验证逻辑是多个接口都需要的 2. **结构问题**:验证逻辑之间存在父子关系和执行顺序 为了解决这些问题,我们使用 **组合模式** 来构建验证树结构。 #### **抽象层设计** 首先,我们思考一个问题:用户注册的三个验证逻辑,类型都是 `USER_REGISTER_CHECK`。如果每个验证类都要实现 `type()` 方法返回相同的值,这不是冗余吗? **解决方案**:再设计一个抽象层,将相同类型的 `type()` 方法抽象出来。 ```java /** * 用户注册验证基类 * 所有用户注册的验证逻辑继承此类即可 */ public abstract class AbstractUserRegisterCheckHandler extends AbstractComposite { @Override public String type() { return CompositeCheckType.USER_REGISTER_CHECK.getValue(); } } ``` 这样,以后所有用户注册的验证逻辑,只需继承 AbstractUserRegisterCheckHandler 即可,不用重复实现 type() 方法。 ![[image-e1c28556.png]] #### **验证树结构** ![[用户注册验证树结构-8d5ef44a.jpg]] ##### **前置防护:全局流量识别** 前端在加载注册页面或提交前,先调用 `/check/need`。后端利用 Redis + Lua 脚本维护一个**全局计数器**(所有服务实例共享)。如果这一秒内的总请求数超过阈值(比如 10 次),则判定系统处于“高并发/受攻击”状态,强制要求当前用户进行验证码校验,并颁发一个带有“需要验证”标记的 `CaptchaId`。 ###### 1. 控制层:暴露检测接口 **类名**:`UserCaptchaController` **作用**:提供给前端调用的入口,尤其是 `/check/need`。 ```java package com.damai.controller; import com.damai.captcha.model.common.ResponseModel; import com.damai.captcha.model.vo.CaptchaVO; import com.damai.common.ApiResponse; import com.damai.service.UserCaptchaService; import com.damai.vo.CheckNeedCaptchaDataVo; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; /** * @description: 验证码控制层 * * 架构定位:防护网关入口 */ @RestController @RequestMapping("/user/captcha") @Tag(name = "captcha", description = "验证码") public class UserCaptchaController { @Autowired private UserCaptchaService userCaptchaService; /** * 核心接口:检查是否需要验证码 * 场景:前端在渲染注册页或点击发送短信前,先调用此接口。 * 逻辑:后端判断当前系统水位(QPS),如果高则返回 true(需要验证码),否则返回 false。 */ @Operation(summary = "检查是否需要验证码") @PostMapping(value = "/check/need") public ApiResponse checkNeedCaptcha(){ return ApiResponse.ok(userCaptchaService.checkNeedCaptcha()); } @Operation(summary = "获取验证码") @PostMapping(value = "/get") public ResponseModel getCaptcha(@RequestBody CaptchaVO captchaVO){ return userCaptchaService.getCaptcha(captchaVO); } @Operation(summary = "验证验证码") @PostMapping(value = "/verify") public ResponseModel verifyCaptcha(@RequestBody CaptchaVO captchaVO){ return userCaptchaService.verifyCaptcha(captchaVO); } } ``` ![[用户注册防护系统-0e28b573.jpg]] ###### 2. 业务层:准备 Lua 参数 **类名**:`UserCaptchaService` **作用**:组装 Redis Key 和参数,调用 Lua 执行器。 ```java package com.damai.service; import com.damai.captcha.model.common.ResponseModel; import com.damai.captcha.model.vo.CaptchaVO; import com.baidu.fsg.uid.UidGenerator; import com.damai.core.RedisKeyManage; import com.damai.redis.RedisKeyBuild; import com.damai.service.lua.CheckNeedCaptchaOperate; import com.damai.vo.CheckNeedCaptchaDataVo; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.List; /** * @description: 验证码业务逻辑 * * 架构定位:第一层防御逻辑(全局限流判断) * 作用:利用 Redis 维护全局计数器,决定当前请求是否需要被“验证码”拦截。 */ @Service public class UserCaptchaService { // 阈值:每秒允许多少次请求不带验证码?(例如:10次) // 如果超过这个数,后续请求会被要求强制验证。 @Value("${verify_captcha_threshold:10}") private int verifyCaptchaThreshold; // 标记的过期时间(例如:60秒) // 生成的 CaptchaId 在 Redis 里存多久。 @Value("${verify_captcha_id_expire_time:60}") private int verifyCaptchaIdExpireTime; // 开关:是否强制所有请求都要验证码(用于调试或紧急防御) // 1=开启,0=关闭 @Value("${always_verify_captcha:0}") private int alwaysVerifyCaptcha; @Autowired private CaptchaHandle captchaHandle; @Autowired private UidGenerator uidGenerator; @Autowired private CheckNeedCaptchaOperate checkNeedCaptchaOperate; /** * 核心方法:判断逻辑 */ public CheckNeedCaptchaDataVo checkNeedCaptcha() { long currentTimeMillis = System.currentTimeMillis(); // 1. 生成本次会话的唯一ID (CaptchaId) // 这个ID后续会传回给注册接口,证明"我检查过验证码逻辑了" long id = uidGenerator.getUid(); // 2. 准备 Redis Keys (Lua脚本需要的 Key 列表) List keys = new ArrayList<>(); // Key 1: 全局计数器的值 (Hash结构或String结构) keys.add(RedisKeyBuild.createRedisKey(RedisKeyManage.COUNTER_COUNT).getRelKey()); // Key 2: 全局计数器的最后重置时间戳 (用于判断是否过了1秒) keys.add(RedisKeyBuild.createRedisKey(RedisKeyManage.COUNTER_TIMESTAMP).getRelKey()); // Key 3: 当前用户的验证标记 Key (VERIFY_CAPTCHA_ID:uid) // Lua 脚本会根据流量情况,把 "YES" 或 "NO" 写入这个 Key 中 keys.add(RedisKeyBuild.createRedisKey(RedisKeyManage.VERIFY_CAPTCHA_ID, id).getRelKey()); // 3. 准备 Args (Lua脚本需要的参数列表) String[] data = new String[4]; data[0] = String.valueOf(verifyCaptchaThreshold); // 参数1: 阈值 (10) data[1] = String.valueOf(currentTimeMillis); // 参数2: 当前时间 data[2] = String.valueOf(verifyCaptchaIdExpireTime); // 参数3: 标记过期时间 (60s) data[3] = String.valueOf(alwaysVerifyCaptcha); // 参数4: 强制开关 // 4. 执行 Lua 脚本 (原子操作) // 返回 true 表示需要验证码,false 表示不需要 Boolean result = checkNeedCaptchaOperate.checkNeedCaptchaOperate(keys, data); // 5. 封装返回结果 CheckNeedCaptchaDataVo checkNeedCaptchaDataVo = new CheckNeedCaptchaDataVo(); checkNeedCaptchaDataVo.setCaptchaId(id); // 把这个 ID 给前端,前端注册时必须带回来 checkNeedCaptchaDataVo.setVerifyCaptcha(result); return checkNeedCaptchaDataVo; } public ResponseModel getCaptcha(CaptchaVO captchaVO) { return captchaHandle.getCaptcha(captchaVO); } public ResponseModel verifyCaptcha(final CaptchaVO captchaVO) { return captchaHandle.checkCaptcha(captchaVO); } } ``` ![[UserCaptchaService_-1f55b487.jpg]] ###### 3. Redis/Lua 执行层 **类名**:`CheckNeedCaptchaOperate` **作用**:加载并执行 Lua 脚本。 **为什么必须用** [[06-Lua 深度学习笔记|Lua 深度学习笔记]]**?** 因为“读取当前请求数”、“判断是否过秒”、“重置计数器”、“写入用户标记”这一系列操作必须是**原子(Atomic)**的。如果不用 Lua,在高并发下会出现多线程竞争导致计数不准,甚至被绕过。 ```java package com.damai.service.lua; import com.damai.initialize.base.AbstractApplicationPostConstructHandler; import com.damai.redis.RedisCache; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.core.io.ClassPathResource; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.scripting.support.ResourceScriptSource; import org.springframework.stereotype.Component; import java.util.List; /** * @description: Lua脚本执行器 - 判断是否需要校验验证码 * * 架构定位:Redis 原子操作层 * 作用:加载并执行 checkNeedCaptcha.lua 脚本,实现分布式的滑动窗口/固定窗口限流。 */ @Slf4j @Component public class CheckNeedCaptchaOperate extends AbstractApplicationPostConstructHandler { @Autowired private RedisCache redisCache; private DefaultRedisScript redisScript; /** * 保证初始化顺序,优先加载脚本 */ @Override public Integer executeOrder() { return 1; } /** * 项目启动时初始化 Lua 脚本 * 作用:将脚本预加载,提升后续执行效率 (避免每次读取文件) */ @Override public void executeInit(final ConfigurableApplicationContext context) { try { redisScript = new DefaultRedisScript<>(); // 指定 Lua 脚本路径:resources/lua/checkNeedCaptcha.lua redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/checkNeedCaptcha.lua"))); // 指定脚本返回值类型 redisScript.setResultType(String.class); } catch (Exception e) { log.error("redisScript init lua error",e); } } /** * 执行 Lua 脚本 * @param keys Redis Key 列表 (计数器Key, 时间戳Key, 用户标记Key) * @param args 参数列表 (阈值, 当前时间, 过期时间, 强制开关) * @return Boolean (true=需要验证码, false=不需要) */ public Boolean checkNeedCaptchaOperate(List keys, String[] args){ // execute 方法内部会调用 Redis 的 EVALSHA 命令 Object object = redisCache.getInstance().execute(redisScript, keys, args); // Lua 脚本返回的是 "true" 或 "false" 字符串 return Boolean.parseBoolean((String)object); } } ``` ###### 4、`checkNeedCaptcha.lua` 实现了一个**基于固定时间窗口(Fixed Window)的限流算法**,并根据限流结果动态决定当前用户是否需要验证码。 ```lua -- ========================================================== -- 脚本说明:判断当前是否需要进行验证码校验 -- 架构定位:第一道全局防线 (Global Rate Limiting) -- 作用: -- 1. 维护一个全局计数器,统计当前 1 秒内的总请求数 -- 2. 根据阈值动态打标,告诉 Java 层当前用户是 "yes" (要验证) 还是 "no" (不要验证) -- ========================================================== -- 1. 接收 Keys 参数 (由 UserCaptchaService.java 传入) -- 全局计数器的 Key (例如: "captcha:count") local counter_count_key = KEYS[1] -- 上次重置时间戳的 Key (例如: "captcha:timestamp") local counter_timestamp_key = KEYS[2] -- 当前用户的验证标记 Key (例如: "verify_captcha_id:123456") -- 用来存储 "yes" 或 "no" local verify_captcha_id = KEYS[3] -- 2. 接收 Args 参数 -- 阈值 (例如: 10) - 超过这个数就开启验证码 local verify_captcha_threshold = tonumber(ARGV[1]) -- 当前系统时间戳 (毫秒) local current_time_millis = tonumber(ARGV[2]) -- 标记 Key 的过期时间 (例如: 60秒) local verify_captcha_id_expire_time = tonumber(ARGV[3]) -- 强制开启开关 (1: 强制开启, 0: 正常逻辑) local always_verify_captcha = tonumber(ARGV[4]) -- 时间窗口大小:1000 毫秒 (1秒) local differenceValue = 1000 -- ========================================================== -- 逻辑 1:强制开启检查 -- 用于系统维护、遭受大规模攻击或测试场景 -- ========================================================== if always_verify_captcha == 1 then -- 直接标记为 "yes" (需要验证码) redis.call('set', verify_captcha_id,'yes') -- 设置过期时间,防止垃圾数据堆积 redis.call('expire',verify_captcha_id,verify_captcha_id_expire_time) return 'true' end -- ========================================================== -- 逻辑 2:获取当前状态 -- ========================================================== -- 获取当前计数,如果不存在则默认为 0 local count = tonumber(redis.call('get', counter_count_key) or "0") -- 获取上次重置时间,如果不存在则默认为 0 local lastResetTime = tonumber(redis.call('get', counter_timestamp_key) or "0") -- ========================================================== -- 逻辑 3:时间窗口滑动 (Fixed Window Reset) -- 判断是否进入了新的 1 秒 -- ========================================================== if current_time_millis - lastResetTime > differenceValue then -- 超过 1 秒了,重置计数器为 0 count = 0 -- 更新计数器 redis.call('set', counter_count_key, count) -- 更新时间戳为当前时间,开启新的窗口 redis.call('set', counter_timestamp_key, current_time_millis) end -- ========================================================== -- 逻辑 4:计数与阈值判定 -- ========================================================== -- 请求数 + 1 count = count + 1 -- 检查:是否超过了设定的阈值 (例如 10 QPS) if count > verify_captcha_threshold then -- 【关键策略】超过阈值后的动作: -- 这里采取的是 "重置策略" (Reset Strategy) -- 一旦触发限流,立刻清零计数器并重置时间。 -- 这意味着拦截当前请求,并尝试平滑流量,而不是简单拒绝后续所有请求。 -- (注:有些策略是直接拒绝窗口内的剩余请求,这里的策略更倾向于动态采样) count = 0 redis.call('set', counter_count_key, count) redis.call('set', counter_timestamp_key, current_time_millis) -- 标记当前用户:必须验证码!("yes") redis.call('set', verify_captcha_id,'yes') redis.call('expire',verify_captcha_id,verify_captcha_id_expire_time) -- 返回 'true' 给 Java 代码 return 'true' end -- ========================================================== -- 逻辑 5:未超限,放行 -- ========================================================== -- 更新计数器 (count 已加 1) redis.call('set', counter_count_key, count) -- 标记当前用户:不需要验证码 ("no") -- 这一步很重要,因为 Java 端的验证逻辑(UserRegisterVerifyCaptcha)会检查这个 Key 是否存在。 -- 即使是 'no' 也要存进去,证明该用户通过了前置检查。 redis.call('set',verify_captcha_id,'no') redis.call('expire',verify_captcha_id,verify_captcha_id_expire_time) return 'false' ``` ![[lua-12ebbc8d.jpg]] **为什么用** `count = 0` **(重置) 而不是** `return error` **(拒绝)?** 在第 64 行:`if count > verify_captcha_threshold then ... count = 0 ...`。 这是一种**“采样触发”**机制。 - **假设阈值是 10**。 - 前 10 个请求进来 -> 正常,标记为 "no"。 - 第 11 个请求进来 -> 触发阈值 -> 标记为 "yes",同时计数器归零。 - **效果**:系统在高负载下,不会把所有人都拦住,而是每隔 10 个请求就拦一个去验证码。这既保护了系统,又不会让用户觉得系统彻底挂了,是一种**柔性降级**策略。 ```text 时间轴 (1秒钟内的请求演示,阈值=10) 第 1 秒: ┌─────────────────────────────────────────────────────────────┐ │ 请求 1 → count=1 → ≤10 → 标记 'no' │ │ 请求 2 → count=2 → ≤10 → 标记 'no' │ │ 请求 3 → count=3 → ≤10 → 标记 'no' │ │ ... │ │ 请求 10 → count=10 → ≤10 → 标记 'no' │ │ 请求 11 → count=11 → >10 → 【触发限流】 │ │ → 标记 'yes' → return true │ │ → count重置为0 │ │ │ │ 请求 12 → count=1 → ≤10 → 标记 'no' │ │ 请求 13 → count=2 → ≤10 → 标记 'no' │ │ ... │ └─────────────────────────────────────────────────────────────┘ 关键点: ✓ 前10个请求丝滑通过("no") ✓ 第11个请求被拦截要求验证码("yes") ✓ 计数器重置,继续服务后续请求 ✓ 这是"采样限流",不是"全量拒绝" 简单拒绝方案(不采用): 请求 1-10 → "no" 请求 11 → "REJECT" (拒绝) 请求 12 → "REJECT" 请求 13 → "REJECT" ...所有后续请求都被拒 ✗ 结果:系统假死,用户体验差 采样重置方案(实际采用): 请求 1-10 → "no" 请求 11 → "yes" (触发验证),count重置 请求 12-21 → "no" 请求 22 → "yes" (触发验证),count重置 ... ✓ 结果:平滑降级,每10个请求采样1个验证,保持可用性 ``` **为什么一定要写入** `verify_captcha_id`**?** 注意最后几行,即使 `return 'false'`,也执行了 `redis.call('set', verify_captcha_id, 'no')`。 这是为了**防绕过**。 - Java 端的 `UserRegisterVerifyCaptcha` 逻辑是:如果 Redis 里没有这个 Key,直接抛异常 `VERIFY_CAPTCHA_ID_NOT_EXIST`。 - 这强制了前端必须先调用 `/check/need` 接口,拿到这个 "no" 的通行证,才能去调注册接口。黑客如果直接调注册接口,因为缺少这个 Key,会被直接踢出。 > 这一套组合拳,确保了只有在**全局请求量**真正飙升时,才会触发验证码机制,平时用户体验丝滑,遇到攻击时自动防御。 ##### **第一层:验证码校验** **类名**:`UserRegisterVerifyCaptcha` **作用**:拦截脚本攻击和机器流量。 ```java package com.damai.service.composite.register.impl; import com.damai.captcha.model.common.ResponseModel; import com.damai.captcha.model.vo.CaptchaVO; import com.damai.core.RedisKeyManage; import com.damai.util.StringUtil; import com.damai.dto.UserRegisterDto; import com.damai.enums.BaseCode; import com.damai.enums.VerifyCaptcha; import com.damai.exception.DaMaiFrameException; import com.damai.redis.RedisCache; import com.damai.redis.RedisKeyBuild; import com.damai.service.CaptchaHandle; import com.damai.service.composite.register.AbstractUserRegisterCheckHandler; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; /** * @description: 用户注册检查 - 第一层:验证码校验 * * 架构定位:【防御漏斗的第一层】 * 作用:拦截大部分自动化脚本和非人为的恶意流量。 * 逻辑:不是所有请求都需要验证码,而是根据 Redis 中的标记(通常由限流逻辑触发)来决定是否开启验证码校验。 */ @Slf4j @Component public class UserRegisterVerifyCaptcha extends AbstractUserRegisterCheckHandler { @Autowired private CaptchaHandle captchaHandle; @Autowired private RedisCache redisCache; @Override protected void execute(UserRegisterDto param) { // 1. 基础参数一致性校验(密码与确认密码) // 这属于最基本的逻辑检查,放在最前面,成本最低 String password = param.getPassword(); String confirmPassword = param.getConfirmPassword(); if (!password.equals(confirmPassword)) { throw new DaMaiFrameException(BaseCode.TWO_PASSWORDS_DIFFERENT); } // 2. 检查当前请求流程是否需要验证码 // 这里的设计非常关键:系统并不是强制所有注册都验证码,而是通过之前的逻辑(如获取验证码接口) // 在 Redis 中生成了一个标记。 // key 结构:VERIFY_CAPTCHA_ID:captchaId String verifyCaptcha = redisCache.get(RedisKeyBuild.createRedisKey(RedisKeyManage.VERIFY_CAPTCHA_ID, param.getCaptchaId()), String.class); // 如果 Redis 中没有这个标记,说明用户可能绕过了前端页面,直接调用注册接口(非法请求) if (StringUtil.isEmpty(verifyCaptcha)) { throw new DaMaiFrameException(BaseCode.VERIFY_CAPTCHA_ID_NOT_EXIST); } // 3. 如果标记为 YES,说明系统要求必须校验验证码 if (VerifyCaptcha.YES.getValue().equals(verifyCaptcha)) { // 校验前端传来的验证码参数是否为空 if (StringUtil.isEmpty(param.getCaptchaVerification())) { throw new DaMaiFrameException(BaseCode.VERIFY_CAPTCHA_EMPTY); } log.info("传入的captchaVerification:{}", param.getCaptchaVerification()); // 构建验证对象,调用验证码组件服务进行校验 // 这里通常涉及复杂的二次校验(如滑块轨迹分析等) CaptchaVO captchaVO = new CaptchaVO(); captchaVO.setCaptchaVerification(param.getCaptchaVerification()); ResponseModel responseModel = captchaHandle.verification(captchaVO); // 4. 验证失败,直接抛出异常,中断注册流程 // 此时不会进入后续的限流检查和数据库检查,有效保护了后端资源 if (!responseModel.isSuccess()) { throw new DaMaiFrameException(responseModel.getRepCode(), responseModel.getRepMsg()); } } // 如果标记为 NO,则跳过验证码检查,直接进入下一层(通常发生在系统负载较低时) } /** * 组合模式:父节点ID * 返回 0 表示它是根节点(Root),也是执行入口 */ @Override public Integer executeParentOrder() { return 0; } /** * 组合模式:层级 * 第 1 层 */ @Override public Integer executeTier() { return 1; } /** * 组合模式:同层级执行顺序 * 第 1 个执行 */ @Override public Integer executeOrder() { return 1; } } ``` 流程说明: ![[验证码校验流程-4b9f4eaf.jpg]] **🤔 为什么要用 Redis 存储验证标识?** 这里涉及到"是否需要验证码"的判断逻辑。在用户点击注册之前,前端会先调用一个接口判断是否需要验证码。这个判断是 **全局的、跨实例共享的**: ![[为什么用_Redis_存储验证标识-95098a4e.jpg]] ##### **第二层:请求数限制(本地计数器)** **类名**:`UserRegisterCountCheckHandler` **作用**:保护当前服务节点的 JVM 和数据库连接池。 ```java package com.damai.service.composite.register.impl; import com.damai.dto.UserRegisterDto; import com.damai.enums.BaseCode; import com.damai.exception.DaMaiFrameException; import com.damai.service.composite.register.AbstractUserRegisterCheckHandler; import com.damai.service.tool.RequestCounter; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; /** * @description: 用户注册请求数检查 - 第二层(节点A):本地限流 * * 架构定位:【防御漏斗的第二层】 * 作用:防止突发流量打垮当前服务节点或耗尽数据库连接池。 * 策略:使用 JVM 本地计数器(RequestCounter),零网络开销,速度极快。 */ @Component public class UserRegisterCountCheckHandler extends AbstractUserRegisterCheckHandler { @Autowired private RequestCounter requestCounter; @Override protected void execute(final UserRegisterDto param) { // 1. 调用本地计数器,判断当前实例这一秒内的请求是否超标 // 注意:这里是单机限流,不是集群限流。 // 它的目的是保护“自己”这台机器不挂掉。 boolean result = requestCounter.onRequest(); // 2. 如果超标(result = true),触发快速失败(Fail Fast) // 直接抛出异常,告诉用户“注册频繁”。 // 这一步非常关键:它阻止了过量请求继续向下执行“布隆过滤器”或“数据库操作”。 if (result) { throw new DaMaiFrameException(BaseCode.USER_REGISTER_FREQUENCY); } // 如果未超标,方法结束,组合模式会自动调用同层级的下一个节点(即 UserExistCheckHandler) } /** * 组合模式:父节点ID * 返回 1,说明它的父节点是 UserRegisterVerifyCaptcha (那个类的 executeOrder 是 1) * 意味着:只有验证码通过了,才会走到这里。 */ @Override public Integer executeParentOrder() { return 1; } /** * 组合模式:层级 * 第 2 层 */ @Override public Integer executeTier() { return 2; } /** * 组合模式:同层级执行顺序 * 第 1 个执行(在第2层中排在最前面) * 必须先限流,再查库(查库更重),所以顺序必须是 1 */ @Override public Integer executeOrder() { return 1; } } ``` RequestCounter 计数器实现: ```java package com.damai.service.tool; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; /** * @program: 极度真实还原大麦网高并发实战项目 * @description: 单机限流计数器 (JVM级别) * @author: 阿星不是程序员 * * 核心架构作用: * 1. 位于“防御漏斗”的第二层(Redis层之后,DB层之前)。 * 2. 作用是保护当前服务实例的 JVM 和 DB 连接池,防止单机流量过载。 * 3. 采用“固定时间窗口”算法的变种。 **/ @Slf4j @Component public class RequestCounter { /** * 当前时间窗口内的请求计数 * 使用 AtomicInteger 虽然是线程安全的,但在下面的 synchronized 块中, * 其实用普通 int 也可以,这里用 Atomic 是为了双重保险或后续可能的无锁优化。 */ private final AtomicInteger count = new AtomicInteger(0); /** * 上一次重置计数器的时间戳 * 用于判断是否跨越了 1 秒的时间窗口 */ private final AtomicLong lastResetTime = new AtomicLong(System.currentTimeMillis()); /** * 每秒最大请求数阈值 * 1. 使用 @Value 读取配置中心(Nacos/Apollo)的值。 * 2. 支持热更新:如果配置中心修改了 request_count_threshold,无需重启服务即可生效。 * 默认值:1000 QPS */ @Value("${request_count_threshold:1000}") private int maxRequestsPerSecond = 1000; /** * 核心限流方法 * * @return true: 表示超过限制(需要拦截); false: 表示未超过限制(放行) * * 重点解析 synchronized: * 1. 必须加锁!虽然 count 和 lastResetTime 是原子的,但在“检查时间 -> 重置 -> 累加” * 这三个步骤组成的复合操作中,必须保证原子性。 * 2. 如果不加锁,线程 A 可能刚检查完时间,线程 B 就把时间重置了,导致计数逻辑混乱。 * 3. 性能问题:JVM 本地锁(偏向锁/轻量级锁)在无竞争或低竞争下性能极高, * 完全能支撑单机几千 QPS 的判断,且没有任何网络 I/O 开销。 */ public synchronized boolean onRequest() { // 1. 获取当前系统时间 long currentTime = System.currentTimeMillis(); // 2. 定义时间窗口大小:1000毫秒(1秒) long differenceValue = 1000; // 3. 检查时间窗口:如果当前时间距离上次重置时间超过了 1 秒 // 说明进入了新的统计周期,需要重置计数器 if (currentTime - lastResetTime.get() >= differenceValue) { // 重置计数为 0 count.set(0); // 更新重置时间为当前时间 lastResetTime.set(currentTime); } // 4. 执行计数并判断阈值 // incrementAndGet(): 先加 1,再获取值(相当于 ++i) if (count.incrementAndGet() > maxRequestsPerSecond) { // 5. 如果超过阈值,打印警告日志 log.warn("请求超过每秒{}次限制", maxRequestsPerSecond); // 策略选择:触发限流后的重置机制 // 这里选择的是“一旦触发限流,立即重置窗口”,这意味着: // 拦截当前这一个请求,并开启一个新的时间窗口。 // (注意:不同的限流算法处理方式不同,有的会直接拒绝该窗口剩余时间的所有请求, // 这里是一种比较激进的重置策略,用于快速恢复服务能力) count.set(0); lastResetTime.set(System.currentTimeMillis()); // 返回 true,表示“请求被拒绝/拦截” return true; } // 返回 false,表示“请求通过” return false; } } ``` **🤔 为什么这里用本地计数器,而不是 Redis?** - **定位不同**:这是**单机限流**,保护的是这台服务器不被挤爆。如果用 Redis,每次检查都要发起网络请求,这本身就是巨大的开销,反而可能成为瓶颈。 - **效率极致**:`synchronized` 在 Java 6 以后经过了大量优化,这种纯内存操作(几十纳秒级)比 Redis 网络 IO(几毫秒级)快了几个数量级。 ![[两层计数器的区别-d2b5e9bd.jpg]] **关于 synchronized 的性能担忧**: 加了 `synchronized` 锁,效率会不会很低? 其实不用担心: 1. `synchronized` 在 JDK 1.5 之后经过大量优化,性能非常好 2. 加锁方法内部只有判断和赋值操作,没有网络请求或数据库操作 3. 这个锁只用在用户注册这一个功能上 4. 相比 Redis 的网络开销,本地锁的效率高得多 **该用锁的时候就要用,不要因为"锁"这个字就害怕!** **原子类的冗余性?** - 细心的你会发现,既然整个方法都加了 `synchronized`,那么内部其实可以用普通的 `int` 和 `long`。 - 使用 `AtomicInteger` 和 `AtomicLong` 在这里主要是为了代码规范性(成员变量由多线程访问),以及方便未来如果去掉 `synchronized` 改用 CAS 乐观锁算法时,不需要修改数据类型。 **限流算法类型** - 这属于**固定窗口算法(Fixed Window)**。 - *缺点*:存在“临界突发”问题(例如在第 0.9 秒来了 1000 个请求,第 1.1 秒又来了 1000 个请求,虽然各自窗口没超标,但在 0.9~1.1 秒这 0.2 秒内承受了 2000 个请求)。 - *适用场景*:对于注册接口这种防御性限流,固定窗口足够简单且有效,不需要令牌桶(Token Bucket)那么复杂的逻辑。 ##### **第二层:用户存在检查(布隆过滤器)** **类名**:`UserExistCheckHandler` **作用**:防止重复注册,利用布隆过滤器拦截数据库查询。 ```java package com.damai.service.composite.register.impl; import com.damai.dto.UserRegisterDto; import com.damai.service.UserService; import com.damai.service.composite.register.AbstractUserRegisterCheckHandler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; /** * @description: 用户已存在检查 - 第二层(节点B):布隆过滤器 + DB兜底 * * 架构定位:【防御漏斗的第三层】 * 作用:解决缓存穿透问题,防止大量重复注册请求打到数据库。 * 逻辑:先问布隆过滤器 -> 再问数据库(仅在布隆误判时)。 */ @Component public class UserExistCheckHandler extends AbstractUserRegisterCheckHandler { @Autowired private UserService userService; @Override public void execute(final UserRegisterDto userRegisterDto) { // 这里的 doExist 方法内部封装了“高并发防穿透”的核心逻辑: // 1. BloomFilter.contains(mobile)? // -> 不存在? 直接通过 (Return) -> 0 DB IO // -> 存在? (可能是误判) -> 查 DB 确认 -> 抛出 USER_EXIST 异常 userService.doExist(userRegisterDto.getMobile()); } /** * 组合模式:父节点ID * 返回 1,说明它也是 UserRegisterVerifyCaptcha 的子节点。 * 它和 UserRegisterCountCheckHandler 是兄弟节点关系。 */ @Override public Integer executeParentOrder() { return 1; } /** * 组合模式:层级 * 第 2 层 */ @Override public Integer executeTier() { return 2; } /** * 组合模式:同层级执行顺序 * 第 2 个执行 * 只有通过了 [本地限流 check],才有资格进行 [用户存在 check]。 * 这是一个非常严谨的“成本控制”排序:先做计算成本低的(计数器),再做计算成本高的(布隆/DB)。 */ @Override public Integer executeOrder() { return 2; } } ``` **doExist 方法实现:** ```java public void doExist(String mobile) { // 1. 先用布隆过滤器快速判断 boolean contains = bloomFilterHandler.contains(mobile); if (contains) { // 2. 布隆过滤器说"存在",可能是误判,需要查数据库确认 LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery(UserMobile.class) .eq(UserMobile::getMobile, mobile); UserMobile userMobile = userMobileMapper.selectOne(queryWrapper); if (Objects.nonNull(userMobile)) { // 3. 数据库确认存在,抛出异常 throw new DaMaiFrameException(BaseCode.USER_EXIST); } } // 4. 布隆过滤器说"不存在",一定不存在,可以继续注册 } ``` 布隆过滤器的特点: ![[布隆过滤器判断逻辑-86f1fcde.jpg]] #### **总结:组合模式构建的执行树** 通过 `executeParentOrder`、`executeTier` 和 `executeOrder` 这三个指标,这三个类在系统启动时会自动组装成如下的**执行树**: 1. **Root (Tier 1)**: `UserRegisterVerifyCaptcha` - **逻辑**:验证码校验。 - *成功后 -> 进入子节点列表* 2. **Children (Tier 2)**: - **Child 1**: `UserRegisterCountCheckHandler` (Order 1) - **逻辑**:本地 JVM 限流。 - *成功后 -> 继续* - **Child 2**: `UserExistCheckHandler` (Order 2) - **逻辑**:布隆过滤器查重。 - *成功后 -> 整个校验流程结束,执行 insert 操作* **设计精髓**: 如果不使用这种模式,代码里会写成 `if...else if...else` 的面条代码。使用组合模式后,如果未来需要加一个“IP黑名单校验”,只需要新建一个类继承 `AbstractUserRegisterCheckHandler`,配置好 Order,不需要修改任何原有代码,符合**开闭原则**。 ## **深度思考:为什么需要双层防护?** 现在让我们回顾一下,为什么要设计两层计数器? ### **职责分离** ![[两层防护的职责分离-f05be42c.jpg]] ### **效率最大化** ![[多层防御的效率分析-adbb9ca1.jpg]] ## **整体流程图** ![[用户注册完整流程-e9b3b9fc.jpg]] ## **核心总结** | **防御层** | **技术实现** | **作用** | **为什么这样设计** | | --- | --- | --- | --- | | **验证码判断** | Redis + Lua | 全局流量控制 | 需要跨实例共享计数 | | **验证码校验** | AJ-Captcha | 拦截机器人 | 从业务层防刷 | | **本地限流** | synchronized + AtomicInteger | 单机保护 | 零网络开销,极高效率 | | **用户存在检查** | 布隆过滤器 + 数据库 | 快速过滤 + 精确确认 | 新用户不查库,老用户防误判 | | **并发控制** | 分布式锁 | 防止重复注册 | 同一手机号串行处理 | | **数据完整性** | 事务 | 保证原子性 | 用户表和手机表同时成功或失败 | --- > *💡 **设计思想**:高并发系统的设计,不是选择一个"银弹"方案,而是从业务角度出发,设计多层防御体系。每一层都有其明确的职责,层层过滤,最终确保系统稳定运行。**正所谓技术都是围绕业务来服务的**,只有深入理解业务特点,才能设计出真正有效的技术方案。* --- **企业级项目导航**:⬅️ [[02-深度解析:用户注册场景下的“缓存穿透”防御战|02-深度解析:用户注册场景下的“缓存穿透”防御战]] | 03-(缓存穿透)用户注册业务深度解析 | ➡️ [[04-(缓存预热)购票人业务架构分析|04-(缓存预热)购票人业务架构分析]]