Улучшение Rails.cache.write путем асинхронной установки пар ключ-значение
В настоящее время я думаю о повышении производительности Rails.cache.write
при использовании dalli для записи элементов в облако memcachier.
Стек в отношении кеширования в данный момент:
heroku, memcachier heroku addon, dalli 2.6.4, rails 3.0.19
Я использую newrelic для мониторинга производительности.
В настоящее время я выбираю "активных студентов" для данного вошедшего в систему пользователя, представленного BusinessUser
случай, когда его active_students
Метод вызывается из контроллера, обрабатывающего запрос, который требует список "активных студентов":
class BusinessUser < ActiveRecord::Base
...
def active_students
Rails.cache.fetch("/studio/#{self.id}/students") do
customer_users.active_by_name
end
end
...
end
Посмотрев на newrelic, я в основном сузил один большой скачок производительности для приложения в установке значений ключей в memcachier. Это занимает в среднем 225 мс каждый раз. Кроме того, похоже, что установка значений ключей memcache блокирует основной поток и в конечном итоге нарушает очередь запросов. Очевидно, что это нежелательно, особенно когда весь смысл стратегии кэширования заключается в уменьшении узких мест производительности.
Кроме того, я сравнил кеш-память с обычным dalli и Rails.cache.write для 1000 наборов кеша того же значения:
heroku run console -a {app-name-redacted}
irb(main):001:0> require 'dalli'
=> false
irb(main):002:0> cache = Dalli::Client.new(ENV["MEMCACHIER_SERVERS"].split(","),
irb(main):003:1* {:username => ENV["MEMCACHIER_USERNAME"],
irb(main):004:2* :password => ENV["MEMCACHIER_PASSWORD"],
irb(main):005:2* :failover => true,
irb(main):006:2* :socket_timeout => 1.5,
irb(main):007:2* :socket_failure_delay => 0.2
irb(main):008:2> })
=> #<Dalli::Client:0x00000006686ce8 @servers=["server-redacted:11211"], @options={:username=>"username-redacted", :password=>"password-redacted", :failover=>true, :socket_timeout=>1.5, :socket_failure_delay=>0.2}, @ring=nil>
irb(main):009:0> require 'benchmark'
=> false
irb(main):010:0> n = 1000
=> 1000
irb(main):011:0> Benchmark.bm do |x|
irb(main):012:1* x.report { n.times do ; cache.set("foo", "bar") ; end }
irb(main):013:1> x.report { n.times do ; Rails.cache.write("foo", "bar") ; end }
irb(main):014:1> end
user system total real
Dalli::Server#connect server-redacted:11211
Dalli/SASL authenticating as username-redacted
Dalli/SASL: username-redacted
0.090000 0.050000 0.140000 ( 2.066113)
Dalli::Server#connect server-redacted:11211
Dalli/SASL authenticating as username-redacted
Dalli/SASL: username-redacted
0.100000 0.070000 0.170000 ( 2.108364)
С простой Далли cache.set
мы используем 2.066113s для записи 1000 записей в кеш, в среднем cache.set
время 2.06мс.
С Rails.cache.write
мы используем 2.108364 для записи 1000 записей в кеш, в среднем Rails.cache.write
время 2,11 мс.
⇒ Кажется, что проблема не в memcachier, а просто в количестве данных, которые мы пытаемся сохранить.
В соответствии с документацией для метода #fetch, похоже, что это не тот путь, по которому я хочу пойти, если я хочу выбросить наборы кэша в отдельный поток или рабочий, потому что я не могу разделить write
от read
- и само собой разумеется, я не хочу читать асинхронно.
Можно ли уменьшить узкое место, бросая Rails.cache.write
в работника, при установке значений ключей? Или, в более общем плане, есть ли лучший шаблон для этого, чтобы я не блокировал основной поток каждый раз, когда я хочу выполнить Rails.cache.write
?
2 ответа
Существует два фактора, которые могут повлиять на общую задержку при нормальных обстоятельствах: сортировка / сжатие на стороне клиента и пропускная способность сети.
Dalli Mashalls и при необходимости сжимает данные, которые могут быть довольно дорогими. Вот некоторые тесты Marshalling и сжатия списка случайных символов (своего рода искусственный список идентификаторов пользователей или что-то в этом роде). В обоих случаях результирующее значение составляет около 200 КБ. Оба теста выполнялись на Heroku dyno - производительность, очевидно, будет зависеть от процессора и нагрузки на машину:
irb> val = (1..50000).to_a.map! {rand(255).chr}; nil
# a list of 50000 single character strings
irb> Marshal.dump(val).size
275832
# OK, so roughly 200K. How long does it take to perform this operation
# before even starting to talk to MemCachier?
irb> Benchmark.measure { Marshal.dump(val) }
=> 0.040000 0.000000 0.040000 ( 0.044568)
# so about 45ms, and this scales roughly linearly with the length of the list.
irb> val = (1..100000).to_a; nil # a list of 100000 integers
irb> Zlib::Deflate.deflate(Marshal.dump(val)).size
177535
# OK, so roughly 200K. How long does it take to perform this operation
irb> Benchmark.measure { Zlib::Deflate.deflate(Marshal.dump(val)) }
=> 0.140000 0.000000 0.140000 ( 0.145672)
Таким образом, мы наблюдаем снижение производительности от 40 мс до 150 мс только для маршалинга и / или архивирования данных. Маршаллинг строки будет намного дешевле, в то время как маршаллинг чего-то вроде сложного объекта будет дороже. Сжатие зависит от размера данных, а также от избыточности данных. Например, сжатие строки размером 1 МБ всех символов "а" занимает всего около 10 мс.
Пропускная способность сети будет играть здесь некоторую роль, но не очень важную. MemCachier имеет ограничение в 1 МБ для значений, которое может занять приблизительно 20 мс для передачи в / из MemCachier:
irb(main):036:0> Benchmark.measure { 1000.times { c.set("h", val, 0, :raw => true) } }
=> 0.250000 11.620000 11.870000 ( 21.284664)
Это составляет около 400 Мбит / с (1 МБ * 8 МБ / МБ * (1000 мс / с / 20 мс)), что имеет смысл. Однако даже при относительно большом, но все же меньшем значении в 200 КБ, мы ожидаем 5-кратное ускорение:
irb(main):039:0> val = "a" * (1024 * 200); val.size
=> 204800
irb(main):040:0> Benchmark.measure { 1000.times { c.set("h", val, 0, :raw => true) } }
=> 0.160000 2.890000 3.050000 ( 5.954258)
Итак, есть несколько вещей, которые вы могли бы сделать, чтобы получить некоторое ускорение:
Используйте более быстрый механизм сортировки. Например, используя
Array#pack("L*")
для кодирования списка из 50000 32-битных целых чисел без знака (как в самом первом тесте производительности) в строку длиной 200000 (4 байта на каждое целое число) требуется всего 2 мс, а не 40 мс. Используя сжатие с той же схемой сортировки, получить значение аналогичного размера также очень быстро (около 2 мс), но сжатие больше не дает ничего полезного для случайных данных (Ruby's Marshal создает довольно избыточную строку даже в списке). случайных целых чисел).Используйте меньшие значения. Это, вероятно, потребует глубоких изменений приложения, но если вам не нужен весь список, вам следует его настроить. Например, протокол memcache имеет
append
а такжеprepend
операции. Если вы только добавляете новые вещи в длинный список, вы можете использовать эти операции вместо этого.
Наконец, как предлагается, удаление набора / получения из критического пути предотвратит любые задержки, влияющие на задержку HTTP-запроса. Вам все еще нужно передать данные работнику, поэтому важно, чтобы при использовании чего-то вроде рабочей очереди сообщение, которое вы отправляете работнику, содержало только инструкции о том, какие данные создавать, а не сами данные (или вы сами). снова в той же лунке, просто с другой системой). Очень легким (с точки зрения усилий по написанию кода) было бы просто раскошелиться на процесс:
mylist = Student.where(...).all.map!(&:id)
...I need to update memcache with the new list of students...
fork do
# Have to create a new Dalli client
client = Dalli::Client.new
client.set("mylistkey", mylist)
# this will block for the same time as before, but is running in a separate process
end
Я не сравнивал полный пример, но так как вы не exec
В случае, если Linux-форк копируется при записи, накладные расходы на сам вызов fork должны быть минимальными. На моей машине это около 500 мкс (это микросекунды, а не миллисекунды).
Использование Rails.cache.write для предварительной выборки и хранения данных в кеше с работниками (например, Sidekiq) - это то, что я видел в больших объемах. Конечно, есть компромисс между скоростью и деньгами, которые вы хотите потратить. Подумать о:
- наиболее часто используемые пути в вашем приложении
active_students
доступ часто?); - что хранить (только идентификаторы или целые объекты или далее по цепочке);
- если вы можете оптимизировать этот запрос (n+1?).
Кроме того, если вам действительно нужна скорость, рассмотрите возможность использования выделенного сервиса memcache вместо дополнения Heroku.