Надежность транспорта Websocket (потеря данных Socket.io при переподключении)


81

Используемый

NodeJS, Socket.io

Проблема

Представьте, что есть 2 пользователя U1 и U2 , подключенных к приложению через Socket.io. Алгоритм следующий:

  1. U1 полностью теряет подключение к Интернету (например, отключает Интернет)
  2. U2 отправляет сообщение U1 .
  3. U1 еще не получает сообщение, потому что Интернет не работает
  4. Сервер обнаруживает отключение U1 по таймауту пульса
  5. U1 повторно подключается к socket.io
  6. U1 никогда не получает сообщение от U2 - я думаю, оно потеряно на шаге 4.

Возможное объяснение

Думаю, я понимаю, почему это происходит:

  • на шаге 4 сервер убивает экземпляр сокета и очередь сообщений для U1 , а
  • Более того, на шаге 5 U1 и сервер создают новое соединение (оно не используется повторно), поэтому, даже если сообщение все еще стоит в очереди, предыдущее соединение все равно теряется.

Нужна помощь

Как я могу предотвратить такую ​​потерю данных? Я должен использовать ритм, потому что я не заставляю людей зависать в приложении навсегда. Кроме того, я должен предоставить возможность повторного подключения, потому что, когда я развертываю новую версию приложения, я хочу, чтобы время простоя было нулевым.

PS То, что я называю «сообщением», - это не просто текстовое сообщение, которое я могу сохранить в базе данных, но ценное системное сообщение, доставка которого должна быть гарантирована, иначе пользовательский интерфейс испортится.

Благодаря!


Дополнение 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 (вроде)

Добавлено 1 фев 2020.

Хотя на самом деле это не решение для Websockets, кому-то оно может пригодиться. Мы перешли с Websockets на SSE + Ajax. SSE позволяет подключаться от клиента, чтобы поддерживать постоянное TCP-соединение и получать сообщения от сервера в реальном времени. Чтобы отправлять сообщения от клиента на сервер - просто используйте Ajax. Есть недостатки, такие как задержка и накладные расходы, но SSE гарантирует надежность, потому что это TCP-соединение.

Поскольку мы используем Express, мы используем эту библиотеку для SSE https://github.com/dpskvn/express-sse , но вы можете выбрать ту, которая вам подходит.

SSE не поддерживается в IE и большинстве версий Edge, поэтому вам понадобится полифил: https://github.com/Yaffle/EventSource .


Правда что. Но socket.io на самом деле всего лишь транспортный протокол. Одно это не может гарантировать последовательную и надежную доставку сообщений. Вам следует изучить (и прочитать) архитектуры pub-sub (публикация-подписка) и очереди сообщений. На практике вы будете использовать постоянную базу данных, такую ​​как redis, для хранения сообщений.
user568109

Так pubsub решит эту проблему? Если вы напишете исчерпывающий ответ и решение сработает, вы будете вознаграждены наградой (50 баллов).
igorpavlov

8
такой красиво организованный вопрос
Кэти

1
Спасибо. Должен сказать, что принятый ответ у меня работает. В настоящее время я использую предложенную схему и проблем нет.
igorpavlov 05

Привет Игорь! Я новичок в Node.js и Socket.io. Если возможно, покажите свой код :)
Eazy

Ответы:


103

Другие намекали на это в других ответах и ​​комментариях, но основная проблема заключается в том, что Socket.IO - это всего лишь механизм доставки, и вы не можете полагаться только на него для надежной доставки. Единственный человек, который точно знает, что сообщение было успешно доставлено клиенту, - это сам клиент . Для такой системы я бы рекомендовал сделать следующие утверждения:

  1. Сообщения не отправляются напрямую клиентам; вместо этого они отправляются на сервер и сохраняются в каком-то хранилище данных.
  2. Клиенты несут ответственность за вопрос «что я пропустил» при повторном подключении и будут запрашивать сохраненные сообщения в хранилище данных, чтобы обновить свое состояние.
  3. Если сообщение отправляется на сервер, когда клиент-получатель подключен, это сообщение будет отправлено клиенту в реальном времени.

