Sijin T V
Sijin T V A passionate Software Engineer who contributes to the wonders happenning on the internet

Database Sharding in Rails: Implementing Multi-Database Architectures

There’s a point in every growing service where the primary database becomes the bottleneck, and it’s not about reads. Read replicas solved reads. The pain is writes: one node, one write path, and every new customer adds to the same contention. Vertical scaling works until it doesn’t — the instance doubles in price for a fraction of the headroom, and the ceiling is the hardware, not your code. That’s when horizontal sharding stops being a theoretical exercise.

Rails 6.1 (December 2020) shipped native support for this, and for the first time you can do it without a gem like Octopus or a hand-rolled connection pool: multiple databases plus horizontal shards, all configured in database.yml and routed through connects_to and connected_to.

Declaring the shards

The split key for us is the tenant (account). Reference data and the accounts directory stay on the primary; the write-heavy transactional tables live on shards. The database layer looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# config/database.yml
production:
  primary:
    adapter: postgresql
    database: app_primary
    host: primary.example.com
  primary_shard_one:
    adapter: postgresql
    database: app_shard_1
    host: shard1.example.com
  primary_shard_two:
    adapter: postgresql
    database: app_shard_2
    host: shard2.example.com
1
2
3
4
5
6
7
8
9
class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true

  connects_to shards: {
    default:   { writing: :primary },
    shard_one: { writing: :primary_shard_one },
    shard_two: { writing: :primary_shard_two }
  }
end

Tables that must never be sharded — accounts, plans, migrations state — subclass a plain abstract class that connects only to primary, so connects_to doesn’t drag them along. Mixing sharded and unsharded tables in one app is normal and desirable; the shard switch only applies to the models that opted in.

Routing per request

The request tells you the tenant, and the tenant maps to a shard. Do the mapping in middleware so controllers never see it:

1
2
3
4
5
6
7
8
9
class ShardRoutingMiddleware
  def call(env)
    tenant_id = env.fetch("HTTP_X_TENANT_ID", "default")
    shard = ShardRouter.shard_for(tenant_id)
    ActiveRecord::Base.connected_to(role: :writing, shard: shard) do
      @app.call(env)
    end
  end
end
1
2
3
4
5
6
7
8
class ShardRouter
  SHARDS = %i[shard_one shard_two].freeze

  def self.shard_for(tenant_id)
    return :default if tenant_id == "default"
    SHARDS[Zlib.crc32(tenant_id.to_s) % SHARDS.length]
  end
end

The hash function matters. It must be stable forever: the day you change it, every tenant’s data is in the “wrong” shard and lookups break. Hash the tenant’s stable identifier (subdomain or UUID), never the row’s auto-increment id, and treat the mapping as a permanent contract. We also keep a tiny tenant → shard directory table on the primary so background jobs and dead-letter reprocessing can resolve the right shard idempotently instead of re-deriving it.

IDs that can’t collide

The moment you have two shards, auto-increment primary keys collide. Use UUIDs for sharded tables from day one:

1
2
3
4
5
6
7
8
9
class CreateOrders < ActiveRecord::Migration[6.1]
  def change
    create_table :orders, id: :uuid do |t|
      t.belongs_to :account, type: :uuid
      t.decimal :total_cents, precision: 12, scale: 0, default: 0
      t.timestamps
    end
  end
end

Postgres generates these with gen_random_uuid() (built in on 13+, or via the pgcrypto extension earlier), so there’s no application coordination and no counter. Snowflake-style 64-bit IDs work too and keep the indexes smaller, but they add a generator service and a clock dependency; UUIDs are the boring, correct default.

What you give up

Sharding isn’t free, and the invoice arrives as three hard limits:

  • No cross-shard joins. The query planner can’t see across nodes. Anything that needs data from two shards gets fanned out in the application — N queries, and the latency of the slowest one. We profile any query touching more than one shard as a bug candidate and keep aggregate/reporting data denormalized on the primary.
  • No cross-shard transactions. A write spanning two shards is two transactions, which means it can partially fail. Keep transaction boundaries inside a single shard, and design the data model so money movement is single-shard or moves through a reconcilable queue on the primary.
  • No global uniqueness. A unique index is per-shard. The only things that must be globally unique (email addresses, customer-facing reference codes) get a global index on the primary — an accounts directory row created before the sharded record — plus the inevitable retry-on-duplicate handling.

Production lessons

  • Pick the shard key by write amplification, not read pattern. Re-keying after launch is a full data migration with a rehearsal, not a config change.
  • Freeze the hash. Add a migration-level check that a tenant’s shard mapping is stable across deploys; the worst bugs we’ve had were shards where “this record went missing” because a mapping changed.
  • Shard count is hard to grow. We sized for two and would rather re-shard by doubling than add a third; plan your capacity for the shard count being fixed for a long time.
  • Watch per-shard skew. Alerts on write latency and size per shard catch a hot tenant before it pins one node.
  • Practice the failover. connected_to(role: :writing, shard: X) scoping is exactly where a missing connects_to silently sends data to the wrong database, and it only takes one.

Sharding is the last database lever you pull, and Rails 6.1 made it an order of magnitude more approachable: routing, connections, and scoping are framework features now, not hacks. The hard part was never the code — it’s respecting that the data lives in N places forever.

comments powered by Disqus