统计用户在线时长(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事件,还需要重新做一套心跳机制,参靠方案一,哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈
声明
本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。