12/08/2018, 16:34

Rails Database Best Practices

Làm việc trên một dự án Oldish Rails, tôi đã gặp một code cần phải refactor với ActiveRecord. Tôi cũng đã dành thời gian tăng tốc các trang với chậm / nhiều truy vấn vào database. Giữa hai kinh nghiệm đó, tôi cảm thấy cảm hứng để viết lên một số best practice về Cơ sở dữ liệu về "Back to Basics" ...

Làm việc trên một dự án Oldish Rails, tôi đã gặp một code cần phải refactor với ActiveRecord. Tôi cũng đã dành thời gian tăng tốc các trang với chậm / nhiều truy vấn vào database. Giữa hai kinh nghiệm đó, tôi cảm thấy cảm hứng để viết lên một số best practice về Cơ sở dữ liệu về "Back to Basics" Rails.

Cơ sở dữ liệu cực kỳ phong phú về tính năng và thực sự rất nhanh khi được sử dụng đúng cách. Nó rất tuyệt khi lọc và phân loại ... và nhiều thứ khác. Nếu cơ sở dữ liệu có thể làm điều đó, nó sẽ làm nó nhanh hơn làm điều tương tự trong Ruby, hoặc bất kỳ ngôn ngữ khác cho vấn đề đó.

Bạn có thể phải tìm hiểu một chút về cách DBS làm việc, nhưng thành thật mà nói, bạn không cần phải đi sâu để thấy được lợi ích của việc này.

Chúng tôi thường sử dụng Postgres. Những gì bạn chọn ít quan trọng hơn việc tìm hiểu nó và sử dụng các tính năng của nó để làm cho sản phẩm của bạn tuyệt vời. Nếu bạn tò mò về Postgres, có một số tài nguyên tốt ở cuối bài đăng này.

Quy tắc bao quát đầu tiên của chúng tôi: Hãy để cơ sở dữ liệu của bạn làm những gì cơ sở dữ liệu tốt, thay vì làm nó trong Ruby.

Scope cho phép bạn tạo ra những helper ngắn gọn để truy cập các tập hợp con dữ liệu có liên quan trong các tình huống cụ thể. Thật là tồi tệ khi tính ích lợi của chúng có thể bị hạn chế đáng kể bởi một vài anti-patterns.

Kiểm tra scope active dưới đây:

class Client < ActiveRecord::Base
  has_many :projects
end

class Project < ActiveRecord::Base
  belongs_to :client
  
  # Please don't do this...
  scope :active, -> {
    includes(:client)
      .where(active: true)
      .select { |project| project.client.active? }
      .sort_by { |project| project.name.downcase }
  }
end

Ở đây chúng ta có thể nhận thấy một số vấn đề:

  1. Scope không trả về một ActiveRecord Relation, do đó nó không phải là chuỗi và cũng không thể được sử dụng với method .merge () (thêm vào hợp nhất sau).
  2. Scope được lọc từ một bộ dữ liệu lớn hơn thành một bộ nhỏ hơn trong Ruby.
  3. Scope được sắp xếp trong Ruby.
  4. Scope được phân loại, giai đoạn.

Trả lại ActiveRecord :: Relation (tức là không kích hoạt truy vấn đó!)

Tại sao trả lại Relation tốt hơn? Quan hệ có thể liên kết. Phạm vi liên kết có thể dễ dàng sử dụng lại. Bạn có thể kết hợp nhiều scope vào một truy vấn đơn lẻ khi mỗi scope trả về một Relation, ví dụ: Athlete.active.men.older_than (40). Relation cũng có thể được sử dụng với .merge ()

Lọc dữ liệu trong database (không phải trong Ruby)

Tại sao lọc trong Ruby một ý tưởng tồi? Bởi vì nó thực sự chậm so với lọc trong cơ sở dữ liệu. Đối với các tập dữ liệu nhỏ (chữ số đơn và số đôi thấp), nó có thể không tạo ra sự khác biệt đáng chú ý. Đối với số liệu lớn hơn, sự khác biệt có thể rất lớn.

Lọc trong Ruby chậm hơn bởi vì:

  • Thời gian dành cho việc vận chuyển dữ liệu từ cơ sở dữ liệu đến máy chủ ứng dụng,
  • ActiveRecord phải phân tích các kết quả truy vấn và tạo ra các đối tượng mô hình AR cho mỗi một
  • Cơ sở dữ liệu của bạn có các chỉ mục (phải không ?!) mà làm cho bộ lọc blazingly nhanh, Ruby thì không.

