Realtime на вашем ресурсе за несколько минут

Во время разработки игры мы столкнулись с необходимостью обеспечения максимального риалтайм обмена данных между пользователями, что повлекло за собой эксперименты с различными comet библиотеками.
Первый велосипед был построен на dklab realplexor, который, при очередной попытке его использования, как и ожидалось, нас подвел. Может у нас руки кривые, но добиться получения ивентов без задержек в 5-10-15 секунд у нас, к сожалению, не получилось.
Танцы с бубном продолжались долго, в результате чего мы решили остановиться на nginx_http_push_module, и потраченное время все же стоило того.

Основные проблемы, которые возникли при работе с данным модулем:
  • Отключенный кеш влечет за собой потерю ивентов (цикл из 250 проходов отдавал клиенту всего 3 ивента).
  • Включенный кеш влечет за собой рекурсивное получение последнего ивента.


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

Немного ягугла помогло найти JS функцию, которая, на первый взгляд, решала проблему:

function Listener(url, successCallback, failureCallback) {
var scope = this;
    var etag = 0, lastModified = 0;

    var launched = false;

    var failure = false;

    this.successTimeout = 0;
    this.failureTimeout = 5000;

    var getTimeout = function () {
        return failure ? this.failureTimeout : this.successTimeout;
    };

    var listen = function () {
        $.ajax(scope.ajaxOptions);
    }

    var beforeSend = function (jqXHR) {
        jqXHR.setRequestHeader("If-None-Match", etag);
        jqXHR.setRequestHeader("If-Modified-Since", lastModified);
    };

    var complete = function (jqXHR) {
        var timeout = getTimeout();

        etag = jqXHR.getResponseHeader('Etag');
        lastModified = jqXHR.getResponseHeader('Last-Modified');

        var timeout = jqXHR.statusText == 'success' ? scope.successTimeout : scope.failureTimeout;

        if (timeout > 0) {
            setTimeout(listen, timeout);
        } else {
            listen();
        }
    };

    this.ajaxOptions = {
        url : url,
        type : 'GET',
        async : true,
        error : failureCallback,
        success : successCallback,
        dataType : 'json',
        complete : complete,
        beforeSend : beforeSend,
        timeout: 1000 * 60 * 60 * 24
    };

    this.start = function (timeout) {
        if (!launched) {
            if (typeof(timeout) == 'undefined' || timeout == 0) {
                listen();
            } else {
                setTimeout(listen, timeout);
            }
            launched = true;
        }
    };
}


Немного танцев с бубнами дали понять, что и у нее есть свои скелеты в шкафу:
  • Если на клиент пришло несколько ивентов, и сразу после этого следует рефреш страницы — они придут к вам еще раз (из кеша).
  • Если вкладка была неактивной в течении длительного времени — реквест умирал, и больше не обрабатывался до рефреша страницы.


Первую проблему удалось устранить довольно просто, заменив
var etag = 0, lastModified = 0;
на
var etag = 0:
lastModified = LOAD_TIME;

где LOAD_TIME задавался константой в индексе, как
var LOAD_TIME = "<?=date('r');?>";
Вторую проблему, я надеялся, решить будет так же просто, заменив
timeout: 1000 * 60 * 60 * 24
на
timeout: 1000 * 20
создав принудительный реконект к nginx каждые Х секунд (в нашем случая оптимальным интервалом стало 20 сек).
Но начав тестирования, получил еще больше коллизий, чем было до этого – после реконектов начинался адский флуд ивентов. Спасибо console, за пояснение ситуации. Проблема была в том, что когда кверик делал реконект, обработчик complete продолжал обновлять данные по хедерам, в связи с чем следующий запрос уходил с If-None-Match и If-Modified-Since null.

В конце концов, получили максимально стабильную функцию:

<script>
var LOAD_TIME = "<?=date('r');?>";
/*
 * ВАЖНО! строка выше должна быть определена в том месте, где есть возможно задать серверное время, например в index.php, но не в отдельном JS файле, так как получим ошибку.
 */
