《Token+Redis双核防御:高并发系统幂等设计的落地实践》

本文介绍了业务处理的多种方式,重点探讨Redis分布式缓存。阐述单纯Redis缓存实现方案,指出缓存击穿、并发安全等潜在问题与风险,并给出相应优化建议,如使用原子操作、增加请求限制等。还提及Redis自增操作及Redisson客户端的优化使用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

业务背景与方案选型

在车联网OTA升级场景中,接口防重放攻击是保障系统安全的核心需求。常见的解决方案包括:

  • 数据库唯一索引约束
  • 分布式锁(RedLock/ZooKeeper)
  • 业务层乐观锁机制
  • 基于Token的签名验证
  • Redis分布式计数器

本文重点剖析Redis分布式方案在车辆接口调用频控中的实践,该方案通过RedisKey.API_ATTACK构建车辆唯一标识+签名特征的复合键,实现分钟级访问频次控制。

幂等性概念

幂等性原本是数学上的概念,用在接口上就可以理解为:同一个接口,多次发出同一个请求,必须保证操作只执行一次。 调用接口发生异常并且重复尝试时,总是会造成系统所无法承受的损失,所以必须阻止这种现象的发生。 比如下面这些情况,如果没有实现接口幂等性会有很严重的后果: 支付接口,重复支付会导致多次扣钱 订单接口,同一个订单可能会多次创建。

幂等性的解决方案

单纯的Redis缓存实现方案

    /**
     * 同一车辆一分钟内访问同一接口次数上限
     *
     * @param apiTypeEnum   接口类型
     * @param vinOrDeviceId 车辆唯一标识
     * @param sign          请求报文中的签名
     */
    @Override
    public void checkAttack(ApiTypeEnum apiTypeEnum, String vinOrDeviceId, String sign) {

        if (!config.isCheckAttack()) {
            return;
        }

        String key = RedisKey.API_ATTACK + apiTypeEnum + ":" + vinOrDeviceId + ":" + sign;
        String countStr = redisClient.get(key);
        if (StringUtils.isEmpty(countStr)) {
            int count = 1;
            redisClient.setKeyValueAndExpired(key, String.valueOf(count), RedisExpire.API_ATTACK);
        } else {
            Integer count = Integer.parseInt(countStr);
            count++;
            redisClient.setKeyValueAndExpired(key, count.toString(), RedisExpire.API_ATTACK);
            if (count.compareTo(config.getNumOfRequestPerMin()) > 0) {
                log.warn("code:[{}],msg:[{}]", ResultEnum.API_ATTACK.getCode(), ResultEnum.API_ATTACK.getMsg());
                throw new BusinessException(ResultEnum.API_ATTACK.getCode(), ResultEnum.API_ATTACK.getMsg());
            }
        }
    }
package com.fh.common.constant;
/**
 * redis缓存数据的key
 *
 * @Author:fh
 * @create 2018-11-20 16:55
 */
public class RedisKey {

    /**
     * 解决重放攻击使用
     */
    public static final String API_ATTACK = ":api:attack:";
}
/**
 * redis缓存的过期时间
 *
 * @Author: FH
 * @create 2019-01-14 13:58
 */
public class RedisExpire {

    /**
     * 小时单位
     */
    public static final Long HOUR_UNIT = (long) 60 * 60;

    /**
     * 接口重放攻击使用
     */
    public static final Long API_ATTACK = (long) 60;

    /**
     * redis 过期设置 KEY=VIN 车辆三天过期
     */
    public static final Long VEHICLE_INFO = 3 * 24 * HOUR_UNIT;

    /**
     * 分布式锁过期时间
     */
    public static final Long LOCK_TIMEOUT = (long) 120;
}

潜在问题与风险提醒

  1. 缓存击穿问题:如果大量请求同时访问同一未存在于缓存中的key,会导致对数据库(或Redis等缓存)的大量读取,从而造成缓存击穿。建议使用分布式锁或者设置热点key的缓存空值来避免这个问题。
  2. 并发安全性:在将count递增并重新设置到Redis的过程中,存在竞态条件(race condition),可能多个线程同时读取到相同的count值,然后各自递增并写回,这样就丢失了部分递增操作。建议在Redis中使用 1. 原子操作(如INCR命令)来直接递增计数器。
  3. 异常处理:当前代码在解析countStr为Integer时,如果出现异常(比如countStr非数字字符串),会导致运行时错误。建议添加异常处理逻辑,确保代码的健壮性。
  4. 数据一致性:如果config.isCheckAttack()的配置在运行时被修改,当前的实现无法实时响应这种变化。考虑定期从配置中心刷新配置,或者在配置变更时通过监听机制主动刷新缓存。
  5. 潜在的DoS攻击:通过构造特定的vinOrDeviceId和sign,攻击者可能会使得系统为处理这些请求而消耗大量资源,尤其是在高频调用情况下。建议增加额外的请求限制,例如通过IP地址进行限制。

