SpringBoot-心跳机制+redis实现网站实时在线人数统计
在社交网站中,通常需要实时统计某个网站的在线人数,通过该指标来实时帮助运营人员更好的维护网站业务:
先说一下目前在市面上主流的做法再加上我自己查阅的资料总结:
- 创建一个session监听器,在用户登录时即创建一个session,监听器记录下来并且把count加一
- 用户点击注销时把session给remove掉,count减一
说一下上面这种做法的弊端:
- 当用户关闭浏览器时并不会触发session监听,当下一次登录时仍然会让count加一
- 或者在session过期时,session监听并不能做一个实时的响应去将在线数减一
- 当用户在次登陆,由于cookie中含有的session_id不同而导致session监听器记录下session创建,而使count加一。
- 对服务器性能影响较大,用户每次访问网站时,服务端都会创建一个session,并将该session与用户关联起来,这样会增加服务器的负担,特别是在高并发的时候,导致服务器压力过大
- 容易被恶意攻击,攻击者不断发送ddox请求大量创建肉鸡用户,从而大量占据服务器资源,从而崩坏
- 分布式环境下不好操作
在网上找了很多博客看,发现好多都是在瞎几把写,没看到什么好一点的方案,经过查阅资料,总结如下一个方案算是比较好的:
使用用户登录凭证:token机制+心跳机制实现
用户登录机制时序图如下
实现思路:
根据时序图的这套方案,用户如果60s内没有任何操作(不调用接口去传递token)则判定该用户为下线状态,当用户重新登陆或者再次操作网站则判定为在线状态,对用户的token进行续期。这其实是心跳机制思想的一种实现,类似于Redis集群中的哨兵对Master主观下线的过程:每10s对Master发送一个心跳包,10s内没有响应则说明Master已经下线了。这里采用的是60s作为一个生存值,如果60s内该用户没有在此页面(如果在此页面,前端会间隔10s发送一次心跳包对Token进行续期+60s过期时间)上执行任何操作,也就不会携带Token发送请求到后端接口中,那么就无法给map中的token过期时间续期,所以该用户就处于过期状态。
代码实现:
1.新建sp项目,导入如下pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><groupId>com.cd</groupId><artifactId>springboot-Comprehensive business</artifactId><version>1.0-SNAPSHOT</version><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>2.1.5.RELEASE</version></parent><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency><!--redis依赖--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency><!--fastjson依赖--><dependency><groupId>com.alibaba</groupId><artifactId>fastjson</artifactId><version>1.2.33</version></dependency><!--jwt依赖--><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt</artifactId><version>0.9.0</version></dependency><dependency><groupId>cn.hutool</groupId><artifactId>hutool-all</artifactId><version>5.6.3</version></dependency><dependency><groupId>org.mybatis.spring.boot</groupId><artifactId>mybatis-spring-boot-starter</artifactId><version>2.1.4</version></dependency><dependency><groupId>com.alibaba</groupId><artifactId>druid</artifactId><version>1.1.19</version></dependency><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId><scope>runtime</scope></dependency></dependencies></project>
2.编写配置文件
server.port=9999spring.datasource.username=root
spring.datasource.password=root
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://192.168.84.135:3307/resource-manage?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai
mybatis.type-aliases-package=com.cd.pojo#配置redis
spring.redis.database=11
spring.redis.host=127.0.0.1
spring.redis.port=6379
3.定义一个类,用户统计用户的在线人数等操作
@Component
public class OnlineCounter {/* 每次打开此类是该属性只初始化一次*/private static Map<String,Object> countMap = new ConcurrentHashMap<>();/* 当一个用户登录时,就往map中构建一个k-v键值对* k- 用户名,v 当前时间+过期时间间隔,这里以60s为例子* 如果用户在过期时间间隔内频繁对网站进行操作,那摩对应* 她的登录凭证token的有效期也会一直续期,因此这里使用用户名作为k可以覆盖之前* 用户登录的旧值,从而不会出现重复统计的情况*/public void insertToken(String userName){long currentTime = System.currentTimeMillis();countMap.put(userName,currentTime+60*1000);}/* 当用户注销登录时,将移除map中对应的键值对* 避免当用户下线时,该计数器还错误的将该用户当作* 在线用户进行统计* @param userName*/public void deleteToken(String userName){countMap.remove(userName);}/* 统计用户在线的人数* @return*/public Integer getOnlineCount(){int onlineCount = 0;Set<String> nameList = countMap.keySet();long currentTime = System.currentTimeMillis();for (String name : nameList) {Long value = (Long) countMap.get(name);if (value > currentTime){// 说明该用户登录的令牌还没有过期onlineCount++;}}return onlineCount;}
}
4.一般在前后分离项目中,都是有统一返回数据格式的,以及一些项目通用配置
/* 统一响应结果* @param <T>*/
public class ResponseResult<T> {/* 状态码*/private Integer code;/* 提示信息,如果有错误时,前端可以获取该字段进行提示*/private String msg;/* 查询到的结果数据,*/private T data;public ResponseResult(Integer code, String msg) {this.code = code;this.msg = msg;}public ResponseResult(Integer code, T data) {this.code = code;this.data = data;}public Integer getCode() {return code;}public void setCode(Integer code) {this.code = code;}public String getMsg() {return msg;}public void setMsg(String msg) {this.msg = msg;}public T getData() {return data;}public void setData(T data) {this.data = data;}public ResponseResult(Integer code, String msg, T data) {this.code = code;this.msg = msg;this.data = data;}}
redis序列化配置
@Configuration
public class RedisConfig {@Beanpublic RedisTemplate<String,Object> redisTemplate(RedisConnectionFactory redisConnectionFactory){RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();// key 序列化方式redisTemplate.setKeySerializer(new StringRedisSerializer());// value 序列化redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());// hash 类型 key序列化redisTemplate.setHashKeySerializer(new StringRedisSerializer());// hash 类型 value序列化方式redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());// 注入连接工厂redisTemplate.setConnectionFactory(redisConnectionFactory);// 让设置生效redisTemplate.afterPropertiesSet();return redisTemplate;}
}
全局异常配置
package com.cd.exception;import com.cd.common.ResponseResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;@RestControllerAdvice
public class GlobalExceptionHandler {private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);@ExceptionHandler(RuntimeException.class)public ResponseResult handleRuntimeException(RuntimeException e) {logger.error(e.toString(), e);return new ResponseResult(400,e.getMessage());}
}
线程隔离工具类
package com.cd.util;import com.cd.pojo.User;
import org.springframework.stereotype.Component;/* 线程隔离,用于替代session*/
@Component
public class HostHolder {private ThreadLocal<User> users = new ThreadLocal<>();public void setUser(User user) {users.set(user);}public User getUser() {return users.get();}public void clear() {users.remove();}}
jwt工具类
package com.cd.util;import cn.hutool.core.lang.UUID;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
import java.util.Date;public class JwtUtil {//有效期为public static final Long JWT_TTL = 60 * 60 *1000L;// 60 * 60 *1000 一个小时//设置秘钥明文public static final String JWT_KEY = "sangeng";public static String getUUID(){String token = UUID.randomUUID().toString().replaceAll("-", "");return token;}/* 生成jtw* @param subject token中要存放的数据(json格式)* @return*/public static String createJWT(String subject) {JwtBuilder builder = getJwtBuilder(subject, null, getUUID());// 设置过期时间return builder.compact();}/* 生成jtw* @param subject token中要存放的数据(json格式)* @param ttlMillis token超时时间* @return*/public static String createJWT(String subject, Long ttlMillis) {JwtBuilder builder = getJwtBuilder(subject, ttlMillis, getUUID());// 设置过期时间return builder.compact();}private static JwtBuilder getJwtBuilder(String subject, Long ttlMillis, String uuid) {SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;SecretKey secretKey = generalKey();long nowMillis = System.currentTimeMillis();Date now = new Date(nowMillis);if(ttlMillis==null){ttlMillis=JwtUtil.JWT_TTL;}long expMillis = nowMillis + ttlMillis;Date expDate = new Date(expMillis);return Jwts.builder().setId(uuid) //唯一的ID.setSubject(subject) // 主题 可以是JSON数据.setIssuer("sg") // 签发者.setIssuedAt(now) // 签发时间.signWith(signatureAlgorithm, secretKey) //使用HS256对称加密算法签名, 第二个参数为秘钥.setExpiration(expDate);}/* 创建token* @param id* @param subject* @param ttlMillis* @return*/public static String createJWT(String id, String subject, Long ttlMillis) {JwtBuilder builder = getJwtBuilder(subject, ttlMillis, id);// 设置过期时间return builder.compact();}public static void main(String[] args) throws Exception {String token = "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiJjYWM2ZDVhZi1mNjVlLTQ0MDAtYjcxMi0zYWEwOGIyOTIwYjQiLCJzdWIiOiJzZyIsImlzcyI6InNnIiwiaWF0IjoxNjM4MTA2NzEyLCJleHAiOjE2MzgxMTAzMTJ9.JVsSbkP94wuczb4QryQbAke3ysBDIL5ou8fWsbt_ebg";Claims claims = parseJWT(token);System.out.println(claims);}/* 生成加密后的秘钥 secretKey* @return*/public static SecretKey generalKey() {byte[] encodedKey = Base64.getDecoder().decode(JwtUtil.JWT_KEY);SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");return key;}/* 解析 @param jwt* @return* @throws Exception*/public static Claims parseJWT(String jwt) throws Exception {SecretKey secretKey = generalKey();return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();}}
有时候我们需要在响应流中设置返回数据,因此有如下工具类
package com.cd.util;import javax.servlet.http.HttpServletResponse;
import java.io.IOException;public class WebUtils {/* 将字符串渲染到客户端 @param response 渲染对象* @param string 待渲染的字符串* @return null*/public static String renderString(HttpServletResponse response, String string) {try{response.setStatus(200);response.setContentType("application/json");response.setCharacterEncoding("utf-8");response.getWriter().print(string);}catch (IOException e){e.printStackTrace();}return null;}}
- 我们这里可以使用springboot的拦截器来拦截需要登录后才能操作的接口,操作这些接口就代表的当前用户属于登录状态,因此需要给用户的登录凭证也就是token续期,对应的往map中添加用户的过期时间来进行覆盖之前的,这样就不会出现同一个用户出现重复统计的情况
配置拦截器
@Component
public class LoginInteceptor implements HandlerInterceptor {@Autowiredprivate OnlineCounter onlineCounter;@Autowiredprivate RedisTemplate redisTemplate;@Autowiredprivate HostHolder hostHolder;@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 1.获取请求头中的tokenString token = request.getHeader("authorization");if (StringUtils.isEmpty(token)){ResponseResult responseResult = new ResponseResult(400,"未携带请求头信息,不合法");String jsonStr = JSONUtil.toJsonStr(responseResult);WebUtils.renderString(response,jsonStr);return false;}User user =(User) redisTemplate.opsForValue().get(token);if (Objects.isNull(user)){ResponseResult responseResult = new ResponseResult(403,"token过期,请重新登录");String jsonStr = JSONUtil.toJsonStr(responseResult);WebUtils.renderString(response,jsonStr);return false;}// 当请求执行到此处,说明当前token是有效的,对token续期redisTemplate.opsForValue().set(token,user,60, TimeUnit.SECONDS);// 在本次请求中持有当前用户,方便业务使用hostHolder.setUser(user);// 覆盖之前的map统计时间,使用最新的token有效期时长onlineCounter.insertToken(user.getName());return true;}@Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {// 释放前挡用户,防止内存泄露hostHolder.clear();}}
使拦截器生效
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {@Autowiredprivate LoginInteceptor loginInteceptor;/* 配置拦截哪些请求* @param registry*/@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(loginInteceptor).excludePathPatterns("/login","/online"); // 不拦截这些资源}
}
数据库创建两个用户,这里直接展示类,数据库字段就不展示了,对象关系映射即可:
对应接口层如下:
@RestController
public class HelloController {@Autowiredprivate Userservice userservice;/* 该接口需要登录后才能操作* @return*/@RequestMapping("/user/list")public ResponseResult hello(){return userservice.selectUserList();}/* 登录* @param loginParam* @return*/@PostMapping("/login")public ResponseResult login(@RequestBody LoginParam loginParam){return userservice.login(loginParam);}/* 退出登录* @param request* @return*/@PostMapping("/logout")public ResponseResult logout(HttpServletRequest request){return userservice.logout(request);}/* 获取当前在线人数* 这个就相当于一个心跳检查机制* 前端每间隔一定时间就请求一下该接口达到在线人数* @return*/@PostMapping("/online")public ResponseResult getOnLineCount(){return userservice.getOnLineCount();}}
对应业务层
@Service
public class UserviceImpl implements Userservice {private static final Logger logger = LoggerFactory.getLogger(UserviceImpl.class);@Autowiredprivate UserMapper userMapper;@Autowiredprivate RedisTemplate redisTemplate;@Autowiredprivate OnlineCounter onlineCounter;/* 用户登录* @param loginParam* @return*/@Overridepublic ResponseResult login(LoginParam loginParam) {String name = loginParam.getName();User user = userMapper.selectByName(name);if (Objects.isNull(user)){throw new RuntimeException("用户名或者密码不正确");}String token = UUID.randomUUID().toString().replaceAll("-", "");logger.info("当前账号对应的token是: {}",token);redisTemplate.opsForValue().set(token,user,60, TimeUnit.SECONDS);// 往map中添加一条用户记录onlineCounter.insertToken(name);return new ResponseResult(200,"登录成功");}/* 退出登录* 需要先有登录才能有退出* @return*/@Overridepublic ResponseResult logout(HttpServletRequest request) {String authorization = request.getHeader("authorization");User user = (User) redisTemplate.opsForValue().get(authorization);redisTemplate.delete(authorization);onlineCounter.deleteToken(user.getName());return new ResponseResult(200,"退出成功");}/* 需要登录才能操作* 获取所有用户列表* @return*/@Overridepublic ResponseResult selectUserList() {List<User> userList = userMapper.selectList();return new ResponseResult(200,"获取列表成功",userList);}/* 不需登录* 获取当前在线人数* @return*/@Overridepublic ResponseResult getOnLineCount() {Integer onlineCount = onlineCounter.getOnlineCount();return new ResponseResult(200,"ok",onlineCount);}}
测试:
未登录时去操作需要登录的接口或者token过期了:
这个时候网站的在线人数:
登录后:
这时候再去请求需要登录才能访问的接口:
可以看到成功访问了,并且该用户的token会一直续期
获取当前在线人数:
大功告成