Tối ưu Rails app với Redis

1420999778redisrails.png

Việc tối ưu một trang web là một công việc khá quan trọng , nó làm cho việc trải nghiệm người dùng (UX) tốt hơn khi mà chương trình của chúng ta trở nên lớn hơn về số lượng người dùng hay dữ liệu. Khi tối ưu ở server mà ta đã tối ưu câu query hết mức, loại bỏ N+1... mà ta vẫn thấy chậm, lúc đó ta có thể nghĩ đến một phương án khác là sử dụng cache dữ liệu.

Trong bài viết này mình sẽ giới thiệu Redis và demo một ứng dụng Rails sử dụng Redis để cache dữ liệu.

1. Redis là gì?

Redis là một dự án mã nguồn mở, dự án có hơn 20k stars và hơn 7k forks trên Github (một con số ấn tượng phải không). Redis thường được coi như là data structures server, điều đó có nghĩa là nó cung cấp quyền truy cập dữ liệu thông qua một tập các câu lệnh, các request sử dụng cấu trúc server-client với giao thức TCP sockets và một giao thức đơn giản khác. Vì vậy, các tiến trình khác nhau có thể query hay modify cùng một dữ liệu dưới nhiều cách khác nhau.

Redis là một in-memory data structure store, điều này có nghĩa là Redis lưu dữ liệu ở trong bộ nhớ chính (RAM), lí giải tại sao Redis lại nhanh.

Tại sao lại chọn Redis?

Có rất nhiều hệ thống lưu trữ dữ liệu ngoài kia như Memcached, Voldemort, MongoDB, Apache Casandra... Vậy tại sao lại chọn Redis?

Việc sử dụng Redis có một số tính năng đặc biệt:

  • Redis sẽ đảm bảo việc lưu dữ liệu vào đĩa, thậm chí dữ liệu được thay đổi, sửa chữa thường xuyên. Ngoài ra Redis cũng rất nhanh nhưng vẫn ổn định.
  • Redis quan tâm đặc biệt vào hiệu quả bộ nhớ, vì vậy dữ liệu bên trong Redis sẽ sử dụng ít bộ nhớ hơn so với những hệ thống lưu trữ dữ liệu sử dụng ngôn ngữ lập trình bậc cao cùng loại.
  • Redis cung cấp mọt số các tính năng như sự sao chép (replication), tính bền bỉ (durability), phân cụm (cluster) hay độ khả dụng cao (high availability).

Kiểu dữ liệu

Redis sử dụng dạng lưu trữ key-value, nhưng không hẳn là text thông thường, Redis hỗ trợ nhiều loại dữ liệu

|_.Loại dữ liệu|_.Mô tả|
|String|Redis sử dụng Binary-safe strings|
|Set|Một tập các string duy nhất và không sắp xếp|
|List|Tập các string được sắp xếp theo thứ tự được chèn vào, cơ bản giống như linked lists|
|Sorted set|Giống Set nhưng các phần tử được sắp xếp thông qua một giá trị được gọi là score|
|Hash|Các cặp key-value, nó giống như Hash ở trong Ruby hay Python|
|Bit array|Lưu trữ dữ liệu ở dạng một mảng các bit|
|HyperLogLog|Được sử dụng để ước lượng các yếu tố của một tập|

Cài đặt

Lan man với lí thuyết thế là đủ, cùng download, giải nén và compile Redis với:

$ wget http://download.redis.io/releases/redis-3.2.5.tar.gz
$ tar xzf redis-3.2.5.tar.gz
$ cd redis-3.2.5
$ make
$ cp src/redis-server src/redis-cli /usr/bin

Để khởi động Redis ta sử dụng câu lệnh:

$ redis-server

2. Tối ưu Rails app với Redis

Tạo dữ liệu

Ở phần demo này mình có 2 bảng là User và Post

# app/models/post.rb
class Post < ActiveRecord::Base
  belongs_to :user
end

# app/models/user.rb
class User < ActiveRecord::Base
  has_many :posts
end

Việc truy vấn với một lượng lớn dữ liệu sẽ mất khá nhiều thời gian query, sẽ mất nhiều thời gian hơn nữa khi ta response cho client. Tiếp theo ta tạo dữ liệu từ file seed.rb, ta cần cài đặt gem faker để tạo dữ liệu ảo.

gem "faker"

Ta tạo ra 10 user và mỗi user có 10000 post

# db/seed.rb
10.times do |n|
  user = User.create! name: Faker::Name.name, address: Faker::Address.city
  10000.times do |m|
    Post.create! title: Faker::Lorem.sentence, content: Faker::Lorem.paragraph,
      user: user
  end
end