优化建议

  • 性能优化:当前实现中,每次请求都会进行一次数据库访问(Redis),对于高并发的接口,这可能成为性能瓶颈。考虑使用连接池等技术优化Redis连接,同时评估是否所有接口都需要防攻击检查,对低风险接口简化或免除检查。
  • 代码优化:checkAttack方法中直接抛出BusinessException可能会导致调用方需要处理多种异常类型,建议定义一个更具体的异常类,或在业务逻辑允许的情况下,通过返回值而不是异常来通知调用方。
  • 可维护性:建议将key的构造过程封装为一个方法,这样如果未来key的格式需要变更,只需修改一个地方。同时,这也有助于代码的可读性和可维护性。
  • 日志记录:当前仅在请求次数超过上限时记录日志,考虑在每次请求(至少是每次命中)时都记录一些基本信息(例如时间、接口类型、车辆标识等),这对于调试和监控系统行为非常有帮助。
  • 安全审计:考虑增加对vinOrDeviceId和sign的合法性校验,确保它们不会被恶意构造或篡改。此外,对于敏感操作,应确保日志中不直接记录敏感信息,或对敏感信息进行脱敏处理
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class AttackChecker {

    private final Lock lock = new ReentrantLock();
    private final RedisClient redisClient; // 假设RedisClient已经存在且实现良好
    private final Config config; // 假设Config已经存在且实现良好

    public AttackChecker(RedisClient redisClient, Config config) {
        this.redisClient = redisClient;
        this.config = config;
    }

    /**
     * 同一车辆一分钟内访问同一接口次数上限
     *
     * @param apiTypeEnum   接口类型
     * @param vinOrDeviceId 车辆唯一标识
     * @param sign          请求报文中的签名
     */
    @Override
    public void checkAttack(ApiTypeEnum apiTypeEnum, String vinOrDeviceId, String sign) {
        if (!config.isCheckAttack()) {
            return;
        }

        String key = constructKey(apiTypeEnum, vinOrDeviceId, sign);
        try {
            lock.lock();
            String countStr = redisClient.get(key);
            if (StringUtils.isEmpty(countStr)) {
                int count = 1;
                redisClient.setKeyValueAndExpired(key, String.valueOf(count), RedisExpire.API_ATTACK);
            } else {
                Integer count;
                try {
                    count = Integer.parseInt(countStr);
                } catch (NumberFormatException e) {
                    log.error("Failed to parse count string", e);
                    return; // 或者根据需求进行其他处理
                }
                count++;
                redisClient.setAtomic(key, count, RedisExpire.API_ATTACK);
                if (count > config.getNumOfRequestPerMin()) {
                    log.warn("code:[{}],msg:[{}]", ResultEnum.API_ATTACK.getCode(), ResultEnum.API_ATTACK.getMsg());
                    throw new BusinessException(ResultEnum.API_ATTACK.getCode(), ResultEnum.API_ATTACK.getMsg());
                }
            }
        } finally {
            lock.unlock();
        }
    }

    private String constructKey(ApiTypeEnum apiTypeEnum, String vinOrDeviceId, String sign) {
        return RedisKey.API_ATTACK + apiTypeEnum + ":" + vinOrDeviceId + ":" + sign;
    }
}

原子操作改造(Jedis方案)

如果通过Redis的原子自增操作(如INCR或INCRBY)来实现计数,可以避免并发问题,并简化代码。以下是使用Redis INCR命令的优化版本:

import redis.clients.jedis.Jedis; // 假设项目中已引入 jedis 库

public class AttackChecker {

    private final Jedis jedis;
    private final Config config;

    public AttackChecker(Jedis jedis, Config config) {
        this.jedis = jedis;
        this.config = config;
    }

    /**
     * 同一车辆一分钟内访问同一接口次数上限
     *
     * @param apiTypeEnum   接口类型
     * @param vinOrDeviceId 车辆唯一标识
     * @param sign          请求报文中的签名
     */
    @Override
    public void checkAttack(ApiTypeEnum apiTypeEnum, String vinOrDeviceId, String sign) {
        if (!config.isCheckAttack()) {
            return;
        }

        String key = constructKey(apiTypeEnum, vinOrDeviceId, sign);
        
        long count = jedis.incr(key);
        if (count == 1) {
            jedis.expire(key, RedisExpire.API_ATTACK.getSeconds());
        }

        if (count > config.getNumOfRequestPerMin()) {
            log.warn("code:[{}],msg:[{}]", ResultEnum.API_ATTACK.getCode(), ResultEnum.API_ATTACK.getMsg());
            throw new BusinessException(ResultEnum.API_ATTACK.getCode(), ResultEnum.API_ATTACK.getMsg());
        }
    }

