← 返回

Cloudflare Turnstile 人机验证的并发竞态问题解决

复制为 Markdown

原始逻辑

起因是我负责的一个后端系统有几个核心接口需要对接 Cloudflare 的人机验证。

业务逻辑非常简单:

  1. 用一个自定义注解 @CloudflareVerification 标注在需要对接人机验证的接口上;
  2. 使用注解切面定义对接人机验证的逻辑,在实际执行业务代码之前先校验前端传递的 Cloudflare token 是否有效,有效就放行,无效就拦截;
  3. 由于 Cloudflare 端每一个 token 只能验证一次,且每次验证存在 10ms ~ 40ms 左右的延迟,所以在后端本地对于每个 token 用一个唯一的 hash 字符串作为 key 放在缓存中。如果前端在短时间内发起了多次携带同一 token 和 hash 的请求,则后端仅需要验证一次即可。

最开始的伪代码如下所示:

java
@Around("@annotation(com.xxx.annoation.CloudflareVerification)")
public Object checkCfRisk(ProceedingJoinPoint joinPoint) throws Throwable {
    // 提取前端传递的 Cloudflare token 密钥
    String token = getCFToken(args, apiPath);
    // 提取前端生成的 token 缓存信息
    String hash = getTokenHash(args, apiPath);
    // check Token Cache
    bool isTokenCached = false;
    
    TurnstileResponse turnstileResponse = null;
    if (!isTokenCached) {
        // 向 Cloudflare 端发送请求验证 token
        turnstileResponse = CfRiskUtil.validateCfTurnstileResponse(token, null);
        if (!turnstileResponse.isSuccess()) {
            // 保存错误信息
            saveTokenLog(token, 'failed');
            // 返回错误
            return Error;
        }
    }
    
    Object result = null;
    try {
        // 执行接口业务逻辑
        result = joinPoint.proceed();
    } finally {
        // 报错日志信息
        saveTokenLog(token);
    }

    return result;
}

乍一看这段逻辑似乎不会有什么并发竞态问题,因为在业务逻辑执行之前 token 和 hash 就已经进缓存了。而且验证过程对业务延迟的影响也很小,写完心里美滋滋,提交->自测->提测->上线 全流程都没出现问题,继续美滋滋~

结果上线后不久,运维老哥巡查系统日志的时候发来了严重警告:缓存中存在两条 token 和 hash 完全相同的键值对,并且检查日志中 Cloudflare 验证的结果一条通过,一条被拦截。

好家伙,我当时就反应过来了,竞态,肯定是竞态!看到警告的一瞬间脑袋里面瞬间浮现出之前背过的洋洋洒洒的八股文:分布式锁、幂等、缓存穿透 …… (没想到八股文真排上用场了)

问题是竞态在哪?我得先弄清楚问题的原因是什么才好判定怎么修复啊!表面看用户使用我们的网站操作核心接口时都会先请求 cloudflare 人机验证,前端拿到了 cloudflare 返回的 token 之后会立马存在自己的缓存里面。先获取,再缓存,下次使用的时候直接读缓存,没问题啊,多么美妙的业务逻辑。


错误复现

万没想到 bug 的根因居然是用户在一个浏览器上将我们的网站打开成了多个标签页,然后同时在上面操作。。。大概类似于这样:

第一个页面再操作时会先请求 cloudflare 端获取 token (这一步有延迟),在第一步延迟的时候用户在另外一个页面又发起了一个核心接口的请求,这第二个请求检测到了第一个页面正在和 cloudflare 交互,所以等待;等第一个页面的请求交互完成之后两个请求都拿到了 token,同时发起,同时到达后端。

好家伙,看这日志里面的时间戳,两个相同的请求中间只隔了几微秒。

由于两个请求同时到达,后端便会有两个线程同时去 cloudflare 验证这个 token,又同时返回验证完成的 token 结果,最后同时存进缓存。由于 cloudflare 一个 token 只能验证一次,因此缓存中展现的就是一个验证通过,一个被拦截(Duplicate)。


解决

确定了问题那就好办了,这是一个简单的竞态条件问题。由于在设计实现时没有考虑到用户多开标签栏使用的这种异常场景(用户使用产品的方式实在令人琢磨~),导致线上缓存被污染。