Sắp xếp dữ liệu trong database (không phải trong Ruby)

Và những gì về sắp xếp? Nó sẽ nhanh hơn trong cơ sở dữ liệu, nhưng trừ khi bạn đang xử lý các tập dữ liệu rất lớn, còn không nó sẽ không thực sự có ý nghĩa. Vấn đề lớn hơn với điều này là .sort_by sẽ kích hoạt các truy vấn và do đó, chúng a mất Quan hệ. Đó là lý do đủ để sắp xếp trong cơ sở dữ liệu.

Để việc sắp xếp ra ngoài scope (hoặc đặt trong một scope riêng biệt)

Chúng ta đang cố gắng xây dựng các scope có thể tái sử dụng nên hầu như không có khả năng mọi lời gọi của một scope sẽ có cùng yêu cầu. Vì lý do đó, tôi khuyên bạn nên bỏ các sắp xếp nhỏ ra khỏi scope cùng nhau. Hoặc, có thể tạo ra các scope riêng biệt như thế này:

scope :ordered, => { order(:status).order('LOWER(name) DESC') }

Như thế nào sẽ tốt hơn ?

class Client < ActiveRecord::Base
  has_many :projects

  scope :active, -> { where(active: true) }
end

class Project < ActiveRecord::Model
  belongs_to :client

  scope :active, -> {
    where(active: true)
      .joins(:client)
      .merge(Client.active)
  }
  
  scope :ordered, -> {
    order('LOWER(name)')
  }
end

Kiểm tra việc sử dụng .merge () (api) trong scope sửa đổi. Method .merge () làm cho dễ dàng sử dụng các scope từ các model khác đã được tham gia vào truy vấn, giảm trùng lặp có thể.

Phiên bản này có hiệu quả tương đương, nhưng không có bất kỳ hạn chế nào so với scope cũ. Bên cạnh đó nó cũng dễ dàng đọc hơn.

ActiveRecord cung cấp một API dễ dàng để làm nhiều thứ với cơ sở dữ liệu, nhưng nó cũng làm cho nó khá dễ dàng để làm những thứ không hiệu quả. Lớp trừu tượng (The layer of abstraction) ẩn những gì thực sự xảy ra.

Một trong những sự thiếu hiệu quả này là sự gia tăng các truy vấn cơ sở dữ liệu. Các kicker là trong quá trình phát triển thường sử dụng local database và datasets nhỏ. Một khi bạn đưa lên môi trường production, đột nhiên độ trễ tăng 10x (hoặc nhiều hơn!) Và tập dữ liệu là lớn hơn đáng kể. Yêu cầu nhận được thực sự rất rất chậm ...

Best practice: nếu một trang thường kích hoạt nhiều hơn một vài cuộc truy vấn đến DB, bạn nên dành ít thời gian để giảm số lượng truy vấn xuống chỉ còn một vài.

Trong nhiều trường hợp, đây chỉ là vấn đề sử dụng .includes () hoặc .joins (). Đôi khi bạn sẽ phải sử dụng .group (), .having () và một số chức năng tổng hợp. Và trong một số trường hợp hiếm hoi, bạn có thể phải viết một số thẳng lên SQL.

Đối với một truy vấn không tầm thường, bắt đầu với CLI. Một khi bạn đã có một truy vấn làm việc trong SQL, tìm ra cách để tích hợp nó vào ActiveRecord. Bằng cách này, bạn chỉ đang vật lộn với một điều tại một thời điểm: SQL thuần túy đầu tiên, sau đó là ActiveRecord. Trên Postgres? Sử dụng pgcli thay vì psql, nó thực sự trất phù hợp.

Rất nhiều tài liệu đã được viết về chủ đề cụ thể này. Dưới đây là một số tài liệu tham khảo:

  • Include vs join trong rails
  • Preload vs Eager Load vs Joins vs Includes
  • Bullet – A gem that warns of N+1 select (and other) issues

Caching? Chắc chắn, bộ nhớ cache là một cách khác để tăng tốc các trang chậm này, nhưng tốt hơn hết là loại bỏ các truy vấn không hiệu quả trước tiên. Nó cải thiện kinh nghiệm khi có một lỗi bộ nhớ cache và đặt tải ít hơn trên cơ sở dữ liệu của bạn nói chung.

