1. 引入依赖
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- 如果你需要前端页面,可以加一个 thymeleaf 方便演示(非必须) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 连接池依赖(提升Redis性能,可选但推荐) -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
2. 配置文件
yaml
spring:
data:
redis:
host: localhost
port: 6379
password:
# 若无密码则不填以下配置
database: 0
lettuce:
pool:
max-active: 8
max-idle: 8
max-wait: 1000ms
min-idle: 0
3. Redis 配置类
java
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// 配置支持 LocalDateTime 的 ObjectMapper
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
// 可选:允许序列化对象时包含类型信息(方便反序列化)
objectMapper.activateDefaultTyping(objectMapper.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.NON_FINAL);
GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(objectMapper);
// key 使用 String 序列化
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
// value 使用自定义的 JSON 序列化器
template.setValueSerializer(serializer);
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
}
4. 消息实体
java
@Data
public class Message implements Serializable {
private static final long serialVersionUID = 1L;
private String fromUserId;
private String toUserId;
private String content;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime timestamp;
private Boolean offline; // 是否为离线消息
}
5. 消息服务
java
@Slf4j
@Service
@RequiredArgsConstructor
public class OfflineMessageService {
private final RedisTemplate<String, Object> redisTemplate;
// Redis key 前缀
private static final String OFFLINE_KEY_PREFIX = "offline:msg:";
// 离线消息保留 7 天
private static final Duration MESSAGE_TTL = Duration.ofDays(7);
/**
* 保存离线消息(存到 Redis List 中)
*/
public void saveOfflineMessage(String userId, Message message) {
message.setOffline(true);
String key = OFFLINE_KEY_PREFIX + userId;
// 右侧推入
redisTemplate.opsForList().rightPush(key, message);
// 设置过期时间(如果 key 已存在会刷新 TTL)
redisTemplate.expire(key, MESSAGE_TTL);
log.info("已保存用户 {} 的离线消息: {}", userId, message.getContent());
}
/**
* 获取并删除该用户的所有离线消息(原子操作)
* 注意:range + delete 并非原子,但能满足大多数场景。如需严格原子可改用 Lua 脚本。
*/
@SuppressWarnings("unchecked")
public List<Message> getAndClearOfflineMessages(String userId) {
String key = OFFLINE_KEY_PREFIX + userId;
List<Message> messages = new ArrayList<>();
// 获取所有消息
List<Object> rawList = redisTemplate.opsForList().range(key, 0, -1);
if (rawList != null && !rawList.isEmpty()) {
for (Object obj : rawList) {
if (obj instanceof Message) {
messages.add((Message) obj);
}
}
}
// 删除 key
if (!messages.isEmpty()) {
redisTemplate.delete(key);
log.info("已拉取并删除用户 {} 的 {} 条离线消息", userId, messages.size());
}
return messages;
}
/**
* 查询离线消息数量(可选,用于前端小红点)
*/
public Long getOfflineMessageCount(String userId) {
String key = OFFLINE_KEY_PREFIX + userId;
Long size = redisTemplate.opsForList().size(key);
return size != null ? size : 0L;
}
}
6. WebSocket 核心处理器
java
@Slf4j
@Component
@RequiredArgsConstructor
public class MyWebSocketHandler extends TextWebSocketHandler {
private final OfflineMessageService offlineMessageService;
private final ObjectMapper objectMapper;
// 在线用户会话池(分布式环境下每个实例存自己的部分,但离线消息通过 Redis 共享)
private final ConcurrentHashMap<String, WebSocketSession> sessionMap = new ConcurrentHashMap<>();
/**
* 连接建立后:记录会话 + 立即推送离线消息
*/
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
String userId = getUserIdFromSession(session);
sessionMap.put(userId, session);
log.info("用户 {} 上线,当前实例在线人数: {}", userId, sessionMap.size());
// 关键:从 Redis 拉取离线消息并推送
List<Message> offlineMessages = offlineMessageService.getAndClearOfflineMessages(userId);
if (!offlineMessages.isEmpty()) {
for (Message msg : offlineMessages) {
String json = objectMapper.writeValueAsString(msg);
session.sendMessage(new TextMessage(json));
log.info("推送离线消息给 {}: {}", userId, msg.getContent());
}
}
}
/**
* 处理客户端发送的消息:转发或存离线
*/
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String fromUserId = getUserIdFromSession(session);
String payload = message.getPayload();
Message msg = objectMapper.readValue(payload, Message.class);
msg.setFromUserId(fromUserId);
msg.setTimestamp(LocalDateTime.now());
String toUserId = msg.getToUserId();
WebSocketSession toSession = sessionMap.get(toUserId);
if (toSession != null && toSession.isOpen()) {
// 目标用户在线,实时推送
toSession.sendMessage(new TextMessage(objectMapper.writeValueAsString(msg)));
log.info("实时消息 {} -> {}: {}", fromUserId, toUserId, msg.getContent());
} else {
// 目标用户离线,存入 Redis
offlineMessageService.saveOfflineMessage(toUserId, msg);
log.info("用户 {} 离线,消息已存入 Redis", toUserId);
}
}
/**
* 连接关闭:移除本地会话
*/
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
String userId = getUserIdFromSession(session);
sessionMap.remove(userId);
log.info("用户 {} 下线,当前实例在线人数: {}", userId, sessionMap.size());
}
/**
* 从 URL 路径中提取 userId(例如 ws://localhost:8080/ws/1001)
*/
private String getUserIdFromSession(WebSocketSession session) {
String path = session.getUri().getPath();
return path.substring(path.lastIndexOf('/') + 1);
}
}
7. WebSocketConfig配置类
java
@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {
private final MyWebSocketHandler myWebSocketHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(myWebSocketHandler, "/ws/{userId}")
.setAllowedOriginPatterns("*");
}
}
8. 前端页面
可直接复制到 src/main/resources/templates/demo.html
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebSocket 离线消息 Demo</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: #f0f2f5;
margin: 0;
padding: 20px;
}
.container {
max-width: 500px;
margin: 0 auto;
background: white;
border-radius: 16px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: #2c3e50;
color: white;
padding: 16px 20px;
font-size: 18px;
font-weight: 600;
display: flex;
justify-content: space-between;
align-items: center;
}
.status {
font-size: 12px;
background: #e67e22;
padding: 4px 8px;
border-radius: 20px;
}
.status.connected {
background: #27ae60;
}
.user-info {
padding: 12px 20px;
background: #ecf0f1;
display: flex;
gap: 12px;
align-items: center;
border-bottom: 1px solid #ddd;
}
.user-info label {
font-weight: 600;
}
.user-info input {
flex: 1;
padding: 8px;
border: 1px solid #ccc;
border-radius: 8px;
}
.user-info button {
background: #3498db;
color: white;
border: none;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
}
.chat-panel {
display: flex;
flex-direction: column;
height: 500px;
}
.message-list {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.message {
max-width: 80%;
padding: 8px 12px;
border-radius: 12px;
font-size: 14px;
word-wrap: break-word;
}
.message.sent {
background: #3498db;
color: white;
align-self: flex-end;
border-bottom-right-radius: 4px;
}
.message.received {
background: #e9ecef;
color: #2c3e50;
align-self: flex-start;
border-bottom-left-radius: 4px;
}
.message.offline-tag {
font-size: 10px;
opacity: 0.7;
margin-top: 4px;
}
.input-area {
display: flex;
padding: 12px;
border-top: 1px solid #ddd;
background: white;
gap: 8px;
}
.input-area input {
flex: 1;
padding: 10px;
border: 1px solid #ccc;
border-radius: 24px;
outline: none;
}
.input-area button {
background: #2ecc71;
border: none;
padding: 0 20px;
border-radius: 24px;
color: white;
font-weight: bold;
cursor: pointer;
}
.info {
font-size: 12px;
color: #7f8c8d;
text-align: center;
padding: 8px;
border-top: 1px solid #eee;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<span>💬 离线消息 Demo</span>
<span class="status" id="statusText">未连接</span>
</div>
<div class="user-info">
<label>我的ID:</label>
<input type="text" id="myUserId" placeholder="例如 1001" value="1001">
<button id="connectBtn">连接/重连</button>
</div>
<div class="chat-panel">
<div class="message-list" id="messageList">
<div class="info">👈 连接后即可收发消息</div>
</div>
<div class="input-area">
<input type="text" id="targetUserId" placeholder="对方ID" value="1002">
<input type="text" id="messageInput" placeholder="输入消息..." onkeypress="if(event.key==='Enter') sendMessage()">
<button id="sendBtn" disabled>发送</button>
</div>
</div>
<div class="info">
💡 提示:关闭页面 = 离线,再次打开自动拉取离线消息
</div>
</div>
<script>
let ws = null;
let currentUserId = null;
const messageList = document.getElementById('messageList');
const statusSpan = document.getElementById('statusText');
const sendBtn = document.getElementById('sendBtn');
const connectBtn = document.getElementById('connectBtn');
const myUserIdInput = document.getElementById('myUserId');
const targetUserIdInput = document.getElementById('targetUserId');
const messageInput = document.getElementById('messageInput');
// 添加一条消息到界面
function appendMessage(msg, isSent, isOffline = false) {
const msgDiv = document.createElement('div');
msgDiv.className = `message ${isSent ? 'sent' : 'received'}`;
let content = `${msg.content}`;
if (msg.fromUserId) {
content = `[${msg.fromUserId}] ${content}`;
}
msgDiv.innerText = content;
if (isOffline) {
const tag = document.createElement('div');
tag.className = 'offline-tag';
tag.innerText = '📦 离线消息';
msgDiv.appendChild(tag);
}
// 添加时间(可选)
if (msg.timestamp) {
const time = new Date(msg.timestamp).toLocaleTimeString();
const timeSpan = document.createElement('div');
timeSpan.style.fontSize = '10px';
timeSpan.style.opacity = '0.6';
timeSpan.innerText = time;
msgDiv.appendChild(timeSpan);
}
messageList.appendChild(msgDiv);
messageList.scrollTop = messageList.scrollHeight;
}
// 更新连接状态UI
function setConnected(connected) {
if (connected) {
statusSpan.innerText = '已连接';
statusSpan.classList.add('connected');
sendBtn.disabled = false;
connectBtn.innerText = '断开连接';
} else {
statusSpan.innerText = '未连接';
statusSpan.classList.remove('connected');
sendBtn.disabled = true;
connectBtn.innerText = '连接';
}
}
// 建立 WebSocket 连接
function connect() {
const userId = myUserIdInput.value.trim();
if (!userId) {
alert('请输入用户ID');
return;
}
// 关闭旧连接
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close();
}
currentUserId = userId;
const wsUrl = `ws://localhost:8080/ws/${userId}`;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket 已连接');
setConnected(true);
// 清空消息列表(可选,也可以保留历史)
// messageList.innerHTML = '<div class="info">✅ 连接成功,开始聊天~</div>';
appendMessage({ content: '✅ 连接成功,离线消息会自动拉取', fromUserId: '系统' }, false);
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('收到消息:', data);
// 判断是否是离线消息(后端已标记 offline 字段)
const isOffline = data.offline === true;
appendMessage(data, false, isOffline);
};
ws.onerror = (error) => {
console.error('WebSocket 错误:', error);
appendMessage({ content: '❌ 连接出错,请检查后端是否启动', fromUserId: '系统' }, false);
};
ws.onclose = () => {
console.log('WebSocket 已关闭');
setConnected(false);
};
}
// 断开连接
function disconnect() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close();
}
}
// 发送消息
function sendMessage() {
if (!ws || ws.readyState !== WebSocket.OPEN) {
alert('未连接到服务器,请先连接');
return;
}
const toUserId = targetUserIdInput.value.trim();
const content = messageInput.value.trim();
if (!toUserId || !content) {
alert('请填写对方ID和消息内容');
return;
}
const msg = {
toUserId: toUserId,
content: content
};
ws.send(JSON.stringify(msg));
// 在本地立即显示"我"发送的消息(乐观更新)
const displayMsg = {
content: content,
fromUserId: currentUserId,
timestamp: new Date().toISOString()
};
appendMessage(displayMsg, true, false);
messageInput.value = '';
}
// 绑定按钮事件
connectBtn.onclick = () => {
if (ws && ws.readyState === WebSocket.OPEN) {
disconnect();
} else {
connect();
}
};
sendBtn.onclick = sendMessage;
// 页面关闭前主动断开连接(可选)
window.addEventListener('beforeunload', () => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close();
}
});
</script>
</body>
</html>
9. 涉及问题
-
为什么离线消息要用 Redis 的 List 结构?
- List 的rpush和lrange天然适合消息队列场景,按顺序存储,用户上线时一次性取出。而且可以用ltrim限制长度,避免单用户消息过多。
-
Redis 存储离线消息,如果用户永远不上线怎么办
- 设置 TTL(上面代码设置了 7 天),过期自动删除。同时可以配合定时任务,扫描offline:msg:*前缀的 key,对超过 30 天未登录的用户进行归档或清理。
-
分布式部署下,用户 A 连接实例 1,用户 B 连接实例 2,B 发送消息给 A 时,实例 2 的sessionMap中没有 A,会错误地存为离线消息。怎么解决?
- **方案一:**使用 Redis Pub/Sub,每个实例订阅一个频道,当收到跨实例消息时,检查本地sessionMap,若有则推送。
- **方案二:**使用 Spring 的@EnableWebSocketMessageBroker+ Redis 作为消息代理(Broker),这样所有会话和消息路由都由 Spring 处理。
- **方案三:**通过一致性哈希,确保同一个用户的连接总是落到同一个实例(需要反向代理支持)。
-
WebSocket 心跳如何结合 Redis 做?
- 在客户端定时发送 ping,服务端更新 Redis 中该用户的"最后活跃时间",并设置一个过期时间。若某个用户超过 N 秒未 ping,则认为离线,强制关闭会话。