Websocket传输可靠性(重新连接期间Socket.io数据丢失)


79

用过的

NodeJS,Socket.io

问题

假设有两个用户U1U2通过Socket.io连接到应用程序。该算法如下:

  1. U1完全失去Internet连接(例如,关闭Internet)
  2. U2U1发送消息。
  3. U1尚未收到消息,因为Internet断开
  4. 服务器通过心跳超时检测到U1断开连接
  5. U1重新连接到socket.io
  6. U1从不接收来自U2的消息-我猜它在步骤4中丢失了。

可能的解释

我想我明白为什么会这样:

  • 第4步服务器杀死Socket实例和消息队列U1以及
  • 此外,在步骤5 U1服务器上创建新连接(不重用),因此,即使消息仍在排队中,仍然会丢失先前的连接。

需要帮忙

如何防止这种数据丢失?我必须使用心跳,因为我没有人永远挂在应用程序上。另外,我仍然必须提供重新连接的可能性,因为当我部署新版本的应用程序时,我希望停机时间为零。

PS我称之为“消息”的东西不仅是我可以存储在数据库中的文本消息,而且是有价值的系统消息,必须保证其传递或UI搞砸。

谢谢!


加法1

我已经有一个用户帐户系统。而且,我的应用程序已经很复杂。添加离线/在线状态将无济于事,因为我已经有了这种东西。问题不同。

签出第2步。从技术上讲,我们无法说出U1是否脱机,他只是失去了连接状态,说了2秒钟,这可能是因为互联网状况不佳。因此,U2向他发送了一条消息,但是U1没有收到该消息,因为互联网对他来说仍然不可用(步骤3)。需要步骤4来检测脱机用户,可以说超时为60秒。最终在另外10秒钟内,U1的互联网连接建立,他重新连接到socket.io。但是来自U2的消息在空间中丢失,因为服务器U1上的超时已将其断开连接。

那就是问题,我不会100%交货。


  1. 收集{}用户中的发射(发射名称和数据),由随机的emitID标识。发送发射
  2. 在客户端确认发射(将发射发送回带有emitID的服务器)
  3. 如果已确认-从{}中删除由emitID标识的对象
  4. 如果用户重新连接-为该用户检查{}并遍历该用户,则对{}中的每个对象执行步骤1
  5. 断开连接或/和/或连接时,如有必要,请向用户冲洗{}
// Server
const pendingEmits = {};

socket.on('reconnection', () => resendAllPendingLimits);
socket.on('confirm', (emitID) => { delete(pendingEmits[emitID]); });

// Client
socket.on('something', () => {
    socket.emit('confirm', emitID);
});

解决方案2(种类)

添加2020年2月1日。

尽管这并不是Websockets的真正解决方案,但仍然有人可以使用。我们从Websockets迁移到SSE + Ajax。SSE允许您从客户端进行连接,以保持持久的TCP连接并实时接收来自服务器的消息。要将消息从客户端发送到服务器-只需使用Ajax。存在诸如延迟和开销之类的缺点,但是SSE由于是TCP连接,因此可以保证可靠性。

由于我们使用Express,因此我们将该库用于SSE https://github.com/dpskvn/express-sse,但是您可以选择适合您的库。

IE和大多数Edge版本不支持SSE,因此您需要使用polyfill:https : //github.com/Yaffle/EventSource


诚然。但是socket.io实际上只是一个传输协议。单靠它不能保证一致和可靠的消息传递。您应该研究(并阅读)pub-sub(发布-订阅)结构和消息队列。实际上,您将使用像redis这样的持久数据库来存储消息。
user568109 2013年

那么pubsub将解决此问题吗?如果您写了一个全面的答案并且解决方案有效,则将获得悬赏奖励(50分)。
igorpavlov

8
如此精心组织的问题
凯蒂(Katie)2014年

1
谢谢。我必须说,已接受的答案对我有用。我目前使用建议的方案,没有问题。
igorpavlov 2014年

嗨,伊戈尔!我是Node.js和Socket.io的新手。如果可能的话,您可以显示代码吗:)
Eazy 2014年

Answers:


101