function Listener(url, successCallback, failureCallback) {
    var scope = this;
    var etag = 0;
    var lastModified = LOAD_TIME;

    var launched = false;

    var failure = false;

    this.successTimeout = 1000;
    this.failureTimeout = 0;

    var getTimeout = function () {
        return failure ? this.failureTimeout : this.successTimeout;
    };

    var listen = function () {
        $.ajax(scope.ajaxOptions);
    }

    var beforeSend = function (jqXHR) {
        jqXHR.setRequestHeader("If-None-Match", etag);
        jqXHR.setRequestHeader("If-Modified-Since", lastModified);
        cw("BEFORE None-Match : "+etag);
        cw("BEFORE Modified : "+lastModified);
    };
    var complete = function (jqXHR) {
        var timeout = getTimeout();
		if (jqXHR.getResponseHeader('Etag') != null && jqXHR.getResponseHeader('Last-Modified') != null) {
	        etag = jqXHR.getResponseHeader('Etag');
	        lastModified = jqXHR.getResponseHeader('Last-Modified');
		}        
        var timeout = jqXHR.statusText == 'success' ? scope.successTimeout : scope.failureTimeout;

        if (timeout > 0) {
            setTimeout(listen, timeout);
        } else {
            listen();
        }
    };
    this.ajaxOptions = {
        url : url,
        type : 'GET',
        async : true,
        error : failureCallback,
        success : successCallback,
        dataType : 'json',
        complete : complete,
        beforeSend : beforeSend,
        timeout: 1000 * 20 
    };
    this.start = function (timeout) {
        if (!launched) {
            if (typeof(timeout) == 'undefined' || timeout == 0) {
                listen();
            } else {
                setTimeout(listen, timeout);
            }
            launched = true;
        }
    };
    this.start();
}
</script>


Вызов обработчика ивентов выглядит примерно так:

<script>
$(document).ready(function() {
	Listener('/listener?cid=chanel_1', onSuccess, onError);
});
/*
 *onSuccess и onError - два обработчика (две функции) полученных данных от кометы, в случае успешного и неудачного выполнения запроса соответственно. 
 */
function onSuccess (data) {
	if (data) {
		$("#messages").prepend(data.time + " | " +data.msg);
	} else {
		console.log("EMPTY DATA");
	}
}
function onError () {
	alert("ERROR");
}
</script>


Поскольку в примерах на сайте модуля нет кода, который мог бы отправлять сообещение сразу в несколько каналов, мы решили написать свою функцию.
Реализация на php:

<?
function push ($cids, $text) {
	/*
	 * $cids - ID канала, либо массив, у которого каждый элемент - ID канала
	 * $text - сообщение, которое необходимо отправить 
	 */
	$c = curl_init();
	$url = 'http://server_name/publisher?cid=';
	
	$message = array(
		'time' => time(), 
		'msg' => $text
	);
	
	curl_setopt($c, CURLOPT_RETURNTRANSFER, true);
	curl_setopt($c, CURLOPT_POST, true);

	if (is_array($cids)) {
		foreach ($cids as $v) {
			curl_setopt($c, CURLOPT_URL, $url.$v);
			curl_setopt($c, CURLOPT_POSTFIELDS, json_encode($message));
			$r = curl_exec($c);
		}
	} else {
		curl_setopt($c, CURLOPT_URL, $url.$cids);
		curl_setopt($c, CURLOPT_POSTFIELDS, json_encode($message));
		$r = curl_exec($c);
	}
	
	curl_close($c);
	
}

// push (1, "Привет");
// push (array(1, 2, 3), "Привет");
// push (array('chanel_1' => 1, 'chanel_2' => 'user_100500', 'chanel_3' => 'global'), "Привет");


