【任务11】数据统计与拦截:统计网站在线人数

打卡要求:
1、说一下你的实现逻辑
2、你所用方法的优缺点
本问题对应的项目链接:从0设计一个web项目

共以下 6 个回答

  • 星河揽我入梦 永久会员 2024年7月18日 下午3:37

    我采用session监听器来统计网站在线人数,每次创建session时就会自动加一,销毁session时就会自动减一;
    先初始化一个类实现HttpSessionlistener,然后定义count,创建session,count+1,销毁session,count-1;然后在类上加注解webListener,启动类上再加@ServletComponentScan(“com.example.demo.listener”)扫描包才有用。
    优点是:
    统计在线人数结果相对准确;
    缺点是:
    当用户关闭浏览器时并不会触发session监听,当下一次登录时仍然会让count加一
    或者在session过期时,session监听并不能做一个实时的响应去将在线数减一
    当用户再次登陆,由于cookie中含有的session_id不同而导致session监听器记录下session创建,而使count加一。
    对服务器性能影响较大,用户每次访问网站时,服务端都会创建一个session,并将该session与用户关联起来,这样会增加服务器的负担,特别是在高并发的时候,导致服务器压力过大
    https://github.com/CurryisCoat/community/tree/dev

    0 赞同 0 条回复
  • 豆子彩虹 普通 2022年10月31日 下午5:53

    之前在用户不能重复登陆处,在redis里设置了userName-session的键值对,5分钟后过期。这次根据帅地上节打卡处的留言,将键值对改为userId-session来防止中文用户名出现的乱码问题。

    方法一 通过keys模糊查询

    在讲前文提到的键值对加入统一前缀“userId:”然后通过keys(“userId:*”)直接匹配
    这种方法在用户量较少的时候没有很大的问题,实现也简单方便。但是用户量增大后,keys是阻塞式查询,效率较低,会导致系统响应时间长用户体验差的情况

    方法二 使用Sorted set

    大体思路和其他同学相似,score处用时间戳,统计一定时间内有操作的用户视为在线用户。
    与其他同学不同的是,我没有采用定时任务的方式。每次统计在线用户后,将这个数存在redis中,有效期1分钟(也就是在线用户数1分钟更新一次),每次需要请求在线用户数时,先检查有无之前保存的在线用户数,如果有的话直接使用,如果没有的话就统计并保存。
    我个人理解这种方法的优点在于,只有有需求时才统计在线用户数,而且对在线用户数做缓存也不用每次访问都统计。
    此外,关于在线用户数刷新间隔的问题,我觉得用户对这一需求并不敏感也不需要很强的准确性,没必要更新的过分频繁反而增加了服务器压力影响用户体验。
    以上是我自己的实现方式以及思考,如有不足之处,请大佬们和帅地哥指正。
    GitHub地址:https://github.com/lei342345795/FromZerotoExpert

    0 赞同 0 条回复
  • lucca 普通 2022年10月14日 下午9:16

    3.1 统计网站用户在线人数

    本次需求有三个要求:

    1. 统计在线人数
    2. 服务端的负荷尽量的小
    3. 相对准确

    v1.0

    维护登录态过程中已经维护了用户的在线token,且唯一。

    因此一开始是打算在前端定时轮询获取最新的在线人数,后端收到请求则重新统计一遍在线token的数量。

    优缺点

    服务端负荷大

    从后面的所有迭代方案来看,这个方法总能获取到最新的数据,满足了1.3

    但服务端负荷大,用户量稍多一点就会有大量的请求打到服务端,而服务端每次都要重新计算。

    v2.0

    1.0主要的缺点在于每次都要计算,因此2.0决定开启定时任务,定时获取在线token数,将结果存储起来。

    在线用户这种频繁变动的数据不应该存到数据库,所以决定放入缓存。

    优缺点

    满足1,2,对于条件3,时间误差取决于定时任务的频率,不过也勉强够用了。

    v3.0

    引入 session 监听器监听 session 的创建和销毁,每次动态维护在线人数。

    创建session时,人数+1

    销毁session时,人数-1

    客户端请求人数时直接返回缓存中的值

    优缺点

    同时满足123,数据几乎实时(可能存在误差),对服务端的影响微乎其微(优于2.0)

    无法监听到 session 没有正常销毁的情况

    session的销毁方法,在 session过期 和 应用正常关闭 的时候会触发。

    而应用非正常关闭(强制关闭或强制重启)时,已经保存的session虽然被释放了,但是没有触发销毁方法,这部分在线数量释放不掉

    因此在每次启动重启应用的时候把在线用户重置为0,只要用户还在继续访问就可以很快恢复,如果在启动时统计一遍的话,会导致用户被重复统计。

    因为,应用重启后,用户再次访问应用,会生成新的session。

    session 共享问题

    同时,session 还存在共享问题,可以引入redis,但监听redis key 的过期同样也是成本比较大的,因此没有实现。

    值是否会出现负数?

    目前为止能想到的还没有会出现负数的场景,可能会有没考虑到的情况,这个还需要继续思考。

    误判

    上述所有的方案都是在服务段判定的离线。

    也就是说,会有误判:用户在网页上,但是没有任何点击动作,没有触发任何请求,虽然登录态还在,但是被认为是下线(session过期)

    同时,为什么不适合直接使用在线token做统计?

    我认为因为时间太久了,维护为24小时,误差太大,如果登陆态的时间也比较短的话,其实可以用来统计。

    github地址(点击即可)

    0 赞同 0 条回复
  • 小肸 永久会员 2022年10月10日 下午8:49
    • 思路:
      • 统计用户登录人数和实时在线人数:之前实现用户登录和单点登录,我是用来redis的string类型,键为账号标识accountId,值为会话标识sessionId,对于统计用户登录人数,这个比较简单,直接统计redis库中有多少条string类型记录,就有多少个账号标识,就有多少个会话标识,就有多少个登录用户。但无法统计用户实时在线人数,比如用户登录网站了,然后走了,留下一台电脑或者用电脑干其它事了(比如你听个网课,老师知道你在课上,不知道你在不在屏幕前),这时用户就不是实时在线网站。为了统计用户在线人数,我们把string类型改为list类型,键为accountId,值为sessionId、最后会话的一次发送请求时间。然后我们统计实时在线人数只需比较当前时间与会话最后一次发送请求的时间的差值是否小于一个指定值(我设为10s),如果小于,说明用户实时在线,否则不在线。(打个比方,上网课时,你和老师在积极互动,老师就知道你肯定在屏幕前)。但是我从网站上只请求了一个静态资源(比如一篇很长的文章),然后浏览,浏览半个小时,但是网站会判定我不在线,这时可以在前端设置一个定时器,当用户鼠标或键盘有操作时,就向网站发送一个请求,告诉用户还在线,同时重置定时器。当用户长时间没有操作时,定时器时间用完了,就向服务器发送一个退出登录请求。
    • 实现代码(前端定时器没有实现)

    登录接口

    @RestController
    @Validated
    public class LoginController {
        @Autowired
        private UserService userService;
    
        @Resource
        private RedisTemplate redisTemplate;
    
        @PostMapping("/login")
        public ResultVO login(@RequestBody @Valid LoginForm loginForm, BindingResult bindingResult,
                              HttpServletRequest request, HttpServletResponse response) {
            HttpSession session = request.getSession();
            if (bindingResult.hasErrors()) {
                return ResultVOUtil.fail(100, "登录参数不正确");
            }
            User user = userService.getOneByAccount(loginForm.getLoginAccount());
            if (user == null) {
                return ResultVOUtil.fail(101, "账号不存在");
            }
            // 根据登录密码生成加密密码
            String entryptPassword = PasswordUtil.getPBKDF2(loginForm.getLoginPassword(), user.getUserSalt());
            if (!user.getUserPassword().equals(entryptPassword)) {
                return ResultVOUtil.fail(102, "密码错误");
            }
            // 取得会话的sessionId
            String sessionId = session.getId();
            // 根据账号生成accountId
            String accountId = AccountIdUtil.createAccountId(loginForm.getLoginAccount());
            // 将accountId 与 sessionId 绑定
            redisTemplate.opsForList().remove(accountId, 0, 0);
            redisTemplate.opsForList().rightPushAll(accountId, sessionId, System.currentTimeMillis());
            redisTemplate.expire(accountId, 60*60*24, TimeUnit.SECONDS);
            Cookie cookie = new Cookie("accountId",accountId);
            response.addCookie(cookie);
            return ResultVOUtil.success("登录");
        }
        @GetMapping("/isLogin")
        public ResultVO isLogin(HttpServletRequest request) {
            if (getCookie(request, "accountId") != null) {
                Cookie cookie = getCookie(request, "accountId");
                String accountId = cookie.getValue();
                String sessionId = (String) redisTemplate.opsForList().index(accountId, 0);
                if (sessionId == null) {
                    return ResultVOUtil.fail(430, "请重新登录");
                }
                if (sessionId.equals(request.getSession().getId())) {
                    return ResultVOUtil.success();
                }
            }
            return ResultVOUtil.fail(430, "请重新登录");
        }
    
        @GetMapping("/logout")
        public ResultVO logout(HttpServletRequest request) {
            if (isLogin(request).getCode() != 0) {
                return ResultVOUtil.fail(431, "请先登录");
            }
            String accountId = getCookie(request, "accountId").getValue();
            redisTemplate.opsForList().remove(accountId, 0, 0);
            return ResultVOUtil.success("退出登录成功");
        }
        private Cookie getCookie(HttpServletRequest request, String target) {
            if (request.getCookies() != null) {
                for (Cookie cookie : request.getCookies()) {
                    if (cookie.getName().equals(target)) {
                        return cookie;
                    }
                }
            }
            return null;
        }
    }
    

    网站人数统计接口

    @RestController
    public class StatisticsController {
        @Resource
        private RedisTemplate redisTemplate;
        @GetMapping("/getUserCount")
        public ResultVO getUserCount() {
            UserCountVO userCountVO = new UserCountVO();
            Set keys = redisTemplate.keys("*");
            int loginCount = 0, realtimeCount = 0;
            for (String key : keys) {
                if (redisTemplate.type(key) == DataType.LIST) {
                    loginCount ++;
                    long oldTime = (long) redisTemplate.opsForList().index(key, 1);
                    if (System.currentTimeMillis() -  oldTime < 10000) {
                        realtimeCount ++;
                    }
                }
            }
            userCountVO.setLoginCount(loginCount);
            userCountVO.setRealtimeCount(realtimeCount);
            return ResultVOUtil.success(userCountVO);
        }
    }
    

    返回给前端的视图

    @Data
    public class UserCountVO {
        // 登录人数
        private Integer loginCount;
        // 实时在线人数
        private Integer realtimeCount;
    }
    

    统计网站人数只能放在服务端进行,因为数据在服务端,但是因为数据在缓存中,查询速度应该较快。统计实时在线人数有些误差。对服务器的性能应该没多大影响,前端的定时器也区分了实时用户(在浏览,打字)和真正离开的用户,节省了一些资源。可能没考虑到并发和分布式。并发和分布式还在学,知道一些概念。
    GitHub链接:https://github.com/shuaishuaixi03/FromZerotoExpert/tree/dev

    0 赞同 0 条回复
  • Second(S) 普通 2022年10月2日 下午10:30

    我的实现方式如下:
    1.思路分析(自己想的,如有错误,请大家直言相告)
    首先,我们要想在前端页面动态的更新在线人数,仅靠前端页面是一定完不成的,我们只有访问服务器,才能知道哪些人在线,哪些人不在线。而用户是否在线,也是一个动态的过程,他可能上一秒在线,下一秒就不在线了。因此想要获取在线人数,我们就一定要周期性的访问服务器,查询哪些用户在线,这样才能实时性的将人数显示出来。根据这个思路,周期性访问服务器是不可避免的了,因此最好将用户在线的信息存储到redis缓存中,尽可能的来减少大量用户周期性访问所带来的压力。
    2.redis存储的数据结构
    在这里我们使用zset结构,键为onlineUser,值为用户名称,score为当前操作时间的时间毫秒值,在用户第一次登录时的登录接口中就把一份该用户的信息存储在zset集合中,表明该用户在此刻的时间毫秒值下为登录在线状态。
    3.js的定时任务
    用户在主页面时,设置js的定时任务,通过鼠标移动检测事件监测用户是否操作过,并每隔一段时间定时地更新在线信息,若用户未关闭页面且在页面中操作过(鼠标移动过)则更新zset集合中的时间毫秒值(若用户是离线状态下操作鼠标,则更新zset用户信息变为添加zset用户信息),若用户未关闭页面且长时间未操作(这里设置60秒未移动过鼠标则改变在线状态以便测试)则通过ajax的post异步提交,在quit接口中将zset中的用户名删除,使集合中在线的用户数减一,当用户不在此页面时,定时任务无法触发,zset集合中的score信息无法更新,因此我们在判断用户个数的时候,只需要在zset查询时设定下边界currentTime(当前时间毫秒值)-timeOut(超时时间毫秒值)即可,因为用户关闭页面了,redis数据库中的该用户的score是不会继续更新的,但currentTime(当前时间毫秒值)是一直增加的,所以用户关闭页面redis不删除该用户信息也能知道此用户因长时间未操作而不在线。

    // 长时间未操作自动退出系统
    var lastTime = new Date().getTime();
    var currentTime = new Date().getTime();
    var timeOut =  60 * 1000; //设置离线超时时间: 60秒
    var check_time = 3 * 1000; //定时任务的时间间隔:3秒一次
    
    function autoQuit(){
        $(function(){
            /* 鼠标移动事件 */
            $(document).mouseover(function(){
                lastTime = new Date().getTime(); //更新操作时间
            });
        });
    
        /* 定时器
     * 间隔3秒检测是否长时间未操作页面
     */
        scheduledTasks();
    
    }
    
    function scheduledTasks(){
        currentTime = new Date().getTime(); //更新当前时间
        if(currentTime - lastTime > timeOut){ //判断是否超时.
            //页面未关闭时,检测到超时改为离线
            $.post("/FromZerotoExpert/main/quit", {}, function (data){
                document.getElementById("current_status").innerHTML = "离线";
            })
        }else{
            //页面未关闭时,检测不超时,更新时间戳
            $.post("/FromZerotoExpert/main/updateOnlineTime", {"time":currentTime}, function (data){
    
            })
        }
        //页面未关闭时,定时更新在线人数
        $.post("/FromZerotoExpert/main/getOnlineNums", {"time":currentTime}, function (data){
            document.getElementById("current_online_numbers").innerHTML = data.message;
        })
    }
    
    
    
    // 长时间未操作退出系统 end
    
    

    4.接口中的方法
    具体前端js调用的接口如下:

    @PostMapping("/quit")
        @ResponseBody
        public RegisterResult quit(HttpServletRequest request, HttpServletResponse response){
            String username = null;
            try {
                username = (String) request.getSession().getAttribute("username");
                if(null != username && !"".equals(username)){
                    stringRedisTemplate.opsForZSet().remove("onlineUser", username);
                    return new RegisterResult(true);
                }
            }catch (Exception e){
                e.printStackTrace();
            }
    
            return new RegisterResult(false);
        }
    
        @PostMapping("/getOnlineNums")
        @ResponseBody
        public RegisterResult getOnlineNums(long time,HttpServletRequest request, HttpServletResponse response){
            String username = null;
            try {
                username = (String) request.getSession().getAttribute("username");
                if(null != username && !"".equals(username)){
                    Long nums = stringRedisTemplate.opsForZSet().count("onlineUser", (double) (time - 60 * 1000), (double) (time + 60 * 1000));
                    return new RegisterResult(true, nums + "人");
                }
            }catch (Exception e){
                e.printStackTrace();
            }
    
            return new RegisterResult(false, "加载中...");
        }
    
        @PostMapping("/updateOnlineTime")
        @ResponseBody
        public RegisterResult updateOnlineTime(long time,HttpServletRequest request, HttpServletResponse response){
            String username = null;
            try {
                username = (String) request.getSession().getAttribute("username");
                if(null != username && !"".equals(username)){
                    stringRedisTemplate.opsForZSet().add("onlineUser", username,(double) time);
                    return new RegisterResult(true);
                }
            }catch (Exception e){
                e.printStackTrace();
            }
    
            return new RegisterResult(false);
        }
    

    该实现方法的优点:
    优点是利用了前端页面的鼠标检测事件更新用户状态,减小了一部分服务器的压力,但服务器还是要被周期性的访问,更新用户在线状态信息。
    该实现方法的缺点:
    服务器压力有点大,在这里我的定时任务设置的是3秒执行一次(一部分原因是为了方便调试把未操作离线的时间改成了60秒,所以定时任务的时间也相应减小到3秒),所以当大量用户同时在线时,服务器的压力会相当大,解决方法就是把定时任务的时间加长,但这样的话就会出现有些用户下线了,但服务器还没有响应到的现象,并且不能及时更新在线人数。所以显示人数的准确性和服务器的压力这两个方面只能选一个,相当于是零和博弈吧。
    第二个缺点就是,因为我的检测用户是否在线的方法是利用的鼠标移动检测事件,所以本方法好像只能适用于PC端,手机端我还没有测试过,不知道触屏算不算鼠标移动。。。。

    在这里提供几个用户名和密码,欢迎大家也来帮我测一测有没有什么bug。
    当登陆后,连续累积时间60s不移动鼠标,你的登录状态变为离线。
    用户名:aaa,密码123456a
    用户名:bbb,密码123456a
    用户名:ccc,密码123456a
    用户名:ddd,密码123456a
    用户名:eee,密码123456a
    用户名:111,密码123456a
    用户名:222,密码123456a
    用户名:333,密码123456a
    用户名:444,密码123456a
    用户名:555,密码123456a

    gitHub地址:https://github.com/Second6s9/FromZerotoExpert/tree/dev
    gitHub防覆盖地址:https://github.com/Second6s9/FromZerotoExpert/tree/day11
    服务器地址:http://123.57.142.33:8088/FromZerotoExpert/
    登录地址:http://123.57.142.33:8088/FromZerotoExpert/login

    注1:服务器昨晚好像是被攻击了,把我mysql的库给删掉了,之前注册的用户信息都丢失了,要想登录原来的账号就重新注册一次吧。。。
    注2:参考秊同学的设计,更新了在线用户的实时显示。

    0 赞同 0 条回复
  • 秊 永久会员 2022年10月2日 上午11:23
    1. 我的思路是: ”心跳信息“ + redis中的 zset (长轮询);
    • 用户登录网站后每隔一段时间向服务器发送一条”心跳信息“:在 redis 中的有序集合中设置 key = oL(onLine),score = 发送心跳信息时的时间戳,member = 账号。
    • 网站每隔一段时间访问 /ShowOnlineUsers 接口:在 zset 中查找出 score 在[当前时间戳 – 3秒]到[当前时间戳]的 member,转为 JSON 数组返回到前端显示

      中间遇到了点困难: 由于我原先的程序只在 UserService 中设置了一个 jedis 静态成员来访问 redis,哪怕只有一个用户登录时:若某一时刻(发送心跳信息的时间与显示在线人数时间的最小公倍数)就会造成 jedis 访问出问题,这是我第一次实践中遇到并发问题。解决方法是改用 jedis连接池,每次用到 jedis 时从池中获取一个 jedis 实例,用完归还就彳亍。

    1. 优点:实现简单
      缺点:用户频繁发出心跳信息会对服务器带来压力,而“当前在线人数显示”可以设置为一个按钮,用户点击时才显示,减轻服务器压力
    2. 我在想是否有一种方法,可以在用户登录网站时在redis中当前用户哈希表中添加 ”online“ = ”1“,用户登出时再将其设置为”0“,这是对服务器最友好的方法;但难点在于如何准确的判断用户登出了网站,我目前找到最有希望的方法是在前端使用 window.onbeforeunload, 但是它必须要用户在网站上做一定的操作后才能生效,如果你刚登录就立马关闭网站窗口,这个 onbeforeunload 就无法执行,此时 ”online“ 字段一直就是“1”。
    3. 我还找到了一种方法:使用 WebSocket 实现服务器和客户端的全双工通信,缺点是部分浏览器不支持(可以在不支持时用长轮询),目前正在实现中。
    4. 测试用的账号:
      账号:123456 密码:123asd
      账号:1234567 密码:123asd
      账号:12345678 密码:123asd

    服务器链接:http://124.223.184.187:8088/FromZerotoExpert
    Github仓库链接:https://github.com/jiangyklala/FromZerotoExpert/tree/dev

    ps: 刚发现任务10 和 任务11下的服务器连接给错了,已修改
    jedis连接池一直出现问题,我把池的容量提高到最大连接数500,三四个人同时访问应该没问题了

    1 赞同 0 条回复

# 回答此问题

后才能回答