Статьи

Игра с параллелизмом и производительностью в Java и Node.js

Представьте себе простой рынок, где покупатели и продавцы, заинтересованные в одном и том же продукте, собираются вместе, чтобы торговать. Для каждого продукта на рынке покупатели, заинтересованные в продукте, могут сформировать упорядоченную очередь, отсортированную по принципу «первым пришел — первым обслужен». Каждый покупатель может затем обратиться к самому дешевому продавцу и совершить сделку, покупая у продавца столько товара, сколько пожелает по цене, продиктованной продавцом. Если ни один продавец не предложит товар по достаточно низкой цене, покупатель может отойти в сторону, предоставив следующему покупателю возможность торговать. Как только все покупатели имеют возможность совершить сделку, и после того, как все продукты на рынке прошли через цикл, весь процесс может начаться снова, после того как удовлетворенные покупатели и продавцы уйдут, а новые займут свое место. В эпоху интернетанет причин, по которым покупатели и продавцы не могли торговать на виртуальной платформе, используя этот тип алгоритма, не выходя из своего кресла. Действительно, подобные торговые платформы существуют уже много лет. 

Хотя этот тип проблемы является базовым, он становится интересным, когда используется для создания торгового механизма на основе компьютера. Простые вопросы создают проблемы:

  • Как рынок может расширяться на несколько ядер?
  • Как рынок может распространяться на несколько машин?

По сути, ответы сводятся к требованию некоторой формы параллелизма, чтобы такой торговый механизм мог масштабироваться. Обычно я начинаю писать решение на основе Java, используя, возможно, пул выполнения и  synchronized ключевое слово, чтобы несколько потоков обновляли центральную модель упорядоченным образом. 

Но недавно я начал играть с Node.js, и эта платформа интересна для проблем, подобных описанным выше, потому что это однопоточная неблокирующая платформа. Идея состоит в том, что у программиста меньше оснований размышлять при разработке и написании алгоритмов, потому что нет опасности, что два потока захотят получить доступ к общим данным одновременно. 

Я потратил время на моделирование рынка, описанного выше в JavaScript, и торговая функция выглядит следующим образом (остальные код JavaScript можно найти здесь  [1]). 

 this.trade = function(){
        var self = this;
        var sales = [];
 
        var productsInMarket = this.getProductsInMarket().values();
...
        //trade each product in succession
        _.each(productsInMarket, function(productId){
            var soldOutOfProduct = false;
            logger.debug('trading product ' + productId);
            var buyersInterestedInProduct = self.getBuyersInterestedInProduct(productId);
            if(buyersInterestedInProduct.length === 0){
                logger.info('no buyers interested in product ' + productId);
            }else{
                _.each(buyersInterestedInProduct, function(buyer){
                    if(!soldOutOfProduct){
                        logger.debug('  buyer ' + buyer.name + ' is searching for product ' + productId);
                        //select the cheapest seller
                        var cheapestSeller = _.chain(self.sellers)
                                              .filter(function(seller){return seller.hasProduct(productId);})
                                              .sortBy(function(seller){return seller.getCheapestSalesOrder(productId).price;})
                                              .first()
                                              .value();
 
                        if(cheapestSeller){
                            logger.debug('    cheapest seller is ' + cheapestSeller.name);
                            var newSales = self.createSale(buyer, cheapestSeller, productId);
                            sales = sales.concat(newSales);
                            logger.debug('    sales completed');
                        }else{
                            logger.warn('    market sold out of product ' + productId);
                            soldOutOfProduct = true;
                        }
                    }
                });
            }
        });
 
        return sales;
    };

Код использует   библиотеку Underscore.js, которая предоставляет кучу полезных функциональных помощников, очень похожих на те, что добавлены в  потоки Java 8

Следующим шагом было создание торгового механизма, который инкапсулирует рынок, как показано в следующем фрагменте, который: подготавливает рынок в строке 1, удаляя тайм-ауты продаж, когда ни один из подходящих покупателей и продавцов не может быть в паре; проходит через торговый процесс в строке 3; отмечает статистику по строке 6; и сохраняет продажи в строке 8. 

