Как Apache APISIX достигает высокой скорости?
June 12, 2023
"Высокая скорость", "минимальная задержка" и "максимальная производительность" — это характеристики, которые часто используются для описания Apache APISIX. Даже когда меня спрашивают о APISIX, мой ответ всегда включает "высокопроизводительный облачный API-шлюз."
Тесты производительности (в сравнении с Kong, Envoy) подтверждают, что эти характеристики действительно точны (проверьте сами).

Тесты проводились в 10 раундах с 5000 уникальными маршрутами на Standard D8s v3 (8 vCPU, 32 ГБ оперативной памяти).
Но как APISIX достигает этого?
Чтобы ответить на этот вопрос, мы должны рассмотреть три вещи: etcd, хэш-таблицы и radix-деревья.
В этой статье мы заглянем под капот APISIX и узнаем, что это такое и как все это работает вместе, чтобы APISIX поддерживал пиковую производительность при обработке значительного трафика.
etcd как центр конфигураций
APISIX использует etcd для хранения и синхронизации конфигураций.
etcd разработан как хранилище ключ-значение для конфигураций крупномасштабных распределенных систем. APISIX изначально задуман как распределенный и высокомасштабируемый, и использование etcd вместо традиционных баз данных способствует этому.

Еще одна ключевая функция, необходимая для API-шлюзов, — это высокая доступность, избежание простоев и потери данных. Этого можно эффективно достичь, развернув несколько экземпляров etcd, чтобы обеспечить отказоустойчивую облачную архитектуру.
APISIX может читать/записывать конфигурации из/в etcd с минимальной задержкой. Изменения в конфигурационных файлах мгновенно уведомляются, что позволяет APISIX отслеживать только обновления etcd, вместо частого опроса базы данных, что может добавить нагрузку на производительность.
Эта диаграмма суммирует, как etcd сравнивается с другими базами данных.
Хэш-таблицы для IP-адресов
Списки разрешенных/запрещенных IP-адресов — это распространенный случай использования для API-шлюзов.
Для достижения высокой производительности APISIX хранит список IP-адресов в хэш-таблице и использует ее для сопоставления (O(1)), вместо перебора списка (O(N)).
По мере увеличения количества IP-адресов в списке становится очевидным влияние на производительность при использовании хэш-таблиц для хранения и сопоставления.
Под капотом APISIX использует библиотеку lua-resty-ipmatcher для реализации этой функциональности. Пример ниже показывает, как используется библиотека:
local ipmatcher = require("resty.ipmatcher") local ip = ipmatcher.new({ "162.168.46.72", "17.172.224.47", "216.58.32.170", }) ngx.say(ip:match("17.172.224.47")) -- true ngx.say(ip:match("176.24.76.126")) -- false
Библиотека использует таблицы Lua, которые являются хэш-таблицами. IP-адреса хэшируются и хранятся как индексы в таблице, и для поиска заданного IP-адреса достаточно проиндексировать таблицу и проверить, является ли значение nil или нет.

