Зохаиб Сибте Хассан, инженер-программист 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 или некоторый тайм-аут ожидания на противодействии, но основная идея остается той же. Для систем, работающих в кластере, этот подход основан на предотвращении паники из одного экземпляра, таким образом сводя к минимуму паническое движение в масштабе всего кластера. В очень большом кластере (тысячи) мы все еще можем столкнуться с давкой, что потребует другого решения. До сих пор этот подход хорошо работал у нас в производственной среде и помогает нам обеспечивать хорошие показатели задержки.

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