IM系统中如何保证消息的可靠投递(即QoS机制)附核心代码

本文章前面部分讲解是转载(https://www.cnblogs.com/firstdream/p/6586815.html),后面是自己实现的代码:

消息的可靠性,即消息的不丢失和不重复,是im系统中的一个难点。当初qq在技术上(当时叫oicq)因为以下两点原因才打败了icq:
1)qq的消息投递可靠(消息不丢失,不重复)
2)qq的垃圾消息少(它antispam做得好,这也是一个难点,但不是本文重点讨论的内容)
今天,本文将用十分通俗的语言,来讲述webim系统中消息可靠性的问题。

一、报文类型
im的客户端与服务器通过发送报文(也就是请求包)来完成消息的传递,报文分为三种,请求报文(request,后简称为为R),应答报文(acknowledge,后简称为A),通知报文(notify,后简称为N),这三种报文的解释如下:

R:客户端主动发送给服务器的报文
A:服务器被动应答客户端的报文,一个A一定对应一个R
N:服务器主动发送给客户端的报文

二、普通消息投递流程
用户A给用户B发送一个“你好”,很容易想到,流程如下:

1)client-A向im-server发送一个消息请求包,即msg:R
2)im-server在成功处理后,回复client-A一个消息响应包,即msg:A
3)如果此时client-B在线,则im-server主动向client-B发送一个消息通知包,即msg:N(当然,如果client-B不在线,则消息会存储离线)

三、上述消息投递流程出现的问题
从流程图中容易看到,发送方client-A收到msg:A后,只能说明im-server成功接收到了消息,并不能说明client-B接收到了消息。在若干场景下,可能出现msg:N包丢失,且发送方client-A完全不知道,例如:
1)服务器崩溃,msg:N包未发出
2)网络抖动,msg:N包被网络设备丢弃
3)client-B崩溃,msg:N包未接收
结论是悲观的:接收方client-B是否有收到msg:N,发送方client-A完全不可控,那怎么办呢?

四、应用层确认+im消息可靠投递的六个报文
upd是一种不可靠的传输层协议,tcp是一种可靠的传输层协议,tcp是如何做到可靠的?答案是:超时、重传、确认。
要想实现应用层的消息可靠投递,必须加入应用层的确认机制,即:要想让发送方client-A确保接收方client-B收到了消息,必须让接收方client-B给一个消息的确认,这个应用层的确认的流程,与消息的发送流程类似:

4)client-B向im-server发送一个ack请求包,即ack:R
5)im-server在成功处理后,回复client-B一个ack响应包,即ack:A
6)则im-server主动向client-A发送一个ack通知包,即ack:N
至此,发送“你好”的client-A,在收到了ack:N报文后,才能确认client-B真正接收到了“你好”。
会发现,一条消息的发送,分别包含(上)(下)两个半场,即msg的R/A/N三个报文,ack的R/A/N三个报文,一个应用层即时通讯消息的可靠投递,共涉及6个报文,这就是im系统中消息投递的最核心技术(如果某个im系统不包含这6个报文,不要谈什么消息的可靠性)。

五、可靠消息投递存在什么问题
期望六个报文完成消息的可靠投递,但实际情况下:
1)msg:R,msg:A报文可能丢失,此时直接提示“发送失败”即可,问题不大
2)msg:N,ack:R,ack:A,ack:N这四个报文都可能丢失(原因如第二章所述,可能是服务器奔溃、网络抖动、或者客户端奔溃),此时client-A都收不到期待的ack:N报文,即client-A不能确认client-B是否收到“你好”,那怎么办呢?

六、消息的超时与重传
client-A发出了msg:R,收到了msg:A之后,在一个期待的时间内,如果没有收到ack:N,client-A会尝试将msg:R重发。可能client-A同时发出了很多消息,故client-A需要在本地维护一个等待ack队列,并配合timer超时机制,来记录哪些消息没有收到ack:N,以定时重发。