Конечно, в зависимости от потребностей вашего приложения вы можете настроить отдельные части этого - например, вы можете использовать, скажем, список Redis или отсортированный набор для сообщений и очистить их, если вы точно знаете, что клиент работает. на свидание.


Вот пара примеров:

Счастливый путь :

  • U1 и U2 оба подключены к системе.
  • U2 отправляет серверу сообщение, которое должен получить U1.
  • Сервер хранит сообщение в каком-то постоянном хранилище, помечая его для U1 какой-либо меткой времени или последовательным идентификатором.
  • Сервер отправляет сообщение U1 через Socket.IO.
  • Клиент U1 подтверждает (возможно, через обратный вызов Socket.IO), что он получил сообщение.
  • Сервер удаляет сохраненное сообщение из хранилища данных.

Автономный путь :

  • U1 теряет подключение к Интернету.
  • U2 отправляет серверу сообщение, которое должен получить U1.
  • Сервер хранит сообщение в каком-то постоянном хранилище, помечая его для U1 какой-либо меткой времени или последовательным идентификатором.
  • Сервер отправляет сообщение U1 через Socket.IO.
  • Клиент 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

Рад, что помог! Дайте мне пинг, если у вас есть вопросы.
Мишель Тилли

Это будет работать в сценарии индивидуального чата. Пример того, что происходит в комнатах, когда сообщение отправлено нескольким пользователям. broadcast / socket.in не поддерживает обратный вызов. так как мы справимся с этой ситуацией? мой вопрос по этому поводу. ( stackoverflow.com/questions/43186636/… )
jit

2

Ответ Мишель довольно точен, но есть еще несколько важных моментов, которые следует учитывать. Главный вопрос, который стоит задать себе: «Есть ли разница между пользователем и сокетом в моем приложении?» Другой способ спросить: «Может ли каждый вошедший в систему пользователь иметь более одного сокета одновременно?»

В веб-мире, вероятно, всегда существует вероятность того, что один пользователь имеет несколько подключений к сокетам, если вы специально не добавили что-то, что предотвращает это. Самый простой пример - если у пользователя открыты две вкладки одной и той же страницы. В этих случаях вам не нужно отправлять сообщение / событие пользователю-человеку только один раз ... вам нужно отправить его в каждый экземпляр сокета для этого пользователя, чтобы каждая вкладка могла запускать свои обратные вызовы для обновления состояния пользовательского интерфейса. Может быть, это не проблема для некоторых приложений, но моя интуиция подсказывает, что это будет для большинства. Если вас это беспокоит, читайте дальше ....

Чтобы решить эту проблему (при условии, что вы используете базу данных в качестве постоянного хранилища), вам понадобятся 3 таблицы.

  1. пользователи - один к одному с реальными людьми
  2. клиенты - что представляет собой «вкладку», которая может иметь одно соединение с сервером сокетов. (у любого «пользователя» может быть несколько)
  3. messages - сообщение, которое необходимо отправить клиенту (не сообщение, которое нужно отправить пользователю или в сокет)

Таблица пользователей является необязательной, если вашему приложению она не требуется, но OP сказал, что она у них есть.

Другой вопрос, который необходимо правильно определить, - это «что такое сокет-соединение?», «Когда создается сокет-соединение?», «Когда оно повторно используется?». Псудокод Мишель создает впечатление, что соединение через сокет можно использовать повторно. С Socket.IO их НЕЛЬЗЯ использовать повторно. Я видел источник большого замешательства. Есть сценарии из реальной жизни, в которых пример Мишель имеет смысл. Но я должен представить, что такие сценарии редки. Что действительно происходит, так это когда соединение с сокетом потеряно, это соединение, идентификатор и т. Д. Никогда не будут повторно использованы. Таким образом, любые сообщения, специально отмеченные для этого сокета, никогда не будут доставлены кому-либо, потому что, когда клиент, который изначально подключился, повторно подключается, он получает совершенно новое соединение и новый идентификатор. Это значит это '

