1 điểm bởi GN⁺ 2025-04-29 | 1 bình luận | Chia sẻ qua WhatsApp
  • Giải thích cách xây dựng kiến trúc sử dụng cơ sở dữ liệu riêng cho từng tenant trong Rails và những thách thức trong quá trình triển khai
  • ActiveRecord về cơ bản được thiết kế với giả định chỉ có một kết nối DB, nên việc chuyển đổi kết nối theo tenant khá phức tạp và khó xử lý
  • Đề xuất cách dùng tính năng connected_to từ Rails 6 trở lên để chuyển đổi kết nối động khi runtime
  • SQLite3 phù hợp để xử lý nhiều DB độc lập, quy mô nhỏ, nên việc sao lưu, gỡ lỗi, xóa đều thuận tiện
  • Nhấn mạnh rằng khác với hạ tầng Rails phát triển theo hướng tối ưu cho các hệ thống quy mô lớn, vẫn có thể xây dựng kiến trúc xoay quanh các cơ sở dữ liệu nhỏ và độc lập

Vì sao dùng cơ sở dữ liệu riêng cho từng tenant

  • Nếu tách theo đơn vị tenant (Site) vận hành độc lập trong mô hình dữ liệu, việc cô lập và quản lý dữ liệu sẽ dễ hơn
  • Lưu dữ liệu của từng tenant vào DB riêng cũng có lợi cho việc mở rộng các site lớn hoặc xử lý vấn đề bảo mật
  • Dùng SQLite cho phép vận hành cơ sở dữ liệu chỉ với một tệp, không cần cấu hình máy chủ, nên đơn giản và linh hoạt

Những điểm khó trong Rails

  • Thao tác open/close cơ bản của SQLite rất đơn giản, nhưng ActiveRecord lại có cấu trúc quản lý kết nối nội bộ khá phức tạp
  • ActiveRecord được thiết kế theo kiểu gắn kết nối cố định với model, nên việc chuyển tenant khi runtime gặp nhiều khó khăn
  • Connection pool, query cache, schema cache... đều phụ thuộc vào kết nối, nên việc đổi kết nối liên tục tạo thêm gánh nặng

Lịch sử quản lý nhiều cơ sở dữ liệu trong Rails

  • Rails 1: có thể chỉ định DB ở cấp ActiveRecord::Base
  • Rails 3: giới thiệu connection pool
  • Rails 4: thêm connection_handling
  • Rails 6: giới thiệu connected_to
  • Rails 7: mở rộng connected_to và hỗ trợ sharding
  • Nhưng các kịch bản như "thêm/xóa tenant động khi runtime" vẫn chưa được hỗ trợ mặc định

Ưu điểm của cơ sở dữ liệu theo từng tenant

  • Có thể chỉ sao lưu hoặc khôi phục tệp của từng tenant, nên vận hành và gỡ lỗi trở nên đơn giản
  • Việc xóa tenant chỉ cần xóa tệp (unlink)
  • Máy chủ cơ sở dữ liệu cỡ lớn tối ưu cho DB hàng chục terabyte, còn SQLite lại tối ưu cho hàng nghìn DB nhỏ
  • Trên thực tế, iCloud cũng áp dụng cấu trúc lưu trữ hàng triệu DB SQLite nhỏ trên Cassandra

Quá trình giải quyết vấn đề

  • Cách cũ (dùng establish_connection thủ công) gây ra lỗi ConnectionNotEstablished trong môi trường nhiều kết nối đồng thời
  • Theo cách làm từ Rails 6 trở đi, thay vì tự quản lý connection pool, cấu trúc được chuyển sang để Rails tự xử lý
  • Tạo connection pool động cho từng tenant, rồi bọc tác vụ trong khối connected_to
  • Cải tiến bằng cách dùng middleware để chuẩn bị và giải phóng kết nối DB cần thiết một cách động tại thời điểm request

Mẫu mã nguồn cốt lõi

  • Kiểm tra connection pool, nếu chưa có thì tạo mới
MUX.synchronize do  
  if ActiveRecord::Base.connection_handler.connection_pool_list(role_name).none?  
    ActiveRecord::Base.connection_handler.establish_connection(database_config_hash, role: role_name)  
  end  
end  
  • Sau khi kết nối, thực thi truy vấn an toàn bên trong khối connected_to
ActiveRecord::Base.connected_to(role: role_name) do  
  pages = Page.order(created_at: :desc).limit(10)  
end  

Xử lý Rack streaming

  • Khi phản hồi Rack ở dạng streaming, để quản lý kết nối, bài viết dùng Rack::BodyProxyFiber để đóng kết nối an toàn
connected_to_context_fiber = Fiber.new do  
  ActiveRecord::Base.connected_to(role: role_name) do  
    Fiber.yield  
  end  
end  
connected_to_context_fiber.resume  
  
status, headers, body = @app.call(env)  
body_with_close = Rack::BodyProxy.new(body) { connected_to_context_fiber.resume }  
  
