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

小黑luck 2024-08-24 08:03:02 阅读 90

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

方案一

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

        实现:基于Redis的 zset特性

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

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

        服务端启动轮询任务:

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

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

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

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

<code>@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.验证配置是否生效

                   

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

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

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

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

方案三

        基于websocket

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

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

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

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



声明

本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。