在社交网站中,通常需要实时统计某个网站的在线人数,通过该指标来实时帮助运营人员更好的维护网站业务:
先说一下目前在市面上主流的做法再加上我自己查阅的资料总结:
- 创建一个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=9999
spring.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 {
@Bean
public 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 {
@Autowired
private OnlineCounter onlineCounter;
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private HostHolder hostHolder;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取请求头中的token
String 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;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
// 释放前挡用户,防止内存泄露
hostHolder.clear();
}
}
使拦截器生效
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private LoginInteceptor loginInteceptor;
/**
* 配置拦截哪些请求
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInteceptor)
.excludePathPatterns("/login","/online"); // 不拦截这些资源
}
}
数据库创建两个用户,这里直接展示类,数据库字段就不展示了,对象关系映射即可:
对应接口层如下:
@RestController
public class HelloController {
@Autowired
private 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);
@Autowired
private UserMapper userMapper;
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private OnlineCounter onlineCounter;
/**
* 用户登录
* @param loginParam
* @return
*/
@Override
public 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
*/
@Override
public 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
*/
@Override
public ResponseResult selectUserList() {
List<User> userList = userMapper.selectList();
return new ResponseResult(200,"获取列表成功",userList);
}
/**
* 不需登录
* 获取当前在线人数
* @return
*/
@Override
public ResponseResult getOnLineCount() {
Integer onlineCount = onlineCounter.getOnlineCount();
return new ResponseResult(200,"ok",onlineCount);
}
}
测试:
未登录时去操作需要登录的接口或者token过期了:
这个时候网站的在线人数:
登录后:
这时候再去请求需要登录才能访问的接口:
可以看到成功访问了,并且该用户的token会一直续期
获取当前在线人数:
大功告成