Итак, для веб-примера я бы рекомендовал следующие шаги:

  • Когда пользователь загружает клиента (обычно одну веб-страницу), который может создать соединение с сокетом, добавьте строку в базу данных клиентов, которая связана с их идентификатором пользователя.
  • Когда пользователь действительно подключается к серверу сокетов, передайте идентификатор клиента серверу с запросом на подключение.
  • Сервер должен подтвердить, что пользователю разрешено подключаться, и строка клиента в таблице клиентов доступна для подключения и разрешить / запретить соответственно.
  • Обновите клиентскую строку идентификатором сокета, созданным Socket.IO.
  • Отправьте любые элементы в таблице сообщений, связанные с идентификатором клиента. При первоначальном подключении их не было, но если это было от клиента, пытающегося повторно подключиться, они могут быть.
  • Каждый раз, когда в этот сокет нужно отправить сообщение, добавляйте строку в таблицу сообщений, которая связана с созданным вами идентификатором клиента (а не идентификатором сокета).
  • Попытка отправить сообщение и прослушать клиента с подтверждением.
  • Когда вы получите подтверждение, удалите этот элемент из таблицы сообщений.
  • Возможно, вы захотите создать некоторую логику на стороне клиента, которая отбрасывает повторяющиеся сообщения, отправленные с сервера, поскольку это технически возможно, как указывали некоторые.
  • Затем, когда клиент отключается от сервера сокетов (намеренно или из-за ошибки), НЕ удаляйте строку клиента, просто очистите идентификатор сокета максимум. Это связано с тем, что тот же клиент может попытаться восстановить соединение.
  • Когда клиент пытается повторно подключиться, отправьте тот же идентификатор клиента, который он отправил при первоначальной попытке подключения. Сервер будет видеть это так же, как при первоначальном подключении.
  • Когда клиент уничтожается (пользователь закрывает вкладку или уходит), это происходит при удалении строки клиента и всех сообщений для этого клиента. Этот шаг может быть немного сложным.

Потому что последний шаг сложен (по крайней мере, раньше я не делал ничего подобного в течение долгого времени), и потому что есть случаи, такие как потеря мощности, когда клиент отключается, не очищая клиентскую строку, и никогда не пытается для повторного подключения к той же клиентской строке - вы, вероятно, захотите иметь что-то, что периодически запускается для очистки устаревших клиентских строк и строк сообщений. Или вы можете просто навсегда сохранить всех клиентов и сообщения навсегда и просто пометить их состояние соответствующим образом.

Итак, для ясности: в случаях, когда у одного пользователя открыты две вкладки, вы будете добавлять два идентичных сообщения в таблицу сообщений, каждое из которых помечено для другого клиента, потому что вашему серверу необходимо знать, получил ли их каждый клиент, а не только каждый пользователь.


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

Это интересно, сейчас я начинаю свой собственный чат-проект, может быть, с веб-RTC: ->
damphat

Мой тоже на WebRTC. Но в данном контексте это не имеет значения. Ах ... Если бы у всех людей был стабильный интернет ... Я так расстроен, когда пользователи имеют скорость 100 Мбит / с на Speedtest, но на самом деле, если они попытаются выполнить ping, у них будет потеря пакетов 20%. Кому нужен такой интернет? =)
igorpavlov

0

Посмотрите здесь: Обработка перезагрузки браузера socket.io .

Я думаю, вы могли бы использовать решение, которое я придумал. Если вы измените его правильно, он должен работать так, как вы хотите.


Это интересно, я не смог найти этот вопрос, но несколько часов гуглил. Посмотрю!
igorpavlov

Кажется, я уже использую такую ​​архитектуру. Это не решает ту проблему, которую я описал.
igorpavlov

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свойство пользователя в базе данных и делает методы частью вашего пользовательского прототипа. База данных не имеет значения, просто сохраните ее, как бы вы ни сохраняли своих пользователей.

Это позволит избежать проблемы, упомянутой в Дополнении 1, за счет запроса подтверждения от клиента перед пометкой сообщения как отправленного. Если вы действительно хотите, вы можете присвоить каждому сообщению идентификатор и попросить клиента отправить идентификатор сообщения msg-conf, а затем проверить его.