[status, headers, body_with_close]  

Cấu trúc middleware cuối cùng

  • Viết middleware Shardine::Middleware để với mỗi request, tìm kết nối DB phù hợp, chuyển bằng connected_to, rồi dọn dẹp sau khi phản hồi kết thúc
  • Có thể áp dụng trong tệp config.ru của dự án Rails như sau
use Shardine::Middleware do |env|  
  site_name = env["SERVER_NAME"]  
  {adapter: "sqlite3", database: "sites/#{site_name}.sqlite3"}  
end  

Những việc còn lại

  • Trong ActiveRecord 6, bài viết chưa dùng tính năng shard, nhưng ở các phiên bản sau thì cũng có thể tách riêng đọc/ghi
  • Chức năng dọn dẹp connection pool khi xóa tenant hiện chưa cần thiết nên chưa được triển khai
  • Trong tương lai, kiến trúc xử lý "nhiều cơ sở dữ liệu nhỏ" có thể sẽ nhận được nhiều sự chú ý hơn

1 bình luận

 
GN⁺ 2025-04-29
Ý kiến trên Hacker News
  • Đang sử dụng mô hình "database-per-tenant" với khoảng 1 triệu người dùng

    • Cách này phù hợp với các ứng dụng thiên về đọc; phần lớn tenant đều nhỏ và không có nhiều bản ghi trong bảng nên ngay cả các phép join phức tạp cũng rất nhanh
    • Vấn đề chính là phải migrate từng cơ sở dữ liệu một, nên thời gian phát hành có thể tăng lên đáng kể
    • Nếu xảy ra schema drift hoặc data drift, việc phát hành sẽ bị chặn và phải tìm ra vì sao tính năng không hoạt động ở một số tenant
  • Thích SQLite, nhưng tự hỏi liệu các cơ sở dữ liệu OLTP truyền thống có cần phải unload một phần index khỏi bộ nhớ hay không

    • Nếu dùng cơ sở dữ liệu theo từng người dùng, thì không cần giữ gì trong bộ nhớ cho các người dùng không hoạt động hoặc chỉ hoạt động ở instance khác
    • Điều này tương tự tình huống JSON của Mongo, và Postgres nhanh gấp đôi Mongo
  • Hầu hết mọi người không cần cơ sở dữ liệu theo tenant, và đây không phải cách làm phổ biến

    • Có những trường hợp cụ thể đủ để bù lại các nhược điểm như migration và schema drift
    • Có thể dùng được không có nghĩa là nhất định nên dùng
    • Hãy tiến hành thận trọng và chỉ dùng khi biết rõ mình cần cơ sở dữ liệu theo tenant
  • Có thể cân nhắc một hướng tiếp cận trung gian như sau

    • Xác định top N tenant lớn nhất
    • Tách DB cho các tenant này
    • Top N được xác định theo IOPS, mức độ quan trọng (xét theo doanh thu), v.v.
    • Mô hình dữ liệu cần được thiết kế sao cho có thể trích xuất các hàng thuộc về từng tenant
  • Tình cờ là đang làm FeebDB cho Elixir

    • Có thể xem đây là một giải pháp thay thế cho Ecto, vốn không hoạt động tốt khi có hàng nghìn cơ sở dữ liệu
    • Ban đầu chủ yếu là một thử nghiệm thú vị, nhưng kiến trúc này sẽ rất hữu ích ở mọi nơi tôi từng làm việc trước đây
    • Mục tiêu là loại bỏ hoặc giảm bớt các vấn đề thường gặp của cách tiếp cận database-per-tenant
    • Đảm bảo single writer cho mỗi cơ sở dữ liệu
    • Cải thiện quản lý kết nối cho mọi tenant
    • Hỗ trợ migration và backup khi cần
    • Hỗ trợ các thao tác map/reduce/filter trên nhiều DB
    • Hỗ trợ triển khai cluster
  • Forward Email làm điều tương tự bằng cách dùng sqlite db được mã hóa cho từng mailbox/người dùng

    • Đây là một cách rất hay để phân tách mức độ bảo vệ theo từng người dùng
  • Cái tên này rất tuyệt. Gợi nhớ đến Sean Connery

  • Workflow "database per tenant" giờ mới chỉ bắt đầu

    • James Edward Gray đã nói về điều này tại RailsConf năm 2012
  • Đã từng dùng một thứ tương tự trước đây và rất hài lòng

    • Nếu người dùng muốn dữ liệu của họ, có thể cung cấp toàn bộ cơ sở dữ liệu
    • Nếu người dùng xóa tài khoản, có thể xử lý đơn giản bằng rm username.sql
    • Việc tuân thủ trở nên rất dễ dàng
  • Khi dữ liệu được cô lập với nhau và không có vấn đề mở rộng trong phạm vi một tenant đơn lẻ, thì rất khó để thiết kế sai

    • Gần như mọi thứ đều sẽ hoạt động