一旦收到了ack:N,说明client-B收到了“你好”消息,对应的消息将从“等待ack队列”中移除。

七、消息的重传存在什么问题
第五章提到过,msg:N报文,ack:N报文都有可能丢失:
1)msg:N报文丢失,说明client-B之前压根没有收到“你好”报文,超时与重传机制十分有效
2)ack:N报文丢失,说明client-B之前已经收到了“你好”报文(只是client-A不知道而已),超时与重传机制将导致client-B收到重复的消息,那怎么办呢?
启示:
平时使用qq,或许大伙都有类似的体验,弹出一个对话框“因为网络原因,消息发送失败,是否要重发”,此时,有可能是对方没有收到消息(发送方网络不好,msg:N丢失),也可能已经收到了消息(接收方网络不好,反复重传后,ack:N依然丢失),出现这个提示时,大伙不妨和对端确认一下,看是哪种情况。

八、消息的去重
解决方法也很简单,由发送方client-A生成一个消息去重的msgid,保存在“等待ack队列”里,同一条消息使用相同的msgid来重传,供client-B去重,而不影响用户体验。

九、其他
1)上述设计理念,由客户端重传,可以保证服务端无状态性(架构设计基本准则)
2)如果client-B不在线,im-server保存了离线消息后,要伪造ack:N发送给client-A
3)离线消息的拉取,为了保证消息的可靠性,也需要有ack机制,但由于拉取离线消息不存在N报文,故实际情况要简单的多,即先发送offline:R报文拉取消息,收到offline:A后,再发送offlineack:R删除离线消息

十、总结
1)im系统是通过超时、重传、确认、去重的机制来保证消息的可靠投递,不丢不重
2)切记,一个“你好”的发送,包含上半场msg:R/A/N与下半场ack:R/A/N的6个报文

个人消息是一个1对1的ack,群消息就没有这么简单了,群消息存在一个扩散系数,如果大家感兴趣,下一次将和大家讨论im群消息的可靠投递。

十一,对消息机制的改进(转自网络)

 IM消息送达保证机制实现的前提就是需要对方反馈消息应答ACK包。那么可以不需要应答吗?
 答案是——不可能:不管是TCP还是UDP理论上都需要应答,因为真正生产环境下,消息的处理并不是一台机器或一个IM实例内的事,它的中转流程可能包括消息队列、转发缓存、离线处理等等,为了高性能,很多环境都是异常实现,即使是TCP传输也不可能同步取得发送结果,所以应答是必然。

主流的移动端IM都有这个机制,逃不掉,不知道有人用过网易的易信没有,易信就更明显,因为它的应答机制里在UI上的表现就是可以显示对方是什么时间读的消息。不信可以去下载一个试试。

 既然需要发送应答包的话,就肯定得产生额外消耗,在用户量大的时候,消耗体现在:IM服务端带宽、IM服务端计算资源上。有办法减小消耗吗?
 答案是——肯定有:有些人说实现一个消息送达保证机制,一共需要6个包,有些人说是4个包,当然是能越少越好,具体能几个这要依赖于具体的算法实现。举个例子:MobileIMSDK 中的送保证算法逻辑基本都是在客户端实现(这样既可以减轻服务端的复杂度、也能减低负载、也利于以后的性能优化),MobileIMSDK中使用了4个包:A->S S->B B->S S->A(准确地说是两个包的两次转发,一个是IM消息包一个是IM应答包)。

 4个包还有办法降低吗?
 答案是有办法——可以合并应答:如果单独从IM的技术实现细节来看,一送一答,这2个来回共4次(或说4个包的转发),是少不了的。没错,对于每一个包的应答来说是少不了的,但应答包并不一定要每个IM消息包对应一个应答包,我们可以改进IM客户端算法,使其在某一段时间内对应答进行合并(比如100条消息共用同一个应答包(只是这个应答包里包含的应答id是多个而非之前的一个),对于很多IM消息包而言,这应答开销就差不多省下来了,对它而言总共就只用了2个包),这样消息收发峰值时应答包就能降下来,进而降低网络和服务器资源压力。

