← blog 专题系列 · 2026-04-06

双Token鉴权

讲解双 Token 鉴权与续费机制:Access/Refresh Token 职责划分、Refresh Token Rotation、哈希与黑名单存储,以及并发刷新冲突的生产级解决方案。

20 min read

双Token续费机制详解

为什么需要双Token?

单Token的问题:

graph TD
    A["用户登录 → 获得Token (24小时有效期)"]
    B["用户正在操作(如提交任务)"]
    C["Token突然过期 → 401错误"]
    D["用户被迫重新登录 → 体验差"]
    A --> B --> C --> D

双Token解决方案: Access Token (短):像临时身份证,进出需要验证(15-30分钟) Refresh Token (长):像长期签证,可以在过期前换新证(7-30天)

用户无感知:前端自动用refreshToken换新accessToken

双Token核心概念

Token类型Access TokenRefresh Token
用途访问API时身份证明换取新的Access Token
有效期15-30分钟(短)7-30天(长)
存储内存 / sessionStoragelocalStorage(更持久)
传输每次请求带仅刷新时使用
失效方式时间过期时间过期 / 被禁用 / 换新后作废

完整流程图

sequenceDiagram
    autonumber
    participant F as 前端
    participant S as 服务器

    Note over F,S: 登录流程
    F->>S: POST /auth/login (username, password)
    S->>S: 验证密码
    S->>S: 生成 access_token (15min)
    S->>S: 生成 refresh_token (7天)
    S->>S: 存储 refresh_token 哈希到DB
    S-->>F: { access_token, refresh_token }
    F->>F: 存入 localStorage (refresh_token 持久化)
    F->>F: 存入内存 (access_token)

    Note over F,S: 正常API请求流程
    F->>S: GET /api/tasks (带 access)
    S->>S: 验证 access_token ✓
    S-->>F: 200 OK (任务列表)

    Note over F,S: Access Token 过期时自动刷新
    F->>S: GET /api/tasks (带 access)
    S-->>F: 401 Unauthorized (access_token 已过期)
    F->>F: 【前端拦截401,自动触发刷新】
    F->>S: POST /auth/refresh { refresh_token }
    S->>S: 验证 refresh_token 哈希
    S->>S: 检查是否已使用/被禁用
    S->>S: 生成新的 access_token
    S->>S: 生成新的 refresh_token (可选)
    S->>S: 更新DB中的 refresh_token 哈希
    S-->>F: { new_access_token, new_refresh_token } (rotation)
    F->>F: 存入 localStorage
    F->>F: 【自动重试刚才失败的请求】
    F->>S: GET /api/tasks (带 new access)
    S-->>F: 200 OK

    Note over F,S: Refresh Token Rotation
    Note over F,S: 旧 refresh_token (abc123) → 作废 + 记录到黑名单<br/>新 refresh_token (xyz789) → 存入DB,取代旧的

好处:即使旧token被盗用,刷新后自动失效,攻击窗口极短


关键安全问题:Refresh Token Rotation

  • 单Token: ❌ 不安全:refresh_token 可以无限次使用 攻击者窃取 refresh_token → 可以一直换新access_token

  • 双Token ✅ 安全:每次刷新后换新 refresh_token(Rotation) 攻击者窃取 refresh_token → 用户下次刷新 → 攻击者的token被作废


实现:数据库存储refresh_token的哈希值

登录时

    String rawRefreshToken = UUID.randomUUID().toString();
    String refreshTokenHash = hash(rawRefreshToken);
    user.setRefreshTokenHash(refreshTokenHash);
    user.setRefreshTokenExpire(at: now + 7 days);
    db.save(user);

刷新时

    1. 验证 rawRefreshToken 的哈希是否与DB中匹配
    2. 验证是否已过期
    3. 验证是否已在黑名单(上次刷新时已替换)
    4. 通过 → 生成新token对,旧token哈希加入黑名单