    private String constructKey(ApiTypeEnum apiTypeEnum, String vinOrDeviceId, String sign) {
        return RedisKey.API_ATTACK + apiTypeEnum + ":" + vinOrDeviceId + ":" + sign;
    }
}

原子操作改造(Redisson分布式锁方案)

import org.redisson.Redisson;
import org.redisson.api.RAtomicLong;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;

public class AttackChecker {

    private final RedissonClient redissonClient;
    private final Config config;

    public AttackChecker(RedissonClient redissonClient, Config config) {
        this.redissonClient = redissonClient;
        this.config = config;
    }

    /**
     * 同一车辆一分钟内访问同一接口次数上限
     *
     * @param apiTypeEnum   接口类型
     * @param vinOrDeviceId 车辆唯一标识
     * @param sign          请求报文中的签名
     */
    @Override
    public void checkAttack(ApiTypeEnum apiTypeEnum, String vinOrDeviceId, String sign) {
        if (!config.isCheckAttack()) {
            return;
        }

        String key = constructKey(apiTypeEnum, vinOrDeviceId, sign);
        RLock lock = redissonClient.getLock(key + "_lock");
        try {
            // 获取分布式锁
            lock.lock();

            RAtomicLong counter = redissonClient.getAtomicLong(key);
            long count = counter.incrementAndGet();
            if (count == 1) {
                counter.expireAsync(RedisExpire.API_ATTACK.getSeconds(), TimeUnit.SECONDS); // 异步设置过期时间
            }

            if (count > config.getNumOfRequestPerMin()) {
                log.warn("code:[{}],msg:[{}]", ResultEnum.API_ATTACK.getCode(), ResultEnum.API_ATTACK.getMsg());
                throw new BusinessException(ResultEnum.API_ATTACK.getCode(), ResultEnum.API_ATTACK.getMsg());
            }
        } finally {
            // 释放分布式锁
            lock.unlock();
        }
    }

    private String constructKey(ApiTypeEnum apiTypeEnum, String vinOrDeviceId, String sign) {
        return RedisKey.API_ATTACK + apiTypeEnum + ":" + vinOrDeviceId + ":" + sign;
    }
}

修改说明
使用Redisson的RLock实现分布式锁,确保在检查计数并更新的过程中不会有并发问题。
使用RAtomicLong代替INCR命令进行原子自增操作,并通过expireAsync方法异步设置过期时间,以减少网络延迟。
其他逻辑保持不变,这里主要解决了多线程环境下对计数器的安全性和性能优化。
注意:对于缓存击穿问题,如果担心大量新请求同时涌入导致大量key创建,可以在初始化系统时预先为所有可能的key(或key模式)设置较短的过期时间。另外,还需要根据项目实际需求考虑其他已提到的潜在优化点

数据库唯一索引约束

唯一索引 使用唯一索引可以避免脏数据的添加,当插入重复数据时数据库会抛异常,保证了数据的唯一性。

乐观锁 这里的乐观锁指的是用乐观锁的原理去实现,为数据字段增加一个version字段,当数据需要更新时,先去数据库里获取此时的version版本号

select version from tablename where xxx
更新数据时首先和版本号作对比,如果不相等说明已经有其他的请求去更新数据了,提示更新失败。
update tablename set count=count+1,version=version+1 where version=#{version}

token机制的实现

在分布式系统中,客户端在发起请求前先获取一个Token,服务端生成并存储这个Token,然后在后续请求中客户端携带该Token,服务端验证后处理请求并删除Token,防止重复提交

然后,我需要分析用户可能没有明确提到的需求。比如,Token的存储方式,是否使用Redis或其他数据库?Token的有效期如何设置?如何处理并发情况下的Token校验?还有,如何防止Token被窃取或重放攻击?

可能关心不同方案的优缺点,比如基于数据库的实现可能性能较低,而Redis方案性能较高。还需要考虑分布式环境下的一致性,比如多个服务实例如何共享Token状态。

还需要注意异常情况的处理,比如客户端获取Token后未使用,导致Token堆积,或者网络延迟导致Token过期后的重试机制。这时候可能需要引入重试机制或者Token刷新策略。

此外,安全性方面,Token需要足够随机,防止被猜测,可以采用UUID或者加密算法生成。传输过程中使用HTTPS防止中间人截获。同时,考虑Token的一次性使用,一旦使用立即失效,避免重复使用

Token机制三阶段

阶段1:Token发放
  • 客户端:发起业务请求前调用/getToken接口
  • 服务端:生成全局唯一Token(如UUID)并存入Redis
  • 响应:返回Token及有效期(如30秒)
阶段2:业务请求携带
  • 客户端:在HTTP Header添加Idempotency-Token: {token}
  • 服务端:拦截器校验Token有效性(存在性+未消费状态)
阶段1:状态变更
  • 原子操作:通过Redis的DEL命令或Lua脚本删除Token
  • 业务处理:仅当Token验证通过后执行核心逻辑
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值