其他人在其他答案和评论中对此有所暗示,但根本问题是Socket.IO只是一种传递机制,您不能仅依靠它来可靠地传递。唯一确定知道消息已成功发送给客户的人就是客户本身。对于这种系统,我建议做出以下断言:

  1. 邮件不会直接发送给客户;相反,它们被发送到服务器并存储在某种数据存储中。
  2. 客户端负责在重新连接时询问“我错过了什么”,并将查询数据存储中存储的消息以更新其状态。
  3. 如果在连接收件人客户端时将消息发送到服务器,则该消息将实时发送到客户端。

当然,根据您的应用程序的需求,您可以对此进行调整-例如,您可以使用Redis列表或消息的排序集,并在知道客户可用的情况下清除它们至今。


以下是几个示例:

幸福的道路

  • U1和U2均已连接到系统。
  • U2将消息发送到U1应该接收的服务器。
  • 服务器将消息存储在某种持久性存储中,并使用某种时间戳或顺序ID将其标记为U1。
  • 服务器通过Socket.IO将消息发送到U1。
  • U1的客户端确认(也许通过Socket.IO回调)它已收到消息。
  • 服务器从数据存储中删除保留的消息。

离线路径

  • U1断开互联网连接。
  • U2将消息发送到U1应该接收的服务器。
  • 服务器将消息存储在某种持久性存储中,并使用某种时间戳或顺序ID将其标记为U1。
  • 服务器通过Socket.IO将消息发送到U1。
  • U1的客户端处于脱机状态,因此无法确认收货。
  • 也许U2向U1发送了一些消息;它们都以相同的方式存储在数据存储中。
  • 当U1重新连接时,它询问服务器“我看到的最后一条消息是X /我的状态为X,我错过了什么”。
  • 服务器根据U1的请求向U1发送从数据存储中丢失的所有消息
  • U1的客户端确认收到,服务器将这些消息从数据存储中删除。

如果您绝对希望有保证的交付,那么以这样一种方式设计系统就很重要,即连接实际上并不重要,并且实时交付只是一个奖励;这几乎总是涉及某种数据存储。正如user568109在评论中提到的那样,有一些消息传递系统可以抽象化所述消息的存储和传递,因此值得研究这种预构建的解决方案。(您可能仍然需要自己编写Socket.IO集成。)

如果您对将消息存储在数据库中不感兴趣,则可以将它们存储在本地数组中,从而摆脱困境。服务器尝试向U1发送消息,并将其存储在“待处理消息”列表中,直到U1的客户端确认它已收到为止。如果客户端处于脱机状态,则当客户端返回时,它可以告诉服务器“嘿,我已断开连接,请将任何我错过的信息发送给我”,然后服务器可以遍历这些消息。

幸运的是,Socket.IO提供了一种机制,该机制允许客户端“响应”看起来像本机JS回调的消息。这是一些伪代码:

// server
pendingMessagesForSocket = [];

