业务背景与方案选型
在车联网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;
}
潜在问题与风险提醒
- 缓存击穿问题:如果大量请求同时访问同一未存在于缓存中的key,会导致对数据库(或Redis等缓存)的大量读取,从而造成缓存击穿。建议使用分布式锁或者设置热点key的缓存空值来避免这个问题。
- 并发安全性:在将count递增并重新设置到Redis的过程中,存在竞态条件(race condition),可能多个线程同时读取到相同的count值,然后各自递增并写回,这样就丢失了部分递增操作。建议在Redis中使用 1. 原子操作(如INCR命令)来直接递增计数器。
- 异常处理:当前代码在解析countStr为Integer时,如果出现异常(比如countStr非数字字符串),会导致运行时错误。建议添加异常处理逻辑,确保代码的健壮性。
- 数据一致性:如果config.isCheckAttack()的配置在运行时被修改,当前的实现无法实时响应这种变化。考虑定期从配置中心刷新配置,或者在配置变更时通过监听机制主动刷新缓存。
- 潜在的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验证通过后执行核心逻辑