В этом примере userэто пользователь шаблона, из которого скопированы все пользователи, или аналогичный пользовательскому прототипу.

Примечание: это не было проверено.


Не могли бы вы сказать мне, что на самом деле является "пользовательской" переменной?
igorpavlov

На самом деле, я думаю, вы ответили на мой вопрос Но не могли бы вы также дать несколько комментариев по каждому фрагменту кода? Пока не понимаю, как это интегрировать в свой код. Также, где мне сохранить его в базе данных и какую базу данных вы имеете в виду? Redis или может быть Mongo или не имеет значения?
igorpavlov

Это все еще не решает проблему. Когда сообщение отправлено, оба пользователя (отправитель и получатель) находятся в режиме ONLINE для сервера. Пожалуйста, внимательно прочтите Дополнение 1 к моему вопросу. В этом случае this.socket.io всегда будет иметь значение true, поэтому сообщение отправляется, но не получено. Вы пытаетесь решить проблему, когда SENDER выходит из сети, а RECEIVER - нет. Или я не прав?
igorpavlov

@igorpavlov, извини, но ты меня неправильно понял. Представьте себе это: U1 хочет отправить сообщение «Привет» U2 : users.getUserByName("U2").send("Hi"). Затем, если U2 находится в сети, socket.io U2 не будет нулевым, поэтому сообщение будет отправлено. Если сокет U2 нулевой, он будет поставлен в очередь до тех пор, пока U2 не перейдет в режим онлайн.
Ари Порад

1
Я считаю, что @igorpavlov прав. Будет период времени, когда клиент будет фактически отключен, но сервер не знает об этом, потому что сердцебиение еще не произошло. В этот период времени, this.socket.ioбудет не быть null, и сервер будет пытаться доставить сообщения.
Мишель Тилли

0

Как уже было написано в другом ответе, я также считаю, что вы должны смотреть на реальное время как на бонус: система должна иметь возможность работать без реального времени.

Я разрабатываю корпоративный чат для крупной компании (ios, android, веб-интерфейс и .net core + postGres backend), и после разработки способа для веб-сокета восстановить соединение (через сокет uuid) и получить недоставленные сообщения (хранится в очереди) Я понял, что есть лучшее решение: повторная синхронизация через rest API.

В основном я закончил тем, что использовал websocket только для реального времени, с целочисленным тегом в каждом сообщении в реальном времени (пользователь онлайн, печатники, сообщение чата и т. Д.) Для мониторинга потерянных сообщений.

Когда клиент получает идентификатор, который не является монолитным (+1), он понимает, что он не синхронизирован, поэтому он отбрасывает все сообщения сокета и запрашивает повторную синхронизацию всех своих наблюдателей через REST api.

Таким образом, мы можем обрабатывать множество вариантов состояния приложения во время автономного периода без необходимости анализировать тонны сообщений веб-сокета подряд при повторном подключении, и мы обязательно будем синхронизированы (поскольку последняя дата синхронизации устанавливается только REST api , а не из розетки).

Единственная сложная часть - это мониторинг сообщений в реальном времени с момента вызова REST api до момента ответа сервера, потому что то, что читается из базы данных, требует времени, чтобы вернуться к клиенту, а в то же время могут происходить изменения, поэтому их нужно кэшировать и учел.

Мы идем в производство через пару месяцев, надеюсь к тому времени снова поспим :)


-2

Недавно смотрел на этот материал и думал, что другой путь может быть лучше.

Попробуйте посмотреть на служебную шину Azure, вопросы и тему, чтобы позаботиться о состояниях офлайн. Сообщение ожидает, пока пользователь вернется, а затем они получают сообщение.

Это стоимость запуска очереди, но это примерно 0,05 доллара за миллион операций для базовой очереди, поэтому затраты на разработку будут больше из-за часов работы, необходимых для написания системы очередей. https://azure.microsoft.com/en-us/pricing/details/service-bus/

А в azure bus есть библиотеки и примеры для 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');

};

Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.