1:如下为消息保送截止的核心代码:

/**
 * QoS机制中提供消息送达质量保证的守护线程。
 * 本类是QoS机制的核心,极端情况下将弥补因UDP协议天生的不可靠性而带来的 丢包情况。
 * 当前MobileIMSDK的QoS机制支持全部的C2C、C2S、S2C共3种消息交互场景下的 消息送达质量保证.
 * 本线程的启停,目前属于本框架算法的一部分,暂时无需也不建议由应用层自行调用。
 * Created by AndyYuan on time at 2019/9/25.
 */

public class QoS4SendDaemon {
    private static final String TAG = QoS4SendDaemon.class.getSimpleName();
    private static QoS4SendDaemon instance = null;
    public static final int CHECH_INTERVAL = 5000;
    public static final int MESSAGES_JUST$NOW_TIME = 3000;
    public static final int QOS_TRY_COUNT = 2;
    //出现 //java.lang.NoSuchMethodError: java.util.concurrent.ConcurrentHashMap.keySet()Ljava/util/concurrent/ConcurrentHashMap$KeySetView
    //报错的时候可以修改为ConcurrentMap类型
    private ConcurrentMap<String, Protocal> sentMessages = new ConcurrentHashMap();
    private ConcurrentMap<String, Long> sendMessagesTimestamp = new ConcurrentHashMap();
    private Handler handler = null;
    private Runnable runnable = null;
    private boolean running = false;
    private boolean _excuting = false;
    private Context context = null;

    public static QoS4SendDaemon getInstance(Context context) {
        if (instance == null) {
            instance = new QoS4SendDaemon(context);
        }

        return instance;
    }

    private QoS4SendDaemon(Context context) {
        this.context = context;
        this.init();
    }