Cơ sở dữ liệu chỉ có thể thực hiện tìm kiếm nhanh cho các cột có chỉ mục, nếu không thì nó đang thực hiện quét tuần tự. Rule: Thêm một chỉ mục trên mỗi cột id cũng như bất kỳ cột nào được sử dụng trong mệnh đề where.

Dễ dàng thêm chúng khi migration trong Rails:

class SomeMigration < ActiveRecord::Migration
  def change
    # Specify that an index is desired when initially defining the table.
    create_table :memberships do |t|
      t.timestamps             null: false
      t.string :status,        null: false, default: 'active', index: true
      t.references :account,   null: false, index: true, foreign_key: true
      t.references :club,      null: false, index: true, foreign_key: true
      # ...
    end
    
    # Add an index to an existing table.
    add_index :payments, :billing_period
    
    # An index on multiple columns.
    # This is useful when we always use multiple items in the where clause.
    add_index :accounts, [:provider, :uid]
  end
end

Có thể lập chỉ mục và chỉ mục quá mức sẽ gây ra thêm chi phí trên chèn / cập nhật, nhưng theo quy tắc chung, tốt hơn là để có chúng hơn không.

Bạn muốn hiểu cơ sở dữ liệu đang làm gì khi bạn kích hoạt truy vấn hoặc cập nhật? Bạn có thể luôn luôn tack method .explain đến hết ActiveRecord Relation của bạn và nó sẽ trả lại kế hoạch truy vấn của cơ sở dữ liệu.

Scope được sử dụng tốt nhất khi chúng đơn giản và không làm quá nhiều. Chúng là những khối có thể tái sử dụng. Nếu cần phải làm một cái gì đó phức tạp hơn, hãy sử dụng một Query class để đóng gói các truy vấn có tiềm năng gnarly. Đây là một ví dụ:

# A query that returns all of the adults who have signed up as volunteers this year,
# but have not yet become a pta member.
class VolunteersNotMembersQuery
  def initialize(year:)
    @year = year
  end

  def relation
    volunteer_ids  = GroupMembership.select(:person_id).school_year(@year)
    pta_member_ids = PtaMembership.select(:person_id).school_year(@year)

    Person
      .active
      .adults
      .where(id: volunteer_ids)
      .where.not(id: pta_member_ids)
      .order(:last_name)
  end
end

Mặc dù nó có thể giống như gây nên nhiều truy vấn, nhưng nó không. Dòng 9-10 xác định quan hệ. Chúng được sử dụng trên dòng 15-16 và kết quả là hai truy vấn phụ. Đây là SQL kết quả (một truy vấn):

SELECT people.*
FROM people
WHERE people.status = 0
  AND people.kind != "student"
  AND (people.id IN (SELECT group_memberships.person_id FROM group_memberships WHERE group_memberships.school_year_id = 1))
  AND (people.id NOT IN (SELECT pta_memberships.person_id FROM pta_memberships WHERE pta_memberships.school_year_id = 1))
ORDER BY people.last_name ASC

Lưu ý, truy vấn này trả về một ActiveRecord :: Relation, lý tưởng vì kết quả có thể được xây dựng thêm sau (ví dụ như sắp xếp).

Sometimes it’s just too hard return a Relation though, or it’s not worth the effort yet because I’m prototyping. In those cases, I’ll write a Query class that returns data instead (i.e. triggers the query/queries and returns data in the form of models, hash, or something else). I use the naming convention .data if it’s returning already-queried data, otherwise .relation (as above).

Đôi khi nó khá là khó để trả về một Relation, hoặc không ccó giá trị nào bởi vì là prototyping. Trong những trường hợp này, tôi sẽ viết một lQuery class trả về dữ liệu thay vào đó (nghĩa là kích hoạt truy vấn / truy vấn và trả về dữ liệu dưới dạng các model, hash hoặc cái gì đó khác). Tôi sử dụng quy ước đặt tên .data nếu nó đang trả lại dữ liệu đã truy vấn, nếu không .relation (như trên).

Lợi ích chính của Query Object là tổ chức code; đó là một cách dễ dàng để kéo một cái gì đó có khả năng phức tạp ra khỏi model (hoặc gasp, một Controller) và vào tập tin riêng của nó. Các truy vấn rất dễ kiểm tra một cách đơn lẻ. Họ cũng tuân theo Nguyên tắc Trách nhiệm Đơn (Single Responsibility Principle).