前端实现逻辑(伪代码)

    // axios 拦截器
    axios.interceptors.response.use(
        response => response,
        async error => {
        if (error.response?.status === 401) {
            // 1. 尝试刷新token
            const newTokens = await refreshToken();

            // 2. 更新本地存储
            localStorage.setItem('access_token', newTokens.access_token);
            localStorage.setItem('refresh_token', newTokens.refresh_token);

            // 3. 重试刚才失败的请求(用新token)
            error.config.headers.Authorization = `Bearer ${newTokens.access_token}`;
            return axios(error.config);
        }
        return Promise.reject(error);
        }
    );

    async function refreshToken() {
        const refresh = localStorage.getItem('refresh_token');
        const res = await fetch('/auth/refresh', {
        method: 'POST',
        body: JSON.stringify({ refresh_token: refresh })
        });
        return res.json();
    }

与单Token对比


| 特性 | 单Token | 双Token |
|------|--------|---------|
| 实现复杂度 | 简单 | 中等 |
| 用户体验 | 差(需频繁登录) | 好(无感知刷新) |
| 安全性 | 一般 | 高(refresh rotation) |
| Token窃取风险 | 高(token长期有效) | 低(短期access + rotation) |
| 服务端存储 | 无需 | 需要存储refresh_token哈希 |

## RefreshToken 安全存储措施

1. 哈希存储(服务端)

永远不在数据库存明文refresh_token

 // ❌ 错误:存明文
 user.setRefreshToken(token);  // token=abc123xyz

 // ✅ 正确:存哈希
 user.setRefreshTokenHash(hashSHA256(token));
攻击场景明文存储哈希存储
数据库泄露攻击者直接拿到可用token攻击者拿到哈希,无法逆向
彩虹表攻击-加盐防彩虹表
  ---
  2. 加盐哈希

    // 每次登录生成唯一盐值
    String salt = UUID.randomUUID().toString();
    String tokenHash = sha256(token + salt);

    // 存储
    user.setRefreshTokenSalt(salt);
    user.setRefreshTokenHash(tokenHash);

  ---
  3. 单次使用(Rotation机制)

  数据库: 存储当前 refresh_token 的哈希

  刷新时:
    1. 验证传入的 token 哈希 == 数据库存储的哈希
    2. 验证通过 → 生成新的 token 对
    3. 新 refresh_token 哈希覆盖数据库旧值
    4. 旧 token 哈希加入"黑名单"(可选)

  效果:token只能用一次,窃取者刷新后原token立即失效

  ---
   ### 过期时间控制
   
    -- 表结构示例
    CREATE TABLE refresh_tokens (
        id BIGINT PRIMARY KEY,
        user_id BIGINT,
        token_hash VARCHAR(64),      -- 哈希值
        salt VARCHAR(36),             -- 盐
        created_at TIMESTAMP,
        expires_at TIMESTAMP,         -- 7天后过期
        revoked BOOLEAN DEFAULT FALSE,
        replaced_by_token_id BIGINT  -- 指向替换它的新token
    );

    -- 查询时过滤
    WHERE revoked = FALSE AND expires_at > NOW()

  ---
  ### Token传输安全
措施说明
HTTPS Only禁止明文传输
HttpOnly Cookie前端JS无法读取refresh_token
SameSite=Strict防止CSRF
禁止本地存储明文密码安全意识

    // 后端设置HttpOnly Cookie
    response.addCookie(new Cookie("refresh_token", token)
        .setHttpOnly(true)
        .setSecure(true)           // HTTPS only
        .setPath("/auth/refresh")  // 仅刷新接口
        .setMaxAge(7 * 24 * 3600));

  ---
   ### 攻击检测与响应

异常检测: ├── IP突变 → 旧token立即加入黑名单 ├── 频率异常 → 临时封禁 + 通知用户 └── 多个token同时使用 → 疑似被盗

响应措施: ├── 立即撤销所有token ├── 强制用户重新登录 └── 记录审计日志

  ---
  ### 完整安全矩阵
层级措施目的
传输HTTPS + Secure Cookie防止网络层窃取
存储加盐哈希 + 单次使用防止数据库泄露
时间短期过期(7-30天)缩短攻击窗口
检测异常IP/频率监控发现即响应
撤销立即 revocation 机制最小化损失

 ### 问题场景:Race Condition