С установкой Dklab Realplexor, никаких проблем не возникло и полная документация доступна на сайте (http://dklab.ru/lib/dklab_realplexor) разработчика.
Однако, с nginx_http_push_module не всё так просто, если честно, на CentOS готовый пакет, включающий данный модуль, найти не удалось, поэтому собирали всё из исходников.

Итак приступим:

Создадим папочку куда будем скачивать все необходимые элементы
mkdir ~/nginx_push;
cd ~/nginx_push;


Скачаем последние исходники с гитхаба:
git clone git://github.com/slact/nginx_http_push_module.git


Качаем последнюю версию nginx, в нашем случае это nginx-1.1.15
wget http://nginx.org/download/nginx-1.1.15.tar.gz;


Качаем дополнительные необходимые библиотеки:
wget ftp://ftp.csx.cam.ac.uk/pub/software/programming/pcre/pcre-8.21.tar.gz;
wget http://zlib.net/zlib-1.2.6.tar.gz;


Распаковываем архивы:
tar -zxf nginx-1.1.15.tar.gz;
tar -zxf pcre-8.21.tar.gz
tar -zxf zlib-1.2.6.tar.gz


Переходим в папку с исходниками nginx'а
cd nginx-1.1.15;


И конфигурируем nginx

./configure --prefix=/usr --sbin-path=/usr/sbin/nginx --conf-path=/etc/nginx/nginx.conf --error-log-path=/var/log/nginx/error.log --pid-path=/var/run/nginx/nginx.pid --lock-path=/var/lock/nginx.lock --user=nginx --group=nginx --with-http_ssl_module --with-http_gzip_static_module --http-log-path=/var/log/nginx/access.log --http-client-body-temp-path=/tmp/nginx/client/ --http-proxy-temp-path=/tmp/nginx/proxy/ --http-fastcgi-temp-path=/tmp/nginx/fcgi --with-pcre=../pcre-8.21 --with-zlib=../zlib-1.2.6 --with-http_perl_module --with-http_stub_status_module –add-module=../nginx_http_push_module/


Затем

make
make install


теперь создадим файл /etc/init.d/nginx
nano /etc/init.d/nginx;


и положим туда такой скрипт:

#!/bin/sh

### BEGIN INIT INFO
# Provides:          nginx
# Required-Start:    $local_fs $remote_fs $network $syslog
# Required-Stop:     $local_fs $remote_fs $network $syslog
# Default-Start:     2 3 4 5
# Default-Stop:      0 1 6
# Short-Description: starts the nginx web server
# Description:       starts nginx using start-stop-daemon
### END INIT INFO

PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
DAEMON=/usr/sbin/nginx
NAME=nginx
DESC=nginx

# Include nginx defaults if available
if [ -f /etc/default/nginx ]; then
        . /etc/default/nginx
fi

test -x $DAEMON || exit 0

set -e

. /lib/lsb/init-functions

test_nginx_config() {
        if $DAEMON -t $DAEMON_OPTS >/dev/null 2>&1; then
                return 0
        else
                $DAEMON -t $DAEMON_OPTS
                return $?
        fi
}

case "$1" in
        start)
                echo -n "Starting $DESC: "
                test_nginx_config
                # Check if the ULIMIT is set in /etc/default/nginx
                if [ -n "$ULIMIT" ]; then
                        # Set the ulimits
                        ulimit $ULIMIT
                fi
                start-stop-daemon --start --quiet --pidfile /var/run/$NAME.pid \
                    --exec $DAEMON -- $DAEMON_OPTS || true
                echo "$NAME."
                ;;

        stop)
                echo -n "Stopping $DESC: "
                start-stop-daemon --stop --quiet --pidfile /var/run/$NAME.pid \
                    --exec $DAEMON || true
                echo "$NAME."
                ;;

        restart|force-reload)
                echo -n "Restarting $DESC: "
                start-stop-daemon --stop --quiet --pidfile \
                    /var/run/$NAME.pid --exec $DAEMON || true
                sleep 1
                test_nginx_config
                start-stop-daemon --start --quiet --pidfile \
                    /var/run/$NAME.pid --exec $DAEMON -- $DAEMON_OPTS || true
                echo "$NAME."
                ;;

        reload)
                echo -n "Reloading $DESC configuration: "
                test_nginx_config
                start-stop-daemon --stop --signal HUP --quiet --pidfile /var/run/$NAME.pid \
                    --exec $DAEMON || true
                echo "$NAME."
                ;;

        configtest|testconfig)
                echo -n "Testing $DESC configuration: "
                if test_nginx_config; then
                        echo "$NAME."
                else
                        exit $?
                fi
                ;;

        status)
                status_of_proc -p /var/run/$NAME.pid "$DAEMON" nginx && exit 0 || exit $?
                ;;
        *)
                echo "Usage: $NAME {start|stop|restart|reload|force-reload|status|configtest}" >&2
                exit 1
                ;;
esac

exit 0


И теперь
chkconfig --add nginx
chkconfig --level 345 nginx on


Всё. на этом установка nginx завершена, и можно запускать сервис nginx'a
service nginx start


Теперь перейдем к настройкам:

/publisher — служит для записи в канал и должен быть доступен только вашему серверу, иначе писать в него сможет кто угодно.
location /publisher {
	   // обозначаем параметр для выбора канала, в нашем случае это ?cid=
                set $push_channel_id $arg_cid; 
	   push_publisher;
                push_message_timeout 1m; // таймаут жизнии сообщения
                push_message_buffer_length 20000; // буфер сообщений
                allow 127.0.0.1;
                deny all;
}


/listener – доступен всем и служит для раздачи сообщений подписчикам канала.
location /listener {
	   // обозначаем параметр для выбора канала, в нашем случае это ?cid=
                set $push_channel_id $arg_cid; 
                push_subscriber;
                push_subscriber_concurrency broadcast;
                default_type text/plain;
}


