Текст для тех, кто хочет писать мелкие сервисы на руби без рельс
Уже много раз видел примеры, где всякие микросервисы, консьюмеры и продюсеры (для кафки например), используют ActiveRecord. Да, окей, вам надо ходить в базу. Да, окей, вы кодите прилу на Rails и привыкли к ActiveRecord, но ведь это часть фреймворка. По сути этот гем - часть большого механизма, вы же не покупаете колесо от машины и не едете на нем? Да, колесо катится хорошо, но вот тормозить оно само по себе не может, тормоза остались в машине. Собственно, “отсутствие тормозов” у ActiveRecord и сподвигло на статью.
Плюсы и минусы
Давайте разберем плюсы и минусы использования ActiveRecord в качестве ORM для некоторого условного микросервиса: из плюсов вижу - знакомый интерфейс: то как мы объявляем модели, DB config; а вот минусов вижу дофига:
Во-первых, я вот не
знаюзнал как он там внутри рельсы прикручен, и вы вряд ли полезете в исходники перед использованием, это порождает неопределенность поведения.Во-вторых, он же такой тяжелый и сущности тяжелые, там столько ненужной шелухи, всяких callback’и, до сохранения, после, во время, сами сущности тоже тяжелые и тд.
В-третьих, если погуглить, то можно найти какой-нибудь Sequel (да да, там тоже есть хуки), который обладает похожим интерфейсом и поставляется как standalone решение. В общем, не понимаю зачем так делать.
Живой пример
А сейчас я вам расскажу что может произойти, если использовать ActiveRecord в более-менее нагруженном сервисе.
Например: нам нужно написать сервис, который будет уметь доставать данные из базы, немного их обрабатывать и отдавать клиенту.
Решение: Берем, пишем этот сервис, в качестве ходилки в базу выбираем ActiveRecord, мы же его знаем, как облупленного, тестим - работает, выливаем на прод - работает.
Первые проблемы: Работает год, два. А потом начинают сыпаться ошибки, да стремные такие, системные. Говорит, мол, не смог сокет открыть, заняты все, или что коннекшены в базу все закончились. Стектрейсы до сишных файлов гема pg. Пока что можно забить на это, потому что есть ретрай и он помогает, мало ли что там, кубер косячит или еще чего… Уберем в дальний бэклог, пока что проблем не вызывает.
Проблемы усиливаются: Но потом, где-то лет через 5, это приобретает лавинообразный вид, ретраи не помогают с первого раза, встают в очередь, не срабатывают и со второго раза, бывает доходит и до ста попыток, некоторые бедолаги после исчерпания ретрай лимита умирают и освобождают очередь. Тут то уже от этой проблемы не отвязаться, это явно не кубер.
Ищем решение
Идем проверять весь код, от и до, ничего сверхестественного нету. Падает на обычном селекте, который должен занять не больше пары десятков миллисекунд. Пару дней медитируем в сорцах pg (гема), ведь ошибка то в нем, судя по трейсу. Не помогает. Коллеги ничем помочь не могут, все разводят руками.
Первые зацепки: Далее строим схему, как должен ActiveRecord в базу ходить и понимаем, что этой проблемы быть не должно, ведь у нас есть ConnectionPool, он же не должен дать выжрать все коннекшены, сокеты и тд, там их всего 20 штук выделяется на весь сервис.
Проверка гипотезы
Быстренько пишем скрипт, который ходит в базу через standalone ActiveRecord, ходит он парраллельно из нескольких тредов, проверяешь на каждой итерации самочувствие ConnectionPool… И да, собака тут зарыта, он не используется.
10.times do
threads << Thread.new do
begin
users_count += User.count
puts ActiveRecord::Base.connection_pool.stat.to_json
rescue => e
puts ActiveRecord::Base.connection_pool.stat.to_json
errors << e
end
end
end
threads.each(&:join)
puts "Finished with #{errors.count} errors #{errors.uniq}"
stdout
{"size":5,"connections":2,"busy":2,"dead":0,"idle":0,"waiting":5,"checkout_timeout":5.0}
{"size":5,"connections":5,"busy":4,"dead":1,"idle":0,"waiting":5,"checkout_timeout":5.0}
{"size":5,"connections":5,"busy":3,"dead":2,"idle":0,"waiting":5,"checkout_timeout":5.0}
{"size":5,"connections":5,"busy":2,"dead":3,"idle":0,"waiting":5,"checkout_timeout":5.0}
{"size":5,"connections":5,"busy":1,"dead":4,"idle":0,"waiting":5,"checkout_timeout":5.0}
{"size":5,"connections":5,"busy":0,"dead":5,"idle":0,"waiting":4,"checkout_timeout":5.0}
{"size":5,"connections":5,"busy":0,"dead":5,"idle":0,"waiting":3,"checkout_timeout":5.0}
{"size":5,"connections":5,"busy":0,"dead":5,"idle":0,"waiting":2,"checkout_timeout":5.0}
{"size":5,"connections":5,"busy":0,"dead":5,"idle":0,"waiting":1,"checkout_timeout":5.0}
{"size":5,"connections":5,"busy":0,"dead":5,"idle":0,"waiting":0,"checkout_timeout":5.0}
// Finished with 5 errors: #<ActiveRecord::ConnectionTimeoutError: could not obtain a connection
// from the pool within 5.000 seconds (waited 5.001 seconds); all pooled connections were in use>
При недолгом ковырянии исходников рельсы выясняется, что где-то там в мидлваре, она оборачивает текущий исполняемый код в ConnectionPool.with_connection {...}. Проверяем работу с with_connection и вуаля, это оно.
10.times do
threads << Thread.new do
begin
ActiveRecord::Base.connection_pool.with_connection do
users_count += User.count
end
puts ActiveRecord::Base.connection_pool.stat.to_json
rescue => e
puts ActiveRecord::Base.connection_pool.stat.to_json
errors << e
end
end
end
threads.each(&:join)
puts "Finished with #{errors.count} errors"
stdout
{"size":5,"connections":5,"busy":3,"dead":0,"idle":2,"waiting":6,"checkout_timeout":5.0}
{"size":5,"connections":5,"busy":2,"dead":0,"idle":3,"waiting":6,"checkout_timeout":5.0}
{"size":5,"connections":5,"busy":3,"dead":0,"idle":2,"waiting":4,"checkout_timeout":5.0}
{"size":5,"connections":5,"busy":2,"dead":0,"idle":3,"waiting":4,"checkout_timeout":5.0}
{"size":5,"connections":5,"busy":4,"dead":0,"idle":1,"waiting":1,"checkout_timeout":5.0}
{"size":5,"connections":5,"busy":4,"dead":0,"idle":1,"waiting":0,"checkout_timeout":5.0}
{"size":5,"connections":5,"busy":3,"dead":0,"idle":2,"waiting":0,"checkout_timeout":5.0}
{"size":5,"connections":5,"busy":2,"dead":0,"idle":3,"waiting":0,"checkout_timeout":5.0}
{"size":5,"connections":5,"busy":1,"dead":0,"idle":4,"waiting":0,"checkout_timeout":5.0}
{"size":5,"connections":5,"busy":0,"dead":0,"idle":5,"waiting":0,"checkout_timeout":5.0}
// Finished with 0 errors
Поиск best practce
Думаешь, ну может это автор кода просто пал жертвой копипасты и не все скопировал, когда искал решение того, как ActiveRecord завести без рельсы, но нет, все попавшиеся мне гайды и решения на стековерфлоу не используют with_connection. Делаю вывод, что все они не использовали свои решения на живом, хоть немного нагруженном проде.
Мораль такова - не ленись погуглить standalone решения. Не привязывайся к рельсе и ее спутникам, да и к любому другому фреймворку.
А зачем вы юзаете ActiveRecord(или ActiveЧтоТоТам) без Rails?
Бонус
Решил проверить как там дела у сиквела и вот что получилось:
10.times do |i|
threads << Thread.new do
begin
users_count += User.where{price > i}.count
puts({:available_connections => DB.pool.available_connections.count}.to_json)
rescue => e
errors << e
end
end
end
threads.each(&:join)
puts "Counted #{users_count}"
puts "Finished with #{errors.count} errors #{errors.map(&:message).uniq}"
stdout
{"available_connections":1}
{"available_connections":1}
{"available_connections":1}
{"available_connections":1}
{"available_connections":1}
{"available_connections":1}
{"available_connections":2}
{"available_connections":3}
{"available_connections":4}
{"available_connections":5}
Counted 232717
Finished with 0 errors []
Видно что тут коннекшен пул работает в многопоточной среде без явного его использования.