问题:
先执行get获取值,判断符合条件再执行incr、decr操作。在临界缓存失效的情况下,会默认赋值当前key为永不过期的0,再执行加减法,导致程序异常。
推荐解决方案:
1、限制接口频率:先incr,执行后值为1,说明是第一次执行,需要额外设置过期时间,再判断是否超过当前接口频率限制(注意上述步骤不可调换顺序)
2、使用lua脚本完整提交一次操作,脚本中的key可以保证一致。以加减库存为例,先查询key存在的情况下,再进行库存变更,如果不存在无需处理,等待下次缓存加载即为最新的值
场景1:我们缓存了一个商品的库存,过期时间为5分钟,根据用户的购买和取消执行 incr、decr 操作。代码通常会这样来编写:
// 库存存在则加一
if(redisService.get(prefix, key, Integer.class) != null){
redisService.incr(prefix, key);
}
场景2:为了接口限流,我们可以通过redis简单实现:
// 首先获取当前访问频次
Integer count = redisService.get(prefix, key, Integer.class);
// 如果频次为空,则设置访问次数为1
if (count == null) {
redisService.set(prefix, key, 1);
} else if (count < checkFrequencyCount) {
// 如果频次小于限制,则设置访问次数加1
redisService.incr(prefix, key);
} else {
// 如果频次超过限制,则限流
throw new AppException("访问频次过高,请稍候再试");
}
两种场景编码看似都没有问题,但实际运行中却发现redis中有一些key变成了永不过期的key,而且值不正确。
原因是: 因为redis的incr操作,当key不存在时, 会生成这个key并将值初始化为0, 并且默认设置key的有效时间为永久。
方案1.优化Java代码,例如场景2,然后我们根据过期时间判断和根据value值判断可分为两种优化策略。
1.不论这个key是否存在都先加一,然后判断其过期时间是否为永不过期,如果是永不过期则说明是新生成的key,给它设置过期时间即可,如果非永不过期则无需操作。最后再判断一下是否值已经大于访问频次了,是则限流。
long count = redisService.incr(prefix, key);
long expire = redisService.ttl(prefix, key);
if (expire == -1) {
redisService.setExpire(prefix, key, accessExpireSecond);
}
// 判断必须放在后面,否则key没有过期时间永远无法清除
if (count > checkFrequencyCount) {
throw new AppException("访问频次过高,请稍候再试");
}
2.不论这个key是否存在都先加一,然后判断其值是否为1,如果是1则说明是新生成的key,给它设置过期时间即可,如果非1则无需操作。最后再判断一下是否值已经大于访问频次了,是则限流。
long count = redisService.incr(prefix, key);
long expire = redisService.ttl(prefix, key);
if (count == 1L) {
redisService.setExpire(prefix, key, accessExpireSecond);
}
// 判断必须放在后面,否则key没有过期时间永远无法清除
if (count > checkFrequencyCount) {
throw new AppException("访问频次过高,请稍候再试");
}
方案2.使用lua脚本执行,一次提交保证原子性。例如场景1,key存在则执行加减操作。
脚本updateStore.lua
--- 获取key
local key = KEYS[1]
--- 获取参数:incr、decr
local action = ARGV[1]
--- 如果key存在,再执行增加或减少的操作
if redis.call('exists', key) == 1 then
redis.call(action, key)
return 1
end
配置类LuaConfiguration.java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
@Configuration
public class LuaConfiguration {
@Bean(name = "update")
public DefaultRedisScript<Boolean> redisScript() {
DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>();
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("luascript/updateStore.lua")));
redisScript.setResultType(Boolean.class);
return redisScript;
}
}
使用方法:
@Resource(name = "update")
private DefaultRedisScript<Boolean> redisScript;
@Resource
private StringRedisTemplate stringRedisTemplate;
// 执行脚本并传参
Boolean result = stringRedisTemplate.execute(redisScript, Arrays.asList(stockPrefix.getPrefix() + key), "incr");