Đôi khi tình huống đặc biệt và những thứ ngoài tầm kiểm soát dẫn đến yêu cầu cực kỳ phi truyền thống. Gần đây, tôi có trải nghiệm cần dùng ActiveRecord mà không dựa vào database ID cho bất kỳ record nào. Nếu ai đang cân nhắc làm tương tự, tôi khuyên mạnh mẽ tìm cách khác! Nhưng, hãy chuyển sang phần còn lại của câu chuyện.
Quyết định đã được đưa ra. Các cơ sở dữ liệu nhỏ hơn (clones về cấu trúc nhưng không phải data) cần được merge. Tôi tham gia project ngay khi team đang hoàn thiện script copy-paste database record từ database này sang database kia. Nó copy mọi thứ exactly as-is, bao gồm IDs.
Database A
| id | fruit | user_id |
|---|---|---|
| ... | ... | ... |
| 123 | orange | 456 |
| ... | ... | ... |
Database B
| id | fruit | user_id |
|---|---|---|
| ... | ... | ... |
| 123 | banana | 74 |
| ... | ... | ... |
Database A Sau Merge
| id | fruit | user_id |
|---|---|---|
| ... | ... | ... |
| 123 | orange | 456 |
| 123 | banana | 74 |
| ... | ... | ... |
Điều này phá vỡ lý do cơ bản có ID: unique identification (nhận dạng duy nhất). Tôi không biết chi tiết, nhưng tôi cảm thấy mọi loại vấn đề sẽ xuất hiện khi duplicate IDs được đưa vào hệ thống. Tôi cố nói gì đó, nhưng tôi mới vào project, và người khác dường như chắc chắn đây là con đường tốt nhất. Trong vài ngày, chúng ta sẽ deploy code và bắt đầu xử lý data với duplicate IDs. Câu hỏi không còn là "chúng ta có nên làm này không?"; mà là "chúng ta làm này như thế nào?" và "nó sẽ mất bao lâu?".
Làm Việc Với Duplicate IDs
Vậy, làm sao handle data với duplicate IDs? Giải pháp là tạo composite ID từ nhiều field. Hầu hết DB fetches của chúng ta trông như này:
# Điều này không hoạt động, có thể có 2 user với id: 123
FavoriteFruit.find(123)
# Multiple IDs scope query đến record chính xác
FavoriteFruit.find_by(id: 123, user_id: 456)
Tất cả ActiveRecord calls được cập nhật theo cách này, và khi lướt qua code, nó có vẻ hợp lý. Cho đến khi chúng ta deploy nó.
Tất Cả Phá Phân Phát Sáng
Ngay sau khi deploy code, điện thoại bắt đầu reo. Khách hàng thấy số không cộng được. Họ không thể update record của họ. Tất cả loại feature đều bị vỡ.
Chúng ta nên làm gì? Chúng ta không chỉ deploy code; chúng ta cũng move data từ database này sang database kia (và data mới được tạo/cập nhật sau khi deploy). Đây không phải tình huống rollback đơn giản. Chúng ta cần fix nhanh.
Rails Đang Làm Gì?
Bước đầu debug là xem current behavior là gì và cách reproduce error. Tôi clone production data và start Rails console. Tùy setup của bạn, bạn có thể không tự động thấy SQL queries Rails chạy khi execute ActiveRecord query. Đây là cách đảm bảo SQL statements visible trên console:
ActiveRecord::Base.logger = Logger.new(STDOUT)
Sau đó, tôi thử một số Rails queries phổ biến:
$ FavoriteFruit.find_by(id: 123, user_id: 456)
FavoriteFruit Load (0.6ms) SELECT "favorite_fruits".* FROM "favorite_fruits" WHERE "favorite_fruits"."id" = $1 AND "favorite_fruits"."user_id" = $2 [["id", "123"], ["user_id", "456"]]
find_by có vẻ hoạt động tốt, nhưng sau đó tôi thấy code như này:
fruit = FavoriteFruit.find_by(id: 123, user_id: 456)
...
...
fruit.reload
Cái reload đó làm tôi tò mò, nên tôi test nó nữa:
$ fruit.reload
FavoriteFruit Load (0.3ms) SELECT "favorite_fruits".* FROM "favorite_fruits" WHERE "favorite_fruits"."id" = $1 LIMIT $2 [["id", 123], ["LIMIT", 1]]
Uh oh. Vì vậy, ngay cả khi chúng ta ban đầu fetch record đúng với find_by, bất cứ khi nào chúng ta gọi reload, nó sẽ lấy ID của record và làm simple find-by-id query, which, of course, thường cho data không đúng do duplicate IDs của chúng ta.
Tại sao nó làm vậy? Tôi examine Rails source code để tìm manh mối. Đây là khía cạnh tuyệt vời của coding với Ruby on Rails, source code là plain Ruby và freely available để truy cập. Tôi đơn giản google "ActiveRecord reload" và nhanh chóng tìm thấy:
# File activerecord/lib/active_record/persistence.rb, line 602
def reload(options = nil)
self.class.connection.clear_query_cache
fresh_object = if options && options[:lock]
self.class.unscoped { self.class.lock(options[:lock]).find(id) }
else
self.class.unscoped { self.class.find(id) }
end
@attributes = fresh_object.instance_variable_get("@attributes")
@new_record = false
self
end
Điều này cho thấy reload nhiều hơn là wrapper cho self.class.find(id). Query chỉ bằng ID được hardwired vào phương thức này. Để làm việc với duplicate IDs, chúng ta cần hoặc override core Rails methods (không bao giờ recommended) hoặc ngừng dùng reload hoàn toàn.
Giải Pháp Của Chúng Tôi
Vì vậy, chúng ta quyết định đi qua mọi reload trong code và thay nó thành find_by để lấy database fetching via multiple keys.
Tuy nhiên, đó chỉ là một số bug resolved. Sau khi đào sâu hơn, tôi quyết định test các update calls:
$ fruit = FavoriteFruit.find_by(id: 123, user_id: 456)
$ fruit.update(last_eaten: Time.now)
FavoriteFruit Update (43.3ms) UPDATE "favorite_fruits" SET "last_eaten" = $1 WHERE "favorite_fruits"."id" = $2 [["updated_at", "2020-04-16 06:24:57.989195"], ["id", 123]]
Uh oh. Bạn thấy rằng ngay cả khi find_by scoped record bằng field cụ thể, khi chúng ta gọi update trên Rails record, nó tạo simple WHERE id = x query, cũng break với duplicate IDs. Chúng ta xử lý thế nào?
Chúng ta tạo custom update method, update_unique, trông như này:
class FavoriteFruit
def update_unique(attributes)
run_callbacks :save do
self.class
.where(id: id, user_id: user_id)
.update_all(attributes)
end
self.class.find_by(id: id, user_id: user_id)
end
end
Cho phép update record scoped đến nhiều hơn ID:
$ fruit.update_unique(last_eaten: Time.now)
FavoriteFruit Update All (3.2ms) UPDATE "favorite_fruits" SET "last_eaten" = '2020-04-16 06:24:57.989195' WHERE "favorite_fruits"."id" = $1 AND "favorite_fruits"."user_id" = $2 [["id", "123"], ["user_id", "456"]]
Code này đảm bảo scope hẹp cho update record, nhưng bằng cách gọi update_all của class, chúng ta mất callbacks thường đi kèm update record. Do đó, chúng ta phải manually run callbacks và làm thêm database call để retrieve updated record vì update_all không trả về updated record. Sản phẩm cuối không quá lộn xộn, nhưng chắc chắn khó đọc hơn fruit.update.
Giải Pháp Thực Sự
Do sunken costs, management, và time constraints, giải pháp của chúng ta là monkey patch Rails vào dùng multiple keys cho tất cả database calls. Điều này hoạt động, trong ý nghĩa khách hàng vẫn mua và dùng product, nhưng nó bad idea vì vài lý do:
- Bất kỳ future development nào có thể vô tình reintroduce bugs bằng cách dùng common Rails methods. Developer mới sẽ cần training nghiêm ngặt để giữ code free của hidden bugs, như dùng phương thức
reload. - Code phức tạp hơn, kém rõ ràng, và kém maintainable. Đây là technical debt làm chậm development speed càng ngày càng nhiều.
- Testing chậm lại rất nhiều. Bạn cần test không chỉ function hoạt động mà còn hoạt động khi các object khác nhau có duplicate IDs. Mất nhiều thời gian viết test, và mỗi lần test suite chạy, nó mất nhiều thời gian hơn để chạy qua tất cả extra test. Testing cũng dễ miss bug nếu mỗi developer không careful test tất cả scenario.
Giải pháp thực sự cho vấn đề này là không bao giờ có duplicate IDs từ đầu. Nếu data cần transfer từ database này sang database kia, thì script làm việc đó nên collect và insert data không có ID, cho receiving database dùng standardized auto-increment counter để give mỗi record unique ID riêng.
Giải pháp khác là dùng UUIDs cho tất cả record. Loại ID này là chuỗi dài ký tự tạo ngẫu nhiên (thay vì đếm từng bước, như integer ID). Vậy, move data sang database khác sẽ không có conflict hoặc issue.
Bottom line là Rails được built với hiểu biết ID là unique per record và cách nhanh, dễ dàng để manipulate specific data trong database. Rails là opinionated framework, và vẻ đẹp của nó là mọi thứ chạy smooth, miễn bạn stick to Rails way. Điều này áp dụng không chỉ cho Rails mà nhiều khía cạnh khác của programming. Khi mọi thứ phức tạp, chúng ta nên biết identify vấn đề; tuy nhiên, nếu chúng ta viết code clear, maintainable, và conventional, chúng ta có thể tránh nhiều complication từ đầu.