prepareMarket(self.market, timeout);
 
        var sales = self.market.trade();
        logger.info('trading completed');
 
        noteMarketPricesAndVolumes(self.marketPrices, self.volumeRecords, sales);
 
        persistSale(sales, function(err){
            if(err) logger.warn(err);
            else {
                logger.info('persisting completed, notifying involved parties...');
                _.each(sales, function(sale){
                    if(sale.buyer.event) sale.buyer.event(exports.EventType.PURCHASE, sale);
                    if(sale.seller.event) sale.seller.event(exports.EventType.SALE, sale);
                });
            }
            ...
            setTimeout(loop, 0 + delay); //let the process handle other stuff too
            ...
        });
    }

До сих пор мы не видели ни одного действительно интересного кода, за исключением строки 8 выше, где продажи сохраняются. Продажи вставляются  в таблицу,  которая содержит индексы по идентификатору продажи (первичный ключ с автоинкрементом), идентификатор продукта, идентификатор заказа на продажу и идентификатор заказа на покупку (который происходит из программы). Вызов persistSale(...) функции делает вызов базы данных MySQL, а используемая библиотека использует  неблокирующий ввод-вывод при вызове  базы данных. Это должно быть сделано, потому что в Node.js нет других потоков, доступных в процессе, и все выполнение в процессе будет блокировать при ожидании результатов вставки базы данных. На самом деле происходит то, что процесс Node.js запускает запрос на вставку, а остальная часть кода выполняется немедленно, до завершения. Если вы изучите  оставшуюся часть кода JavaScript , вы заметите, что на самом деле нет другого кода, который выполняется после вызова  persistSale(...) функции. В этот момент Node.js переходит в очередь событий и ищет что-то еще. 

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

Названный скрипт  trading-engine-parent3.js имеет зависимость от небольшой веб-среды с именем  express , и соответствующие части этого скрипта показаны ниже: 

logger.info('setting up HTTP server for receiving commands');
 
var express = require('express')
var app = express()
var id = 0;
app.get('/buy', function (req, res) {
    logger.info(id + ') buying "' + req.query.quantity + '" of "' + req.query.productId + '"');
    ...
});
app.get('/sell', function (req, res) {
    logger.info(id + ') selling "' + req.query.quantity + '" of "' + req.query.productId + '" at price "' + req.query.price + '"');
    ...
});
app.get('/result', function (req, res) {
    var key = parseInt(req.query.id);
    var r = results.get(key);
    if(r){
        results.delete(key);
        res.json(r);
    }else{
        res.json({msg: 'UNKNOWN OR PENDING'});
    }
});
 
var server = app.listen(3000, function () {
  logger.warn('Trading engine listening at http://%s:%s', host, port)
});

Строки 8 и 12 вызывают двигатель и добавляют заказ на покупку / заказ на продажу соответственно. Точно, как это то, что мы рассмотрим в ближайшее время. Строка 16 показывает важный выбор, который я сделал в дизайне, а именно, HTTP-запросы не остаются открытыми в ожидании результата торгового ордера. Первоначально я пытался держать запросы открытыми, но во время нагрузочного тестирования я столкнулся с классическими проблемами блокировки. Рынок содержал заказы, но ни одного с соответствующими продуктами, и сервер не принимал новые запросы после заполнения своего  протокола TCP  (см. Также  здесь ), поэтому другие заказы на покупку и продажу не могли быть созданы другими клиентами, поэтому рынок не сделал этого. не содержат необходимые продукты для продаж, чтобы течь последовательно. 

Итак, давайте вернемся к тому, что происходит после того, как продажи сделки сохраняются. Поскольку постоянство является асинхронным, мы предоставляем функцию обратного вызова в строках 8-20 предыдущего скрипта (trading-engine-loop.js), которая обрабатывает результат, отправляя соответствующие события покупателю / продавцу (строки 13-14) и делая вызов, setTimeout(loop, 0+delay) который сообщает Node.js о запуске  loop функции по крайней мере через  delay миллисекунды.  setTimeout Функция ставит эту работу на очередь событий. Вызывая эту функцию, мы позволяем Node.js обслуживать другую работу, которая была помещена в очередь событий, например, HTTP-запросы на добавление заказов на покупку или продажу, или действительно вызывать  loop функцию, чтобы начать торговлю снова. 