由于这个问题根因在后端程序,存在同时写入的竞态条件,在缓存上加分布式锁或者是数据唯一性校验并不能实质上的解决问题。而且当前是单机应用程序,所以最佳的且改动比较小的办法是在切面上加写入缓存时的内存锁,同样以缓存的 hash 值为内存锁的 key ,如果当前内存中有相同 hash 的 key 则代表锁存在,此时不能写入。更改后的代码如下:

java
/**
 * 内存锁的定义,以 缓存的 hash 值为内存锁的 key
 * 仅在单实例部署下生效
 */
private final ConcurrentHashMap<String, Object> hashLocks = new ConcurrentHashMap<>();

@Around("@annotation(com.xxx.annoation.CloudflareVerification)")
public Object checkCfRisk(ProceedingJoinPoint joinPoint) throws Throwable {
    // 提取前端传递的 Cloudflare token 密钥
    String token = getCFToken(args, apiPath);
    // 提取前端生成的 token 缓存信息
    String hash = getTokenHash(args, apiPath);
    // check Token Cache
    bool isTokenCached = false;
    // 拿锁
    Object lock = hashLocks.computeIfAbsent(hash, k -> new Object());
    // 查询缓存中有无 token 并判断是否超时
    try {
        // 再套一个排他锁,同一时间内只有一个线程能进入临界值代码
        synchronized (lock) {
            // 查询缓存中有无 token 并判断是否超时

            // 向 Cloudflare 端发送请求验证 token
            TurnstileResponse turnstileResponse = null;
            if (!isTokenCached) {
                turnstileResponse = CfRiskUtil.validateCfTurnstileResponse(token, null);
                if (!turnstileResponse.isSuccess()) {
                    // 保存错误信息
                    saveTokenLog(token, 'failed');
                    // 返回错误
                    return Error;
                }
            }
        }
    } finally {
        hashLocks.remove(hash);
    }
    
    Object result = null;
    try {
        // 执行接口业务逻辑
        result = joinPoint.proceed();
    } finally {
        // 报错日志信息
        saveTokenLog(token);
    }

    return result;
}

接下来我们进一步分析一下:

如果并发量再极端一点,有很多个请求再同一时间到达后端,会发生下面一种情况 :

这种情况对业务实际上并不造成影响,因为即使请求 B、C 同时进入的临界值,他们也都回去读缓存,此时缓存已经被请求 A 写入了,所以 B 和 C 拿到缓存之后就会返回,不存在同时写入的情况。

但是在技术实现上,要怎么彻底规避这个潜在的锁静态问题呢?答案是 引入锁的计数,只有最后一个锁的使用者才可以释放锁。所以最终的代码应该是:

java
// 锁池改为带计数结构的
private final ConcurrentHashMap<String, LockEntry> hashLocks;

@Around("@annotation(com.xxx.annoation.CloudflareVerification)")
public Object checkCfRisk(ProceedingJoinPoint joinPoint) throws Throwable {
    // 提取前端传递的 Cloudflare token 密钥
    String token = getCFToken(args, apiPath);
    // 提取前端生成的 token 缓存信息
    String hash = getTokenHash(args, apiPath);
    // check Token Cache
    bool isTokenCached = false;
    // 拿锁的同时递增计数
    LockEntry entry = hashLocks.computeIfAbsent(hash, k -> new LockEntry());
    entry.refCount.incrementAndGet();
    // 查询缓存中有无 token 并判断是否超时
    try {
        // 再套一个排他锁,同一时间内只有一个线程能进入临界值代码
        synchronized (lock) {
            // 查询缓存中有无 token 并判断是否超时

            // 向 Cloudflare 端发送请求验证 token
            TurnstileResponse turnstileResponse = null;
            if (!isTokenCached) {
                turnstileResponse = CfRiskUtil.validateCfTurnstileResponse(token, null);
                if (!turnstileResponse.isSuccess()) {
                    // 保存错误信息
                    saveTokenLog(token, 'failed');
                    // 返回错误
                    return Error;
                }
            }
        }
    } finally {
        if (entry.refCount.descrementAndGet() == 0){
            hashLocks.remove();  // 只有最后一个使用者才删除锁。
        }
    }
    
    Object result = null;
    try {
        // 执行接口业务逻辑
        result = joinPoint.proceed();
    } finally {
        // 报错日志信息
        saveTokenLog(token);
    }

    return result;
}

完美解决。虽然喜提一个线上 bug,但咱学到了东西不是(自我 PUA ing~)。

最热文章