1 问题背景
客户频繁绑定域名,这会影响电商的整个域名绑定基础服务。因此要限制客户绑定域名的次数,阻止客户频繁绑定域名。要求是10分钟内只能允许操作10次,随后必须要等待10min过后才能再次操作。
2 前言
本方案是笔者自己想出来的,还未经过生产环境的考验,如有不正确的地方欢迎在评论区中指出。
3 思路
核心是限制调用次数,想到了限流,常见的有滑动窗口算法的限流,使用到的是Redis的Sorted Set。本篇使用Redis的另外一个数据结构——List。
利用List实现队列,存储的元素是时间戳。如果队列中的元素个数小于10个,那么往队列的左端添加元素并放行,让这个请求执行后面的业务逻辑。如果队列中元素个数大于等于10个,判断最新添加进去的元素距离当前时间是否已经超过了10min,如果不超过,则先从队列的右端弹出元素,随后从队列的左端添加元素,阻止执行后面的业务逻辑。如果超过10min,则移除队列里面所有元素,从队列的左端添加元素,放行执行业务逻辑。
考虑到并发线程引发的一系列问题,使用LUA脚本保证原子性。
4 代码实现
核心代码如下所示(主要看LUA脚本怎么实现):
redisTemplate.execute()的入参也要仔细看是什么类型,笔者在这里调试了很久才跑通
@Slf4j
public class IssueDomainLimitTest extends AppTest {
@Resource
private RedisTemplate<String, String> redisTemplate;
private static final DefaultRedisScript<Long> SCRIPT = new DefaultRedisScript<>();
public static final String SAVE_LUA_SCRIPT = "local count = redis.call('LLEN', KEYS[1])\n" +
"local value0 = redis.call('LINDEX', KEYS[1], 0)\n" +
"local result\n" +
"if( count < tonumber(ARGV[1]))\n" +
"then\n" +
" redis.call('LPUSH', KEYS[1], ARGV[2])\n" +
" result = 1\n" +
"elseif( tonumber(ARGV[2]) < tonumber(value0) + tonumber(ARGV[4]) )\n" +
"then \n" +
" redis.call('RPOP', KEYS[1])\n" +
" redis.call('LPUSH', KEYS[1], ARGV[2])\n" +
" redis.call('EXPIRE', KEYS[1], ARGV[3])\n" +
" result = 0\n" +
"else\n" +
" redis.call('LTRIM', KEYS[1], 1, 0)\n" +
" redis.call('LPUSH', KEYS[1], ARGV[2])\n" +
" redis.call('EXPIRE', KEYS[1], ARGV[3])\n" +
" result = 1\n" +
"end\n" +
"return result";
static {
SCRIPT.setScriptText(SAVE_LUA_SCRIPT);
SCRIPT.setResultType(Long.class);
}
private static final Integer COUNT_LIMIT = 10;
private static final Integer TIMEOUT = 3600;
private static final Integer TIME_INTERVAL = 60 * 10 * 1000;
@Test
void test() {
String key = "issue_domain:%s:%s";
Long shopId = 0L;
String domain = "ac.sho.com";
String redisKey = String.format(key, shopId, domain);
long currentTimeMillis = System.currentTimeMillis();
log.info("redisKey: {}, currentTimeMillis: {}", redisKey, currentTimeMillis);
Long countResult = redisTemplate.execute(SCRIPT, Collections.singletonList(redisKey), COUNT_LIMIT, currentTimeMillis, TIMEOUT, TIME_INTERVAL);
if (countResult <= 0) {
// 阻止继续执行
return ;
}
log.info("countResult: {}", countResult);
}
}