sequenceDiagram
  participant U as 正常用户
  participant T as 窃取者
  U->>T: POST /refresh (token_A)
  Note right of T: 服务器: 验证通过<br/>生成新 token_B 替换 token_A<br/>token_A 已作废!
  T-->>U: 新 token_B
  Note over U: 用户下次刷新时
  U->>T: POST /refresh (token_A)
  T-->>U: 401 Unauthorized (验证失败! token已rotation)
  Note over U: ❌ 用户被踢了! 需要重新登录
  ---
  ### 解决方案

  方案1:宽松的并发控制(推荐)

    // 刷新时检查:允许"最近一个"token 刷新
    // 而不是强制"只能当前这个"token 刷新

    boolean isCurrentToken = hash(incoming) == hash(databaseStored);
    boolean isRecentlyRotated = isRotatedRecently(incoming, databaseStored); // 5分钟内

    if (isCurrentToken || isRecentlyRotated) {
        // 允许刷新
    }

  效果:窃取者刷新后,用户还有5分钟宽限期去刷新

  ---
  方案2:家族追踪(Family Rotation)

    每个refresh_token属于一个"家族"(family_id)
    同一个家族内,只允许最新那个token有效

    家族ID: uuid-family-001
        ├── token_v1 (已作废, parent=null)
        ├── token_v2 (已作废, parent=token_v1)
        └── token_v3 (当前有效, parent=token_v2)

    刷新 token_v3 时:
        生成 token_v4,家族ID不变
        token_v3 加入黑名单

  用户被踢的唯一情况:窃取者刷新后,用户超过宽限期没刷新

  ---
  方案3:检测+通知(最佳实践)

    // 每次刷新记录审计日志
    logRefreshAttempt(userId, ip, userAgent, success, tokenId);

    // 异常检测
    if (isSuspiciousRefresh(userId, ip, userAgent)) {
        // 窃取者IP/设备与用户正常使用时不同
        // 触发安全告警:发送邮件/短信通知用户
        sendSecurityAlert(user, "检测到异常token刷新,请确认是否本人操作");

        // 可选:暂时不解禁,等用户确认
        // 或者:自动解绑所有token,强制重新登录
    }

  ---
  ### 完整安全策略
层级措施应对
检测记录每次刷新的IP/设备发现异常
通知实时告警(邮件/短信)用户知情
宽限5分钟rotation宽限期减少用户体验影响
撤销一键解绑所有设备登录用户自助控制
审计完整刷新历史事后追溯
  ---
  **用户体验 vs 安全的权衡**

高安全模式: ├── 严格单次使用(无宽限期) ├── 异常立即封号 └── 用户频繁需要重新登录

平衡模式(推荐): ├── 5分钟宽限期 ├── 异常告警但不封号 └── 用户可自助解绑

低安全模式: ├── 只验证是否当前token └── 不做异常检测

  ---
  ### 实际生产环境的最佳实践

  1. 家族追踪 (Family Rotation)
     - 每个设备登录生成独立家族
     - 设备A刷新 → 设备A的token rotation
     - 设备B不受影响

  2. 设备指纹
     refresh_token 绑定设备指纹
     窃取者换个设备用token → 立即告警

  3. 用户控制台
     显示所有活跃设备/登录记录
     用户可一键"退出所有设备"

  ---
  ## 总结
问题解决方案
窃取者先刷新5分钟宽限期 + 告警
用户被踢宽限期内刷新即可
窃取者跨设备设备指纹检测
不知道被盗实时告警通知用户
  关键:不能100%防止攻击,但能做到早期检测 + 用户知情 + 快速响应

