Текст для тех, кто хочет писать мелкие сервисы на руби без рельс

Уже много раз видел примеры, где всякие микросервисы, консьюмеры и продюсеры (для кафки например), используют 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 []

Видно что тут коннекшен пул работает в многопоточной среде без явного его использования.