Partition trong SQL và áp dụng trong Rails.

Giới thiệu qua về partition trong SQL

Table partition là kĩ thuật cho phép phân chia 1 bảng lớn (hoặc index) thành các đơn vị nhỏ hơn. Bảng này sau đó vẫn có thể thực hiện query hay update dữ liệu dưới dạng 1 thực thể logic thống nhất (người dùng có thể không nhìn thấy / quan tâm tới việc câu lệnh được thực thi trên những partition con nào). Với các bảng có dữ liệu lớn, các tác vụ quản trị như backup/restore, create/rebuild, hoặc thậm chí các câu lệnh query cũng có thể tốn nhiều thời gian. Table partitioning có thể giải quyết các vấn đề đó, khi nó đi kèm với 1 số ưu điểm:

  • Backup/restore/rebuild index trên các phân đoạn con (partition) mà không ảnh hưởng tới phần còn lại của bảng.
  • Cho phép thực hiện các câu lệnh query trên 1 vài partition và bỏ qua các phân vùng còn lại.
  • Dữ liệu ở các partition thực chất được lưu ở các phân vùng vật lý khác nhau => giảm thiểu việc chờ đợi (vd 2 câu lệnh thực hiện trên cùng 1 bảng nhưng ở 2 partition khác nhau hoàn toàn có thể chạy song song)

Cách tạo migration cho 1 table trong Rails sử dụng partition by hash

Ta sẽ sử dụng 1 CSDL mẫu cho bài viết này. CSDL sẽ bao gồm 3 bảng:

  • Category: dùng để nhóm các record của 2 bảng dưới.
class CreateCategories < ActiveRecord::Migration[5.1]
  def change
    create_table :categories do |t|
      t.string :title
      t.timestamps
    end
  end
end
  • Book: Bảng demo - không sử dụng partition.
class CreateBooks < ActiveRecord::Migration[5.1]
  def change
    create_table :books do |t|
      t.references :category, foreign_key: true
      t.string :title
      t.text :context

      t.timestamps
    end

  end
end
  • Manga: Bảng demo - sử dụng kĩ thuật partition.

Bảng được phân đoạn dựa vào giá trị của một hoặc nhiều trường (gọi là partition key). Có 1 số chiến lược phân chia partition phổ biến:

Range partition:

Cách này thường được sử dụng khi partition key nằm trong 1 khoảng giá trị. Các khoảng dùng để phân chia nên nối tiếp và KHÔNG ĐƯỢC TRÙNG LÊN NHAU. Một ví dụ tiêu biểu cho cách phân chia này đó là phân chia theo thời gian: có thể phân chia các record dựa theo thời gian chúng được tạo.

PARTITION BY RANGE ( YEAR(created_at) ) (
    PARTITION p0 VALUES LESS THAN (2014),
    PARTITION p1 VALUES LESS THAN (2015),
    PARTITION p2 VALUES LESS THAN (2016),
    PARTITION p3 VALUES LESS THAN MAXVALUE
);

Với cách này, ta có thể nhanh chóng xóa đi các record đã cũ (vd các record từ trước năm 2014) bằng cách clear toàn bộ partition p0.

List partition:

Tương tự với Range partition, tuy nhiên với cách phân chia này, giá trị của cột partiton key được xác định theo 1 nhóm các giá trị định trước, hơn là 1 dãy các khoảng nối tiếp nhau.

Hash partition:

Với các phân chia này, 1 partiton được lựa chọn dựa trên kết quả trả về được tính toán dựa trên giá trị của cột (hay các cột) được lựa chọn làm partition key. Lưu ý: công thức để tính toán cho hash partition có thể là bất kì 1 công thức nào valid trong SQL nhưng PHẢI TRẢ VỀ GIÁ TRỊ KHÔNG ÂM (non-negative integer).

Implement trong Rails:

Với ví dụ demo trong bài , ta sẽ sử dụng chiến lược hash partition cho bảng Manga. Với nhận xét sau:

  • Cột được sử dụng làm partition key nên là cột thường xuyên được sử dụng để query trên bảng đó.

Ta sẽ sử dụng trường category_id để phân chia cho bảng Manga, đồng thời sử dụng số lượng partitions là 10. Khi đó, các record được lưu vào bảng sẽ được phân chia rải rác đều cho 10 parttion con, dựa vào giá trị của category_id tương ứng.

Có thể sử dụng cách dưới đây để tạo migration cho bảng này:

class CreateMangas < ActiveRecord::Migration[5.1]
  def change
    create_table :mangas, id: false, options: "PARTITION BY HASH(category_id) PARTITIONS 10" do |t|
      t.integer :id, null: false, auto_increment: true, index: true
      t.integer :category_id, null: false, index: true
      t.string :title
      t.text :context

      t.index ["id", "category_id"], unique: true

      t.timestamps
    end
  end
end
  • Truyền option PARTION BY HASH(category_id) PARTITONS 10 vào options tạo bảng của migration.

Cần lưu ý: Các trường được sử dụng làm partiton key phải xuất hiện trong MỌI key của bảng. Vì vậy để áp dụng vào migration, ta cần chỉnh sửa 1 số chỗ:

  • Bỏ đi khóa chính mặc định của Rails bằng cách set id: false: (do category_id bắt buộc phải xuất hiện trong khóa chính, và Rails cũng không hỗ trợ mặc định composite primary key, nên ta có thể bỏ đi cũng được)
  • Khai báo lại trường id, cùng với đánh index với set auto_increment cho id
  • Ta cũng có thể đánh unique index cho 2 cột id, category_id.

Config model cho table được partition

Với model manga, ta cũng set các association như bình thường.

class Manga < ApplicationRecord
   belongs_to :category
end

Cần chú ý vì bây giờ ta đã bỏ đi việc kiểm tra unique cho id ở tầng sql => nên kiểm tra unique ít nhất ở Rails.

validates :id, uniqueness: true

đồng thời, do đã bỏ đi primary_key nên các câu lệnh kiểu như sau sẽ sinh ra lỗi.

Ta có thể by-pass nó bằng cách định nghĩa primary_key ở tầng Rails :p

self.primary_key = 'id'

Lưu ý khi query

Partition sẽ chỉ phát huy được tác dụng nếu trong câu lệnh query có sự xuất hiện của trường được sử dụng làm partition key.

Manga.where(category_id: category_ids, other_query ...)

Benchmark

Với bài viết này, dữ liệu demo được build với 30000 record (được chia ra làm 10 category).

  • Với bảng Book (bảng không được chia partition), 1 câu lệnh query sẽ phải chạy qua toàn bộ các dòng (30000)

  • Đối với bảng đã chia partition, câu lệnh trên sẽ giới hạn số lượng partition cần tìm xuống còn 1 nửa, thời gian query trung bình giảm xuống còn 3/4

  • Tương tự như vậy, 1 query trung bình trên bảng Book sẽ mất khoảng 20 -> 25 ms cho việc duyệt qua toàn bộ bảng.

  • Trong khi đó, với cùng câu lệnh trên, khi giới hạn số lượng partition cần duyệt xuống còn 2, thời gian truy vấn sẽ giảm chỉ còn 1 nửa, thậm chí 1/3.