Для поиска IP-адреса сначала вычисляется хэш (индекс) и проверяется его значение. Если оно не пустое, у нас есть совпадение. Это выполняется за постоянное время O(1).
Radix-деревья для маршрутизации
Пожалуйста, простите меня за то, что я заманил вас на урок по структурам данных! Но выслушайте меня; здесь становится интересно.
Ключевая область, где APISIX оптимизирует производительность, — это сопоставление маршрутов.
APISIX сопоставляет маршрут с запросом на основе его URI, HTTP-методов, хоста и другой информации (см. router). И это должно быть эффективным.
Если вы читали предыдущий раздел, очевидным ответом будет использование хэш-алгоритма. Но сопоставление маршрутов сложно, потому что несколько запросов могут соответствовать одному маршруту.
Например, если у нас есть маршрут /api/*, то и /api/create, и /api/destroy должны соответствовать этому маршруту. Но это невозможно с хэш-алгоритмом.
Регулярные выражения могут быть альтернативным решением. Маршруты можно настроить в виде регулярного выражения, и оно может соответствовать нескольким запросам без необходимости жестко кодировать каждый запрос.
Если взять наш предыдущий пример, мы можем использовать регулярное выражение /api/[A-Za-z0-9]+, чтобы соответствовать как /api/create, так и /api/destroy. Более сложные регулярные выражения могут соответствовать более сложным маршрутам.
Но регулярные выражения медленные! А мы знаем, что APISIX быстрый. Поэтому вместо этого APISIX использует radix-деревья, которые являются сжатыми префиксными деревьями (trie), которые отлично подходят для быстрого поиска.
Давайте рассмотрим простой пример. Предположим, у нас есть следующие слова:
- romane
- romanus
- romulus
- rubens
- ruber
- rubicon
- rubicundus
Префиксное дерево будет хранить их так:

Выделенный обход показывает слово "rubens."
Radix-дерево оптимизирует префиксное дерево, объединяя дочерние узлы, если у узла только один дочерний узел. Наше примерное дерево будет выглядеть так как radix-дерево:

Выделенный обход все еще показывает слово "rubens." Но дерево выглядит намного меньше!
Когда вы создаете маршруты в APISIX, APISIX хранит их в этих деревьях.
APISIX может работать безупречно, потому что время, необходимое для сопоставления маршрута, зависит только от длины URI в запросе и не зависит от количества маршрутов (O(K), где K — длина ключа/URI).
Таким образом, APISIX будет так же быстр при сопоставлении 10 маршрутов, как и при сопоставлении 5000 маршрутов, когда вы масштабируетесь.
Этот грубый пример показывает, как APISIX может хранить и сопоставлять маршруты с использованием radix-деревьев:

Выделенный обход показывает маршрут /user/*, где * представляет префикс. Таким образом, URI типа /user/navendu будет соответствовать этому маршруту. Пример кода ниже должен прояснить эти идеи.
APISIX использует библиотеку lua-resty-radixtree, которая оборачивает rax, реализацию radix-дерева на C. Это улучшает производительность по сравнению с реализацией библиотеки на чистом Lua.
Пример ниже показывает, как используется библиотека:
local radix = require("resty.radixtree") local rx = radix.new({ { paths = { "/api/*action" }, metadata = { "metadata /api/action" } }, { paths = { "/user/:name" }, metadata = { "metadata /user/name" }, methods = { "GET" }, }, { paths = { "/admin/:name" }, metadata = { "metadata /admin/name" }, methods = { "GET", "POST", "PUT" }, filter_fun = function(vars, opts) return vars["arg_access"] == "admin" end } }) local opts = { matched = {} } -- соответствует первому маршруту ngx.say(rx:match("/api/create", opts)) -- metadata /api/action ngx.say("action: ", opts.matched.action) -- action: create ngx.say(rx:match("/api/destroy", opts)) -- metadata /api/action ngx.say("action: ", opts.matched.action) -- action: destroy local opts = { method = "GET", matched = {} } -- соответствует второму маршруту ngx.say(rx:match("/user/bobur", opts)) -- metadata /user/name ngx.say("name: ", opts.matched.name) -- name: bobur local opts = { method = "POST", var = ngx.var, matched = {} } -- соответствует третьему маршруту -- значение для `arg_access` берется из `ngx.var` ngx.say(rx:match("/admin/nicolas", opts)) -- metadata /admin/name ngx.say("admin name: ", opts.matched.name) -- admin name: nicolas
Возможность эффективно управлять большим количеством маршрутов сделала APISIX API-шлюзом выбора для многих крупномасштабных проектов.
Загляните под капот
Есть только столько, сколько я могу объяснить о внутренней работе APISIX в одной статье.
Но самое приятное то, что упомянутые здесь библиотеки и Apache APISIX полностью открыты, что означает, что вы можете заглянуть под капот и изменить что-то самостоятельно.
И если вы можете улучшить APISIX, чтобы получить последний бит производительности, вы можете внести изменения в проект и позволить всем воспользоваться вашей работой.