Scale rails from one box to three, four and five
Courtenay : July 29th, 2007
Taking a vanilla rails application from one box and up is a fun process. The exact path you’ll take depends on the nature of your data, and the ratio of database reads to writes. I’m going to cover some of the more common use cases. If you don’t want to get your hands dirty and it’s kind of an emergency, look at stage zero, then skip to the end where I tell you who you can just pay to fix it.
The path you’ll take also depends on how much money you have to play with, and how quickly your site is growing. For example, if you’re sitting on a mountain of cash, and the facebook users are coming in like lemmings, then you can just throw hardware at it. However, if things are tight, and it’s a nice linear growth curve, then you can play around with caching.
Let’s assume you have a slice or VPS (if you’re on shared hosting, the first step should be to get a dedicated box or at least a xen instance).
Stage zero: fix any ‘duh’ errors
Make sure you’re on a database that can handle the load. This doesn’t include sqlite. I’m going to suggest MySQL in this article, because it’s where I have the most experience.
Make sure you’re not serving up static files through mongrel. This will happen if you are proxying everything through the webserver.
Upgrade your webserver to something like nginx. Alternatively, you might use pound as a load balancer, pointing dynamic requests at mongrels, and static requests at lighttpd. (Interested? I can write an article on this. Let me know.)
Move off that $20/month shared box and get your own server. You can lease a phat server in a data center on 100mbit pipe for $100/month. If you want to colo, I recommend Corporate Colo in Los Angeles.
Move any slow actions into a dedicated process. For example, you have some code that takes 4 seconds to update a bunch of tables? You probably want to fire an asynchronous event to a BackgrounDRB process that handles this exclusively.
Move your uploads to a dedicated merb cluster – it’s like a cut-down rails with less magic and more speed.
Stage one: clean up your database
Take a look at your logs – are you performing over 10 database calls per request? You need to fix this. Are you performing over 90? You’re a dumbass. (yes, even I am guilty of this).
Generally you can reduce the number of requests by denormalizing; for example, you have a list of users and a count of how many comments they’ve made.
<% @users.each do |user| %>
<%=h user.name %> (<%= user.comments.count %>)
<% end %>
You’re performing a “COUNT” for every single user, every time the page loads. Yuk! This is a “read-optimizable” situation, since there are many READS for each WRITE (comments don’t get created that often).
Add a counter_cache to the comments belongs_to :user association and change this to
<%=h user.name %> (<%= user.comments_count %>)
You can do this in other situations where you’re chaining through associations.
Your task is <%=h @list_item.task.name %>.
This call has to find the list_item task, and then grab the name. You can fix this by either adding :include to the ListItem.find, or, you can denormalize the task name.
Include isn’t always an option, and can be slow. Nothing’s faster than denormalizing. Add a “taskname” to the listitem model.
Your task is <%=h @list_item.task_name %>.
Then make sure to update that field if the task gets updated.
class Task < ActiveRecord::Base
has_many :list_items
after_save :update_list_item_names
def update_list_item_names
list_items.each { |li| li.update_attribute(:name, self.name) }
end
end
Yes, there are faster ways of doing this, and yes, I should probably wrap that in a transaction. But you get the point. (note to self: if rails had dirty-field checking, this would be much better)
Stage two: cache the hell out of it
Next thing you want to do: caching. If you haven't already, install memcached, use the cache-fu plugin, and start saving the results of long-running or frequent queries into the cache. Set the TTL (timeout) at about 5 minutes; that way you won't need to write any expiry code (it's lazy, but you're busy!) You'll immediately notice a drop in load. If you have time, write some cache-expiring observers and up the TTL to 15 minutes or even an hour.
Eventually you want to have memcached sitting between your application and the database. Most of your database calls' results will be stored for at least 5 minutes, and maybe forever, in memcached.
If you can, add some action caching. Action cache is like page caching, but it runs any filters you may have. Action caching isn't always easy, particularly if you have "current_user" dependent code in your views. I have a solution for this which I'll be releasing soon, but in the mean time, you may not be able to action cache. Any action-cached pages will be vastly beneficial to your load, and combining memcached with action caches means that you can virtually eliminate any database slowness and is almost as good as the page cache.
If you can action cache, then you can probably page cache. A page-cached site will get you about 3,000 requests per second, thereabouts, and a simple GET request won't even hit your application; you're serving raw html through the webserver. You will soon start thinking of rails as an HTML generator, rather than an app server.
However, all these caching measures won't hide a basic problem: you are performing lots of database queries, and it's harshing your mellow.
Stage three: move the database to another server
This should be fairly painless. Get yourself a fat database server. By fat I mean, super-fast disks, plenty of RAM, and the fastest networking you can afford.
Set it up so that it's only accessible from your main box, which will now be known as the app server. Point your database.yml at the IP of the database server.
Now your app server has much less load, so you can increase the number of mongrels. Add some more RAM to the app server box, too, if you can.
How many mongrels?
Here's a simple formula to follow.
A. Take the (average or median) request time, in seconds. Say, 0.250 seconds (250ms)
B. How many requests do you want to handle at peak? (e.g. 10,000 a minute, 166 a second)
C. Multiply A x B : 0.250 * 166 = 41.5
So you need about 40 mongrels to handle the load. At about 60MB per listener, that's 2.4GB of RAM, plus a bit of room for leakiness and swapping. Ezra at Engine Yard suggests "about 10 dogs per CPU core", which means that if we have a 4-core opteron box with 3GB of RAM, then this is possible on one box.
Your mileage will vary, which means, if the box is lagging, remove a few mongrels.
Stage four: add more servers as necessary
Here's where it gets interesting. Which of your servers has the most load?
If it's the app server, then setup another box as an exact copy. Now you have app1 and app2. You will need to load balance between app1 and app2. You can do this with a hardware load balancer, or you can use pound on app1 to balance to listeners on app2. (You'll have a single point of failure on app1 if you do it this way)
If the db server is the most heavily loaded box, things start getting interesting: you'll either need some kind of replication, or you'll need to shard (partition) your data.
Replication vs Sharding
Take a look at the data in your application. If it were a person, would it be "extroverted" or "introverted"? That is, could you split the data into many sections (no friends, introverted), or is it all cross-linked (lots of friends)?
For example, you are hosting subversion repositories. You can easily send half the records to one database and half to the other. Or, you host thousands of social networks, each with about 50 users (collectivex, I’m looking at you.)
In this case, one database box would handle all users with names A–L, and another box from M–Z.
If you have a social networking site where anyone can be friends with anyone else, you’re going to have difficulty partitioning the data. (Astute readers will instantly think about denormalizing to make this still possible).
If you have one shared table (users) but the rest of the data can be sharded, then you will want some bastard stepchild method.
Replication: Master-Slave
So, replication (MySQL only from here on). Let’s say you have a few writes (inserts, updates) and a lot of SELECTs. Most people are just viewing things, not updating records. This is fun and easy.
You set up one database box as the “master”. This box will behave as normal. You can read and write data as before.
You then set up as many “slave” boxes as you like. These boxes will be read-only, but because you have a large amount of reads, then much of the load can be pushed out to these slaves. You’ll need to hack at your rails app to direct simple reads at a slave DB. Luckily, someone’s already done the work and called it acts_as_readonlyable.
The problem here is that the slaves will always be lagging, depending on load. Under light load, they may only be 100ms behind. Under heavy load, you can’t be guaranteed of any sort of synchronization. In this case, you’ll want to use memcached heavily. Here’s some cache-fu code.
class Category < ActiveRecord::Base
acts_as_cached
def after_save
Category.set_cache(id, self)
end
end
When you save the category, it pushes the record (self) into memcached. That means, with a long TTL, you'll never need to do a simple “find” on category from the database, and replication lag won’t matter.
Finally, you’ll want to load-balance to the database servers, an exercise left to the reader.
/ write==[db1] master
[app1]
\ /==[db2] slave
\ read==LB==[db3] slave
\==[db4] slave
Replication: Simple Master-Master
In this situation, you have two sets of stacks. Each stack has an app box and a database box. They are almost identical; the app server is wired to one database server. There is no crossover. ASCII-tabulous diagram:
/[app1]====[db1] master+slave
LB | replicate
[app2]====[db2] master+slave
Both databases in this case are masters. That means, both act as masters, but both act as slaves as well. You can even set up the boxes so that if one goes down, the other fails over and takes on both IP addresses.
Because the setup is asynchronous, you need to assign each database a separate set of autoincrement keys. DB1 will increment values like 11, 21, 31, 41, 51, and DB2 ids will increment like 12, 22, 32, 42, 52. You set these with auto_increment_increment and auto_increment_offset.
Take care! If you have a UNIQUE index on other, non-auto-increment fields, you need to make sure that the same database will be used for CREATEs. You’ll need some algorithm, such as checking the final character or number of the unique field. You’ll also need some way of redirecting writes to a specific database, as well as dealing with load balancers. You may find MySQL Proxy useful here – you can use Lua to control the load-balancing at the db layer.
Master:Master replication doesn’t really scale past 10 boxes, because the databases will be so busy updating that they won’t be able to serve requests. However, 99% of rails applications won’t get to this stage.
And remember – there will be some replication lag between the boxes, so your code will need to be tolerant of this issue.
Stage Six: More boxes!
At this stage, you should have most of your data stored in memcached, and it’s time to get yourself a dedicated memcached box with gigabit networking and a metric crapton of RAM.
Your data should be nicely segmented (sharded, or partitioned) into separate databases.
Stage Seven: You’re Going To Need Help
If you’ve roughly followed all of the above steps, and your site is still lagging, you either didn’t follow the instructions, or you’re beyond this and need to bring in some experts. Replicating and sharding should cover most people’s scaling needs, such that you just keep adding stacks of app+db and expanding the memcached cluster.
You can hire skilled rails consultants to clean up your code (there are plenty of #caboosers with the requisite experience), or you can use a hosting service like Engine Yard (staffed almost exclusively with caboosers) where they will have your app running on a cluster pretty much like I’ve described above, only bigger and faster. It’s going to cost you, but you get what you pay for. Hell, they even deploy your app for you.
Developers, if you’re not an avid reader of the MySQL Performance Blog, go subscribe now. They are also for hire, and I hear they’re most excellent. Pricey, but worth every damn penny.
Got any more tricks? Hook me up in the comments.
16 Responses to “Scale rails from one box to three, four and five”
Sorry, comments are closed for this article.
July 29th, 2007 at 05:17 PM
Question about stage 1:
couldn’t you just do <%= user.comments.length %>
would that be more accurate?
July 29th, 2007 at 11:06 PM
Dude. How dare you write about Rails (TM). DHH is gonna sue your ass.
July 30th, 2007 at 10:12 AM
@dave
That’d load every comment for every user.
comments.count runs a “COUNT(*)” statement in SQL giving an extra DB hit per user. The cache_count saves a cache count in the User table so you’d only hit the DB to load all the user data.
July 30th, 2007 at 10:19 AM
RE user.comments.length
That query does a join on the user and comments table. Using counter_cache means the query from active record only hits the user table. It is a good performance optimization, but it does denormalize the database by adding a count column to the user table. It gets updated by rails every time a comment is added/deleted.
When the rate of reading the comments count (or another child object) is greater than the rate of adding comments (or another child object), then this optimization is well worth it. SQL joins are expensive.
The only other time where this type of optimization wouldn’t work well is if you are using a database that has multiple rails and/or non-rails applications using it.
July 30th, 2007 at 12:32 PM
Another option, which I am using currently, is MySQL Cluster (http://www.mysql.com/products/database/cluster/). It allows for massive scaling and it’s very fast (all tables can be in memory). We’re using it on our rails apps and can serve up to around 1000 req/sec without caching. Most of the work happens in the database so Cluster was the only way to go really. There are some pains in using Cluster (schema changes are tricky and you need loads of RAM for example) but in the end it can be a good alternative to traditional replication.
Just food for thought…
Ed
July 30th, 2007 at 01:04 PM
I’m guessing 1,000 is peak capacity and not your load 24/7 :)
July 30th, 2007 at 03:31 PM
Awesome post mate - a true gift - thanks a load :)
July 31st, 2007 at 08:25 AM
Yeah, 1,000 is peak…thankfully!
July 31st, 2007 at 11:17 AM
Excellent post! Shouldn’t <%= user.comments.count %> transparently use the counter_cache in production instead of querying the comments table? I just assumed it was that way, given that that’s totally possibly in Ruby…
July 31st, 2007 at 10:49 PM
@Ismael: <%= user.comments.size %> will transparently use the counter cache.
don’t call .count explicitly unless you really want to hit the database every time.
August 2nd, 2007 at 10:17 AM
any experience with EC2 (amazon’s elastic compute cloud) or scaling on it? aside from wrapping my head around a new paradigm and no persistent DB storage location it seems like it could be the best new way to do things.
August 3rd, 2007 at 10:13 AM
“Action caching isn’t always easy, particularly if you have “current_user” dependent code in your views. I have a solution for this which I’ll be releasing soon, but in the mean time, you may not be able to action cache.”
Very very interested in this.
August 4th, 2007 at 04:23 AM
Great article!! @gilltots, We actually use EC2 at Geezeo, but there is a lot of great info in this article that should be applied to any application before even thinking about adding hardware… (e.g. ec2 instances).
August 6th, 2007 at 06:52 PM
Yes, thanks for the article Courtenay, very useful and timely as I worry about scaling an app we’re working on ;-)
I’d also put a vote in for being very curious about how you’re using action caching with current_user dependent code in the view. Would love to see what you have in mind.
August 17th, 2007 at 09:11 PM
On sharding: “You can easily send half the records to one database and half to the other.”
… then rebalancing data between databases monitoring thier loads.
Thanks for the article.
August 19th, 2007 at 08:45 PM
@Courtney, Adam and Cameron: The Action Cache plugin: http://agilewebdevelopment.com/plugins/actioncache has the ability to alter the cache key used for the action cache, allowing you to vary the cached data based on currentuser, or a cookie value, or whatever else you want.