统计用户在线时长(Redis、web-socket)

所有方案均基于用户异常下线。非正常调用登出API下线

方案一

        心跳机制:用户调用登陆API后,持续向服务端发送心跳,向服务端告知自己健康。等服务端x分钟没有收到客户端的心跳。则视为用户下线,记录下线时间。

        实现:基于Redis的 zset特性

        用户调用登陆API后,将用户的登录时间记录在LOGIN_KEY

        客户端启用心跳(调用心跳API),记录HEART_KEY

        服务端启动轮询任务:

                1.通过 zset.rangeByScore 查询x分钟以前数据

                2.取出用户最新心跳时间(可能在x分钟内再次有了心跳,所以要取最新的)

                3.计算最后心跳时间和当前时间间隔,如果超过x分钟,则为离线

                4.如果离线,调用 logout,并计算用户时长,将用户时长记录在 ONLINE_KEY

@Slf4j
@Component
public class OnlineService {
    /**
     * 登陆key
     */
    private static final String LOGIN_KEY = "online:login";
    /**
     * 在线时长
     */
    private static final String ONLINE_KEY = "online:live";
    /**
     * 心跳
     */
    private static final String HEART_KEY = "online:heart";

    //默认4分钟判定为离线
    private static final Integer DEAD_LINE = 4*60;

    @Autowired
    private RedisTemplate<String,Object> redisTemplate;

    private ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();

    /**
     * 退出
     *
     * @param userId
     */
    public void login(String userId) {
        redisTemplate.opsForHash().putIfAbsent(LOGIN_KEY, userId, System.currentTimeMillis());
    }

    public void loginOut(String userId) {
        Object loginTime = redisTemplate.opsForHash().get(LOGIN_KEY, userId);
        if (loginTime != null) {
            doLogout(userId, loginTime, Instant.now());
            log.info("user {} is logout", userId);
        }
    }

    private void doLogout(String userId, Object loginTime, Instant lastTime) {
        Instant login = Instant.ofEpochMilli(Long.valueOf(loginTime.toString()));
        Duration liveDuration = Duration.between(login, lastTime);
        redisTemplate.opsForZSet().add(ONLINE_KEY, userId, liveDuration.getSeconds());
        //删除登陆记录
        redisTemplate.opsForHash().delete(LOGIN_KEY, userId);
    }


    /**
     * 心跳
     *
     * @param userId
     */
    public void heartBeat(String userId) {
        redisTemplate.opsForZSet().add(HEART_KEY, userId, System.currentTimeMillis());
    }

    /**
     * 获取用户登陆时长
     * @param userId
     * @return
     */
    public Long getOnlineDuration(String userId) {
        Double score = redisTemplate.opsForZSet().score(ONLINE_KEY, userId);
        return score != null ? score.longValue() : null;
    }


    @PostConstruct
    public void offlineJob() {
        //假定3分钟没有心跳就是离线
        scheduledExecutorService.scheduleAtFixedRate(() -> {
            Instant now = Instant.now();
            log.info("online scan: {}",
                    LocalDateTime.ofInstant(now, ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("yyyy-MM" +
                            "-dd HH:mm:ss")));
            Instant deadline = now.minusSeconds(DEAD_LINE);
            //查询4分钟以前数据
            Set<Object> userListTmp = redisTemplate.opsForZSet().rangeByScore(HEART_KEY, 0, deadline.toEpochMilli());
            if (CollectionUtils.isEmpty(userListTmp)) {
                log.info("无记录");
                return;
            }
            //查询所有用户记录
            List<String> userList = userListTmp.stream().map(Object::toString).collect(Collectors.toList());
            for (String u : userList) {
                //取出用户最新心跳时间(可能在结束时间内再次有了心跳,所以要取最新的)
                Double score = redisTemplate.opsForZSet().score(HEART_KEY, u);
                if (score != null) {
                    //只要有一条记录,就和当前时间比较,是否超过结束时间分钟
                    Instant lastTime = Instant.ofEpochMilli(score.longValue());
                    Duration duration = Duration.between(lastTime, now);
                    if (duration.getSeconds() >= DEAD_LINE) {
                        //判定为离线
                        //取登陆时间   和用户最后一次心跳时间差即为登录时间
                        Object loginTime = redisTemplate.opsForHash().get(LOGIN_KEY, u);
                        if (loginTime != null) {
                            doLogout(u, loginTime, lastTime);
                            log.info("user {} is offline", u);
                        } else {
                            
                        }
                        //删除用户心跳记录
                        redisTemplate.opsForZSet().remove(HEART_KEY,u);
                    }
                }
            }
//每30秒轮询一次
        }, 5, 30, TimeUnit.SECONDS);
    }


}
@RestController
public class OnlineController {