RefreshToken 黑名单机制详解

 ##  黑名单的核心作用

  黑名单用途:
    1. 记录已失效的 refresh_token
    2. 防止"双花"(double-spend)攻击:同一token被刷新两次
    3. 实现强制单次使用的安全保证

  ---
  ### 黑名单实现机制

  1. 基于 Redis 的高速方案(推荐)

    // 添加到黑名单
    public void blacklistToken(String tokenId, long ttlSeconds) {
        redis.opsForValue().set(
            "blacklist:refresh:" + hash(tokenId),  // key
            "1",                                      // value
            Duration.ofSeconds(ttlSeconds)            // 自动过期
        );
    }

    // 检查是否在黑名单
    public boolean isBlacklisted(String tokenId) {
        return Boolean.TRUE.equals(
            redis.hasKey("blacklist:refresh:" + hash(tokenId))
        );
    }

    // 刷新时:旧token加入黑名单
    public void refresh(String rawToken) {
        TokenData data = parseAndValidate(rawToken);

        // 检查是否已黑名单(防止重复刷新)
        if (isBlacklisted(data.getJti())) {
            throw new TokenReusedException("Token已被使用,请重新登录");
        }

        // ... 生成新token ...

        // 旧token加入黑名单,过期时间 = 剩余有效期 + 缓冲时间
        long remainingTTL = data.getExp() - Instant.now().getEpochSecond();
        blacklistToken(data.getJti(), remainingTTL + 300); // 多加5分钟缓冲
    }

    Redis 内存结构:
        blacklist:refresh:sha256(token_abc123) → "1" (TTL: 15分钟)
        blacklist:refresh:sha256(token_xyz789) → "1" (TTL: 5分钟)

  ---
  2. 基于数据库的持久化方案

    CREATE TABLE refresh_token_blacklist (
        id            BIGINT PRIMARY KEY AUTO_INCREMENT,
        token_jti     VARCHAR(64) NOT NULL,        -- token唯一标识(JTI)
        token_hash    VARCHAR(64) NOT NULL,         -- token的SHA256哈希
        revoked_at    TIMESTAMP NOT NULL,           -- 撤销时间
        expires_at    TIMESTAMP NOT NULL,          -- token本身过期时间
        reason        VARCHAR(128),                -- 撤销原因: ROTATED / LOGOUT / COMPROMISED
        created_by    VARCHAR(64),                 -- 操作者: USER / SYSTEM / ADMIN
        INDEX idx_token_hash (token_hash),
        INDEX idx_expires_at (expires_at)          -- 定期清理用
    );

    // 添加到黑名单
    public void blacklist(TokenData data, String reason) {
        // 计算token真正过期的绝对时间
        long expiresAt = data.getExp(); // 秒级时间戳

        refreshTokenBlacklistMapper.insert(
            RefreshTokenBlacklist.builder()
                .tokenJti(data.getJti())
                .tokenHash(hash(data.getRawToken()))
                .revokedAt(Instant.now())
                .expiresAt(Instant.ofEpochSecond(expiresAt))
                .reason(reason)
                .createdBy("USER")
                .build()
        );
    }

    // 检查黑名单
    public boolean isBlacklisted(String tokenJti) {
        return refreshTokenBlacklistMapper
            .selectCount(new QueryWrapper<RefreshTokenBlacklist>()
                .eq("token_jti", tokenJti)) > 0;
    }

  ---
  3. 混合方案(最佳实践)

高速场景用Redis: ├── token验证(每次刷新)→ Redis O(1) 查询 └── 高频场景的性能保障

持久化场景用数据库: ├── 审计日志(合规要求) ├── 用户查看”登录设备历史” └── 管理员手动撤销

    @Service
    public class TokenBlacklistService {

        @Autowired private RedisTemplate<String, String> redis;
        @Autowired private RefreshTokenBlacklistMapper dbMapper;

        // 双写:Redis快速查询 + DB持久化审计
        public void blacklist(TokenData data, String reason) {
            // 1. Redis黑名单(快速生效)
            String key = "blacklist:refresh:" + data.getJti();
            long ttl = data.getExp() - Instant.now().getEpochSecond() + 300;
            redis.opsForValue().set(key, reason, Duration.ofSeconds(ttl));

            // 2. DB持久化(审计用)
            dbMapper.insert(RefreshTokenBlacklist.builder()
                .tokenJti(data.getJti())
                .tokenHash(hash(data.getRawToken()))
                .revokedAt(Instant.now())
                .expiresAt(Instant.ofEpochSecond(data.getExp()))
                .reason(reason)
                .createdBy("USER")
                .build());
        }

        // 查询:优先Redis,Redis没有查DB
        public boolean isBlacklisted(String jti) {
            String key = "blacklist:refresh:" + jti;
            if (Boolean.TRUE.equals(redis.hasKey(key))) {
                return true;
            }
            // Redis没有,查DB(兜底)
            return dbMapper.selectCount(new QueryWrapper<RefreshTokenBlacklist>()
                .eq("token_jti", jti)) > 0;
        }
    }

  ---
  ### 有效期管理

  1. 黑名单条目的生命周期

