Sync vs Async на примере Firebird

в 6:32, , рубрики: async, asynchronous, firebird, javascript, node.js, nodejs, метки: , , , ,

image

В этой публикации я поставил перед собой несколько целей:

  • Сравнить разные стили программирования работы с БД Firebird в NodeJS;
  • Найти наиболее производительный вариант;
  • Получить в результате руководство к действию тем, кто начинает использовать Firebird в NodeJS.

Дальше много кода…

Тестовая задача выглядит очень просто:
вернуть результат запроса

select * from rdb$relations

в формате JSON. На моей тестовой базе это будет ответ размером порядка 19 кб.

Напишем различные варианты реализации этой задачи и, используя Apache Benchmark, найдем оптимальный вариант. Тестировать производительность будем такой командой:

ab -n 10000 -c 5  http://localhost:1337/

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

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

var cfg = require("../config").cfg;
var fb = require('../../firebird');
var util = require('util');

var http = require('http');

http.createServer(function (req, res) {
    res.writeHead(200, {'Content-Type': 'text/plain'});
    var con = fb.createConnection();
    con.connectSync(cfg.db, cfg.user, cfg.password, cfg.role);
    var rs = con.querySync('select * from rdb$relations');
    var rows = rs.fetchSync("all",true);
    con.disconnect();
    res.write('[');
    rows.forEach(function(r){
     res.write(JSON.stringify(r)+',');
    });
    res.end(']');
}).listen(1337, "127.0.0.1");
console.log('Server running at http://127.0.0.1:1337/');