Rule: "Hạn chế quyền truy cập vào các phương thức xây dựng truy vấn chung của ActiveRecord (ví dụ: .where, .group, .joins, .not, v.v ...) đến scope và Query Object"

Nghĩa là, đóng gói dữ liệu vào các scope và các Query Object, thay vì tạo các truy vấn ad-hoc trong các service, controller, task, v.v ...

Tại sao? Một truy vấn ad-hoc được nhúng trong controller (hoặc view, task, v.v ...) sẽ khó để test hơn và không thể được sử dụng lại. Bên cạnh đó, nó dễ dàng hơn khi follow theo một quy tắc, dễ hiểu và bảo trì hơn.

Mỗi cơ sở dữ liệu cung cấp nhiều kiểu dữ liệu hơn ORM của bạn có thể biết. Đây là một số loại Postgres ít phổ biến hơn mà tôi nghĩ có thể áp dụng cho phần lớn các ứng dụng:

  • Bạn muốn preserve case, nhưng có tất cả so sánh không phân biệt chữ hoa chữ thường hay không? citext (tài liệu) là kiểu mà bạn cần. Sử dụng nó trong migration giống như một chuỗi.
  • Bạn cần nắm bắt một tập hợp các sự kiện (ví dụ: locations, tags, keywords) nhưng một bảng riêng biệt và join table lại có cảm giác quá mức cần thiết? Sử dụng kiểu array (PG docs / Rails docs)
  • Modeling date, int, hay float? Sử dụng một trong các kiểu range (PG docs / Rails docs)
  • Bạn cần ID duy nhất (khóa chính hoặc cách khác)? Sử dụng loại UUID (PG docs / Rails docs)
  • Cần lưu trữ một blob JSON, hoặc suy nghĩ về một NoSQL DB? Sử dụng một trong các loại JSON (PG docs / Rails docs)

Đây chỉ là một vài kiểu dữ liệu đặc biệt có sẵn. Bạn có thể xem Hiểu được sức mạnh của các kiểu dữ liệu - Vũ khí bí mật của PostgreSQL để tìm hiểu về các kiểu khác.

Cả hai Postgres và MySQL có hỗ trợ full-text search. Mặc dù không mạnh như Elastic Search hay Solr, nhưng đối với nhiều vấn đề, nó đủ tốt và đảm bảo được kiến trúc tổng quan bởi vì bạn đã phụ thuộc vào cơ sở dữ liệu của mình. Dưới đây có một số blog về sử dụng full-text search trong Postgres:

  • Tìm kiếm nâng cao với Postgres phần 1 và phần 2
  • Pg docs

Đơn giản là hãy bỏ các business logic ra khỏi cơ sở dữ liệu, ít nhất đến khi có một cái gì đó để đánh giá lại.

Tôi tin rằng một sản phẩm sẽ hoạt động tốt hơn và dễ dàng làm việc hơn khi cơ sở dữ liệu được sử dụng đúng cách, phát huy hết khả năng của nó. Nếu bạn đọc kĩ các rule trên thì nguyên tắc số 1 gần như là điểm then chốt trong tất cả các vấn đề. Mình đã từng nghe một câu nói: "Hãy để người giỏi nhất làm những việc mà họ giỏi nhất" rất phù hợp với trường hợp này. Các bạn developer mới thường có thói quen là sử dụng các phương thức được hỗ trợ sẵn trong Ruby mà không quan tâm tới việc có thể ảnh hưởng rất lớn tới performance của hệ thống. Việc giảm số lượng truy vấn, sử dụng các chỉ mục hoặc bất kỳ đề xuất nào khác không phải là tối ưu hóa sớm IMHO. Nó giúp bạn sử dụng cơ sở dữ liệu một cách chính xác. Tất nhiên, có tăng thêm một số chi phí: ví dụ: viết một truy vấn SQL khéo để có thể giảm từ 3 câu truy vấn đơn giản còn 1.

Mình hi vọng bài viết này sẽ giúp các bạn có thêm một số kinh nghiệm khi thao tác với cơ sở dữ liệu một các dễ dàng hơn. Bài viết này mình dịch từ bài viết Rails database best practice.

0