┌─────────────────────────────────────────────────────────────┐ │ Token黑名单有效期设计 │ └─────────────────────────────────────────────────────────────┘

  Token有效期: 7天 (604800秒)

  黑名单条目有效期 = Token剩余有效期 + 安全缓冲

  - 场景1: Token刚创建就被刷新
    剩余有效期: 7天
    黑名单TTL: 7天 + 5分钟缓冲 = 604800 + 300 ≈ 7天

  - 场景2: Token即将过期时才刷新
    剩余有效期: 5分钟
    黑名单TTL: 5分钟 + 5分钟缓冲 = 10分钟

  - 场景3: 用户主动登出
    剩余有效期: 任意
    黑名单TTL: 剩余有效期 + 30天清理窗口
    原因: 用户可能忘记密码,需要保留登出凭证一段时间

  ---
  2. 清理策略

    // 方式1: Redis TTL自动清理
    redis.opsForValue().set(key, "1", Duration.ofSeconds(ttl));

    // 方式2: 数据库定时任务清理
    @Scheduled(cron = "0 0 3 * * ?")  // 每天凌晨3点
    public void cleanupExpiredBlacklistEntries() {
        // 删除已过期的黑名单记录
        // (因为过期的token本身就无法使用了)
        int deleted = refreshTokenBlacklistMapper.delete(
            new QueryWrapper<RefreshTokenBlacklist>()
                .lt("expires_at", Instant.now())
                .eq("reason", "ROTATED")  // 只清理rotation产生的
        );
        log.info("Cleaned up {} expired blacklist entries", deleted);
    }

  ---
  3. Token有效期配置矩阵