function sendMessage(message) {
  pendingMessagesForSocket.push(message);
  socket.emit('message', message, function() {
    pendingMessagesForSocket.remove(message);
  }
};

socket.on('reconnection', function(lastKnownMessage) {
  // you may want to make sure you resend them in order, or one at a time, etc.
  for (message in pendingMessagesForSocket since lastKnownMessage) {
    socket.emit('message', message, function() {
      pendingMessagesForSocket.remove(message);
    }
  }
});

// client
socket.on('connection', function() {
  if (previouslyConnected) {
    socket.emit('reconnection', lastKnownMessage);
  } else {
    // first connection; any further connections means we disconnected
    previouslyConnected = true;
  }
});

socket.on('message', function(data, callback) {
  // Do something with `data`
  lastKnownMessage = data;
  callback(); // confirm we received the message
});

这与上一个建议非常相似,只是没有持久性数据存储。


您可能还对事件源的概念感兴趣。


2
我一直在等待最终的全面答复,并声明:客户必须确认交货。似乎真的没有其他方法。
igorpavlov

很高兴为您提供帮助!如果您有任何疑问,请给我Ping
Michelle Tilley 2013年

这将在一对一聊天情况下工作。在将邮件发送给多个用户的房间示例中发生了什么。broadcast / socket.in不支持回调。那么我们如何处理这种情况?我的问题。(stackoverflow.com/questions/43186636/...
JIT

2

米歇尔的答案很正确,但是还有一些其他重要的事情需要考虑。要问自己的主要问题是:“用户和我的应用程序中的套接字之间是否有区别?” 另一种询问方式是“每个登录用户可以一次拥有多个套接字连接吗?”

在网络世界中,一个用户可能总是有多个套接字连接的可能性,除非您专门放置了一些阻止该连接的东西。最简单的示例是,如果用户打开了同一页面的两个选项卡。在这些情况下,您不必担心仅一次向人类用户发送消息/事件……您需要将其发送到该用户的每个套接字实例,以便每个选项卡都可以运行其回调以更新ui状态。也许这对某些应用程序不是一个问题,但我的直言不讳地指出,这对于大多数应用程序来说都是如此。如果您对此感到担心,请继续阅读...。

要解决此问题(假设您使用数据库作为持久性存储),您将需要3个表。

  1. 用户-与真实的人一对一
  2. 客户端-代表一个“选项卡”,可以与套接字服务器建立单一连接。(任何“用户”可能有多个)
  3. 消息-需要发送给客户端的消息(而不是需要发送给用户或套接字的消息)

如果您的应用程序不需要users表,则该表是可选的,但OP表示他们有一个。

需要正确定义的另一件事是“什么是套接字连接?”,“何时创建套接字连接?”,“何时重新使用套接字连接?”。Michelle的伪代码使套接字连接看起来可以重用。使用Socket.IO,它们将无法重用。我已经看到了很多混乱的根源。在现实生活中,米歇尔的例子确实有意义。但是我必须想象那些情况很少见。真正发生的是,当套接字连接丢失时,该连接,ID等将永远不会被重用。因此,专门为该套接字标记的任何消息都将永远不会传递给任何人,因为当最初连接的客户端重新连接时,它们将获得全新的连接和新的ID。这意味着

因此,对于基于Web的示例,这里是我建议的一组步骤:

  • 当用户加载有可能创建套接字连接的客户端(通常是单个网页)时,请在客户端数据库中添加一行链接到其用户ID。
  • 当用户实际连接到套接字服务器时,将客户机ID和连接请求一起传递给服务器。
  • 服务器应验证允许用户连接,并且客户表中的客户行可用于连接并相应地允许/拒绝。
  • 使用由Socket.IO生成的套接字ID更新客户端行。
  • 发送消息表中与客户端ID连接的所有项目。初始连接上没有任何连接,但是如果这是来自尝试重新连接的客户端,则可能会有一些连接。
  • 每当需要将消息发送到该套接字时,请在消息表中添加一行,该行链接到您生成的客户端ID(而不是套接字ID)。
  • 尝试发出消息并听取客户端的确认。
  • 收到确认后,从邮件表中删除该项目。
  • 您可能希望在客户端创建一些逻辑,以丢弃从服务器发送的重复消息,因为正如某些人指出的那样,这在技术上是可能的。
  • 然后,当客户端断开与套接字服务器的连接(有意或通过错误)时,请勿删除客户端行,最多只需清除套接字ID。这是因为该客户端可以尝试重新连接。
  • 当客户端尝试重新连接时,请发送与原始连接尝试发送的客户端ID相同的客户端ID。服务器将像初始连接一样查看它。
  • 当客户端被销毁(用户关闭选项卡或导航离开)时,这是您删除客户端行和该客户端的所有消息的时候。此步骤可能有些棘手。

因为最后一步很棘手(至少过去是这样,很长一段时间我都没有做过这样的事情),而且由于断电等情况,客户端会断开连接而不会清理客户端行,并且从不尝试重新连接到相同的客户端行-您可能希望定期运行某些内容以清理所有过时的客户端和消息行。或者,您可以永久地永久存储所有客户端和消息,并适当地标记它们的状态。

因此,为了清楚起见,在一个用户打开两个选项卡的情况下,您将向消息表中添加两个相同的消息,每个消息都标记了一个不同的客户端,因为您的服务器需要知道每个客户端(而不是每个用户)是否都收到了它们。


1

看来您已经有用户帐户系统。您知道哪个帐户是在线/离线的,您可以处理连接/断开事件:

因此,解决方案是在数据库中为每个用户添加联机/脱机和脱机消息:

chatApp.onLogin(function (user) {
   user.readOfflineMessage(function (msgs) {
       user.sendOfflineMessage(msgs, function (err) {
           if (!err) user.clearOfflineMessage();
       });
   })
});

chatApp.onMessage(function (fromUser, toUser, msg) {
   if (user.isOnline()) {
      toUser.sendMessage(msg, function (err) {
          // alert CAN NOT SEND, RETRY?
      });
   } else {
      toUser.addToOfflineQueue(msg);
   }
})

请阅读我的问题中的“加法1”部分。我认为您的答案不是解决方案。
igorpavlov 2013年

有趣的是,我现在开始我自己的聊天项目,也许使用Web RTC:->
wethat 2013年

也可以使用WebRTC。但是在这种情况下,这并不重要。啊...如果所有人都拥有稳定的互联网...当用户在Speedtest上获得100Mbps的速度时,我会感到非常沮丧,但实际上,如果他们尝试ping,则他们有20%的数据包丢失。谁需要这样的互联网?=)
igorpavlov

0

看这里:处理浏览器重载socket.io

我认为您可以使用我想出的解决方案。如果您对其进行了适当的修改,它应该可以正常运行。


有趣的是,我找不到这个问题,但是用谷歌搜索了几个小时。会看看!
igorpavlov

看来我已经使用了这种架构。它不能解决我描述的确切问题。
igorpavlov 2013年

0

我想您想要的是为每个用户提供一个可重用的套接字,例如:

客户:

socket.on("msg", function(){
    socket.send("msg-conf");
});

服务器:

// Add this socket property to all users, with your existing user system
user.socket = {
    messages:[],
    io:null
}
user.send = function(msg){ // Call this method to send a message
    if(this.socket.io){ // this.io will be set to null when dissconnected
        // Wait For Confirmation that message was sent.
        var hasconf = false;
        this.socket.io.on("msg-conf", function(data){
            // Expect the client to emit "msg-conf"
            hasconf = true;
        });
        // send the message
        this.socket.io.send("msg", msg); // if connected, call socket.io's send method
        setTimeout(function(){
            if(!hasconf){
                this.socket = null; // If the client did not respond, mark them as offline.
                this.socket.messages.push(msg); // Add it to the queue
            }
        }, 60 * 1000); // Make sure this is the same as your timeout.

    } else {
        this.socket.messages.push(msg); // Otherwise, it's offline. Add it to the message queue
    }
}
user.flush = function(){ // Call this when user comes back online
    for(var msg in this.socket.messages){ // For every message in the queue, send it.
        this.send(msg);
    }
}
// Make Sure this runs whenever the user gets logged in/comes online
user.onconnect = function(socket){
    this.socket.io = socket; // Set the socket.io socket
    this.flush(); // Send all messages that are waiting
}
// Make sure this is called when the user disconnects/logs out
user.disconnect = function(){
    self.socket.io = null; // Set the socket to null, so any messages are queued not send.
}

然后,在断开连接之间保留套接字队列。

确保将每个用户socket属性保存到数据库中,并使方法成为用户原型的一部分。数据库并不重要,只需保存即可,但是您一直在保存用户。

通过在标记消息为已发送之前要求客户端确认,可以避免Additon 1中提到的问题。如果您确实想要,可以给每个消息指定一个ID,然后让客户端将消息ID发送给msg-conf,然后进行检查。

在此示例中,user是从中复制所有用户的模板用户,或类似于用户原型。

注意:这尚未经过测试。


您能否告诉我“用户”变量实际上是什么?
igorpavlov

实际上,我认为您已提出了我的问题。但是您能否也为每段代码提供一些注释?我还不了解如何将其集成到我的代码中。另外,我应该将其保存到数据库中以及您指的是哪种数据库?Redis或可能是Mongo还是没关系?
igorpavlov

它仍然不能解决问题。发送消息后,两个用户(发送者和接收者)均在服务器上联机。请非常仔细地阅读我的问题中的加法1。在这种情况下,“ this.socket.io”将始终为“ true”,因此正在发送但未接收到消息。您尝试解决此问题,当SENDER离线时,但RECEIVER离线。还是我不对?
igorpavlov

@igorpavlov,对不起,但是您误会了我。想象一下:U1要向U2发送消息“ Hi” :users.getUserByName("U2").send("Hi")。然后,如果U2联机,则U2的socket.io不会为null,因此将发送消息。如果U2的套接字为空,则它将排队,直到U2联机。
阿里·波拉德

1
我相信@igorpavlov是正确的。有时会断开客户端的连接,但服务器尚不知道该连接,因为心跳尚未发生。在这一段时间内,this.socket.io不会null,服务器将尝试传递邮件。
米歇尔·蒂里

0

正如已经在另一个答案中所写的那样,我还相信您应该将实时性视为一种奖励:该系统也应该能够不实时地工作。

我正在为一家大型公司(iOS,Android,Web前端和.net Core + postGres后端)开发企业聊天室,并为Websocket开发了一种方法来重新建立连接(通过套接字uuid)并获取未传递的消息后(存储在队列中)我知道有一个更好的解决方案:通过rest API重新同步。

基本上,我最终只是使用websocket进行实时操作,在每条实时消息(用户在线,键入器,聊天消息等)上都使用了一个整数标签来监视丢失的消息。

当客户端获取的ID不是单片(+1)时,它就知道它不同步,因此它将丢弃所有套接字消息,并通过REST API请求所有观察者重新同步。

这样,我们可以处理离线状态下应用程序状态的多种变化,而不必在重新连接时连续解析大量的websocket消息,并且我们一定会被同步(因为最后的同步日期仅由REST API设置) ,而不是来自套接字)。

唯一棘手的部分是监视从调用REST api到服务器回复的实时消息,因为从db读取的内容需要时间才能返回到客户端,同时可能会发生变化,因此需要对其进行缓存并考虑到了

我们将在几个月后投入生产,希望到那时再入睡:)


