所有方案均基于用户异常下线。非正常调用登出API下线
方案一
心跳机制:用户调用登陆API后,持续向服务端发送心跳,向服务端告知自己健康。等服务端x分钟没有收到客户端的心跳。则视为用户下线,记录下线时间。
实现:基于Redis的 zset特性
用户调用登陆API后,将用户的登录时间记录在LOGIN_KEY
客户端启用心跳(调用心跳API),记录HEART_KEY
服务端启动轮询任务:
1.通过 zset.rangeByScore 查询x分钟以前数据
2.取出用户最新心跳时间(可能在x分钟内再次有了心跳,所以要取最新的)
3.计算最后心跳时间和当前时间间隔,如果超过x分钟,则为离线
4.如果离线,调用 logout,并计算用户时长,将用户时长记录在 ONLINE_KEY
@Slf4j@Componentpublic 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); }}
@RestControllerpublic 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事件,还需要重新做一套心跳机制,参靠方案一,哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