Зохаиб Сибте Хассан, инженер-программист DoorDash
Один из основных девизов команды инженеров DoorDash:
Мы готовы к следующей доставке!
При высокой загрузке трафика и поступлении заказов каждое инженерное решение имеет решающее влияние на то, что будут испытывать наши клиенты, продавцы и дилеры. Мы должны уделять особое внимание деталям и характеристикам системы, чтобы все три стороны бизнеса работали безупречно.
Игра тайников
Кэширование - это один из распространенных и хорошо зарекомендовавших себя способов снизить нагрузку на базу данных и уменьшить задержку для любой конкретной службы. Обычно это эффективно для систем с интенсивным чтением, например. получение меню для ресторана. Хранилища данных в памяти, такие как Redis и Memcached, являются обычно используемыми инструментами для такой задачи; хотя они вводят дополнительную сериализацию / десериализацию и накладные расходы сети. Эти накладные расходы могут быть уменьшены с помощью внутрипроцессного поточно-безопасного кеша (обычно хэш-карты LRU). Этот внутрипроцессный кеш служит кешем L1, Redis - кешем L2, а БД - главным.
Чтения оптимизируются, когда кэш L1 заполнен необходимыми записями. Проблема возникает, когда происходит промах кеша, что может быть связано с истечением срока действия кеша, развертыванием или перезапуском сервера. В этом случае новые запросы должны поступать в L2 или DB, чтобы снова прочитать значение. Выполнение этого в часы пик и высокая нагрузка могут привести к нескольким параллельным повторяющимся чтениям. Такое поведение обычно известно как штамповка кэша или Cache Miss Storm, которое вызывает резкий скачок сетевого трафика и задержки.
Существующие работы
Существуют некоторые существующие подходы, основанные на блокировке, обновлении кеша внешней системой или вероятном раннем истечении срока действия. В DoorDash мы хотели решить проблему нехватки кеша, которая могла быть вызвана промахом в кэше L1, что приводило к параллельному дублированию чтения в L2 или DB. Используя встроенные конструкции из сопрограмм Kotlin, мы решили проблему, не изобретая еще одну сложную библиотеку. Ближе к концу поста мы поделимся некоторыми цифрами и достигнутыми результатами. Мы сильно полагаемся на сопрограммы GRPC, Netty и Kotlin, чтобы поддерживать производительность внутренних микросервисов. В этой статье предполагается, что читатели имеют некоторое базовое представление о сопрограммах или эквивалентах в их технологическом стеке (Go называет их go-подпрограммами, C # называет их задачами, Python 3 также называет их сопрограммами и т. Д.). Хотя обсуждаемое здесь решение более специфично для Kotlin, общая идея верна везде и действительно хорошо работает для любой асинхронной системы, основанной на циклах событий. Например, того же эффекта можно добиться, используя обещание Node.js с простым словарем.
Подход дебаунсера
Чтобы решить эту проблему, мы черпали вдохновение из того, что часто используют фронтенд-инженеры. Debouncing - обычная практика в мире JS для предотвращения срабатывания повторяющихся событий и возникновения шума в системе. Хорошо известный способ создания функции устранения неполадок выглядит примерно так (с использованием lodash или underscore.js):
let debouncedFetchData = _.debounce(fetchData, 100); // use debouncedFetchData …
Вышеупомянутая строка создает противодействующую функцию, которая откладывает вызов fetchData
до тех пор, пока не пройдет 100 миллисекунд с момента последнего вызова противодействующей функции.
Нам нужно было что-то похожее на функцию debounced, но вместо ожидания 100 миллисекунд победившая сопрограмма среди гоночных сопрограмм должна быстро возвращать Deferred
, а оставшиеся сопрограммы должны ждать возвращенного Deferred
, а не разворачивать свое собственное чтение. Различные технологии имеют разные имена для Deferred
, JavaScript - Promise
, C # - Task
и так далее. Это устранение неполадок можно сгруппировать по идентификатору операции. Например, если мы пытаемся получить меню из Redis или базы данных с идентификатором 701064
, мы можем использовать restaurant-fetch-701064
в качестве ключа для однозначной идентификации операции. Эта операция может внутренне использовать экспоненциальные откаты, вызывать другую службу, читать L2, возвращаться к базе данных или может закончиться чтением нескольких таблиц для получения одного значения; но он должен однозначно идентифицировать операцию, которую мы хотим дедуплицировать.
Наше решение основано на безопасном для сопрограмм (как и потокобезопасном) табло, которое отслеживает ожидающие Deferred
с использованием идентификатора. После того, как сопрограмма была запланирована для выполнения Deferred
по идентификатору, последующие сопрограммы с тем же идентификатором используют ожидающую Deferred
для ожидания результатов. После завершения Deferred
он удаляется с табло. Код читателя выглядит примерно так:
Метод getRestaurantMenus
, когда одновременно вызывается многими сопрограммами, приводит к тому, что одна из сопрограмм выигрывает условие гонки и успешно входит в тело для выполнения fetchMenuFromRemoteCacheOrDatabase
. Этот метод противодействия немедленно возвращает Deferred<List<Menu>>
всем сопрограммам во время выполнения fetchMenuFromCacheOrDatabase
. Затем все сопрограммы переходят к await
, чтобы прочитать результаты.
Как на самом деле работает дебаунсер? В приведенном выше коде CoroutineDebouncer(ConcurrentHashMap())
полагается на computeIfAbsent
для создания или чтения Deferred
атомарно (помните о реализации карты, которую вы используете, убедитесь, что реализация применяет функцию только один раз). Точная реализация предельно проста и выглядит так:
computeIfAbsent
позволяет нам запускать асинхронный обратный вызов, который запланирован для выполнения, и после завершения мы выполняем remove
с ожидающей платы. Для параметра ConcurrentMap
, необходимого в конструкторе, мы использовали ConcurrentHashMap
для простоты, но его можно заменить на NonBlockingHashMap для более производительной карты без блокировок или на вашу собственную реализацию, которая гарантирует атомарные операции.
Сравнение яблок с яблоками
После применения изменений к нашему микросервису мы сравнили нашу новую версию со старой. Нашей машиной был MacBook Pro i7 с тактовой частотой 2,2 ГГц, 16 ГБ ОЗУ и флагами JVM -Xms2g -Xmx2g -XX:+UseConcMarkSweepGC -XX:+ParallelRefProcEnabled -XX:+UseCMSInitiatingOccupancyOnly -XX:CMSInitiatingOccupancyFraction=60
.
Тестируемая конечная точка GRPC выполняет операцию полного чтения в диапазоне от кеша L1 (TTL 5 секунд), кеша L2 (TTL 10 секунд) и, наконец, возвращается к нашей базе данных Postgres. Мы использовали ghz для тестирования службы в течение 60 секунд с 2000 одновременных подключений и без ограничения скорости. Мы явно выбрали время короткого истечения для имитации множественных давок и наблюдали общий эффект в течение 60-секундного окна. Вот результаты:
Холодный ботинок
Без дебаунсера:
Summary: Count: 887495 Total: 60059.11 ms Slowest: 6908.89 ms Fastest: 0.55 ms Average: 135.10 ms Requests/sec: 14777.03 Response time histogram: 0.546 [1] | 691.381 [870160] |∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎ 1382.216 [7434] | 2073.050 [4404] | 2763.885 [2186] | 3454.720 [209] | 4145.555 [312] | 4836.390 [559] | 5527.225 [1056] | 6218.060 [505] | 6908.895 [669] | Latency distribution: 10% in 27.40 ms 25% in 57.70 ms 50% in 84.08 ms 75% in 112.40 ms 90% in 170.33 ms 95% in 254.05 ms 99% in 1549.95 ms Status code distribution: [OK] 887495 responses
С дебаунсером:
Summary: Count: 1156274 Total: 60041.89 ms Slowest: 1731.10 ms Fastest: 32.23 ms Average: 103.68 ms Requests/sec: 19257.79 Response time histogram: 32.227 [1] | 202.115 [972011] |∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎ 372.003 [23286] |∎ 541.890 [2702] | 711.778 [0] | 881.665 [0] | 1051.553 [0] | 1221.440 [0] | 1391.328 [43] | 1561.216 [942] | 1731.103 [1015] | Latency distribution: 10% in 66.05 ms 25% in 77.36 ms 50% in 92.63 ms 75% in 113.26 ms 90% in 147.19 ms 95% in 178.08 ms 99% in 264.65 ms Status code distribution: [OK] 1156274 responses
Разогретый
Без дебаунсера:
Summary: Count: 1053108 Total: 60769.34 ms Slowest: 8058.86 ms Fastest: 0.38 ms Average: 114.43 ms Requests/sec: 17329.60 Response time histogram: 0.382 [1] | 806.230 [982042] |∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎ 1612.078 [9147] | 2417.926 [5000] | 3223.774 [1944] | 4029.621 [479] | 4835.469 [953] | 5641.317 [371] | 6447.165 [1] | 7253.012 [3] | 8058.860 [59] | Latency distribution: 10% in 23.74 ms 25% in 48.83 ms 50% in 78.63 ms 75% in 98.37 ms 90% in 122.91 ms 95% in 158.75 ms 99% in 1474.71 ms Status code distribution: [OK] 1053108 responses
С дебаунсером:
Summary: Count: 1321340 Total: 60064.00 ms Slowest: 578.69 ms Fastest: 36.04 ms Average: 90.77 ms Requests/sec: 21998.87 Response time histogram: 36.045 [1] | 90.309 [574748] |∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎ 144.573 [401937] |∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎ 198.838 [16613] |∎ 253.102 [705] | 307.367 [0] | 361.631 [0] | 415.896 [78] | 470.160 [2159] | 524.425 [2099] | 578.689 [1660] | Latency distribution: 10% in 68.67 ms 25% in 76.69 ms 50% in 87.01 ms 75% in 99.63 ms 90% in 112.48 ms 95% in 124.60 ms 99% in 176.08 ms Status code distribution: [OK] 1321340 responses
Заключение
Из-за уменьшения нагрузки на память и сеть мы наблюдали среднюю разницу более ~ 4K запросов в секунду. Что еще более важно, задержка p99 была уменьшена с ~ 1550 мс до ~ 267 мс (сокращение почти на 83%) для случая холодной загрузки и с ~ 1447 мс до ~ 176 мс (сокращение почти на 87%) для случая разогрева.
Чтобы проверить и наглядно увидеть, сколько дополнительных вызовов мы экономили с течением времени, мы инструментировали CoroutineDebouncer
код, указанный выше, и добавили маркеры для подсчета количества раз, когда computeIfAbsent
вызывал обратный вызов, по сравнению с общим количеством вызовов метода противодействия. Мы провели наш тест в течение 120 секунд с 4000 одновременных запросов и сочетанием повторяющихся случайных идентификаторов для имитации реальной нагрузки. Результаты обнадеживают:
Мы также собрали образец JS-реализации, который вы можете использовать для самостоятельного моделирования результатов. Он следует точному принципу, описанному выше, вы можете форкнуть и поэкспериментировать с разными параметрами.
Могут быть разные подходы к выселению Deferred
или некоторый тайм-аут ожидания на противодействии, но основная идея остается той же. Для систем, работающих в кластере, этот подход основан на предотвращении паники из одного экземпляра, таким образом сводя к минимуму паническое движение в масштабе всего кластера. В очень большом кластере (тысячи) мы все еще можем столкнуться с давкой, что потребует другого решения. До сих пор этот подход хорошо работал у нас в производственной среде и помогает нам обеспечивать хорошие показатели задержки.
При высокой загрузке наших систем каждое улучшение имеет значение и способствует быстрому взаимодействию с нашими клиентами, продавцами и торговцами. Мастерство и внимание к деталям помогает нам своевременно доставить наш следующий заказ.