На этом по настройки всё. Решение всем хорошо, однако у нас возникли трудности с настройкой crossdomain-ajax, а в модуле, в главной ветке, поддержка jsonp, на данный момент, отсутствует. НО, был найден форк с данным функционалом (https://github.com/Kronuz/nginx_http_push_module). Его установка никак не отличается от оригинала.

Чтобы включить поддержку jsonp в раздел location /listener нужно добавить
// Обозначаем параметр для колбека /?callback=
push_channel_jsonp_callback $arg_callback; 


Теперь, в случае передачи в параметр callback данных, модуль будет заворачивать данные в jsonp.

Отдельной демки нет, но зарегистрированные пользователи в вк могут посмотреть работу на живом примере в нашей игре — vk.com/app2814545
На данной технологии полностью построены бои (собственно говоря, они и требовали этого риалтайма), казино, сообщения о получении / трате ресурсов и множество других событий.
Поделиться публикацией
Комментарии 11
    +3
    А в сторону NodeJS & Socket.IO не смотрели? Там все намного проще, как по мне. Мы используем, а в качестве хранения данных и обработки каналов Redis с его pub/sub. Скрипт на ноде получился простейший, а на клиенте так вообще 2 строки — обычная подписка на событие, которое прозрачно проксируется на сервер с помощью Socket.IO
      0
      на момент реализации задачи у нас не было человека, который хоть раз бы работал с нодой или сокет.ио, а времени было довольно мало, поэтому задачу начали делать на риалплексоре, так как по нему у нас уже были готовые решения, но после запуска игры, наткнулись на фризы, и пришлось в минимальные сроки искать альтернативу.
        0
        Вот мы тоже так думали, если честно — все завелось за 2 дня. Потом во время тестированя было потрачено еще 2 на допил. Действительно не сложно. Вот так наш фронтэндер стал одной ногой на сторону бэкэнда =)
      0
      Я раньше пользовался данным модулем но потом перелез на использование github.com/wandenberg/nginx-push-stream-module
        0
        Я так понимаю, в сторону RabbitMQ тоже не смотрели?
          0
          А кто-нибудь пробовал это реализовать на сокетах? Это же в разы быстрее должно быть, и поддерживается мобильными устройствами (без использования браузера).
          Никто не хочет помочь с простейшей реализацией приложения?
            0
            А чем именно dklab realplexor подвел и почему это ожидалось? Там есть C++-версия, которая работает даже под очень высокими нагрузками. И с задержками не должно было быть проблем…
              0
              Мы ставили перл. первый эксперимент был с очень высоким хайлоадом (порядко 600к-1кк уников в сутки), это было примерно пол года назад. основная проблема, которая возникала — если на канал А не приходят сообщения (не отправляются), а на каналы Б-Я сообщения отправляются довольно часто, то при отправке сообщения на канал А его доставка происходила от 5 до 30 секунд. В принципе, такое наблюдалось и раньше, при попытке поднять риалплексор, на ВПС, но тогда я грешил на убогий сервер.
              В то же время, сообщения, на самые активные каналы доставляются практически моментально, то есть эти каналы не чувствуют задержек в основном, но вот неактивные очень страдают.
              В нынешней ситуации, я полагаю, проблема была схожей, так как некоторые пользователи, да и сами пару раз натыкались на аналогии, ожидали сообщений с кометы по 5-15 секунд, но на этот раз, если сопоставлять ресурсы сервера и нагрузку на него, то это в сотни раз производительнее, чем было под хайлоадом. то есть, что я хочу сказать — на текущем железе, при мизерном онлайне, ситуация возникала та же, что и на более слабом железе, при более сильном хайлоаде.
              то есть, либо мы не правильно настраиваем конфиги (хотя весь форум ваше перерыли), либо есть какая то бага с малоактивными каналами.
              я был бы вам признателен, если бы вы смогли обьяснить, в чем может быть проблема, так как ваша библиотека довольно удобна и легка в использовании, но на текущий момент, к сожалению, мы не можем ее использовать, так как моментальность доставки сообщений очень важна.
                0
                Надо смотреть воспроизводимость конкретно в вашем случае. Каналы независимы, не должна загруженность одних каналов влиять на доставку в других. В вашем примере когда вы отправляете данные в канал A и метод отправки возвращает управление в вызывающий код, данные уже в памяти realplexor-а. Теперь любой клиент, подключившийся к А с правильным идентификатором, сразу же получит эти данные (т.к. они просто достанутся из памяти) — тем более вот вы говорите, что при запросе активных каналов ответ приходит сразу же, а значит, перегруженности очереди запросов нет. Т.е. если для одного канала запрос клиента отстреливается моментально, то и для любого другого канала запрос тоже будет моментально отстреливаться, если есть данные. И наоборот, если есть тормоза, то одинаково будут тормозить как активные, так и неактивные каналы.

                Я думаю, вы просто где-то неверный изначальный курсор передавали, или еще что-то.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое