在软件开发中,我们经常需要统计接口的访问次数,以便了解系统的运行状态,优化性能,或者进行数据分析。本文将show三种不同的方法来统计一小时内的接口访问次数,抛砖引玉
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.text.MessageFormat;
import java.util.Calendar;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
@Slf4j
@Component
@RequiredArgsConstructor
public class MethodCallStatisticsUtils {
private static String METHOD_CALL_FREQUENCY = "v1:method:{0}:{1}:{2}";
@Autowired
private static RedisTemplate<String, Long> redisTemplate;
@Autowired
public void setRedisTemplate(RedisTemplate<String, Long> redisTemplate1) {
MethodCallStatisticsUtils.redisTemplate = redisTemplate1;
}
public static void calculateTimes(String shopNo, String method) {
try {
long now = System.currentTimeMillis();
Calendar date = Calendar.getInstance();
date.setTimeInMillis(now);
int day = date.get(Calendar.DAY_OF_MONTH);
int house = date.get(Calendar.HOUR);
Long time = redisTemplate.opsForValue().get(MessageFormat.format(METHOD_CALL_FREQUENCY, shopNo, day, house));
if (Objects.isNull(time)) {
redisTemplate.opsForValue().set(MessageFormat.format(METHOD_CALL_FREQUENCY, shopNo, day, house), 1L, 2, TimeUnit.HOURS);
time = 1L;
} else {
time++;
redisTemplate.opsForValue().set(MessageFormat.format(METHOD_CALL_FREQUENCY, shopNo, day, house), time, 2, TimeUnit.HOURS);
}
log.info("使用统计 -> {},方法 : {},所有接口总次数 : {}", shopNo, method, time);
}
catch (Exception e) {
log.info("统计失败");
}
}
}
点评: 段位:青铜?
具备了计数的能力,但在并发高时有一定概率少统计。 如果只是想大概了解下访问次数,并不要求很准确,这个代码也满足了需求。
问题剖析:有线程安全问题。 上面用来计数的代码拆解一下,是分了3步:
本例中,这三个操作不是原子的,但只有三步同时完成,才能满足计数的累加。 所有线程都可以同时访问Redis中的值,且这三步操作结束才能完成对这个临界资源的更新,如果没有加锁来确保这三个动作是原子的,则必然存在线程安全问题。 详细解析:
场景:1个接口收到3个请求。如下图所求
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.text.MessageFormat;
import java.util.Calendar;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
@Slf4j
@Component
@RequiredArgsConstructor
public class MethodCallStatisticsUtils {
private static final String METHOD_CALL_FREQUENCY = "v1:method:{0}:{1}:{2}";
@Autowired
private static RedisTemplate<String, Long> redisTemplate;
@Autowired
public void setRedisTemplate(RedisTemplate<String, Long> redisTemplate1) {
MethodCallStatisticsUtils.redisTemplate = redisTemplate1;
}
public static Long calculateTimes(String shopNo, String method, int timeoutPeriod, TimeUnit timeUnit) {
try {
long now = System.currentTimeMillis();
Calendar date = Calendar.getInstance();
date.setTimeInMillis(now);
int day = date.get(Calendar.DAY_OF_MONTH);
int house = date.get(Calendar.HOUR);
String redisKey = MessageFormat.format(METHOD_CALL_FREQUENCY, shopNo, day, house);
Long time = redisTemplate.opsForValue().get(redisKey);
if (Objects.isNull(time)) {
redisTemplate.opsForValue().set(redisKey, 1L, timeoutPeriod, timeUnit);
return 1L;
}
Long times = redisTemplate.opsForValue().increment(redisKey);
log.info("调用接口使用统计 -> {},方法 : {},所有接口总次数 : {} timeoutPeriod {} timeUnit {} ", shopNo, method, times, timeoutPeriod, timeUnit);
return times;
} catch (Exception e) {
log.info("调用接口使用统计失败 shopNo {} method {} timeoutPeriod {} timeUnit {} msg {} ", shopNo, method, timeoutPeriod, timeUnit, e.getMessage());
}
return 1L;
}
}
点评: 段位:白银?
只是实现了功能,仅在记录第一次访问时有一定概率漏统计访问次数。
问题剖析:有线程安全问题。
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
/**
* @Auther: cheng.tang
* @Date: 2023/11/22
* @Description: gbb-centering-incr
*/
@Service
@Slf4j
public class RateLimiter {
@Autowired
private RedisTemplate<String, Long> redisTemplate;
private static final String RATE_LIMITER_LUA_SCRIPT = "local current " +
"current = redis.call(\"incr\",KEYS[1]) " +
"if current == 1 then \n" +
" redis.call(\"expire\",KEYS[1],ARGV[1] ) \n" +
"end \n" +
"return current \n";
/**
* 统计expireTimeSeconds期间有多少次请求
*
* @param key 一类数据的标识
* @param expireTimeSeconds 统计时间期间
* @return 已经调用了多少次
*/
public Long limitCall(String key, Integer expireTimeSeconds) {
return limitCall(RATE_LIMITER_LUA_SCRIPT, Collections.singletonList(key), expireTimeSeconds);
}
/**
* 统计expireTimeSeconds期间有多少次请求
*
* @param luaScript 一个lua脚本
* @param keys lua脚本需要的KEYS
* @param args lua脚本需要的参数
* @return 已经调用了多少次
*/
public Long limitCall(String luaScript, List<String> keys, Object... args) {
RedisScript<Long> script = new DefaultRedisScript<>(luaScript, Long.class);
Long currentTimes = redisTemplate.execute(script, keys, args);
String redisKey = keys.get(0);
log.info("redisKey {} currentTimes {} ", redisKey, currentTimes);
return currentTimes;
}
}
In the above code there is a race condition. This can be fixed easily turning the INCR with optional EXPIRE into a Lua script that is send using the EVAL command (only available since Redis version 2.6). 唐成,公众号:的数字化之路干货|RedisTemplate调lua踩了个坑
点评: 这是Redis官方给的方案,访问次数统计准确。
该是哪个段位?黄金?
问题: 1、对Redis的版本有依赖,需要2.6+ 2、涉及到的知识点多且引入了一门新语言Lua,使用的门槛变高。
补充:
如果要抽取组件,建议自定义一个RedisTemplate实例。想一想为什么? 代码示例:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;
/**
* @Auther: cheng.tang
* @Date: 2023/11/22
* @Description: gbb-centering-incr
*/
@Configuration
public class RedisTemplateConfig {
@Bean
public RedisTemplate<String, Object> centeringIncrToolRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setEnableDefaultSerializer(true);
redisTemplate.setDefaultSerializer(RedisSerializer.json());
redisTemplate.setKeySerializer(RedisSerializer.string());
redisTemplate.setValueSerializer(RedisSerializer.json());
redisTemplate.setHashKeySerializer(RedisSerializer.string());
redisTemplate.setHashValueSerializer(RedisSerializer.json());
return redisTemplate;
}
}
总的来说,统计接口访问次数是一个常见的需求,但是实现的方法有很多种。我们需要根据实际的需求和环境来选择最合适的方法。无论选择哪种方法,都需要考虑到并发问题,确保统计数据的准确性。 另外,在技术方案上,也可以使用redis的zset,同一维度的数据使用一个key,value存一个保证唯一性的做任意值,score存放访问的时间毫秒粒度的时间戳,,通过score来圈出指定时间窗口的记录数,就得到访问次数了。
在代码设计上,怎么样才能算是好的代码?“There are a thousand Hamlets in a thousand people's eyes.”,我认为,好的代码要在阅读上赏心悦目,在修改上得心应手。
如果一定要定一个标准,那么最常用到几个评判代码质量的标准是:可维护性、可读性、可扩展性、灵活性、简洁性、可复用性、可测试性。其中,可维护性、可读性、可扩展性又是提到最多的、最重要的三个评价标准。
“任何傻瓜都会编写计算机能理解的代码。好的程序员能够编写人能够理解的代码。”
好代码的要求:
如果每个例程都让你感到深合己意,那就是整洁代码,就是好的代码。如果代码让编程语言看上去像是专为解决那个问题而存在,就可以称之为漂亮的代码。
https://redis.io/commands/incr/