    private void init() {
        this.handler = new Handler();
        this.runnable = new Runnable() {
            public void run() {
                if (!QoS4SendDaemon.this._excuting) {
                    (new AsyncTask<Object, Integer, ArrayList<Protocal>>() {
                        private ArrayList<Protocal> lostMessages = new ArrayList();

                        protected ArrayList<Protocal> doInBackground(Object... params) {
                            QoS4SendDaemon.this._excuting = true;

                            try {
                                if (ClientCoreSDK.DEBUG) {
                                    Log.d(QoS4SendDaemon.TAG, "【IMCORE】【QoS】=========== 消息发送质量保证线程运行中, 当前需要处理的列表长度为" + QoS4SendDaemon.this.sentMessages.size() + "...");
                                }

                                Iterator var3 = QoS4SendDaemon.this.sentMessages.keySet().iterator();

                                while (true) {
                                    while (var3.hasNext()) {
                                        String key = (String) var3.next();
                                        Protocal p = (Protocal) QoS4SendDaemon.this.sentMessages.get(key);
                                        if (p != null && p.isQoS()) {
                                            if (p.getRetryCount() >= 2) {
                                                if (ClientCoreSDK.DEBUG) {
                                                    Log.d(QoS4SendDaemon.TAG, "【IMCORE】【QoS】指纹为" + p.getFp() + "的消息包重传次数已达" + p.getRetryCount() + "(最多" + 2 + "次)上限,将判定为丢包!");
                                                }

                                                this.lostMessages.add((Protocal) p.clone());
                                                QoS4SendDaemon.this.remove(p.getFp());
                                            } else {
                                                long delta = System.currentTimeMillis() - ((Long) QoS4SendDaemon.this.sendMessagesTimestamp.get(key)).longValue();
                                                if (delta <= 3000L) {
                                                    if (ClientCoreSDK.DEBUG) {
                                                        Log.w(QoS4SendDaemon.TAG, "【IMCORE】【QoS】指纹为" + key + "的包距\"刚刚\"发出才" + delta + "ms(<=" + 3000 + "ms将被认定是\"刚刚\"), 本次不需要重传哦.");
                                                    }
                                                } else {
                                                    (new LocalUDPDataSender.SendCommonDataAsync(QoS4SendDaemon.this.context, p) {
                                                        protected void onPostExecute(Integer code) {
                                                            if (code.intValue() == 0) {
                                                                this.p.increaseRetryCount();
                                                                if (ClientCoreSDK.DEBUG) {
                                                                    Log.d(QoS4SendDaemon.TAG, "【IMCORE】【QoS】指纹为" + this.p.getFp() + "的消息包已成功进行重传,此次之后重传次数已达" + this.p.getRetryCount() + "(最多" + 2 + "次).");
                                                                }
                                                            } else {
                                                                Log.w(QoS4SendDaemon.TAG, "【IMCORE】【QoS】指纹为" + this.p.getFp() + "的消息包重传失败,它的重传次数之前已累计为" + this.p.getRetryCount() + "(最多" + 2 + "次).");
                                                            }

                                                        }
                                                    }).execute(new Object[0]);
                                                }
                                            }
                                        } else {
                                            QoS4SendDaemon.this.remove(key);
                                        }
                                    }

                                    return this.lostMessages;
                                }
                            } catch (Exception var7) {
                                Log.w(QoS4SendDaemon.TAG, "【IMCORE】【QoS】消息发送质量保证线程运行时发生异常," + var7.getMessage(), var7);
                                return this.lostMessages;
                            }
                        }

                        protected void onPostExecute(ArrayList<Protocal> al) {
                            if (al != null && al.size() > 0) {
                                QoS4SendDaemon.this.notifyMessageLost(al);
                            }

                            QoS4SendDaemon.this._excuting = false;
                            QoS4SendDaemon.this.handler.postDelayed(QoS4SendDaemon.this.runnable, 5000L);
                        }
                    }).execute(new Object[0]);
                }

            }
        };
    }

    protected void notifyMessageLost(ArrayList<Protocal> lostMessages) {
        if (ClientCoreSDK.getInstance().getMessageQoSEvent() != null) {
            ClientCoreSDK.getInstance().getMessageQoSEvent().messagesLost(lostMessages);
        }

    }

    public void startup(boolean immediately) {
        this.stop();
        this.handler.postDelayed(this.runnable, (long) (immediately ? 0 : 5000));
        this.running = true;
    }

    public void stop() {
        this.handler.removeCallbacks(this.runnable);
        this.running = false;
    }

    public boolean isRunning() {
        return this.running;
    }

    boolean exist(String fingerPrint) {
        return this.sentMessages.get(fingerPrint) != null;
    }

    public void put(Protocal p) {
        if (p == null) {
            Log.w(TAG, "Invalid arg p==null.");
        } else if (p.getFp() == null) {
            Log.w(TAG, "Invalid arg p.getFp() == null.");
        } else if (!p.isQoS()) {
            Log.w(TAG, "This protocal is not QoS pkg, ignore it!");
        } else {
            if (this.sentMessages.get(p.getFp()) != null) {
                Log.w(TAG, "【IMCORE】【QoS】指纹为" + p.getFp() + "的消息已经放入了发送质量保证队列,该消息为何会重复?(生成的指纹码重复?还是重复put?)");
            }

            this.sentMessages.put(p.getFp(), p);
            this.sendMessagesTimestamp.put(p.getFp(), Long.valueOf(System.currentTimeMillis()));
        }
    }

