(缓存穿透)用户注册业务深度解析
视频
引子:看似简单的注册逻辑
对于普通项目来说,用户注册的逻辑非常简单:
验证参数 → 查询用户是否存在 → 不存在则添加用户 → 完成
看似岁月静好,一切都那么自然。但对于高并发系统来说,事情远没有这么简单。
深入分析为什么一个简单的注册功能,需要如此精心的设计。
高并发下的问题
场景还原
想象一下周杰伦演唱会开票的场景:
问题一:数据库压力
当用户量达到千万级别时,单表已经无法承受,需要进行 分库分表。这部分可以参考用户服务分库分表设计思维全景相关文档。
问题二:缓存穿透(核心问题)
在上一篇文章 深度解析:用户注册场景下的“缓存穿透”防御战已经做了系统的分析,这里进行简单回顾:
这就是经典的缓存穿透问题:查询的数据在缓存和数据库中都不存在,导致每次查询都"穿透"缓存,直接查询数据库。
更糟糕的是,在高并发场景下:
解决方案:多层防御体系
通过前面的分析,我们知道单一的解决方案都有其局限性:
| 方案 | 问题 |
|---|---|
| 缓存空对象 | 每个手机号都不同,空值无法复用 |
| 分布式锁 | 串行执行,严重影响并发性能 |
| 布隆过滤器 | 存在误判,需要配合其他手段 |
正所谓技术都是围绕业务来服务的,我们必须从业务角度出发,设计一套多层防御体系。
整体架构设计
代码实现详解
注册入口
@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 |
注册成功后加入布隆过滤器 |
验证逻辑的组合模式设计
在分析具体验证逻辑之前,我们先来理解一个设计问题。
🤔 问题思考
参数验证是很常见的需求,但当验证逻辑变得复杂后,会存在以下问题:
- 复用问题:有些验证逻辑是多个接口都需要的
- 结构问题:验证逻辑之间存在父子关系和执行顺序
为了解决这些问题,我们使用 组合模式 来构建验证树结构。
抽象层设计
首先,我们思考一个问题:用户注册的三个验证逻辑,类型都是 USER_REGISTER_CHECK。如果每个验证类都要实现 type() 方法返回相同的值,这不是冗余吗?
解决方案:再设计一个抽象层,将相同类型的 type() 方法抽象出来。
/**
* 用户注册验证基类
* 所有用户注册的验证逻辑继承此类即可
*/
public abstract class AbstractUserRegisterCheckHandler extends AbstractComposite<UserRegisterDto> {
@Override
public String type() {
return CompositeCheckType.USER_REGISTER_CHECK.getValue();
}
}
这样,以后所有用户注册的验证逻辑,只需继承 AbstractUserRegisterCheckHandler 即可,不用重复实现 type() 方法。
验证树结构
前置防护:全局流量识别
前端在加载注册页面或提交前,先调用 /check/need。后端利用 Redis + Lua 脚本维护一个全局计数器(所有服务实例共享)。如果这一秒内的总请求数超过阈值(比如 10 次),则判定系统处于“高并发/受攻击”状态,强制要求当前用户进行验证码校验,并颁发一个带有“需要验证”标记的 CaptchaId。
1. 控制层:暴露检测接口
类名:UserCaptchaController 作用:提供给前端调用的入口,尤其是 /check/need。
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<CheckNeedCaptchaDataVo> 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);
}
}
2. 业务层:准备 Lua 参数
类名:UserCaptchaService 作用:组装 Redis Key 和参数,调用 Lua 执行器。
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<String> 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);
}
}
3. Redis/Lua 执行层
类名:CheckNeedCaptchaOperate 作用:加载并执行 Lua 脚本。
为什么必须用 Lua 深度学习笔记? 因为“读取当前请求数”、“判断是否过秒”、“重置计数器”、“写入用户标记”这一系列操作必须是**原子(Atomic)**的。如果不用 Lua,在高并发下会出现多线程竞争导致计数不准,甚至被绕过。
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<String> 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<String> 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)的限流算法,并根据限流结果动态决定当前用户是否需要验证码。
-- ==========================================================
-- 脚本说明:判断当前是否需要进行验证码校验
-- 架构定位:第一道全局防线 (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'
为什么用 count = 0 (重置) 而不是 return error (拒绝)? 在第 64 行:if count > verify_captcha_threshold then ... count = 0 ...。 这是一种**“采样触发”**机制。
-
假设阈值是 10。
-
前 10 个请求进来 -> 正常,标记为 "no"。
-
第 11 个请求进来 -> 触发阈值 -> 标记为 "yes",同时计数器归零。
-
效果:系统在高负载下,不会把所有人都拦住,而是每隔 10 个请求就拦一个去验证码。这既保护了系统,又不会让用户觉得系统彻底挂了,是一种柔性降级策略。
时间轴 (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 作用:拦截脚本攻击和机器流量。
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;
}
}
流程说明:
🤔 为什么要用 Redis 存储验证标识?
这里涉及到"是否需要验证码"的判断逻辑。在用户点击注册之前,前端会先调用一个接口判断是否需要验证码。这个判断是 全局的、跨实例共享的:
第二层:请求数限制(本地计数器)
类名:UserRegisterCountCheckHandler 作用:保护当前服务节点的 JVM 和数据库连接池。
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 计数器实现:
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(几毫秒级)快了几个数量级。
关于 synchronized 的性能担忧:
加了 synchronized 锁,效率会不会很低?
其实不用担心:
synchronized在 JDK 1.5 之后经过大量优化,性能非常好- 加锁方法内部只有判断和赋值操作,没有网络请求或数据库操作
- 这个锁只用在用户注册这一个功能上
- 相比 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 作用:防止重复注册,利用布隆过滤器拦截数据库查询。
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 方法实现:
public void doExist(String mobile) {
// 1. 先用布隆过滤器快速判断
boolean contains = bloomFilterHandler.contains(mobile);
if (contains) {
// 2. 布隆过滤器说"存在",可能是误判,需要查数据库确认
LambdaQueryWrapper<UserMobile> 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. 布隆过滤器说"不存在",一定不存在,可以继续注册
}
布隆过滤器的特点:
总结:组合模式构建的执行树
通过 executeParentOrder、executeTier 和 executeOrder 这三个指标,这三个类在系统启动时会自动组装成如下的执行树:
- Root (Tier 1):
UserRegisterVerifyCaptcha- 逻辑:验证码校验。
- 成功后 -> 进入子节点列表
- Children (Tier 2):
- Child 1:
UserRegisterCountCheckHandler(Order 1)- 逻辑:本地 JVM 限流。
- 成功后 -> 继续
- Child 2:
UserExistCheckHandler(Order 2)- 逻辑:布隆过滤器查重。
- 成功后 -> 整个校验流程结束,执行 insert 操作
- Child 1:
设计精髓: 如果不使用这种模式,代码里会写成 if...else if...else 的面条代码。使用组合模式后,如果未来需要加一个“IP黑名单校验”,只需要新建一个类继承 AbstractUserRegisterCheckHandler,配置好 Order,不需要修改任何原有代码,符合开闭原则。
深度思考:为什么需要双层防护?
现在让我们回顾一下,为什么要设计两层计数器?
职责分离
效率最大化
整体流程图
核心总结
| 防御层 | 技术实现 | 作用 | 为什么这样设计 |
|---|---|---|---|
| 验证码判断 | Redis + Lua | 全局流量控制 | 需要跨实例共享计数 |
| 验证码校验 | AJ-Captcha | 拦截机器人 | 从业务层防刷 |
| 本地限流 | synchronized + AtomicInteger | 单机保护 | 零网络开销,极高效率 |
| 用户存在检查 | 布隆过滤器 + 数据库 | 快速过滤 + 精确确认 | 新用户不查库,老用户防误判 |
| 并发控制 | 分布式锁 | 防止重复注册 | 同一手机号串行处理 |
| 数据完整性 | 事务 | 保证原子性 | 用户表和手机表同时成功或失败 |
💡 设计思想:高并发系统的设计,不是选择一个"银弹"方案,而是从业务角度出发,设计多层防御体系。每一层都有其明确的职责,层层过滤,最终确保系统稳定运行。正所谓技术都是围绕业务来服务的,只有深入理解业务特点,才能设计出真正有效的技术方案。
企业级项目导航:⬅️ 02-深度解析:用户注册场景下的“缓存穿透”防御战 | 03-(缓存穿透)用户注册业务深度解析 | ➡️ 04-(缓存预热)购票人业务架构分析
💬 评论