Из-за неблокирующей асинхронной природы кода, который я написал для этого решения Node.js, больше нет необходимости в дополнительных потоках. Кроме … как мы можем увеличить процесс и использовать другие ядра на машине? Node.js поддерживает создание дочерних процессов, и это действительно очень просто, как показано в следующих фрагментах.

// ////////////////
// Parent
// ////////////////
...
var cp = require('child_process');
...
//TODO use config to decide how many child processes to start
var NUM_KIDS = 2;
var PRODUCT_IDS = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 
                   '10', '11', '12', '13', '14', '15', '16', '17', '18', '19',
                   ...
                  ];
 
var chunk = PRODUCT_IDS.length / NUM_KIDS;
var kids = new Map();
for (var i=0, j=PRODUCT_IDS.length; i<j; i+=chunk) {
    var n = cp.fork('./lib/trading-engine-child.js');
    n.on('message', messageFromChild);
    var temparray = PRODUCT_IDS.slice(i,i+chunk);
    logger.info('created child process for products ' + temparray);
    _.each(temparray, function(e){
        logger.debug('mapping productId "' + e + '" to child process ' + n.pid);
        kids.set(e, n);
    });
}
...
 
// ////////////////
// Child
// ////////////////
process.on('message', function(model) {
    logger.debug('received command: "' + model.command + '"');
    if(model.command == t.EventType.PURCHASE){
        var buyer = ...
        var po = new m.PurchaseOrder(model.what.productId, model.what.quantity, model.what.maxPrice, model.id);
        buyer.addPurchaseOrder(po);
    }else if(model.command == t.EventType.SALE){
        ...
    }else{
        var msg = 'Unknown command ' + model.command;
        process.send({id: model.id, err: msg});            
    }
});

Строка 5 импортирует API для работы с дочерними процессами, и мы разбиваем рынок, группируя идентификаторы продуктов в строках 14-25. Для каждого раздела мы запускаем новый дочерний процесс (строка 17) и регистрируем обратный вызов для получения данных, передаваемых из дочернего процесса обратно в родительский процесс в строке 18. Мы вставляем ссылку на дочерний процесс в карту, кодируемую идентификатором продукта. на линии 23 , так что мы можем отправить его сообщения по телефону, например:  n.send(someObject). Довольно просто, как просто отправлять и получать объекты и как они транспортируются как (предположительно) JSON — это очень похоже на вызовы RMI в Java. 

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

Если вам интересно, может ли покупатель присутствовать на нескольких рынках, то ответ, конечно, да — рынки виртуальные, и покупатели не ограничены физическим местоположением, каким они могут быть в реальной жизни 🙂 

Что бы эквивалентно Как выглядит решение Java, и как оно будет работать? Полный  код Java доступен здесь  [1]. 

Начиная с рынка и его trade() метод, код Java выглядит аналогично версии JavaScript, используя Java 8 Streams вместо библиотеки Underscore. Интересно, что он примерно идентичен по количеству строк кода или, если говорить более субъективно, удобство обслуживания 

public List<Sale> trade() {
    List<Sale> sales = new ArrayList<>();
    Set<String> productsInMarket = getProductsInMarket();
    collectMarketInfo();
 
    // trade each product in succession
    productsInMarket.stream()
        .forEach(productId -> {
            MutableBoolean soldOutOfProduct = new MutableBoolean(false);
            LOGGER.debug("trading product " + productId);
            List<Buyer> buyersInterestedInProduct = getBuyersInterestedInProduct(productId);
            if (buyersInterestedInProduct.size() == 0) {
                LOGGER.info("no buyers interested in product " + productId);
            } else {
                buyersInterestedInProduct.forEach(buyer -> {
                    if (soldOutOfProduct.isFalse()) {
                        LOGGER.debug("  buyer " + buyer.getName() + " is searching for product " + productId);
                        // select the cheapest seller
                        Optional<Seller> cheapestSeller = sellers.stream()
                            .filter(seller -> { return seller.hasProduct(productId);})
                            .sorted((s1, s2) -> 
                                Double.compare(s1.getCheapestSalesOrder(productId).getPrice(),
                                               s2.getCheapestSalesOrder(productId).getPrice()))
                            .findFirst();
                        if (cheapestSeller.isPresent()) {
                            LOGGER.debug("    cheapest seller is " + cheapestSeller.get().getName());
                            List<Sale> newSales = createSale(buyer, cheapestSeller.get(), productId);
                            sales.addAll(newSales);
                            LOGGER.debug("    sales completed");
                        } else {
                            LOGGER.warn("    market sold out of product " + productId);
                            soldOutOfProduct.setTrue();
                        }
                    }
                });
            }
        });
    return sales;
}

