现在记录话单的时候想加一个参数:每秒接口调用的并发量,也就是所谓的QPS(Queries per second)。QPS即每秒请求数,是对一个特定的接口在规定时间内请求流量的衡量标准。那么如何实现QPS的计算呢?我想到的是两种方案:
1、一定时间内(比如一分钟)的请求总量/统计时间段(比如一分钟),最终得出就是每秒的并发量,它是基于某一段时间来统计的
2、直接统计一秒钟内的请求总量,就是按每秒的时间段来统计,简单粗暴
方案一的适用场景应该是报表、运维统计之类的,只关心QPS曲线;如果用来做并发量校验,明显只能用方案二,需要实时获取QPS。那么如何统计一秒内的并发量?假设某一个时间点有接口到来,那么就开始统计该接口,在一秒之内,来多少个累加多少次。一秒之后,统计数清零。之后的某一个时间点,又有接口到来,又开始统计一秒之内的接口调用量,如此循环往复。
那么如何维护一个一秒之内的接口计数器呢?我觉得失效缓存是一个合适的选择,缓存的键即为接口名,值就是接口统计数,过期时间一秒。为了避免引入第三方中间件,我们自己实现该过期缓存,需要维护一个定时器和一个优先级队列,每秒清理一次队列中已过期的缓存。
废话说完了,看代码:
1、缓存的值
import lombok.Getter; import lombok.Setter; import java.util.concurrent.atomic.AtomicLong; /** * 内部类,缓存对象,按失效时间排序,越早失效越前 * @author wulf * @since 20200422 */ @Getter @Setter public class CacheNode implements Comparable<CacheNode> { private String key; private AtomicLong callQuantity; private long expireTime; public CacheNode(String key, AtomicLong callQuantity, long expireTime) { this.key = key; this.callQuantity = callQuantity; this.expireTime = expireTime; } @Override public int compareTo(CacheNode o) { long dif = this.expireTime - o.expireTime; if (dif > 0) { return 1; } else if (dif < 0) { return -1; } return 0; } }
2、过期缓存:
import com.wlf.bean.CacheNode; import java.util.Map; import java.util.PriorityQueue; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.ReentrantLock; /** * 带过期时间的缓存 * * @author wulf * @since 2020/04/21 */ public class ExpiredCache { // 缓存key=接口名,value=接口调用量、过期时间戳 private Map<String, CacheNode> cache = new ConcurrentHashMap<>();// 重入锁 private ReentrantLock lock = new ReentrantLock(); // 失效队列 private PriorityQueue<CacheNode> queue = new PriorityQueue<>(); // 启动定时任务,每秒清理一次过期缓存 private final static ScheduledExecutorService scheduleExe = new ScheduledThreadPoolExecutor(10); // 构造函数中启动定时任务,执行对已过期缓存的清理工作,每秒执行一次 public ExpiredCache() { scheduleExe.scheduleAtFixedRate(new CleanExpireCacheTask(), 1L, 1L, TimeUnit.SECONDS); } /** * 内部类,清理过期缓存对象 */ private class CleanExpireCacheTask implements Runnable { @Override public void run() { long currentTime = System.currentTimeMillis(); // 取出队列中的队头元素,对已过期的元素执行清除计划,剩下没有过期则退出 while (true) { lock.lock(); try { CacheNode cacheNode = queue.peek(); // 已经把队列清空了,或者所有过期元素已清空了,退出 if (cacheNode == null || cacheNode.getExpireTime() > currentTime) { return; } // 开始大清理了 cache.remove(cacheNode.getKey()); queue.poll(); } finally { lock.unlock(); } } } } /** * 根据缓存key获取values * * @param cacheKey * @return */ public CacheNode getCacheNode(String cacheKey) { return cache.get(cacheKey); } /** * 加入缓存,设置存活时间 * * @param cacheKey * @param ttl 缓存的存活时间 * return */ public AtomicLong set(String cacheKey, long ttl) { // 若缓存中已存在缓存节点,不需要更新过期时间,仅更新QPS值 CacheNode oldNode = cache.get(cacheKey); if (oldNode != null) { AtomicLong oldQps = oldNode.getCallQuantity(); oldQps.incrementAndGet(); } else { // 否则新创建CacheNode对象,失效时间=当前时间+缓存存活时间 AtomicLong qps = new AtomicLong(1); CacheNode newNode = new CacheNode(cacheKey, qps, System.currentTimeMillis() + ttl * 1000); // 放入缓存,加入过期队列 cache.put(cacheKey, newNode); queue.add(newNode); } return cache.get(cacheKey).getCallQuantity(); } }
3、在切面中统计接口QPS:
package com.wlf.cdr; import com.wlf.javabean.ots.TranslateCdr; import com.wlf.utils.ExpiredCache; import com.wlf.utils.IPUtil; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import javax.servlet.http.HttpServletRequest; import java.text.SimpleDateFormat; import java.util.Date; @Slf4j @Aspect @Component public class CdrAsept { private final static SimpleDateFormat SF = new SimpleDateFormat("yyyyMMddHHmmss"); // 话单格式:接口名称|话单记录时间|接口时延|调用方IP|本地IP|用户ID|用户名|源语言|目标语言|结果码|QPS private final static String CDR_FORMAT = "{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}"; // 过期缓存 private ExpiredCache expiredCache = new ExpiredCache(); @Around("execution(* com.wlf.translateprovider.controller.TranslateController.*(..))") public Object recordCdr(ProceedingJoinPoint joinPoint) throws Throwable { long startTime = System.currentTimeMillis(); String startDate = SF.format(new Date(startTime)); // 白名单校验 ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest httpServletRequest = attributes.getRequest(); String localIp = IPUtil.getLocalIp(); String remoteIp = IPUtil.getRemoteIp(httpServletRequest); TranslateCdr cdr = new TranslateCdr(); cdr.setRemoteIp(remoteIp); CdrThreadLocal.setTranslateCdr(cdr); // 获取接口名 String requestPath = httpServletRequest.getRequestURI(); String cacheKey = requestPath.substring(requestPath.lastIndexOf("/") + 1, requestPath.length()); // 设置过期时间为1秒 long qps = expiredCache.set(cacheKey, 1).get(); Object result = joinPoint.proceed(); long endTime = System.currentTimeMillis(); cdr = CdrThreadLocal.getTranslateCdr(); if (cdr != null) { log.error(CDR_FORMAT, cacheKey, startDate, endTime - startTime, remoteIp, localIp, cdr.getUserId(), cdr.getUserName(), cdr.getFrom(), cdr.getTo(), cdr.getResultCode(), qps); } CdrThreadLocal.delThreadLocal(); return result; } }
在切面中只需set一下,如果这时缓存有数据,就累加统计数,没有就设置统计数为1,再get出来的得到QPS。但这里为了兼顾吞吐量,让接口的调用不受QPS统计的影响,并没有在切面或者过期缓存的set方法加锁,因此对两个并发时间很短的接口,统计数会相同。