基于Redis的Redisson分布式延迟队列(Delayed Queue)结构的 RDelayedQueue。 Java对象在实现了RQueue接口的基础上提供了向队列按要求延迟添加项目的功能。该功能可以用来实现消息传送延迟按几何增长或几何衰减的发送策略。
常用的使用场景:订单的支付超时关闭、订单签收超x天自动好评、商家超时未接单自动取消等
redisson里一共有消息延时队列、消息顺序队列、消息目标队列三个队列,当我们发送消息时消息先发送到了延时与顺序队列中,而我们消费端监听的是目标队列。当延时时间到期后,消息会从延时与顺序队列中取出放入目标队列中,由于我们一直在阻塞监听消费目标队列,这时我们立马就会消费这个刚放进来的消息,达到延时消费的效果。
pom依赖
<dependency>
<groupId>org.redissongroupId>
<artifactId>redissonartifactId>
<version>3.7.4version>
dependency>
conf配置
/**
* @author: tanghaizhi
* @CreateTime: 2022/11/3 10:17
* @Description:
*/
@Configuration
public class RedissonConfig {
@Autowired
private RedisConfigProperties redisConfigProperties;
@Bean
public RedissonClient redissonClient() {
//redisson版本是3.5,集群的ip前面要加上“redis://”,不然会报错,3.2版本可不加
List<String> clusterNodes = new ArrayList<>();
for (int i = 0; i < redisConfigProperties.getCluster().getNodes().size(); i++) {
clusterNodes.add("redis://" + redisConfigProperties.getCluster().getNodes().get(i));
}
Config config = new Config();
if (clusterNodes.size() == 1){
// 单机
SingleServerConfig singleServerConfig = config.useSingleServer()
.setAddress(clusterNodes.get(0));
singleServerConfig.setPassword(redisConfigProperties.getPassword());
} else if(clusterNodes.size() > 1){
// 添加集群地址
ClusterServersConfig clusterServersConfig = config.useClusterServers().
addNodeAddress(clusterNodes.toArray(new String[clusterNodes.size()]));
// 设置密码
clusterServersConfig.setPassword(redisConfigProperties.getPassword());
}
RedissonClient redissonClient = Redisson.create(config);
return redissonClient;
}
}
/**
* @author: tanghaizhi
* @CreateTime: 2022/11/3 10:45
* @Description:
*/
@Component
@ConfigurationProperties(prefix = "spring.redis")
public class RedisConfigProperties {
private String password;
private cluster cluster;
public static class cluster {
private List<String> nodes;
public List<String> getNodes() {
return nodes;
}
public void setNodes(List<String> nodes) {
this.nodes = nodes;
}
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public RedisConfigProperties.cluster getCluster() {
return cluster;
}
public void setCluster(RedisConfigProperties.cluster cluster) {
this.cluster = cluster;
}
}
yaml配置
spring:
redis:
timeout: 6000
database: 0
password: 123456
cluster:
nodes:
- 172.31.134.6:7001
- 172.31.134.6:7002
- 172.31.134.6:7003
max-redirects: 3
jedis:
pool:
max-active: 1024
max-wait: 1000
max-idle: 200
min-idle: 10
@Autowired
private RedissonClient redissonClient;
public void sendMessage(String message){
RBlockingQueue<Object> blockingDeque = redissonClient.getBlockingQueue("heartbeatQueue");
RDelayedQueue<Object> delayedQueue = redissonClient.getDelayedQueue(blockingDeque);
//如果延时队列中原先存在这条消息,remove可以删除延时队列中的这条消息
//如果多次发送相同的消息,先remove再offer,只有最后一条会被延时消费,延时时间以最后一条的发送时间开始延时
delayedQueue.remove(message);
delayedQueue.offer(message, 60L, TimeUnit.SECONDS);
}
/**
* @author: tanghaizhi
* @CreateTime: 2022/11/3 11:50
* @Description:
*/
@Component
@Slf4j
public class RedisDelayHandle {
@Autowired
private RedissonClient redissonClient;
@PostConstruct
public void listener() {
new Thread(()->{
while (true){
RBlockingQueue<String> blockingDeque = redissonClient.getBlockingQueue(WebSocketServer.heartbeatQueue);
// delayedQueue 是没有用到的,那么为什么要加这一行呢
//首先再啰嗦一句,初始化延时队列的作用是会定时去把【消息延时队列】里的到期数据移动到【消息目标队列】
//如果只有发送方初始化延时队列:
// 1.发送方发送了延迟消息,在到期之前下线了(它就不能把【消息延时队列】里的到期数据移动到【消息目标队列】),而且没有其他发送方。
// 2.接收方不管有多少个,都没人能把【消息延时队列】里的到期数据移动到【消息目标队列】
//所以接收方代码里也初始化延时队列能够避免一部分数据丢失问题。
RDelayedQueue<String> delayedQueue = redissonClient.getDelayedQueue(blockingDeque);
String msg = null;
try {
msg = blockingDeque.take();
//你的消费逻辑
WebSocketServer.heartbeatHandle(msg);
} catch (Exception e) {
log.error("延时心跳消息消费失败[{}],报错信息:[{}]",msg,e.getMessage());
e.printStackTrace();
}
}
}).start();
}
}
我的大致思路是这样的,当ws链接建立之后由客服端没10s向服务端发送一次心跳消息。当服务端接收到心跳消息后发送一个60s的延时消息,延时消息到期消费的业务逻辑就是断开这个ws链接。
由于延时消息为60s而心跳消息10s一个,在每次接收到心跳消息后,先将上一次的延时消息remove掉,再发送新的延时消息,这样如果60s内没有收到一次心跳消息,则认为客户端已经下线了,我们断开连接。
/**
* @author: tanghaizhi
* @CreateTime: 2022/10/31 10:29
* @Description:
*/
@Component
@ServerEndpoint(value = "/websocket/{client}}")
@Slf4j
public class WebSocketServer {
@Autowired
private RedissonClient redissonClient;
public static final String heartbeatQueue = "SMDS_WEBSOCKET_HEARTBEAT";
//concurrent包的线程安全Set,用来存放每个客户端对应的MyWebSocket对象。
// private static CopyOnWriteArraySet webSocketSet = new CopyOnWriteArraySet();
private static Map<String,WebSocketServer> webSocketMap = new ConcurrentHashMap();
//与某个客户端的连接会话,需要通过它来给客户端发送数据
private Session session;
/**
* 建立连接时调用
* @param session
* @param config
*/
@OnOpen
public void onOpen(Session session, EndpointConfig config){
log.info(session + "建立了连接");
this.session = session;
//ws://127.0.0.1:8099/websocket/ws-03
String uri = session.getRequestURI().toString();
String client = uri.substring(uri.lastIndexOf("/")+1);
webSocketMap.put(client,this);
//心跳消息
ReceiveMsgModel receiveMsg = new ReceiveMsgModel();
receiveMsg.setTaskId(client);
receiveMsg.setType("heartbeat");
receiveMsg.setData("建立连接时服务端创建心跳");
String message = JSON.toJSONString(receiveMsg);
RBlockingQueue<Object> blockingDeque = redissonClient.getBlockingQueue(heartbeatQueue);
RDelayedQueue<Object> delayedQueue = redissonClient.getDelayedQueue(blockingDeque);
delayedQueue.remove(message);
delayedQueue.offer(message, 60L, TimeUnit.SECONDS);
}
/**
* 断开连接时调用
* @param session
*/
@OnClose
public void onClose(Session session) {
log.info(session + "断开了连接");
String uri = session.getRequestURI().toString();
String client = uri.substring(uri.lastIndexOf("/")+1);
if(webSocketMap.containsKey(client)){
webSocketMap.remove(client);
}
}
/**
* 消息到达时调用
*
* @param
* @param message
* @param session
*/
@OnMessage
public void onMessage(String message, Session session){
log.debug("websocket收到消息:{}",message);
try{
if(StringUtils.isNotBlank(message)){
ReceiveMsgModel receiveMsg = JSON.parseObject(message,ReceiveMsgModel.class);
if("heartbeat".equals(receiveMsg.getType())){
//心跳消息处理
RBlockingQueue<Object> blockingDeque = redissonClient.getBlockingQueue(heartbeatQueue);
RDelayedQueue<Object> delayedQueue = redissonClient.getDelayedQueue(blockingDeque);
delayedQueue.remove(message);
delayedQueue.offer(message, 60L, TimeUnit.SECONDS);
} else {
log.error("websocket未知的消息类型:{}",message);
}
}
} catch (Exception e){
e.printStackTrace();
log.error("websocket消息处理失败:{}",message);
}
}
/**
* 发生错误时调用
*
* @param session
* @param throwable
*/
@OnError
public void onError(Session session, Throwable throwable) {
log.error("ws发生错误:[{}]",throwable.getLocalizedMessage());
}
public void sendMessage(String message) throws IOException {
this.session.getBasicRemote().sendText(message);
}
/**
* 群发自定义消息
*/
public static void sendInfo(String message) throws Exception {
for (Map.Entry<String, WebSocketServer> entry : webSocketMap.entrySet()) {
String key = entry.getKey();
try {
webSocketMap.get(key).sendMessage(message);
} catch (Exception e) {
log.error("群发自定义消息[{}]错误:[{}]",key,e.getMessage());
e.printStackTrace();
continue;
}
}
}
/**
* 向指定用户发送自定义消息
*/
public static void sendInfo(String message,String client) throws Exception {
if(webSocketMap.containsKey(client)){
webSocketMap.get(client).sendMessage(message);
}else {
log.info("未找到ws:[{}]消息发送失败",client);
}
}
/**
* 心跳延时消息消费,关闭链接
*/
public static void heartbeatHandle(String message) {
ReceiveMsgModel receiveMsg = JSON.parseObject(message,ReceiveMsgModel.class);
String taskId = receiveMsg.getTaskId();
if(StringUtils.isNotBlank(taskId)){
WebSocketServer client = webSocketMap.get(taskId);
if(client != null){
client.onClose(client.session);
}
}
}
}
延时消息的消费与延时消息使用示例中的相同
参考浅析 Redisson 的分布式延时队列 RedissonDelayedQueue 运行流程