    @Autowired
    private OnlineService onlineService;

    /**
     * 登陆
     * @param userId
     */
    @GetMapping("/online/{userId}")
    public String login(@PathVariable("userId") String userId) {
        onlineService.login(userId);
        return "success";
    }

    /**
     * 登出
     * @param userId
     */
    @GetMapping("/online/logout/{userId}")
    public String loginOut(@PathVariable("userId") String userId) {
        onlineService.loginOut(userId);
        return "success";
    }

    /**
     * 心跳API
     * @param userId
     */
    @GetMapping("/online/heart/{userId}")
    public String heart(@PathVariable("userId") String userId) {
        onlineService.heartBeat(userId);
        return "success";
    }


    /**
     * 获取用户在线时长
     */
    @GetMapping("/online/duration/{userId}")
    public Long duration(@PathVariable("userId") String userId) {
        return onlineService.getOnlineDuration(userId);
    }


}

 方案二

        基于Redis过期监听

                注意:

                在 Redis 官方手册的 keyspace-notifications: timing-of-expired-events 中明确指出:

                Basically expired events are generated when the Redis server deletes the key and not                 when the time to live theoretically reaches the value of zero

                Redis 自动过期的实现方式是:定时任务离线扫描并删除部分过期键;在访问键时惰性                    检查是否过期并删除过期键。

                Redis 从未保证会在设定的过期时间立即删除并发送过期通知。实际上,过期通知晚于                    设定的过期时间数分钟的情况也比较常见。

        redis过期监听设置方式:

                  1.打开conf/redis.conf 文件,取消注释:notify-keyspace-events Ex

                  2.重启redis

                  3.如果设置了密码需要重置密码:config set requirepass ****

                  4.验证配置是否生效

                   

  1. 进入redis客户端:redis-cli
  2. 执行 CONFIG GET notify-keyspace-events ,如果有返回值证明配置成功,如果没有执行步骤三
  3. 执行CONFIG SET notify-keyspace-events "Ex",再查看步骤二是否有值

              实现:用户登陆后,写进REDIS用户标识和过期时间,比如设置expire_time为3分钟,则用户每次操作,都会进行续期,保证不过期,等用户3分钟内不进行操作,则服务端监听到过期键。进行时间运算。当前时间减去登录时间,为最后的登陆时长。

             1.但是并发量大可能会产生重复消费,所以视情况加分布式锁等。

              2.redis延迟(redis机制问题)

方案三

        基于websocket

        逻辑:用户登陆后,客户端和服务端建立一个长连接。客户端关闭调用close关闭连接,进行时间计算。

        问题:1.如果是网页应用。当用户关闭TAB后。socket也会随之关闭。用另一个网页打开后,                       系统如果是免登陆或基于服务端存储登陆状态自动登陆。无法累积时长。时间又开始                      重新计算。所以登陆时长计算不正确。

                    2.服务端耗费资源大,当用户量巨大。每个用户挂一个长连接。服务端压力大。

                    3.部分浏览器版本,无法触发close事件,还需要重新做一套心跳机制,参靠方案一,哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值