Token类型建议有效期黑名单TTL适用场景
Access Token15-30分钟不需要API访问
Refresh Token7天7天 + 5分钟长期会话
记住我 Token30天30天 + 1天持久登录
关键操作 Token5分钟即时敏感操作确认
  ---
  ## 存储结构设计

  1. Redis 结构

    # Hash结构(推荐,用于家族追踪)
    HSET refresh_family:user:12345
        token_v1 "{\"jti\":\"xxx\",\"revoked\":true,\"revoked_at\":1700000000}"
        token_v2 "{\"jti\":\"yyy\",\"revoked\":true,\"revoked_at\":1700003600}"
        token_v3 "{\"jti\":\"zzz\",\"revoked\":false}"

    # String结构(简单场景)
    SET blacklist:refresh:jti_abc123 "1" EX 604800
    SET blacklist:refresh:jti_xyz789 "1" EX 300

    # Set结构(记录当前有效token)
    SADD active_tokens:user:12345 jti_abc123 jti_xyz789
    SREM active_tokens:user:12345 jti_abc123  # 刷新时移除旧的

  ---
  2. 数据库表结构

    -- 核心黑名单表
    CREATE TABLE refresh_token_blacklist (
        id              BIGINT AUTO_INCREMENT PRIMARY KEY,
        user_id         BIGINT NOT NULL,
        token_jti       VARCHAR(64) NOT NULL UNIQUE,   -- JWT ID,唯一标识
        token_hash      VARCHAR(64) NOT NULL,            -- 哈希存储
        family_id       VARCHAR(64),                     -- 家族ID(用于追踪)
        token_version   INT DEFAULT 1,                   -- 版本号(家族内序号)
        revoked_at      TIMESTAMP NOT NULL,
        expires_at      TIMESTAMP NOT NULL,             -- token本身过期时间
        reason          ENUM('ROTATED','LOGOUT','EXPIRED','COMPROMISED','ADMIN') NOT NULL,
        created_by      VARCHAR(64) NOT NULL,           -- USER / SYSTEM / ADMIN
        ip_address      VARCHAR(45),
        user_agent      VARCHAR(256),
        INDEX idx_user_id (user_id),
        INDEX idx_token_hash (token_hash),
        INDEX idx_family_id (family_id),
        INDEX idx_expires_at (expires_at),
        INDEX idx_revoked_at (revoked_at)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

    -- 活跃Token追踪表(可选,用于快速查询用户当前有效token)
    CREATE TABLE refresh_token_active (
        id              BIGINT AUTO_INCREMENT PRIMARY KEY,
        user_id         BIGINT NOT NULL,
        token_jti       VARCHAR(64) NOT NULL UNIQUE,
        token_hash      VARCHAR(64) NOT NULL,
        family_id       VARCHAR(64) NOT NULL,
        version         INT NOT NULL DEFAULT 1,
        issued_at       TIMESTAMP NOT NULL,
        expires_at      TIMESTAMP NOT NULL,
        last_used_at    TIMESTAMP,
        device_info     VARCHAR(256),
        ip_address      VARCHAR(45),
        is_active       BOOLEAN DEFAULT TRUE,
        INDEX idx_user_id (user_id),
        INDEX idx_family_id (family_id),
        INDEX idx_expires_at (expires_at)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

    -- 审计日志表(可选,合规用)
    CREATE TABLE refresh_token_audit_log (
        id              BIGINT AUTO_INCREMENT PRIMARY KEY,
        user_id         BIGINT NOT NULL,
        token_jti       VARCHAR(64) NOT NULL,
        action          ENUM('ISSUED','REFRESHED','REVOKED','EXPIRED','REJECTED') NOT NULL,
        ip_address      VARCHAR(45),
        user_agent      VARCHAR(256),
        success         BOOLEAN NOT NULL,
        failure_reason  VARCHAR(128),
        created_at      TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
        INDEX idx_user_id (user_id),
        INDEX idx_created_at (created_at)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

  ---
  3. 家族追踪(Family)完整示例

    // 家族结构
    /*
    家族: family_abc123
    ├── token_v1 (jti=aaa, version=1, revoked=true, revoked_by=bbb)
    ├── token_v2 (jti=bbb, version=2, revoked=true, revoked_by=ccc)
    └── token_v3 (jti=ccc, version=3, revoked=false) ← 当前有效

    每次刷新:
        旧token作废,新token创建(同一家族,版本+1)
    */

    @Service
    public class FamilyRotationService {

        public RefreshTokenResult refresh(String rawToken) {
            TokenData data = parseToken(rawToken);
            String familyId = data.getFamilyId();

            // 1. 检查token是否属于黑名单
            if (isTokenBlacklisted(data.getJti())) {
                // 异常:同一token被使用两次!
                handleReuseAttack(data);
                throw new TokenReusedException();
            }

            // 2. 获取当前家族有效token
            TokenData currentActive = getCurrentActiveToken(familyId);

            // 3. 确保传入的是最新那个(家族内只能最新token刷新)
            if (!data.getJti().equals(currentActive.getJti())) {
                // 攻击者用了旧token
                revokeEntireFamily(familyId); // 整个家族作废
                throw new TokenReusedException("使用了过期token");
            }

            // 4. 生成新token,版本+1
            String newJti = UUID.randomUUID().toString();
            int newVersion = currentActive.getVersion() + 1;

            // 5. 作废旧token
            blacklistToken(currentActive, "ROTATED");

            // 6. 保存新token
            saveNewToken(data.getUserId(), newJti, familyId, newVersion);

            return new RefreshTokenResult(newJti, newVersion);
        }
    }

  ---
  ## 总结
维度推荐方案
高速查询Redis String/Hash,TTL过期
持久审计MySQL/PostgreSQL
防复用攻击黑名单 + JTI唯一约束
家族追踪family_id + version字段
清理策略Redis TTL自动清理 + DB定时任务
有效期Token剩余有效期 + 5-30分钟缓冲
  ## 最佳实践:
  Redis(高速) + DB(审计) 混合,黑名单TTL设为Token剩余有效期+缓冲时间。

 ## 双Token并发刷新冲突问题

  问题场景

  用户点击"刷新"按钮多次触发 /auth/refresh
  或
  多个标签页同时打开,都检测到token过期

  时间线:

    T1: 请求A 发送 refresh_token (v1)
    T2: 请求B 发送 refresh_token (v1)  ← 并发!
    T3: 服务器处理请求A → 通过 → 生成 v2 → v1加入黑名单
    T4: 服务器处理请求B → 通过 → 生成 v3 → v2加入黑名单
  
问题:请求A和B都成功了,但生成了两个新token,浪费且混乱

  ---
  ### 核心冲突类型
冲突类型描述后果
重复刷新同一token短时间内多次请求生成多个新token,token版本混乱
新旧版本交错v1还在处理时,v2来了v2可能基于过期的旧状态生成
客户端状态不一致标签页A拿到v2,标签页B还持有v1后续请求用v1被拒
---
## 解决方案
方案1:分布式锁(Redis)
```java
public RefreshTokenResult refresh(String rawToken) {
    TokenData data = parseAndValidate(rawToken);
    String lockKey = "lock:refresh:user:" + data.getUserId();

    // 获取分布式锁,100ms超时,10秒自动释放
    Boolean acquired = redis.opsForValue()
        .setIfAbsent(lockKey, "1", Duration.ofMillis(100));

    if (!acquired) {
        // 另一个刷新正在进行,等待重试
        return retryRefreshAfterDelay(rawToken, 200);
    }

    try {
        // 检查当前token是否已是最新
        String currentJti = getCurrentActiveJti(data.getUserId());
        if (!data.getJti().equals(currentJti)) {
            // 已有更新的token被生成,说明本token已过期
            throw new TokenOutdatedException();
        }

        // 执行刷新逻辑
        return doRefresh(data);

    } finally {
        redis.delete(lockKey); // 释放锁
    }
}
    锁粒度:按 userId 加锁
          刷新请求A ──▶ 获取锁 user:123 ──▶ 处理中...
          刷新请求B ──▶ 获取锁 user:123 ──▶ 等待/拒绝

方案2:数据库乐观锁

  // 在active_token表加 version 乐观锁
  @Entity
  public class RefreshToken {
      @Id
      private String jti;

      private Long userId;
      private Integer version;  // 版本号,每次刷新+1

      @Version  // JPA乐观锁注解
      private Long optimisticLock;
  }

  // 刷新时
  public RefreshTokenResult refresh(String rawToken) {
      TokenData data = parseToken(rawToken);
      RefreshToken current = findActiveByFamily(data.getFamilyId());

      // 乐观锁检查版本
      if (!current.getJti().equals(data.getJti())) {
          throw new TokenOutdatedException("已有新token生成");
      }

      // 更新时自动版本+1,冲突则抛异常
      current.setVersion(current.getVersion() + 1);
      current.setJti(newJti);
      refreshTokenRepository.save(current); // OptimisticLockException
  }

并发场景:

      请求A: UPDATE token SET jti='v2', version=2 WHERE jti='v1' AND version=1  → 成功
      请求B: UPDATE token SET jti='v3', version=2 WHERE jti='v1' AND version=1  → 0行affected → 失败

方案3:幂等性保障(Token版本号 + 请求ID)

    public RefreshTokenResult refresh(RefreshRequest request) {
        String tokenJti = parseToken(request.getRefreshToken()).getJti();
        String requestId = request.getIdempotencyKey(); // 客户端生成唯一ID

        // 1. 检查请求是否已处理(幂等表)
        if (idempotencyService.alreadyProcessed(requestId)) {
            return idempotencyService.getCachedResult(requestId);
        }

        // 2. 检查token是否已是最新版本
        TokenMeta meta = getTokenMeta(tokenJti);
        if (meta.isNotLatest()) {
            throw new TokenOutdatedException();
        }

        // 3. 处理刷新
        RefreshTokenResult result = doRefresh(meta);

        // 4. 缓存结果(防重复)
        idempotencyService.cacheResult(requestId, result, Duration.ofMinutes(5));

        return result;
    }
    幂等Key: sha256(tokenJti + ":" + clientNonce)

    请求A: key=hash(v1 + "abc") ──▶ 处理中...
    请求B: key=hash(v1 + "def") ──▶ 两个请求都成功(因为nonce不同)

    正确做法:同一个token只能有一个刷新请求成功
    请求A: key=hash(v1) ──▶ 处理中...
    请求B: key=hash(v1) ──▶ 幂等检查通过,复用请求A的结果

方案4:客户端防抖(前端协作)

    // 刷新锁
    let refreshPromise = null;

    async function safeRefreshToken() {
      // 如果已有刷新在进行中,返回那个Promise
      if (refreshPromise) {
        return refreshPromise;
      }

      refreshPromise = doRefresh()
        .finally(() => {
          refreshPromise = null; // 完成后释放
        });

      return refreshPromise;
    }

    // 所有401拦截器调用同一个Promise
    async function handle401() {
      return safeRefreshToken(); // 不会并发
    }

    // 标签页间协调(BroadcastChannel)
    const channel = new BroadcastChannel('token_refresh');

    let isRefreshing = false;
    let refreshQueue = [];

    channel.onmessage = (event) => {
      if (event.data === 'REFRESHING') {
        // 另一个标签页正在刷新,等待结果
        queueRefresh();
      } else if (event.data === 'REFRESHED') {
        // 刷新完成,通知等待的标签页
        processQueue(event.data.newToken);
      }
    };

    async function refreshToken() {
      if (isRefreshing) {
        return waitForRefresh(); // 等待
      }

      isRefreshing = true;
      channel.postMessage('REFRESHING');

      try {
        const result = await doRefresh();
        channel.postMessage({ type: 'REFRESHED', token: result });
        return result;
      } finally {
        isRefreshing = false;
      }
    }

综合解决方案(生产环境推荐)

  ```mermaid
graph TD
    CLIENT["客户端层<br/>• 刷新锁(单例Promise)<br/>• 标签页协调(BroadcastChannel)<br/>• 请求队列化"]
    GW["网关/接口层<br/>• 幂等Key检查(tokenJti hash)<br/>• 请求去重(Sliding Window)<br/>• 限流(单用户刷新频率限制)"]
    SRV["服务端业务层<br/>• 分布式锁(Redis SETNX)<br/>• Token版本校验(最新版本才能刷新)<br/>• 乐观锁(数据库version字段)"]
    DB["数据持久层<br/>• 唯一约束(token_jti UNIQUE)<br/>• 家族追踪(family_id + version)<br/>• 黑名单(已失效token快速查询)"]
    CLIENT --> GW --> SRV --> DB
  ---
  ### 完整代码示例

    @Service
    public class ConcurrentRefreshProtection {

        private final RedisTemplate<String, String> redis;
        private final RefreshTokenRepository tokenRepo;
        private final IdempotencyService idempotencyService;

        public RefreshTokenResult refresh(RefreshRequest request) {
            String rawToken = request.getRefreshToken();
            TokenData data = tokenParser.parse(rawToken);

            // ========== 第一层:幂等检查 ==========
            String idempotencyKey = "idem:refresh:" + data.getJti();
            if (idempotencyService.isProcessed(idempotencyKey)) {
                return idempotencyService.getResult(idempotencyKey);
            }

            // ========== 第二层:分布式锁 ==========
            String lockKey = "lock:refresh:user:" + data.getUserId();
            Boolean locked = redis.opsForValue()
                .setIfAbsent(lockKey, data.getJti(), Duration.ofSeconds(10));

            if (!locked) {
                throw new RefreshInProgressException();
            }

            try {
                // ========== 第三层:版本校验 ==========
                RefreshToken activeToken = tokenRepo
                    .findActiveByFamily(data.getFamilyId());

                if (!activeToken.getJti().equals(data.getJti())) {
                    throw new TokenOutdatedException(
                        "Token已被更新,请使用最新token");
                }

                // ========== 第四层:数据库乐观锁 ==========
                int updated = tokenRepo.updateWithVersion(
                    data.getFamilyId(),
                    data.getJti(),
                    data.getVersion()
                );

                if (updated == 0) {
                    throw new ConcurrentModificationException();
                }

                // ========== 执行刷新 ==========
                RefreshTokenResult result = generateNewTokenPair(data);

                // ========== 缓存结果(幂等) ==========
                idempotencyService.cache(idempotencyKey, result, Duration.ofMinutes(5));

                return result;

            } finally {
                redis.delete(lockKey);
            }
        }
    }
 ### 冲突处理策略对比
策略复杂度并发保护用户体验适用场景
分布式锁中✅ 严格等待/拒绝高并发生产环境
乐观锁低✅ 严格失败重试单机/低并发
幂等Key中✅ 严格复用结果多节点
客户端防抖低⚠️ 部分好配合服务端使用
请求队列化中✅ 严格依次执行严格顺序场景
  ---
### 总结

  并发冲突解决方案:

  1. 幂等性:同一token刷新多次,结果相同
     → 缓存刷新结果,首次处理,后续复用

  2. 分布式锁:同一用户同时只能有一个刷新请求
     → Redis SETNX + TTL,10秒自动释放

  3. 版本校验:只有最新版本的token才能刷新
     → 传入token的jti必须等于数据库中当前活跃的jti

  4. 乐观锁:数据库层面防止并发更新
     → version字段 + UPDATE WHERE条件

  5. 客户端防抖:前端避免重复请求
     → Promise单例 + 标签页协调

  推荐组合:幂等 + 分布式锁 + 版本校验 = 生产级安全

Sources

No external sources for this entry.

Related