-2

最近一直在看这些东西,并认为不同的路径可能会更好。

尝试查看Azure Service总线,问题和主题要注意离线状态。该消息等待用户返回,然后他们收到消息。

运行一个队列的成本很高,但是对于一个基本队列来说,每百万次操作大约0.05美元,因此开发人员的成本会从编写一个排队系统所需的工作时间中增加。 https://azure.microsoft.com/zh-cn/pricing/details/service-bus/

而且azure总线具有PHP,C#,Xarmin,Anjular,Java Script等的库和示例。

因此,服务器发送消息,无需担心对其进行跟踪。客户端也可以使用消息发送回去,因为可以根据需要处理负载平衡。


对我来说,这似乎是产品展示位置。有人可能会觉得这很有帮助,但这甚至不是一项技术,而是一项完整的服务,也是有偿的。
igorpavlov

-2

试试这个发射聊天列表

io.on('connect', onConnect);

function onConnect(socket){

  // sending to the client
  socket.emit('hello', 'can you hear me?', 1, 2, 'abc');

  // sending to all clients except sender
  socket.broadcast.emit('broadcast', 'hello friends!');

  // sending to all clients in 'game' room except sender
  socket.to('game').emit('nice game', "let's play a game");

  // sending to all clients in 'game1' and/or in 'game2' room, except sender
  socket.to('game1').to('game2').emit('nice game', "let's play a game (too)");

  // sending to all clients in 'game' room, including sender
  io.in('game').emit('big-announcement', 'the game will start soon');

  // sending to all clients in namespace 'myNamespace', including sender
  io.of('myNamespace').emit('bigger-announcement', 'the tournament will start soon');

  // sending to individual socketid (private message)
  socket.to(<socketid>).emit('hey', 'I just met you');

  // sending with acknowledgement
  socket.emit('question', 'do you think so?', function (answer) {});

  // sending without compression
  socket.compress(false).emit('uncompressed', "that's rough");

  // sending a message that might be dropped if the client is not ready to receive messages
  socket.volatile.emit('maybe', 'do you really need it?');

  // sending to all clients on this node (when using multiple nodes)
  io.local.emit('hi', 'my lovely babies');

};

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.