Как я писал в своей книге пару лет назад, в наши дни нормально писать мультипарадигмальные решения: функциональное программирование используется для манипулирования данными, объектная ориентация используется для инкапсуляции, скажем, покупателя, продавца или рынка, и, как мы увидим, вкратце, сервисное и аспектно-ориентированное программирование для склеивания сложного фреймворкового кода для предоставления, скажем, REST-подобного HTTP-сервиса. Далее,  run метод торгового движка в Java, который торгует, пока движок находится в рабочем состоянии: 

public void run() {
    while (running) {
        prepareMarket();
 
        List<Sale> sales = market.trade();
        LOGGER.info("trading completed");
 
        noteMarketPricesAndVolumes(sales);
 
        persistSale(sales);
        LOGGER.info("persisting completed, notifying involved parties...");
        sales.stream().forEach(sale -> {
            if (sale.getBuyer().listener != null)
                sale.getBuyer().listener.onEvent(EventType.PURCHASE, sale);
            if (sale.getSeller().listener != null)
                sale.getSeller().listener.onEvent(EventType.SALE, sale);
        });
        ...
        try {
            Thread.sleep(delay);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

Дизайн Java немного отличается от дизайна Node.js тем, что я создал простой метод с именем,  run который я вызову один раз. Он работает снова и снова, пока булево поле с именем  running true. Я могу сделать это в Java, потому что я могу использовать другие потоки для параллельной торговли. Чтобы настроить движок, я ввел небольшую настраиваемую задержку в конце каждой итерации, где поток останавливается. Он был настроен на паузу в 3 миллисекунды во всех тестах, которые я проводил, что было то же самое, что и для решения JavaScript. 

Теперь я только что упомянул использование потоков для масштабирования системы. В этом случае потоки аналогичны дочерним процессам, используемым в решении Node.js. Как и в решении Node.js, решение Java разделяет рынок по идентификатору продукта, но вместо использования дочерних процессов решение Java запускает каждый торговый механизм (который инкапсулирует рынок) в отдельном потоке. Теория диктует, что оптимальное количество разделов будет похоже на количество ядер, но опыт показывает, что это также зависит от того, сколько потоков заблокировано, ожидая, например, сохранения продаж в базе данных. Заблокированные потоки освобождают место для работы других потоков, но слишком большое количество потоков снижает производительность, так как переключение контекста между потоками становится более актуальным.Единственный надежный способ настройки системы — запустить несколько нагрузочных тестов и поиграть с такими переменными, как количество используемых двигателей. 

Поток просто делегирует работу движку, который, как показано выше, работает в цикле, пока он не выключится. 

public class TradingEngineThread extends Thread {
    private final TradingEngine engine;
 
    public TradingEngineThread(long delay, long timeout, Listener listener) throws NamingException {
        super("engine-" + ID++);
        engine = new TradingEngine(delay, timeout, listener);
    }
 
    @Override
    public void run() {
        engine.run();
    }

Для решения Java я использовал Tomcat в качестве веб-сервера и создал простой HttpServlet для обработки запросов на создание заказов на покупку и продажу. Сервлет разделяет рынок и создает соответствующие потоки, а также запускает их (обратите внимание, что лучший способ сделать это — запустить потоки при запуске сервлета и выключить двигатели, когда сервлет остановлен — показанный код не готов к работе !). Строка 15 следующего кода запускает потоки, показанные в предыдущем фрагменте. 

@WebServlet(urlPatterns = { "/sell", "/buy", "/result" })
public class TradingEngineServlet extends HttpServlet {
    private static final Map<String, TradingEngineThread> kids = new HashMap<>();
    static {
        int chunk = PRODUCT_IDS.length / NUM_KIDS;
        for (int i = 0, j = PRODUCT_IDS.length; i < j; i += chunk) {
            String[] temparray = Arrays.copyOfRange(PRODUCT_IDS, i, i + chunk);
            LOGGER.info("created engine for products " + temparray);
            TradingEngineThread engineThread = new TradingEngineThread(DELAY, TIMEOUT, (type, data) -> event(type, data));
            for (int k = 0; k < temparray.length; k++) {
                LOGGER.debug("mapping productId '" + temparray[k] + "' to engine " + i);
                kids.put(temparray[k], engineThread);
            }
            LOGGER.info("---started trading");
            engineThread.start();
        }

Сервлет обрабатывает запросы на покупку и продажу следующим образом: 

@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    String path = req.getServletPath();
    LOGGER.debug("received command: '" + path + "'");
 
    String who = req.getParameter("userId");
    String productId = req.getParameter("productId");
    TradingEngineThread engine = kids.get(productId);
    int quantity = Integer.parseInt(req.getParameter("quantity"));
    int id = ID.getAndIncrement();
 
    // e.g. /buy?productId=1&quantity=10&userId=ant
    if (path.equals("/buy")) {
        PurchaseOrder po = engine.addPurchaseOrder(who, productId, quantity, id);
        resp.getWriter().write("\"id\":" + id + ", " + String.valueOf(po));
    } else if (path.equals("/sell")) {

Соответствующий механизм ищется в строке 8, и, например, приводится подробное описание создания заказа на покупку в строке 14. Теперь он изначально выглядит так, как будто у нас есть все необходимое для решения Java, но не раньше, чем я загружаю сервер. Я столкнулся  ConcurrentModificationExceptionс s, и было очевидно, что происходит: строка 14 в приведенном выше фрагменте добавляла заказы на покупку к модели в двигателе в то же время, когда рынок говорил, повторяя заказы на покупку покупателей, чтобы определить, какие покупатели были заинтересованы в каких продуктах. 

Именно такой проблемы Node.js избегает благодаря однопоточному подходу. Это также проблема, которую действительно трудно решить в мире Java! Следующие советы могут помочь:

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

Первый совет может привести к тупикам и несколько печально известен в мире Java. Второй совет иногда полезен, но включает в себя накладные расходы на копирование данных. Третий совет иногда помогает, но обратите внимание на следующий комментарий, содержащийся в Javadocs java.util.Collections#synchronizedCollection(Collection)

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

Использование поточно-безопасных коллекций просто недостаточно, и проблемы, связанные с первым советом, не исчезают так просто, как можно было бы надеяться. Это оставляет четвертый совет. Если вы посмотрите на приведенный выше код, вы найдете метод с именем prepareMarket(). Почему бы нам не хранить все заказы на покупку и продажу в их собственной модели, пока торговый движок, работающий в своем собственном потоке, не достигнет точки, в которой он должен подготовить рынок, и в этот момент возьмет все эти открытые ордера и добавит их модели рынка, прежде чем начнется торговля? Таким образом, мы можем избежать одновременного доступа из нескольких потоков и необходимости синхронизации данных. Когда вы посмотрите на  весь исходный код Java,  вы увидите, что он  TradingEngine делает именно это с двумя полями, названными  newPurchaseOrders и newSalesOrders

Интересная особенность такого дизайна заключается в том, что он очень похож на  модель актера , и идеальная библиотека для Java уже существует, а именно  Akka., Поэтому я добавил второй сервлет в приложение, которое использует Akka, а не потоки, чтобы показать, как он решает проблемы параллелизма. Описанный в основном, субъект — это объект, который содержит состояние (данные), поведение и входящие сообщения. Никто не имеет доступа к состоянию, кроме актера, поскольку он должен быть закрытым для актера. Актер отвечает на сообщения в папке «Входящие» и выполняет свое поведение в зависимости от того, что сообщения говорят ему делать. Актер гарантирует, что он будет когда-либо только читать и отвечать на одно сообщение в любое время, так что никакие параллельные изменения состояния не могут произойти. Новый сервлет создает новых акторов следующим образом, в строке 13, используя систему акторов, созданную в строке 4. Обратите внимание, что, как и выше, этот код не готов к работе,поскольку система актера должна запускаться при запуске сервлета, а не в статическом контексте, как показано ниже, и она должна быть закрыта при остановке сервлета. Строка 19 отправляет сообщение вновь созданному действующему субъекту, чтобы сказать ему запустить торговый движок, который он содержит. 

@WebServlet(urlPatterns = { "/sell2", "/buy2", "/result2" })
public class TradingEngineServletWithActors extends HttpServlet {
 
    private static final ActorSystem teSystem = ActorSystem.create("TradingEngines");
    private static final Map<String, ActorRef> kids = new HashMap<>();
 
    static {
        int chunk = PRODUCT_IDS.length / NUM_KIDS;
        for (int i = 0, j = PRODUCT_IDS.length; i < j; i += chunk) {
            String[] temparray = Arrays.copyOfRange(PRODUCT_IDS, i, i + chunk);
            LOGGER.info("created engine for products " + temparray);
     
            ActorRef actor = teSystem.actorOf(Props.create(TradingEngineActor.class), "engine-" + i);
            for (int k = 0; k < temparray.length; k++) {
                LOGGER.debug("mapping productId '" + temparray[k] + "' to engine " + i);
                kids.put(temparray[k], actor);
            }
            LOGGER.info("---started trading");
            actor.tell(TradingEngineActor.RUN, ActorRef.noSender());
        }

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

private static class TradingEngineActor extends AbstractActor {
 
    // STATE
    private TradingEngine engine = new TradingEngine(DELAY, TIMEOUT, (type, data) -> handle(type, data), true);
 
    public TradingEngineActor() throws NamingException {
 
        // INBOX
        receive(ReceiveBuilder
            .match(SalesOrder.class, so -> {
                // BEHAVIOUR (delegated to engine)
                engine.addSalesOrder(so.getSeller().getName(),
                    so.getProductId(),
                    so.getRemainingQuantity(),
                    so.getPrice(), so.getId());
                })
            .match(PurchaseOrder.class, po -> {
                ...
            .match(String.class, s -> RUN.equals(s), command -> {
                engine.run();
            })
            .build());
    } 
}

Вы можете видеть, что торговый механизм в строке 4 класса актера является частным и когда-либо использовался только при получении сообщений, например, в строках 12, 18 или 20. Таким образом, гарантия того, что никакие два потока не смогут получить к нему доступ одновременно время можно поддерживать, и, что немаловажно для нас, абсолютно не нужно синхронизировать движок, а это значит, что наша способность рассуждать о параллелизме была значительно улучшена! Обратите внимание, что для разрешения обработки сообщений в папке «Входящие» торговый механизм запускает одну торговую сессию, а затем в папку «Входящие» помещается новое сообщение «Выполнить». Таким образом, любые сообщения от HTTP-сервера для добавления заказов на покупку / продажу сначала обрабатываются до продолжения торговли. 

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

  • «Высокопроизводительный» 6-ядерный процессор AMD с 16 ГБ оперативной памяти под управлением Linux (Fedora Core 20),
  • Четырехъядерный процессор I5 средней производительности с 4 ГБ оперативной памяти под управлением Windows 7 и
  • «Низкоэффективный» процессор Intel Core 2 Duo с 4 ГБ ОЗУ также работает под управлением Linux.

Из всех возможных комбинаций развертывания я выбрал следующие два: 

# Загрузить тестовый клиент Торговый движок База данных
1 средний быстро медленный
2 средний медленный быстро

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

Три машины были подключены к кабельной сети со скоростью 100 Мбит / с. Клиент нагрузочного тестирования представлял  собой пользовательскую Java-программу который использует пул выполнения для непрерывного выполнения 50 параллельных потоков, выполняющих произвольные заказы на покупку и продажу. Между запросами клиент делает паузу. Время паузы было настроено таким образом, чтобы худшие показатели процессов Java и Node.js могли не отставать от нагрузки, но были близки к критической точке, где они начинали отставать, и записываются ниже в результатах. Результаты не были записаны до того, как по крайней мере полмиллиона продаж были сохранены, и не раньше, чем стабилизировалась пропускная способность (подумайте об оптимизации горячих точек). Пропускная способность измерялась по количеству строк, вставленных в базу данных, а не по хитрой статистике, которую выводят программы. 

Результаты были: 

Случай 1 — время ожидания клиента 200 мс, 4 торговых движка
Быстрые торговые движки, медленная база данных
Синхронизированная Java Ява с Аккой Node.js
пропускная способность (продажи в минуту) +5100 5000 +6400
средний процессор на машине с торговыми движками <50% <40% 40-60%

Случай 2 — время ожидания клиента 50 мс, 2 торговых движка
Медленные торговые движки, быстрая база данных
Синхронизированная Java Ява с Аккой Node.js
пропускная способность (продажи в минуту) 32800 30100 15000
средний процессор на машине с торговыми движками 85% 90% > 95%

В первом случае торговые движки не были связаны с процессором. Во втором случае торговые механизмы были связаны с процессором, но система в целом работала быстрее, чем в первом случае. Ни в том, ни в другом случае системная сеть не была связана, потому что я измерял скорость передачи до 300 килобайт в секунду, что составляет менее 3% от пропускной способности сети. В первом случае, когда база данных была самым медленным компонентом, торговые движки, по-видимому, были связаны с вводом / выводом, ожидая результатов вставок базы данных. Поскольку Node.js использует неблокирующую парадигму для всего своего кода, он работал лучше, чем решение Java. В то время как я использовал Tomcat 8 с его предварительно сконфигурированным неблокирующим (NIO) коннектором, драйвер MySQL был стандартной версией блокировки JDBC. Во втором случае, когда база данных была быстрее, торговые механизмы были связаны с процессором, а решение Java работало быстрее. 

Мои результаты на самом деле не были такими уж удивительными — общеизвестно, что Node.js хорошо работает, особенно в условиях блокировки. Смотрите следующие две ссылки для результатов, которые, я думаю, хорошо коррелируют с моими результатами:  Что делает Node.js быстрее, чем Java?  и анализ показателей PayPal по Node-vs-Java . Комментарии в конце второй ссылки очень интересны, и я чувствую в основном правильные моменты. 

Я не пытался оптимизировать решение Java, сделав постоянство также неблокирующим, чтобы оно также было полностью неблокирующим. Это было бы возможно, потому что  существует неблокирующий (хотя и не JDBC) драйвер MySQL, Но это также потребует изменения дизайна Java-решения. И, как указано в одном из комментариев в приведенных выше ссылках, возможно, этот редизайн будет самой сложной частью для  среднего Java-программист, которому до недавнего времени никогда не приходилось программировать в рамках асинхронной неблокирующей парадигмы. Дело не в том, что это сложно, а в том, что это не так, и я подозреваю, что после недавнего успеха Node.js все больше и больше асинхронных библиотек Java начнут появляться. Пожалуйста, обратите внимание, что этот последний абзац не предназначен, чтобы спровоцировать любые дебаты — я ни в коем случае не говорю, что любой из Java, JavaScript, JVM или Node.js лучше. Я хочу сказать, что а) я был убежденным сторонником Java и ее экосистемы, и за последние несколько лет я созрел, чтобы понять, что другие платформы также хороши, и б) выбрать правильные инструменты для работы под рукой, оценка с доказательством концепции, например, как я сделал здесь. 

[1] Обратите внимание, что код, представленный в этой статье, не подходит для каких-либо целей и, конечно, не готов к работе, и не является репрезентативным для того, что я мог бы создать профессионально — он взломан вместе, чтобы исследовать темы, обсужденные выше!