(缓存穿透)用户注册业务深度解析

视频

image-ea4ce66f

引子:看似简单的注册逻辑

对于普通项目来说,用户注册的逻辑非常简单:

验证参数 → 查询用户是否存在 → 不存在则添加用户 → 完成

看似岁月静好,一切都那么自然。但对于高并发系统来说,事情远没有这么简单。

深入分析为什么一个简单的注册功能,需要如此精心的设计。

高并发下的问题

场景还原

想象一下周杰伦演唱会开票的场景:

高并发注册场景还原-f30e9099

问题一:数据库压力

当用户量达到千万级别时,单表已经无法承受,需要进行 分库分表。这部分可以参考用户服务分库分表设计思维全景相关文档。

问题二:缓存穿透(核心问题)

在上一篇文章 深度解析:用户注册场景下的“缓存穿透”防御战已经做了系统的分析,这里进行简单回顾:

注册场景的缓存穿透问题-e29c2f1e

这就是经典的缓存穿透问题:查询的数据在缓存和数据库中都不存在,导致每次查询都"穿透"缓存,直接查询数据库。

更糟糕的是,在高并发场景下:

高并发下的灾难-391f74cc

解决方案:多层防御体系

通过前面的分析,我们知道单一的解决方案都有其局限性:

方案 问题
缓存空对象 每个手机号都不同,空值无法复用
分布式锁 串行执行,严重影响并发性能
布隆过滤器 存在误判,需要配合其他手段

正所谓技术都是围绕业务来服务的,我们必须从业务角度出发,设计一套多层防御体系。

整体架构设计

用户注册多层防御体系-f6dc12ca

代码实现详解

注册入口

@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() 方法抽象出来。

/**
 * 用户注册验证基类
 * 所有用户注册的验证逻辑继承此类即可
 */
public abstract class AbstractUserRegisterCheckHandler extends AbstractComposite<UserRegisterDto> {

    @Override
    public String type() {
        return CompositeCheckType.USER_REGISTER_CHECK.getValue();
    }
}

这样,以后所有用户注册的验证逻辑,只需继承 AbstractUserRegisterCheckHandler 即可,不用重复实现 type() 方法。

image-e1c28556

验证树结构

用户注册验证树结构-8d5ef44a
前置防护:全局流量识别

前端在加载注册页面或提交前,先调用 /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);
    }
}
用户注册防护系统-0e28b573
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);
    }
}
UserCaptchaService_-1f55b487
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'
lua-12ebbc8d

为什么用 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;
    }
}

流程说明:

验证码校验流程-4b9f4eaf

🤔 为什么要用 Redis 存储验证标识?

这里涉及到"是否需要验证码"的判断逻辑。在用户点击注册之前,前端会先调用一个接口判断是否需要验证码。这个判断是 全局的、跨实例共享的

为什么用_Redis_存储验证标识-95098a4e
第二层:请求数限制(本地计数器)

类名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(几毫秒级)快了几个数量级。
两层计数器的区别-d2b5e9bd

关于 synchronized 的性能担忧

加了 synchronized 锁,效率会不会很低?

其实不用担心:

  1. synchronized 在 JDK 1.5 之后经过大量优化,性能非常好
  2. 加锁方法内部只有判断和赋值操作,没有网络请求或数据库操作
  3. 这个锁只用在用户注册这一个功能上
  4. 相比 Redis 的网络开销,本地锁的效率高得多

该用锁的时候就要用,不要因为"锁"这个字就害怕!

原子类的冗余性?

  • 细心的你会发现,既然整个方法都加了 synchronized,那么内部其实可以用普通的 intlong
  • 使用 AtomicIntegerAtomicLong 在这里主要是为了代码规范性(成员变量由多线程访问),以及方便未来如果去掉 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. 布隆过滤器说"不存在",一定不存在,可以继续注册
}

布隆过滤器的特点:

布隆过滤器判断逻辑-86f1fcde

总结:组合模式构建的执行树

通过 executeParentOrderexecuteTierexecuteOrder 这三个指标,这三个类在系统启动时会自动组装成如下的执行树

  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

效率最大化

多层防御的效率分析-adbb9ca1

整体流程图

用户注册完整流程-e9b3b9fc

核心总结

防御层 技术实现 作用 为什么这样设计
验证码判断 Redis + Lua 全局流量控制 需要跨实例共享计数
验证码校验 AJ-Captcha 拦截机器人 从业务层防刷
本地限流 synchronized + AtomicInteger 单机保护 零网络开销,极高效率
用户存在检查 布隆过滤器 + 数据库 快速过滤 + 精确确认 新用户不查库,老用户防误判
并发控制 分布式锁 防止重复注册 同一手机号串行处理
数据完整性 事务 保证原子性 用户表和手机表同时成功或失败

💡 设计思想:高并发系统的设计,不是选择一个"银弹"方案,而是从业务角度出发,设计多层防御体系。每一层都有其明确的职责,层层过滤,最终确保系统稳定运行。正所谓技术都是围绕业务来服务的,只有深入理解业务特点,才能设计出真正有效的技术方案。


企业级项目导航:⬅️ 02-深度解析:用户注册场景下的“缓存穿透”防御战 | 03-(缓存穿透)用户注册业务深度解析 | ➡️ 04-(缓存预热)购票人业务架构分析