Total transferred: 199310000 bytes
HTML transferred: 198670000 bytes
Requests per second: 52.47 [#/sec] (mean)

Я оставил количество переданных байт, чтобы убедиться, что все варианты скриптов генерируют одинаковый результат.
Библиотека доступа к Firebird позволяет заменить все вызовы на асинхронные. Вот как это будет выглядеть:

var cfg = require("../config").cfg;
var fb = require('../../firebird');
var util = require('util');

var http = require('http');

http.createServer(function (req, res) {
    res.writeHead(200, {'Content-Type': 'text/plain'});
    var con = fb.createConnection();
    con.connect(cfg.db, cfg.user, cfg.password, cfg.role,function(){
       con.query('select * from rdb$relations',function(err,rs){
          res.write('[');
          rs.fetch("all",true,function(r){
               res.write(JSON.stringify(r)+',');
          }, function(err){
            con.disconnect();
                res.end(']');
          });
       
       });
    });
    
}).listen(1337, "127.0.0.1");
console.log('Server running at http://127.0.0.1:1337/');

Total transferred: 199310000 bytes
HTML transferred: 198670000 bytes
Requests per second: 129.26 [#/sec] (mean)

Простая замена синхронных вызовов на асинхронные дала прирост производительности до
129 запросов в секунду против 52. Асинхронный подход позволяет выполнять запросы параллельно, установив отдельное подключения для каждого клиента. Кроме того, в синхронном варианте весь результат запроса сначала записывается в память, а потом отдается клиенту. В асинхронном же строки результата отдаются клиенту по мере выборки из базы «на лету».

Однако, в этом коде еще есть неоптимальные моменты, которые могут быть оптимизированы. Например, это то, что каждое подключение клиента создает новое соединение с БД. Как крайний случай, попробуем обойтись единственным подключением. Вначале для синхронного варианта:

var cfg = require("../config").cfg;
var fb = require('../../firebird');
var util = require('util');

var http = require('http');

var con = fb.createConnection();
    con.connectSync(cfg.db, cfg.user, cfg.password, cfg.role);

http.createServer(function (req, res) {
    res.writeHead(200, {'Content-Type': 'text/plain'});
    if(!con.inTransaction) con.startTransactionSync();
    var rs = con.querySync('select * from rdb$relations');
    var rows = rs.fetchSync("all",true);
    res.write('[');
    rows.forEach(function(r){
     res.write(JSON.stringify(r)+',');
    });
    res.end(']');
    con.commitSync();
}).listen(1337, "127.0.0.1");
console.log('Server running at http://127.0.0.1:1337/');

Total transferred: 199310000 bytes
HTML transferred: 198670000 bytes
Requests per second: 159.55 [#/sec] (mean)

Как видно, этот вариант быстрее предыдущего асинхронного. Установление подключения к БД — довольно затратная по времени операция. Повторное использование подключения — известная и давно применяемая техника. Стоит брать на вооружение.

Теперь попробуем добавить асинхронности. Следующий код является заведомо неоптимальным — использование единственного подключения и асинхронность не позволят нам выполнять запросы параллельно. Тем не менее для чистоты эксперимента…

var cfg = require("../config").cfg;
var fb = require('../../firebird');
var util = require('util');

var http = require('http');
var con = fb.createConnection();
    con.connectSync(cfg.db, cfg.user, cfg.password, cfg.role),
    busy = false,
    next = [];

http.createServer(function (req, res) {
    res.writeHead(200, {'Content-Type': 'text/plain'});
                
    function doReq(){
       con.query('select * from rdb$relations',function(err,rs){
          res.write('[');
          rs.fetch("all",true,function(r){
           res.write(JSON.stringify(r)+',');
          }, function(err){
            res.end(']');
            con.commit(function(){
              busy = false;
              var n = next.pop();
              if(n) n();
            });
          });
       });
    }
    
    function process(){
       busy = true;     
       if(!con.inTransaction) con.startTransaction(doReq);
       else doReq(); 
    }   
     
   if(busy) next.push(function(){
      process();  
   }); 
   else process();
    
}).listen(1337, "127.0.0.1");
console.log('Server running at http://127.0.0.1:1337/');

Total transferred: 199310000 bytes
HTML transferred: 198670000 bytes
Requests per second: 129.85 [#/sec] (mean)

Асинхронный вариант выглядит довольно громоздким. И дает всего 129 запросов в секунду. Проблема в том, что использование единственного подключения не дает выигрыша при асинхронной выборке, поскольку клиентская библиотека Firebird не поддерживает асинхронные вызовы by design. Модуль Firebird для NodeJS запускает все асинхронные вызовы в параллельных потоках с использованием встроенного в libuv пула. Нельзя использовать единственное подключение для выполнения параллельных запросов. Поэтому код содержит проверки на занятость соединения и организует очередь вызовов. Видимо, организация очереди оказалась в данном случае замедляющим фактором. Ведь синхронный вариант оказался быстрее. Но использовать отдельное соединение с БД для каждого клиента тоже не всегда оптимально. Логичным решением было бы использование пула соединений.

var cfg = require("../config").cfg;
var fb = require('../../firebird');
var util = require('util');
var events = require('events');

var http = require('http');

function ConnectionPool()
{
    events.EventEmitter.call(this);
    this.conns = [];
    this.busy = [];
    this.MaxConns = 5;
    this.newConn = function(){
        var c = fb.createConnection();  
        c.connectSync(cfg.db, cfg.user, cfg.password, cfg.role);
        this.conns.push(c); 
    };
    this.get = function(cb)
    {
        var self = this;
        var c = this.conns.pop();
        if(c) {
           this.busy.push(c); 
           cb(c);
        }
        else
        if((this.busy.length) >= this.MaxConns){
          this.once('release',function(){
           self.get(cb); 
          });
        }
        else {
            this.newConn();
            this.get(cb);
        }   
    };
    
    this.release = function(con){
        for(var i=0;i<this.busy.length;i++)
        {
            if(this.busy[i] == con){
                this.conns.push(this.busy[i]);
                this.busy.splice(i,1);
                var self = this;
                process.nextTick(function(){
                  self.emit('release');
                });  
                return;
            }
        }
    };
}

util.inherits(ConnectionPool, events.EventEmitter);

var pool = new ConnectionPool();
pool.setMaxListeners(2000);

function exec(con,res){
         con.query('select * from rdb$relations',function(err,rs){
              res.write('[');
              rs.fetch("all",true,function(r){
               res.write(JSON.stringify(r)+',');
              },function(err){
                res.end(']');
                con.commit(function(){
                 pool.release(con);
                });
            });    
        });
};

http.createServer(function (req, res) {
    res.writeHead(200, {'Content-Type': 'text/plain'});
    pool.get(function(con){
        if(!con.inTransaction) con.startTransaction(function(err){
          if(!err) exec(con,res);
        });
        else exec(con,res);     
    });   
}).listen(1337, "127.0.0.1");
console.log('Server running at http://127.0.0.1:1337/');

Total transferred: 199310000 bytes
HTML transferred: 198670000 bytes
Requests per second: 187.82 [#/sec] (mean)

Кода получилось много, но и выигрыш в производительности очевиден. Тем более код организации пула соединений можно вынести в отдельный модуль для повторного использования, а код работы с БД останется простым и компактным.

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

var cfg = require("../config").cfg;
var fb = require('../../firebird');
var util = require('util');
var events = require('events');

var http = require('http');

function StatementPool()
{
    events.EventEmitter.call(this);
    this.conns = [];
    this.busy = [];
    this.MaxConns = 5;
    this.newConn = function(){
        var c ={
           conn: fb.createConnection()  
        };
        c.conn.connectSync(cfg.db, cfg.user, cfg.password, cfg.role);
        c.stmt = c.conn.prepareSync('select * from rdb$relations');
        this.conns.push(c); 
    };
    this.get = function(cb)
    {
        var self = this;
        var c = this.conns.pop();
        if(c) {
           this.busy.push(c); 
           cb(c);
        }
        else
        if((this.busy.length) >=this.MaxConns){
          this.once('release',function(){
           self.get(cb); 
          });
        }
        else {
            this.newConn();
            this.get(cb);
        }   
    };
    this.release = function(con){
        for(var i=0;i<this.busy.length;i++)
        {
            if(this.busy[i] == con){
                this.conns.push(this.busy[i]);
                this.busy.splice(i,1);
                var self = this;
                process.nextTick(function(){
                  self.emit('release');
                });  
                return;
            }
        }
    };
    
}
util.inherits(StatementPool, events.EventEmitter);

var pool = new StatementPool();
pool.setMaxListeners(2000);

    
http.createServer(function (req, res) {
    res.writeHead(200, {'Content-Type': 'text/plain'});
    pool.get(function(con){
    var exec = function(){
      con.stmt.exec();
      con.stmt.once('result',function(err){
          res.write('[');
          con.stmt.fetch("all",true,function(r){
            res.write(JSON.stringify(r)+',');
          }, function(err){
            res.end(']');
            con.conn.commit(function(){
              pool.release(con);
            });
          });
      });
    };    
        
   if(!con.conn.inTransaction) con.conn.startTransaction(function(err){
          if(!err) exec();
   });
   else exec();     
  });  
    
}).listen(1337, "127.0.0.1");
console.log('Server running at http://127.0.0.1:1337/');

Total transferred: 199310000 bytes
HTML transferred: 198670000 bytes
Requests per second: 214.37 [#/sec] (mean)

Несмотря на бОльший объем кода, такой вариант дает более 210 запросов в секунду.

Использование нескольких соединений одновременно, подготовленных запросов и асинхронных вызовов дает выигрыш по крайней мере в 4 раза по сравнению с полностью синхронным вариантом. Однако эта цифра верна лишь для моего тестового окружения. Надо также понимать, что сама по себе асинхронность в NodeJS не позволяет увеличить производительность сервера БД, она лишь помогает его нагрузить по полной. В данном случае серверу БД было выделено 256Мб памяти и 1 ядро процессора — этим я объясняю в целом небольшие показатели RPS. Впрочем, нагрузка по памяти и так не превышала 3%. Также асинхронный подход позволяет параллельно обрабатывать другие запросы, не касающиеся запросов к БД. Таких запросов в данных тестах не было. Наиболее влияющим фактором на количество RPS стал размер выборки запроса. Так, при ограничении выборки 10 записями (около 1кб в результирующем JSON) и выделении серверу БД 4 ядер процессора, удалось получить порядка 1000 RPS, но и синхронный вариант в этом случае выдавал около 200 RPS. Стоит учесть, что асинхронный вариант, выполняя 1000 запросов к БД в секунду, способен при этом параллельно рисовать из них HTML по шаблонам, в то время как синхронный нет.
Все тестовые скрипты являются частью модуля доступа к Firebird для NodeJS
И доступны на GitHub.

Автор: xdenser

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js