在社交网站中,通常需要实时统计某个网站的在线人数,通过该指标来实时帮助运营人员更好的维护网站业务:

先说一下目前在市面上主流的做法再加上我自己查阅的资料总结:

  • 创建一个session监听器,在用户登录时即创建一个session,监听器记录下来并且把count加一
  • 用户点击注销时把session给remove掉,count减一

说一下上面这种做法的弊端:

  • 当用户关闭浏览器时并不会触发session监听,当下一次登录时仍然会让count加一
  • 或者在session过期时,session监听并不能做一个实时的响应去将在线数减一
  • 当用户在次登陆,由于cookie中含有的session_id不同而导致session监听器记录下session创建,而使count加一。
  • 对服务器性能影响较大,用户每次访问网站时,服务端都会创建一个session,并将该session与用户关联起来,这样会增加服务器的负担,特别是在高并发的时候,导致服务器压力过大
  • 容易被恶意攻击,攻击者不断发送ddox请求大量创建肉鸡用户,从而大量占据服务器资源,从而崩坏
  • 分布式环境下不好操作

在网上找了很多博客看,发现好多都是在瞎几把写,没看到什么好一点的方案,经过查阅资料,总结如下一个方案算是比较好的:

使用用户登录凭证:token机制+心跳机制实现

用户登录机制时序图如下

redis 实现 设备 在线 列表 redis实时在线人数_java

实现思路:
根据时序图的这套方案,用户如果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;
    }

}
  1. 我们这里可以使用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"); // 不拦截这些资源
    }
}

数据库创建两个用户,这里直接展示类,数据库字段就不展示了,对象关系映射即可:

redis 实现 设备 在线 列表 redis实时在线人数_java_02

对应接口层如下:

@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过期了:

redis 实现 设备 在线 列表 redis实时在线人数_spring boot_03


这个时候网站的在线人数:

redis 实现 设备 在线 列表 redis实时在线人数_java_04


登录后:

redis 实现 设备 在线 列表 redis实时在线人数_java_05


这时候再去请求需要登录才能访问的接口:

redis 实现 设备 在线 列表 redis实时在线人数_redis_06


可以看到成功访问了,并且该用户的token会一直续期

获取当前在线人数:

redis 实现 设备 在线 列表 redis实时在线人数_spring boot_07

大功告成