微信如何实现聊天消息实时推送,消息不丢失,多设备消息共享呢。带着这个疑问,我们尝试实现一个较为合理的消息拉取机制。
1.消息存储机制
消息存储在数据库,为了存储海量级的消息内容,肯定是需要分库分表等机制。这里我们不讨论这种级别的消息存储,只使用单表存储。
对于讨论组的每一条消息,假设讨论组有1000个人,用户发送一条消息,典型地,有两种方式来存储这条消息。
1.1.扩散读
该机制下,每条消息只存储一份数据,不同用户使用自己的消息seq来拉取数据。这种做法,最大的优势是减轻数据库压力。但缺点是,对于每个用户,不同的讨论组,用户要分别存储对应的seq。关于seq机制,后文介绍。
1.2.扩散写
该机制下,对于每条消息,每个用户都独立复制一份。这种做法,最大的优势是,统一seq。无论是讨论组还是qq群,还是点对点聊天,用户只需要保存统一的seq,就可以批量拉取所有消息。业务逻辑相对简单很多。(据说,企业微信是采用此方式)。但缺点是非常浪费存储,毕竟,1000个人,每个人发一条消息,就要存储100w条数据。
本文采用扩散读机制。
2.消息拉取机制
如何实现消息实时推送与消息不丢失呢?
2.1.消息实时推送
当有新消息到达,需要推送给用户知道。对于http客户端来说,只能使用http轮训。对于socket客户端,则可以主动推送消息。如果涉及到移动客户端,还需要借助移动设备的Push功能。
当消息到达之后,根据消息所属的频道,如果是讨论组,则目标是所有成员;如果是私聊,则目标是消息的发送方与接收方。这里为什么要加上发送方,有两个目的,一个是为了简化逻辑,不管是发送还是接收,都使用统一的拉取机制。第二个是为了保证消息已发送到服务器,而不会因为网络异常而丢失。
对于所有在线成员,推送一个新消息到达的协议。(这里不考虑移动设备的离线用户)
2.2.消息不丢失
对于在线用户,不能说因为用户在线,就认为用户一定可以收到消息,有可能是发送的时刻用户是在线的,但是消息到达用户客户端的时候,用户突然下线了。这时候,消息就丢失了。
当有新消息的时候,不使用推送的机制。而是告诉客户端有新消息了,然后客户端发送自己的消息seq,从服务器批量获取差量消息。同时,服务器返回消息列表的时候,不会自动更新客户端的seq。当客户端真正获取到信息之后,让发送确定协议,通知服务器更新seq。由于是客户端收到消息之后,才更新seq。这种机制就不会丢失消息。
当客户端收到消息之后,可以选择把消息保存到本地。这样,服务器理论上不会再把相同id的消息推送给客户端。
2.3.使用seq实现批量拉取
设想这种场景,讨论组成员噼里啪啦聊了很多东西。第二天,另外一个成员上线,如何高效批量获取获取这种消息呢?答案是使用最大seq机制。
前文说过,每一条消息都有一个递增性的消息id(可以使用数据库的自增长机制),这个id可以直接作为消息的seq。
对于群里的每个成员,都独立存在一个最大seq。当一个成员上线之后,会根据成员这个maxseq与群的消息id进行比较,所有大于成员seq的消息都是这个成员未读的,可以推送给成员。
当新成员加入讨论组,则把新成员的maxseq置为当前讨论组的最大消息的id。
3.服务器代码实现
3.1.适配各种频道
对于不同的频道,消息拉取机制都是一样的,大部分代码都是可以重用的。例如,对于私聊,讨论组,不同的只是接收方列表,最大seq的存储位置。
我们统一定义各种频道处理消息的行为接口
/**
* 消息频道处理器
*/
public interface ChatChannelHandler {
void send(Long senderId, Long target, MessageContent content);
/**
* 对消息进行持久化
* @param senderId 发送方id
* @param target 目标id,频道不同,目标id的种类也不同
* @param content 消息类型,支持各种消息格式
*/
void saveToDb(Long senderId, Long target, MessageContent content);
/**
* 消息的接收方列表
* @param senderId
* @param target
* @return
*/
Collection<Long> receivers(Long senderId, Long target);
/**
* 成员批量拉取消息
* @param receiver
* @param target
* @param maxSeq
* @return
*/
List<ChatMessage> pullMessages(Long receiver, Long target, long maxSeq);
/**
* 频道类型 {@link pers.kinson.im.common.constants.Channels}
* @return
*/
byte channelType();
}
3.1.1.私聊频道处理器
@Service
public class PersonalChannelHandler implements ChatChannelHandler {
@Autowired
MessageDao messageDao;
@Override
public void send(Long senderId, Long target, MessageContent content) {
if (SpringContext.getUserService().getUserName(target) == null) {
return;
}
saveToDb(senderId, target, content);
ResNewMessageNotify notify = new ResNewMessageNotify();
notify.setChannel(Channels.person);
notify.setTopic(target);
receivers(senderId, target).forEach(e -> SessionManager.INSTANCE.sendPacketTo(e, notify));
}
@Override
public void saveToDb(Long senderId, Long target, MessageContent content) {
Message message = new Message();
message.setChannel(channelType());
message.setType(content.getType());
message.setDate(new Date());
message.setSender(senderId);
message.setReceiver(target);
message.setContent(JsonUtil.object2String(content));
messageDao.insert(message);
}
@Override
public Collection<Long> receivers(Long senderId, Long target) {
return Arrays.asList(senderId, target);
}
@Override
public List<ChatMessage> pullMessages(Long receiver, Long target, long maxSeq) {
List<Message> newMessages = messageDao.fetchNewPersonal(receiver, maxSeq);
return newMessages.stream().map(e -> {
ChatMessage vo = new ChatMessage();
vo.setId(e.getId());
vo.setType(e.getType());
vo.setJson(e.getContent());
vo.setDate(DateUtil.format(e.getDate()));
vo.setSenderId(e.getSender());
vo.setReceiverId(e.getReceiver());
vo.setReceiverName(SpringContext.getUserService().getUserName(e.getReceiver()));
vo.setSenderName(SpringContext.getUserService().getUserName(e.getSender()));
return vo;
}).collect(Collectors.toList());
}
@Override
public byte channelType() {
return Channels.person;
}
}
3.1.2.讨论组频道处理器
@Service
public class PersonalChannelHandler implements ChatChannelHandler {
@Autowired
MessageDao messageDao;
@Override
public void send(Long senderId, Long target, MessageContent content) {
if (SpringContext.getUserService().getUserName(target) == null) {
return;
}
saveToDb(senderId, target, content);
ResNewMessageNotify notify = new ResNewMessageNotify();
notify.setChannel(Channels.person);
notify.setTopic(target);
receivers(senderId, target).forEach(e -> SessionManager.INSTANCE.sendPacketTo(e, notify));
}
@Override
public void saveToDb(Long senderId, Long target, MessageContent content) {
Message message = new Message();
message.setChannel(channelType());
message.setType(content.getType());
message.setDate(new Date());
message.setSender(senderId);
message.setReceiver(target);
message.setContent(JsonUtil.object2String(content));
messageDao.insert(message);
}
@Override
public Collection<Long> receivers(Long senderId, Long target) {
return Arrays.asList(senderId, target);
}
@Override
public List<ChatMessage> pullMessages(Long receiver, Long target, long maxSeq) {
List<Message> newMessages = messageDao.fetchNewPersonal(receiver, maxSeq);
return newMessages.stream().map(e -> {
ChatMessage vo = new ChatMessage();
vo.setId(e.getId());
vo.setType(e.getType());
vo.setJson(e.getContent());
vo.setDate(DateUtil.format(e.getDate()));
vo.setSenderId(e.getSender());
vo.setReceiverId(e.getReceiver());
vo.setReceiverName(SpringContext.getUserService().getUserName(e.getReceiver()));
vo.setSenderName(SpringContext.getUserService().getUserName(e.getSender()));
return vo;
}).collect(Collectors.toList());
}
@Override
public byte channelType() {
return Channels.person;
}
}
3.2.聊天消息接收门面
public void chatToChannel(Long sender, byte channel, long target, byte contentType, String content) {
ChatChannelHandler handler = handlers.get(channel);
MessageContent chatMessage = SpringContext.getMessageContentFactory().parse(contentType, content);
chatMessage.setType(contentType);
handler.send(sender, target, chatMessage);
}
3.3.不同频道逻辑差异
从私聊与讨论组消息的处理代码可知,最大的不同是消息拉取的逻辑。
对于讨论组而已,查找的是指定讨论组id,并且id>member.maxSeq, sql如下:
SELECT * FROM message
WHERE
channel = #{channelType}
AND receiver = #{receiver}
AND id > #{maxSeq}
对于私聊来说,由于发送方,接收方采用同样的机制。对于消息发送方来说,也是发送之后,再重新根据自己的seq来拉取已经发送的消息。sql如下:
SELECT * FROM message
WHERE
channel = 0
AND (receiver = #{receiver} OR sender = #{receiver})
AND id > #{maxSeq}
4.客户端代码实现
4.1.消息发送
客户端点击消息发送按钮之后,会把频道类型,接收方,消息类容(打包成json格式),发送到服务器
public void sendMessageTo(long friendId, MessageContent content) {
ReqChatToChannel request = new ReqChatToChannel();
request.setChannel(Constants.CHANNEL_PERSON);
request.setTarget(friendId);
request.setContent(JsonUtil.object2String(content));
request.setContentType(content.getType());
IOUtil.send(request);
}
4.2.消息拉取
服务器收到消息之后,推送一个新消息到达的消息给客户端。客户端收到之后,根据频道的类型,使用不同的maxseq作为参数(前文说的分散读存储机制),进行批量获取消息
private void notifyNewMessage(Object packet) {
ResNewMessageNotify message = (ResNewMessageNotify) packet;
ReqFetchNewMessage reqFetchNewMessage = new ReqFetchNewMessage();
// 这里先写点丑代码,后续优化
if (message.getChannel() == Constants.CHANNEL_DISCUSSION) {
DiscussionGroupVo targetDiscussionGroup = Context.discussionManager.getDiscussionGroupVo(NumberUtil.longValue(message.getTopic()));
if (targetDiscussionGroup != null) {
reqFetchNewMessage.setChannel(Constants.CHANNEL_DISCUSSION);
reqFetchNewMessage.setTopic(targetDiscussionGroup.getId());
reqFetchNewMessage.setMaxSeq(targetDiscussionGroup.getMaxSeq());
}
} else if (message.getChannel() == Constants.CHANNEL_PERSON) {
reqFetchNewMessage.setTopic(Context.userManager.getMyUserId());
reqFetchNewMessage.setMaxSeq(Context.userManager.getMyProfile().getChatMaxSeq());
}
IOUtil.send(reqFetchNewMessage);
}
4.3.消息seq确认
客户端收到消息之后,对消息进行展示(同时将消息保存在本地客户端)。
此时,再发送消息跟服务器进行确认,如此,就不会发生消息丢失的情况了。
private void refreshNewMessage(Object packet) {
ResNewMessage message = (ResNewMessage) packet;
if (message.getMessages() == null || message.getMessages().isEmpty()) {
return;
}
long maxSeq = 0;
for (ChatMessage e : message.getMessages()) {
e.setContent(Context.messageContentFactory.parse(e.getType(), e.getJson()));
maxSeq = Math.max(maxSeq, e.getId());
}
ReqMarkNewMessage reqMarkNewMessage = new ReqMarkNewMessage();
reqMarkNewMessage.setChannel(message.getChannel());
reqMarkNewMessage.setMaxSeq(maxSeq);
// 根据消息来源进行分发
if (message.getChannel() == Constants.CHANNEL_DISCUSSION) {
long discussionId = message.getMessages().get(0).getReceiverId();
reqMarkNewMessage.setTopic(discussionId);
Context.discussionManager.receiveDiscussionMessages(maxSeq, message.getMessages());
} else if (message.getChannel() == Constants.CHANNEL_PERSON) {
Context.userManager.getMyProfile().setChatMaxSeq(maxSeq);
Context.chatManager.receiveFriendPrivateMessage(message.getMessages());
}
// 收到消息之后再通知服务器,保证不丢消息
IOUtil.send(reqMarkNewMessage);
}
4.4.多端同步机制
对于支持多端登录的聊天软件,例如桌面版,手机版,网页版,可以对每个频道保存自己的maxseq,以实现各个频道消息同步接收不丢失。
全部代码已在github上托管
服务端代码请移步 --> 聊天室服务器
客户端代码请移步 --> 聊天室客户端