    public void remove(final String fingerPrint) {
        (new AsyncTask() {
            protected Object doInBackground(Object... params) {
                QoS4SendDaemon.this.sendMessagesTimestamp.remove(fingerPrint);
                return QoS4SendDaemon.this.sentMessages.remove(fingerPrint);
            }

            protected void onPostExecute(Object result) {
                Log.w(QoS4SendDaemon.TAG, "【IMCORE】【QoS】指纹为" + fingerPrint + "的消息已成功从发送质量保证队列中移除(可能是收到接收方的应答也可能是达到了重传的次数上限),重试次数=" + (result != null ? Integer.valueOf(((Protocal) result).getRetryCount()) : "none呵呵."));
            }
        }).execute(new Object[0]);
    }

    public void clear() {
        this.sentMessages.clear();
        this.sendMessagesTimestamp.clear();
    }

    public int size() {
        return this.sentMessages.size();
    }
}


2:调用的时候是发送方UDP发送后:
public int sendCommonData(Protocal p) {
    if (p != null) {
        byte[] b = p.toBytes();
        int code = this.send(b, b.length);//具体的发送方法,可自定义
        if (code == 0 && p.isQoS() && !QoS4SendDaemon.getInstance(this.context).exist(p.getFp())) {
            QoS4SendDaemon.getInstance(this.context).put(p);
        }

        return code;
    } else {
        return 4;
    }
}

3:发送方法send为:

private int send(byte[] fullProtocalBytes, int dataLen) {
    if (!ClientCoreSDK.getInstance().isInitialed()) {//检测应用是否初始化
        return 203;
    } else if (!ClientCoreSDK.getInstance().isLocalDeviceNetworkOk()) {//这里检测网络可自定义
        Log.e(TAG, "【IMCORE】本地网络不能工作,send数据没有继续!");
        return 204;
    } else {
        //下面的是核心发送代码
        DatagramSocket ds = LocalUDPSocketProvider.getInstance().getLocalUDPSocket();
        if (ds != null && !ds.isConnected()) {
            try {
                if (ConfigEntity.serverIP == null) {
                    Log.w(TAG, "【IMCORE】send数据没有继续,原因是ConfigEntity.server_ip==null!");
                    return 205;
                }
                ds.connect(InetAddress.getByName(ConfigEntity.serverIP), ConfigEntity.serverUDPPort);
            } catch (Exception var5) {
                Log.w(TAG, "【IMCORE】send时出错,原因是:" + var5.getMessage(), var5);
                return 202;
            }
        }

        return UDPUtils.send(ds, fullProtocalBytes, dataLen) ? 0 : 3;
    }
}

4:消息发送工具类:

/**
 * 数据发送
 * Created by AndyYuan on time at 2019/9/25.
 */

public class UDPUtils {
    private static final String TAG = UDPUtils.class.getSimpleName();

    public UDPUtils() {
    }

    public static boolean send(DatagramSocket skt, byte[] d, int dataLen) {
        if (skt != null && d != null) {
            try {
                return send(skt, new DatagramPacket(d, dataLen));
            } catch (Exception var4) {
                Log.e(TAG, "【IMCORE】send方法中》》发送UDP数据报文时出错了:remoteIp=" + skt.getInetAddress() + ", remotePort=" + skt.getPort() + ".原因是:" + var4.getMessage(), var4);
                return false;
            }
        } else {
            Log.e(TAG, "【IMCORE】send方法中》》无效的参数:skt=" + skt);
            return false;
        }
    }

    public static synchronized boolean send(DatagramSocket skt, DatagramPacket p) {
        boolean sendSucess = true;
        if (skt != null && p != null) {
            if (skt.isConnected()) {
                try {
                    skt.send(p);
                } catch (Exception var4) {
                    sendSucess = false;
                    Log.e(TAG, "【IMCORE】send方法中》》发送UDP数据报文时出错了,原因是:" + var4.getMessage(), var4);
                }
            }
        } else {
            Log.w(TAG, "【IMCORE】在send()UDP数据报时没有成功执行,原因是:skt==null || p == null!");
        }

        return sendSucess;
    }
}

猜你喜欢

转载自blog.csdn.net/qq_42618969/article/details/106397432