Ở controller index ta load hết tất cả 100000 post và trả về ở dạng json.

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    @posts = Post.includes(:user).all
    respond_to do |format|
      format.json { render json: @posts, status: :ok }
    end
  end
end

Thử chạy chương trình xem có ổn không nào.

Selection_007.png

Selection_008.png

Ta có thể thấy là với 100000 bản ghi server mất 86ms để truy vấn dữ liệu và mất toàn bộ gần 21s để trả về được cho client dữ liệu dưới dạng json.

Khởi tạo Redis Rails

Tiếp theo ta cần cài đặt một số gem để có thể sử dụng Redis

gem "redis"
gem "redis-namespace"
gem "redis-rails"
gem "redis-rack-cache"

Ta cần khai báo với Rails rằng là sử dụng Redis như một cache store, ở đây ta cần khai báo địa chỉ host, cổng và số thứ tự database (Redis mặc định có 16 database được đánh số thứ tự từ 0-15)

# config/application.rb
config.cache_store = :redis_store, {
  host: "localhost",
  port: 6379,
  db: 0,
}, {expires_in: 7.days}

Ta cần phải tạo ra một Redis instance để có thể gọi được ở trong ứng dụng Rails, bằng việc sử dụng redis-namespace điều này khá dễ dàng. Sau này khi cần thực hiện query Redis sẽ thông qua biến này.

# config/initializers/redis.rb
$redis = Redis::Namespace.new "demo-redis", :redis => Redis.new

Giờ thì ta đã có thể sử dụng được Redis để lưu trữ dữ liệu rồi

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    @posts = fetch_from_redis
    respond_to do |format|
      format.json { render json: @posts, status: :ok }
    end
  end

  private
    def fetch_from_redis
      posts = $redis.get "posts"

      if posts.nil?
        posts = Post.includes(:user).all.to_json
        $redis.set "posts", posts
      end
      JSON.load posts
    end
end

Chạy thử và xem kết quả nào

Selection_005.png

Selection_006.png

Server không hề mất thời gian truy vấn dữ liệu thay vào đó là lấy dữ liệu từ Redis (rất nhanh) và cũng chỉ mất tổng cộng hơn 7s để trả lại dữ liệu cho client dưới dạng json, thời gian đã được giảm xuống còn 1/3 so với lúc trước.

Một số vấn đề gặp phải khi sử dụng redis

Khi Redis bị lỗi thì server của chúng ta cũng bị lỗi

Để khắc phục điều này ta cần tạo 1 exception cho việc gọi Redis (good practice), ta có thể viết lại hàm fetch_from_redis

# app/controllers/posts_controller.rb
def fetch_from_redis
  begin
    posts = $redis.get "posts"

    if posts.nil?
      posts = Post.includes(:user).all.to_json
      $redis.set "posts", posts
    end
    posts = JSON.load posts
  rescue => error
    puts error.inspect
    posts = Post.includes(:user).all
  end
  posts
end

Dữ liệu trả về không còn là Active Record

Một điều cần lưu ý là khi ta load dữ liệu từ Redis thì ta cần phải chuyển dữ liệu cần lưu thành string thì mới có thể lưu vào được Redis, và khi lấy ra ta cần phải convert từ string thành hash. Vì vậy khi sử dụng dữ liệu ở view thì cần chú ý vì dữ liệu bây giờ không phải là Active Record nữa.

Việc convert sang json và dump lại thành hash có thể mất nhiều thời gian, ta có thể sử dụng yajl-ruby hay Oj

Dữ liệu khi bị sửa đổi hay xóa thì dữ liệu trong redis sẽ không còn đúng nữa

Có một vấn đề là khi ta cập nhật hay xóa dữ liệu thì khi ta lấy dữ liệu từ Redis ra sẽ không còn đúng nữa, vì vậy ta cần phải có một bước cập nhật dữ liệu Redis mỗi khi có thay đổi về dữ liệu.

Điều này giải quyết khá đơn giản là ta lại xóa dữ liệu trong Redis đi.

class Post < ActiveRecord::Base
  after_save :clear_cache

  private
  def clear_cache
    $redis.del "posts"
  end
end

Đặt key có tính phân biệt

Giả định ở index ta chỉ lấy những posts của user hiện tại, khi đó ta sẽ gặp trường hợp là 2 user khác nhau sẽ lấy cùng một dữ liệu ở Redis vì vậy kết quả sẽ không đúng.

Để giải quyết vấn đề này cũng khá đơn giản, là ta chỉ cần đặt key khi lưu vào Redis có thể phân biệt được 2 user đó, ví dụ ta có thể đặt key là posts&user_id=1 thay vì là posts

Tài liệu tham khảo