Scaling Rails with PostgreSQL Read Replicas: Part 1 - Understanding the Basics
· 7 min read
In this three-part series, we’ll explore how to effectively use PostgreSQL read replicas with Rails applications. While the official Rails guide covers the configuration basics, implementing read replicas in production requires understanding the nuances that can make or break your application’s performance.
When Do You Actually Need Read Replicas?
Before diving into implementation, let’s understand if read replicas are the right solution for your scaling challenges.
Signs You Need Read Replicas
Your application might benefit from read replicas when you see these key indicators:
- Read-heavy workload: Your read:write ratio exceeds 80:20
- Long-running queries: Reports and analytics slow down transactional queries
- Different access patterns: OLTP (fast transactions) vs OLAP (complex analytics)
- Geographic distribution: Users in different regions need low-latency reads
Does this query seem familiar?
1
2
3
4
5
6
# Your analytics queries are blocking user-facing features
User.joins(:orders)
.group('users.id')
.having('COUNT(orders.id) > ?', 100)
.pluck('users.email, COUNT(orders.id), SUM(orders.total)')
# This query takes 5+ seconds and runs frequently
When Read Replicas Won’t Help
Read replicas are not a silver bullet. They won’t help if:
- Write bottlenecks: Your database is slow due to heavy writes
- Poor query performance: Unoptimized queries will be slow on replicas too
- Real-time requirements: If you can’t tolerate any replication lag
Understanding PostgreSQL Streaming Replication
PostgreSQL uses streaming replication to keep replicas synchronized. Here’s what happens under the hood:
1
2
3
4
5
6
-- On the primary database
INSERT INTO orders (user_id, total) VALUES (1, 99.99);
-- This generates a WAL (Write-Ahead Log) record
-- The WAL record streams to replicas
-- Replicas apply the change, but with a slight delay
This delay, called replication lag, is crucial to understand:
1
2
3
4
5
6
7
8
9
# On primary
user = User.create!(name: "Jane")
# user.id = 123
# Immediately on replica (within milliseconds)
User.find(123) # => ActiveRecord::RecordNotFound
# After replication lag (typically < 1 second)
User.find(123) # => #<User id: 123, name: "Jane">
Setting Up Your First Read Replica
Let’s implement a basic read replica setup that handles the most common use case: offloading analytics queries.
Step 1: Database Configuration
First, configure your database.yml
to define both primary and replica connections:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
production:
primary:
adapter: postgresql
host: <%= ENV['PRIMARY_DB_HOST'] %>
database: myapp_production
username: <%= ENV['DB_USERNAME'] %>
password: <%= ENV['DB_PASSWORD'] %>
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
primary_replica:
adapter: postgresql
host: <%= ENV['REPLICA_DB_HOST'] %>
database: myapp_production
username: <%= ENV['DB_REPLICA_USERNAME'] %>
password: <%= ENV['DB_REPLICA_PASSWORD'] %>
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
replica: true # This marks it as a read-only connection
The replica: true
flag is important—it tells Rails this connection should never receive writes.
Step 2: Model Configuration
Now, tell your models about these connections:
1
2
3
4
5
6
7
8
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
connects_to database: {
writing: :primary,
reading: :primary_replica
}
end
This configuration means:
- All write operations (INSERT, UPDATE, DELETE) go to
:primary
- Read operations can be directed to
:primary_replica
- By default, all operations still go to primary (for safety)
Step 3: Basic Read/Write Splitting
Here’s how to explicitly route queries to your replica:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class AnalyticsController < ApplicationController
def revenue_report
# This block ensures all queries inside use the replica
ApplicationRecord.connected_to(role: :reading) do
@revenue_by_month = Order
.group("DATE_TRUNC('month', created_at)")
.sum(:total)
@top_customers = User
.joins(:orders)
.group('users.id')
.order('SUM(orders.total) DESC')
.limit(10)
.pluck('users.name, SUM(orders.total)')
end
# Queries here go back to primary
current_user.update!(last_viewed_report_at: Time.current)
end
end
The connected_to
block is like a database transaction—everything inside it uses the specified connection.
Real-World Example: Separating Analytics
Let’s build a practical example where analytics queries never impact your main application:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# app/models/analytics/base.rb
module Analytics
class Base < ApplicationRecord
self.abstract_class = true
# Always use replica for analytics models
connects_to database: {
writing: :primary,
reading: :primary_replica
}
# Force all queries to use replica
def self.default_role
:reading
end
end
end
# app/models/analytics/user_metrics.rb
module Analytics
class UserMetrics < Base
self.table_name = 'users'
def self.monthly_cohort_retention
# This complex query runs on replica, not affecting login performance
sql = <<-SQL
WITH cohorts AS (
SELECT
DATE_TRUNC('month', created_at) as cohort_month,
id as user_id
FROM users
),
activities AS (
SELECT
user_id,
DATE_TRUNC('month', created_at) as activity_month
FROM orders
GROUP BY 1, 2
)
SELECT
cohorts.cohort_month,
COUNT(DISTINCT cohorts.user_id) as cohort_size,
COUNT(DISTINCT activities.user_id) as active_users,
ROUND(100.0 * COUNT(DISTINCT activities.user_id) /
COUNT(DISTINCT cohorts.user_id), 2) as retention_rate
FROM cohorts
LEFT JOIN activities ON cohorts.user_id = activities.user_id
GROUP BY 1
ORDER BY 1
SQL
connection.exec_query(sql)
end
end
end
Now your data team can run heavy queries without fear:
1
2
3
4
5
6
# This query might take 30 seconds, but won't affect your app
retention_data = Analytics::UserMetrics.monthly_cohort_retention
# Meanwhile, your users can still log in quickly
user = User.find_by(email: params[:email])
user.authenticate(params[:password])
Monitoring Replication Health
You can’t use read replicas effectively without monitoring. For a comprehensive guide on identifying performance bottlenecks in your Rails app (including database issues), see our guide on measurement-driven optimization. Here’s a simple health check for replica monitoring:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class ReplicaHealthCheck
def self.replication_lag
result = ApplicationRecord.connected_to(role: :reading) do
ApplicationRecord.connection.execute(<<-SQL).first
SELECT CASE
WHEN pg_is_in_recovery() THEN
EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp()))
ELSE 0
END as lag_seconds
SQL
end
result['lag_seconds'].to_f.seconds
end
def self.healthy?
lag = replication_lag
lag < 5.seconds
rescue PG::ConnectionBad
false
end
end
# app/jobs/replica_health_monitor_job.rb
class ReplicaHealthMonitorJob < ApplicationJob
queue_as :monitoring
def perform
lag = ReplicaHealthCheck.replication_lag
# Report metric to your monitoring service
StatsD.gauge('database.replica.lag_seconds', lag.to_f)
# Alert if lag exceeds threshold
if lag > 10.seconds
Rails.error.report(
HighReplicationLagError.new("Replication lag: #{lag}"),
severity: :warning,
context: { lag_seconds: lag.to_f }
)
end
# Schedule next check
self.class.set(wait: 30.seconds).perform_later
end
end
# Start monitoring (in an initializer or deploy task)
ReplicaHealthMonitorJob.perform_later if Rails.env.production?
Common Pitfalls in Basic Setups
1. Accidental Writes to Replicas
1
2
3
4
# This will raise an error
ApplicationRecord.connected_to(role: :reading) do
User.create!(name: "Bob") # ActiveRecord::ReadOnlyError
end
Rails protects you by default, but always double-check your replica database user has read-only permissions:
1
2
3
4
-- On your PostgreSQL replica
CREATE USER replica_user WITH PASSWORD 'secret';
GRANT SELECT ON ALL TABLES IN SCHEMA public TO replica_user;
-- No INSERT, UPDATE, DELETE permissions
2. Lazy Loading Across Connection Boundaries
1
2
3
4
5
6
7
8
9
10
11
12
# Be careful with lazy-loaded queries
ApplicationRecord.connected_to(role: :reading) do
@users = User.where(active: true) # Query not executed yet
end
# This executes the query outside the block, using primary instead of replica
@users.each { |u| puts u.name } # Uses primary connection!
# Solution: Force execution within the connection block
ApplicationRecord.connected_to(role: :reading) do
@users = User.where(active: true).load # Forces execution on replica
end
Further Reading
For more details on the concepts covered in this post:
- Rails Documentation
- Active Record Multiple Databases Guide - Official Rails guide on database configuration
- Database Connection Switching - API documentation for
connected_to
- PostgreSQL Documentation
- Streaming Replication - How PostgreSQL streaming replication works
- Monitoring Replication - Using pg_stat_replication view
- High Availability - PostgreSQL’s approach to HA and read replicas
What’s Next?
Make sure to check out:
Remember: start simple. Begin by moving just your analytics queries to replicas and expand from there. The goal is to improve performance without adding unnecessary complexity.