Pessimistic locking (Khoá bi quan) là một chiến thuật xử lí có thể tổng quát rằng, giả dụ, bạn có một tập dữ liệu đang bị truy cập đồng thời, người đầu tiên truy cập sẽ khoá tập dữ liệu này lại, độc quyền sửa đổi nó, cho đến khi phiên đó hoàn thành. Điều này đảm bảo dữ liệu sẽ chuẩn xác hơn khoá lạc quan, nhưng cũng có thể tạo ra Deadlock
Ví dụ bài toán
Bạn đang chơi một con game sinh tồn, có một chiếc rương chứa đồ chung mà hai người chơi đều có thể mở, nhưng nếu cả hai cùng mở, người này lấy đồ, người kia lại cho đồ vào, đôi khi dữ liệu sẽ bị loạn
Khoá bi quan sẽ xử lí như sau
Khi người chơi A mở rương, khoá rương lại. Lúc này người chơi khác ấn vào rương sẽ bị thông báo như dạng “rương đang được mở bởi người khác”, và không thể truy cập cho đến khi người chơi A đóng rương.
Deadlock
Deadlock xảy ra khi hai phiên không thể tiến triển được nữa, vì cùng phụ thuộc vào nhau chờ phía bên kia mở khoá, có thể xem hình dưới đây
Ta có một database post chứa các bài viết. Trong đó có 1 bài viết id 1 và title là “Transaction”
Ngoài ra ta có 1 post chứa post_detail, là nội dung bài viết, số lượng like comment, …
Alice đầu tiên truy cập vào bảng post_detail, cập nhật số lượng like comment. Lúc này bảng post_detail thay đổi “người thay đổi cuối” thành Alice. Alice lock bảng post_detail
Lúc này Bob cũng truy cập vào bảng post, đổi tên nó thành thứ khác. Bob lock bảng post
Do bảng post và postdetail liên kết với nhau, lúc này ở phiên của Bob sẽ gọi về post_detail của post id 1 để thay đổi “người thay đổi” thành Bob. Tuy nhiên, nó đang bị khoá bởi Alice, vì vậy phải chờ Alice xong đã
Phía Alice lúc này cũng cập nhật lên bảng post, nhưng nó lại bị khoá bởi Bob, nên cũng phải đợi Bob xong đã
=> Điều này trở thành vòng tuần hoàn vô hạn, cho đến khi hệ quản trị cơ sở dữ liệu có cơ chế phát hiện ra, và huỷ cả 2 phiên.
Deadlock ở mọi nơi: hệ điều hành, trong code đa luồng, ..
Deadlock không chỉ xảy ra trong database, nó có thể xảy ra trong mọi hệ thống. Trong hệ điều hành, trong …., chỉ cần nếu chúng có thể truy cập từ nhiều nguồn cùng lúc
Ví dụ, đa luồng trong Java cũng có thể tạo deadlock khi 2 luồng cùng đợi nhau chứ không ai chạy trước
Cách xử lí
Khi Alice thực hiện khoá post, ta có thể khoá luôn cả post_Detail và các bảng liên quan
Hệ thống của ta cần có cơ chế phát hiện deadlock, ví dụ lock chờ đợi quá lâu, …
In computer science, ACID (atomicity, consistency, isolation, durability) is a set of properties of database transactions intended to guarantee data validity despite errors, power failures, and other mishaps.[1] In the context of databases, a sequence of database operations that satisfies the ACID properties (which can be perceived as a single logical operation on the data) is called a transaction. For example, a transfer of funds from one bank account to another, even involving multiple changes such as debiting one account and crediting another, is a single transaction.
Bản dịch
Trong khoa học máy tính, thuật ngữ ACID gồm bốn yếu tố mà database transaction (phiên cơ sở dữ liệu) được xây dựng để đảm bảo dữ liệu sẽ luôn chuẩn xác, cho dù có xảy ra lỗi, vấn đề điện, …. Trong database, một dãy các câu lệnh database mà thoả mãn các tính chất ACID được gọi là một transaction (phiên). Ví dụ đơn giản, việc bạn chuyển tiền từ ngân hàng A sang ngân hàng B, chúng chứa rất nhiều sự thay đổi, như giảm tiền ở ví A, tăng tiền ở ví B, tạo thêm lịch sử, …. là một phine
Định nghĩa một Transaction
Một transaction (phiên) thể hiên một nhóm các câu lệnh được chạy trong database, thông thường chứa nhiều lệnh khác nhau
Ví dụ việc chuyển tiền trong ngân hàng gồm 4 câu lệnh từ tài khoản Alice sang tài khoản Bob
ACID là gì
ACID, là viết tắt của 4 chữ cái đầu trong (Atomicity, Consistency, Isolation, Durability) là 4 tính chất của một transaction, nhằm đảm bảo dữ liệu luôn chuẩn xác, đúng điều kiện cho dù server sập, mất điện, ..
Atomic – Tính đồng bộ
Đảm bảo rằng toàn bộ câu lệnh trong một phiên luôn phải đồng bộ trạng thái. Tức là, 1 là toàn bộ đều được chạy thành công, thay đổi xảy ra, hoặc là toàn bộ đều phải bị chặn, chứ không lọt cái gì riêng lẻ cả
Consistent – Tính nhất quán
Đảm bảo rằng một phiên chỉ có thể chuyển một database từ một trạng thái đang đúng (valid) sang một trạng thái valid (đúng) khác, ngăn chặn các dữ liệu bị lỗi, …
Các kiểu dữ liệu, kiểm tra, trigger, …. các điều kiện đều phải đảm bảo pass trước khi phiên chạy
Isolation – Tính cách ly
Quy định việc khi nào và bằng cách nào mà một thay đổi từ một phiên có thể hiển thị cho phần khác. Serializable và Snapshot Isolation là 2 level trong sự chặt chẽ này (Đọc thêm: …)
Có thể đạt được Isolation bằng pessimistic or optimistic locking.
Đảm bảo rằng kết quả của phiên luôn được chắc chắn lưu trong hệ thống. Sự thay đổi phải được giữ cho dù hệ thống sập, mất điện, ….
Chúng có thể được lưu như 1 transaction log, … và khi hệ thống khởi động lại, ta có thể chạy lại mọi thay đối giữa chừng để đảm bảo dữ liệu ổn định
Các ưu điểm của ACID Transaction
Dữ liệu sạch
Đảm bảo rằng dữ liệu luôn chuẩn, không bị mất mát. Tránh các cập nhật bị mất, dirty read, stale read (thảo luận bên dưới). Đảm bảo các tiêu chuẩn về dữ liệu cho phần code. Điều này là tuyệt đối cần thiết cho các hệ thống ngân hàng, tiền bạc, … vì mọi dữ liệu sai sót đều ảnh hưởng trực tiếp đến công ty. Xử lí các vấn đề sai sót ở ngay tầng database bằng sự nhất quán, chuẩn chỉnh được tạo ra bởi phiên ACID là một cách khá đơn giản.
Xử lí đồng bộ đồng thời (Concurrency)
Truy cập đồng thời xảy ra thường xuyên trong các hệ thống lớn. Ví dụ như một cái rương vật phẩm chung trong game, một số tiền chung trong tài khoản ngân hàng (tưởng tượng bạn nhận tiền và chuyển tiền cùng lúc), bảng xếp hạng trong game, ….
Tính chất Isolation – cách ly của ACID giúp các lập trình viên trong điều này
Database sẽ đảm bảo transaction với một ” serializable isolation”, lập trình viên có thể xử lí transaction một cách “tuần tự” như queue vậy, dù thực tế chúng xảy ra đồng thời.
Trong khoa học máy tính, “state” nghĩa là một trạng thái của hệ thống, của một thành phần hoặc ứng dụng của chúng ta tại một điểm thời gian
Ví dụ: Bạn đang mua hàng trong amazon.com, ta có thể coi việc bạn “đang đăng nhập” là một trạng thái, “đang có hàng trong giỏ” là một trạng thái, ….
Trạng thái biểu diễn dữ liệu đang được lưu trữ, và giúp chúng ta kiểm soát hiện trạng hiện tại của ứng dụng.
Kiến trúc Stateful
Tưởng tượng bạn đến một cửa hàng pizza để ăn. Hiện tại quán chỉ có 1 phục vụ bàn, phục vụ bàn sẽ lấy các note từ bàn của bạn, bạn order gì, hay là những món bạn trước đây từng ăn, bạn có dị ứng gì không, …
Tất cả những thông tin trên sẽ được phục vụ bàn ghi lại về một “state” hay trạng thái của khách hàng. Chỉ có phục vụ bàn có những thông tin này, nếu bạn dạo này thích ăn món gì đó khác hơn, hoặc bị dị ứng gì đó mới, bạn cần cập nhật đúng với phục vụ này để thay đổi. Nhưng mà cả quán chỉ có 1 phục vụ nên bạn không lo nhầm người.
Tưởng tượng rằng, quán ăn có đông khách hơn, và phục vụ phải phục vụ rất nhiều khach. Quán cần nhiều phục vụ hơn, tuy nhiên lúc này, nếu bạn muốn đổi khẩu vị hay món khác, thì nếu bạn chọn người phục vụ khác sẽ không được.
Những nhân viên phục vụ khác thì chẳng có thông tin gì về state của bạn cả. Họ không thể đổi đơn hàng của bạn, hay đọc chúng. Nếu một nhà hàng hoạt động theo kiểu như vậy, tức là chỉ 1 phục vụ có thông tin của bạn, sẽ được gọi là cấu trúc stateful
Tưởng tượng về dạng server, một hệ thống stateful sẽ chỉ có 1 server duy nhất ghi nhớ dữ liệu của người dùng. Toàn bộ các request, làm việc sau này của 1 khách hàng sẽ chỉ được điều hướng vào người đó qua các trình load balancer (cân bằng tải) với 1 sticky session. Một server sẽ luôn biết client của mình là ai.
Sticky session (phiên được ghim) là một cài đặt cho phép cân bằng tải (load balancer) điều hướng các request của người dùng đến riêng 1 server duy nhất trong quá trình 1 phiên của họ. (1 phiên có thể kết thúc khi người dùng tắt browser, tắt máy, sau 1 ngày, …)
Điều này đi ngược lại với cách cân bằn gtari truyền thống, khi mà request của bạn được phân bổ đến các server khác nhau bằng round-robin hoặc các cách phân bổ khác. (kiểu phân bổ dần 1->2->3->4 rồi lại quay về 1 theo thứ tự request, hoặc phân bổ random, …)
Cách làm trên nghe có vẻ khá ổn với các hệ thống nhỏ. Tuy nhiên, thế giới chúng ta không lý tưởng. Giả sử, nếu 1 server bị chết, bị dồn quá tải, … thì toàn bộ thông tin của người dùng với server đó sẽ mất hết, điều này hoàn toàn không ổn chút nào.
Hơn nữa, việc phân bổ dữ liệu như vậy cũng không tốt trong nhiều trường hợp. Ví dụ, có một số client gửi rất nhiều request, khiến cho 1 server bị quá tải, trong khi vài server khác lại chỉ có vài request nhỏ, khiến việc cân bằng chung không ổn.
Kiến trúc Stateless
Kiến trúc stateless, dịch thuần tiếng anh là (không có state). Không hẳn là không có state, mà lúc này các server – hay vị phục vụ bàn sẽ không trực tiếp nhớ xem là khách hàng order gì, thông tin như nào, mà thay vì đó, họ sẽ dùng một phần mềm gì đó, một điện thoại, một tờ giấy chung để ghi thông tin khách hàng. Khi đó, nếu phục vụ khác đến, họ chỉ cần đọc tờ giấy lưu trữ chung để xem khách hàng muốn gì.
Bằng việc lưu các trạng thái của khách hàng ở một hệ thống chung nào đó, có thể truy cập bởi mọi phục vụ/server, server nào cũng có thể phục vụ chung 1 khách hàng.
Nhìn về khoa học máy tính, lúc này ta có thể phân bổ request của người dùng đến server bất kì, việc dữ liệu của người dùng có thể lưu ở 1 database nào đó khác biệt, có thể truy cập bằng mọi server.
Việc cân bằng tải cũng tốt hơn, vì lúc này ta có thể chọn các server sđang ít khách phục vụ, hoặc đánh theo tuần tự, …
Tổng kết
Nhìn chung, stateless mang lại tính mở rộng lâu dài và ổn định tốt hơn stateful
Stateless/stateful không chỉ là cách thiết kế server, cân bằng tải như ví dụ trên. Mà có thể áp dụng vào các design pattern, các hàm trong code, ….
Đây có lẽ là cách lưu trữ thường được thấy và dễ tưởng nhất. Trường primary key sẽ đóng vai trò như số thứ tự, giúp phân biệt giữa các row khác nhau, cách lưu trữ bằng số có thể được DB tự gen bằng cách gen sequence.
Khá tiện quản lý, sử dụng trong tầng dữ liệu
Dễ hiểu, dễ đọc, dễ tìm
Điểm trừ của cách làm này là:
Giả sử bạn đang lưu trữ dữ liệu cho 1 tệp khách hàng, được đánh id từ 1-1000, một ngày nào đó, bạn cần migrate data từ các bảng khác, từ những bảng cũ, … (kiểu sát nhập công ty, …) và cũng có tập dữ liệu được đánh số từ 1-100, 500-1000 gì đó. Điều này làm việc sát nhập trở lên khó khăn hơn do key chính để phân biệt các hàng đã bị trùng
Việc đánh số tuần tự cũng có thể tạo các risk về security, … Ví dụ bài viết đang được lưu đơn giản đánh bài viết số 1,2,… Từ đó từ bên ngoài có thể đoán được và truy cập được 1 bài viết bất kì.
Lưu trữ bằng UUID
Lúc này, một cách làm khác đã thường được sử dụng là UUID (Universally unique identifier). Sẽ khó có ai có thể đoán được uuid của 1 bài viết được sinh ra ngẫu nhiên.
UUID được sinh ngẫu nhiên, tỉ lệ trùng lặp là rất thấp
Tránh được việc user tự mở 1 bài viết bất kì
Tuy nhiên nó cũng đi kèm một số nhược điểm quan trọng
UUID dài. Để lưu trữ 1 uuid, thông thường ta cần tới 16 byte. Nó tốn dữ liệu hơn nhiều trong database so với lưu số thông thường. Sau này, khi ta sử dụng các database quan hệ giữa nhiều bảng, việc join các cột foreign keys sẽ ngày càng lớn hơn giữa các primary key
UUID do được sinh random, nên database sẽ khó lòng index một bảng để truy vấn nhanh hơn (VD MySQL sử dụng B+ Tree, cần một trường nào đó đánh theo một dãy giá trị có thể sắp xếp được: số, …).
Một số cách làm khác
ULID (Universally Unique Lexicographically Sortable Identifier). Vẫn là 16 byte, nhưng phần đầu sẽ chứa dữ liệu của timestamp (ngày tháng), và còn lại là ngẫu nhiên. Điều này giúp xử lí việc index database, dễ sắp xếp hơn
TSID (Time-Sorted Unique Identifiers). Cũng giống với ý tưởng của ULID nhưng chỉ tốn 8 byte. Tuy nhiên chúng cũng đi kèm với 1 số tradeoff
Đi sâu vào TSID
Có nhiều cách để triển khai TSID, trong bài này mình sẽ nói về Hypersistence TSID (Thư viện OSS), cho phép tạo 1 TSID 64-bit gồm 2 phần
Vì TSID là một số 64-bit (có thể sắp xếp), ta có thể lưu nó vào database dưới dạng bigint
CREATE TABLE post (
id bigint NOT NULL,
title varchar(255),
PRIMARY KEY (id)
)
Lưu ở phía Entity
@Entity
@Table(name = "post")
public class Post {
@Id
private Long id;
private String title;
}
Tổng kết về TSID
Đa phần giữ được các ưu điểm của UUID như khó đoán ở phía người dùng, tạo sự phân biệt, …
Lưu trữ nhẹ hơn UUID (8 byte vs 16 byte)
Tuy nhiên TSID có thể có tỉ lệ bị sinh ra ngẫu nhiên trùng cao hơn do chỉ có 22bit ngẫu nhiên (xác suất sinh trùng là 0.3%)
Xử lí việc sinh trùng, bạn có thể thêm số lần retry để tự động sinh lại nếu xảy ra collision
Khi nào nên sử dụng TSID
Phần ID có thể expose cho phía client người dùng, ví dụ như id bài viết, video, …. còn các bảng phụ hỗ trợ liên quan có thể cứ dùng dạng số để thuận tiện và tiết kiệm
Các database expose cho người dùng, cần tính đến việc scale lâu dài, và tiết kiệm
Bất kì mô hình hệ thống nào đều nhắm đến giúp code của bạn được tổ chức tốt hơn, đẹp hơn. Khi mà nhu cầu của người dùng ngày càng cao, độ phức tạp của hệ thống càng gia tăng, mô hình hệ thống của bạn cũng cần phải bắt kịp chúng. Với một số dự án nhỏ, có thể bạn chỉ cần thêm thẳng code vào controller, tuy nhiên, ở các hệ thống lớn hơn bạn sẽ cần một số cách tiếp cận hợp lí hơn.
Đa số hệ thống chỉ cần cấu trúc N-Layered (N tầng) với 3 tầng cơ bản:
User Interface (UI) — chịu trách nhiệm tương tác với người dùng (giao diện)
Business logic layer (BLL)— Xử lí các logic chính của hệ thống
Data Access layer (DAL) — Là tầng giao tiếp với dữ liệu như database, …
VỚi một hệ thống như vậy, đa số vấn đề của bạn sẽ giải quyết theo 1 chiều thông thường. Các tầng sẽ phụ thuộc trực tiếp vào nhau, sau đó gọi dần xuống tầng bên dưới.
Ví dụ một luồng:
User click vào 1 nút trên tầng UI của website. Tầng UI sau đó gọi 1 API vào Bussiness Logic, bussiness logic gọi xuống Data Access để lấy dữ liệu bài viết ở database, sau đó ở bussiness logic sẽ xử lí dữ liệu đó (format, thêm thắt ngày tháng, ….), rồi cuối cùng trả về tầng trên cùng UI.
Tuy rằng kiến trúc N-Layered giúp ta xử lí đa số vấn đề, giúp code gọn hơn, clean hơn, tổ chức các chức năng liên quan thành nhiều layer khác nhau, dễ cài đặt, nhưng chúng cũng có một số vấn đề:
Khi hệ thống lớn dần, thường các logic sẽ được xử lí ở tầng Data Access (database), chứ không phải ở Business Logic. Điều này khiến lập trình viên thường tập trung vào tầng db hơn là tầng logic chính.
Tầng Data Access dần làm mọi việc, khó để thay đổi, vì các tầng trên đều nhảy vào nó.
Các tầng liên kết với nhau chặt chẽ, và vài thay đổi ở tầng Data Access sẽ làm hỏng các phần khác.
Ví dụ: Sau khi code nhiều ngày đêm, bạn gửi sản phẩm là một trang đọc báo cho khách hàng. Trang đọc báo này có cơ chế như sau:
Tầng UI: Hiển thị một bài viết
Business Logic: Gọi xuống database lấy dữ liệu các bài viết
Data Access: Làm việc với database, lấy các dữ liệu bài viết ra từ một database MySQL chẳng hạn.
Tầng Business Logic chứa các hàm:
layBaiViet() -> dataAccess.layBaiVietDb()
Tầng Data Access sẽ chứa hàm bên trên, nơi Business gọi xuống, và thực hiện các tác vụ, vì lúc này các model, object đang nằm ở tầng Data, chứ không ở tầng Business:
Do các model, entity ta làm việc với db đều chứa hết ở tầng này, nên ta cần xử lí các hàm ở tầng Data Access. Và kể cả có chuyển các hàm này lên tầng bên trên, thì nhìn chung các Model đều vẫn chứa ở Data Access. Chỉ cần mình thay đổi ví dụ Model ở Data Access một hàm mới, bên trên đều phải thay đổi.
Tưởng tượng lúc này, một ngày đẹp trời, khách hàng của bạn muốn chuyển DB sang MongoDB chẳng hạn, hoặc chuyển sang abcxyz gì đó. Lúc này bạn lại phải sửa hết code ở tầng Data Access cho hợp DB mới, và việc sửa ở đây cũng dẫn đến sửa các tầng bên trên
DDD – Domain Driven Design
Mô hình trên cho rằng, ta nên tập trung vào phần Business, hay còn gọi là Domain là phần core, cốt lõi của hệ thống hơn, thay vì Data Access. Data Access chỉ nên tập trung vào lấy dữ liệu, cập nhật dữ liệu, …. , còn xử lí logic nên nằm ở Domain. Tuy nhiên, với cấu trúc như thông thường khi mà chiều phụ thuộc, gọi xuống đang đi từ trên xuống dưới, ta buộc phải dồn logic xuống dưới cùng. Lúc này, ta có một kĩ thuật mới (Dependency Inversion – Đảo chiều sự phụ thuộc) , đây cũng là chữ cái “D” trong S.O.L.I.D
Điều gì đã thay đổi ? Ta vẫn giữ những cấu trúc tầng, cho phép code được tổ chức, chia thành các khối, giữ nguyên các điểm tốt của N-Layered, tuy nhiên với một vài cải tiến:
Business Logic/Domain sẽ là phần lõi, ở giữa của hệ thống, được dùng ở các phần khác
Business Logic giờ mới là phần khó thay đổi. Điều này có thể chấp nhận, bạn hãy tưởng tượng, có thể hệ thống bạn sẽ đổi database khác, đổi cách lấy dữ liệu, hay giao diện khác, còn logic trung tâm của hệ thống (ví dụ hệ thống ngân hàng thì logic trung tâm là xử lí tiền, …) thì không thể đổi được (trừ khi công ty sập)
Mỗi thay đổi của tầng Business/Domain sẽ cần đổi ở tầng khác, điều này cũng đúng hơn với tự nhiên, khi mà concept cốt lõi là logic của ứng dụng, mục tiêu của ứng dụng thay đổi thì mới thay đổi các tầng ocn kia.
Nhưng làm sao để có thể đảo chiều sự phụ thuộc?
Trước tiên, hãy xem cách triển khai thông thường của N-Layered
OrderService là một module xử lí các order (đơn hàng) cho hệ thống shop của bạn. Nhưng chúng chưa có dữ liệu, vì vậy chúng cần gọi xuống SqlOrderRepository là một module lấy dữ liệu từ database. Như vậy, OrderService phụ thuộc vào SqlOrderRepository
Thông thường, người ta sẽ viết OrderService phụ thuộc vào một IOrderRepository (interface), để giúp việc thay thế dễ dàng hơn (phụ thuộc 1 interface ảo, chứ không phải 1 class)
Lúc này ta chỉ cần di chuyển interface trên về business
Lúc này, ta sẽ quy định các method của repository, các hàm như kiểu getOrderByName (lấy dữ liệu từ tên), update, create, …. OrderService phụ thuộc trực tiếp vào repository này (Vẫn ở tầng Business)
Lúc này tầng Data Access, các class xử lí dữ liệu thực sự sẽ implement lại repository từ tầng Business, implement lại các hàm trên, nơi viết logic thực sự.
Lúc này, tầng Data Access đang phụ thuộc ngược vào tầng Bussiness
Rõ ràng, OrderService không cần quan tâm Data Access có thể là database nào nữa, nó có thể là class MySQL, Môngo,…. , chỉ cần biết chúng phải implement interface Repository có ở Business, và việc xử lí logic thì data access xử lí sao thì business không cần quan tâm.
Việc gắn các class ở dataaccess vào business có thể thông qua các IoC Container (Ví dụ như Spring IoC – Inversion of Control)
Cách làm trên được gọi là Dependency Inversion (đảo ngược sự phụ thuộc) khi mà sự phụ thuộc, nơi xử lí được đảo ngược lại.
Phân trang đơn giản là ta sẽ chia một tập dữ liệu lớn và truyền cho user thành từng phần nhỏ hơn. Hãy tưởng tượng, bạn đang thiết kế một hệ thống backend cho phía frontend của một trang tin tức, phía FE muốn một lệnh API để có lấy dữ liệu các bài viết từ database của bạn.
Bài toán sẽ khá đơn giản nếu bạn chỉ có 10-20 bài viết, bạn chỉ cần làm theo cách truyền thống, lấy dữ liệu từ database từ backend, gửi một file json chứa dữ liệu cho FE, mọi thứ nghe có vẻ đơn giản.
Tuy nhiên khi tập dữ liệu của ta ngày càng lớn, ví dụ, lúc này 1 Model bài viết có rất nhiều trường (ảnh đại diện, chuyên mục, thời gian đăng, ….), và ta có hàng 1000-10000 bài viết, việc trả 1 lần tập dữ liệu lớn như thế sẽ làm chậm ở 2 chỗ:
Lấy toàn bộ dữ liệu từ database tốn thời gian
Truyền 1 lần dữ liệu lớn như vậy sẽ rất nặng, tốn tiền mạng thật sự của user (tải 1 file lớn mỗi lần), và lúc tải 1 file lớn như thế cũng rất chậm, tốn thời gian, gây khó chịu người dùng
Tổng kết 1
Khi đó, ta có thể sử dụng đến phân trang, và mỗi lần người dùng chỉ có thể xem 1 trang, ta chỉ gửi khoảng 10-15 bài viết 1 lần thôi, khi người dùng sang trang khác ta sẽ gửi tập dữ liệu khác, khá đơn giản.
Triển khai phân trang bằng Spring Boot
Triển khai Model
Triển khai Repository
Để có thể truy cập các JPA Entity trên từ database, ta sẽ tạo 1 PostRepository
JPARepository đã kế thừa sẵn một interface là PagingAndSortingRepository của Spring hỗ trợ việc phân trang và sắp xếp. Dù là một interface, không có implemention, nhưng khi chương trình khởi chạy, Spring sẽ tự động generate các code implemention thật cho chúng ta, thực thi các thao tác với database, và chúng ta chỉ cần define các method có sẵn thôi.
Phân trang
Do Spring đã làm đa số các phần code, giờ ta chỉ cần triển khai thêm 2 việc
Tạo một PostPageRequest class, implement từ Pageable interface của Spring
Truyền tham số cần tìm cho PostPageRequest
Khi implement interface Pageable, ta sẽ phải triển khai một số hàm. Lúc này class PostPageRequest sẽ như một object “trang” , kiểu trang 5 thì là một object Trang, trang 6 là một object khác. Từ object trang đó ta có thể lấy trang tiếp theo, trang phía trước, các bài viết trong trang,….
offset và limit
Bạn có thể thấy trong code trên có các tham số offset và limit. Hai tham số trong sql có ý nghĩa như sau
offset x : Lùi x kết quả từ dãy kết quả trả ra
limit y: Từ danh sách kết quả trả ra, lấy y kết quả đầu tiên.
Ví dụ: ta có các bài viết đánh số từ 1-100. offset 5 thì ta sẽ có danh sách là 6-100. limit tiếp 10 thì ta có danh sách 6-16…
Nếu bạn đã hiểu định nghĩa offset và limit, hãy thử đọc lại code bên trên, bạn sẽ dễ dàng hiểu được các method lấy trang tiếp theo, lấy trang trước, …. đang làm gì.
Tổng kết 2
Như vậy, ta đã triển khai được phân trang bằng Spring Boot, tuy nhiên liệu như vậy đã tối ưu cho trang web của bạn chưa?
Tối ưu limit và offset trong MySQL
Offset
Query Duration (ms)
0
1
50
1
1000
13
10000
150
25000
500
50000
930
100000
1750
Những biểu đồ và bảng
Ta có thể thấy, bằng việc dùng offset để lùi kết quả đi một đoạn, rồi lấy limit để lấy lượng bài ở trang đó nghe có vẻ rất đơn giản, dễ hiểu. Nhưng thực tế trong MySQL chúng được thực hiện như sau:
…the rows are first sorted according to the <order by clause> and then limited by dropping the number of rows specified in the <result offset clause> from the beginning…
…các dòng đầu tiên được sắp xếp (ví dụ theo id, thời gian đăng bài…) sau đó xóa x hàng đầu tiên được yêu cầu khi sử dụng offset
Nếu bạn nghĩ kĩ, câu lệnh offset chỉ nhận đúng 1 tham số: lượng dòng bị bỏ qua cho đến tập kết quả muốn nhận.
Cách duy nhất hệ thống database có thể làm điều này là lấy toàn bộ dữ liệu cần tìm, sau đó ném đi x hàng đầu bạn đã đặt ra yêu cầu. Khi offset đủ lớn, lượng công việc cho database sẽ rất nhiều và thời gian để truy vấn sẽ tăng khó kiểm soát.
Khi sử dụng offset, ta mở trang đầu tiên, trang thứ 2, thời gian mất chỉ 1ms (1 phần nghìn giây), gần như không có vấn đề gì.
Trang thứ 10000, 150ms, vẫn chưa nhận ra điều gì quá lo ngại
100000 1750ms, 1,75 giây.
Ta có thể thấy, nếu người dùng mở 1 trang càng xa, hiệu suất của trang sẽ càng giảm, việc một user mở trang thứ 10000 có thể gây vấn đề hiệu năng hơn nhiều cho database hơn 100 người dùng khác mở trang 1
Hướng khắc phục
Trước hết, ta cần tìm hiểu xem những hệ quản trị cơ sở dữ liệu đang làm gì để sắp xếp dữ liệu của chúng ta. Ta sẽ assume hệ cơ sở dữ liệu đang sử dụng một B-Tree để index database (một bản nâng cấp của cây nhị phân cân bằng).
Nếu bạn chưa từng nghe đến cụm từ trên, bạn có thể tìm hiểu ở link sau:
Lúc này database của chúng ta lưu dữ liệu theo dạng như sau:
Khi ta sử dụng offset 0, limit 5 để lấy 5 bài viết đầu tiên chẳng hạn, database sẽ chạy các kết quả sau
SELECT * FROM my_table ORDER BY id LIMIT 5
Tuy nhiên, với offset, sau khi offset 5 và limit 5, ta có
Như vậy, ta phải đi qua 5 cái đầu trước, bỏ dần nó, rồi sau đó mới limit 5 cái sau. Khá là tốn thời gian
Vấn đề: Database không biết điểm khởi đầu của trang tiếp theo ở đâu, vì vậy nó cứ phải bỏ dần các hàng phía trước cho đến khi đến được vị trí chỉ định
=> Khắc phục: Ta nhớ xem vị trí lần cuối là ở đâu ?
Kĩ thuật: keyset pagination and seek method
Ở một số trang web, bạn sẽ thấy, bạn không thể đi đến thẳng trang cuối, hoặc nhảy đến 1 trang bất kì, mà thông thường sẽ có nút để sang trang kế và trang phía trước. Như vậy ta có thể assume rằng:
Người dùng sẽ chỉ mở trang 10 sau khi mở trang 9.
Vậy, ta chỉ cần nhớ vị trí cuối cùng của bài viết ở trang 9 là ở id bao nhiêu, rồi dùng WHERE để truy vấn từ điểm đó, chứ không cần bỏ dần để đi đến điểm đó nữa
SELECT * FROM my_table WHERE id > 21 ORDER BY id LIMIT 5
Ví dụ: Sort theo thời gian
SELECT *
FROM my_table
WHERE (update_date = '2017-12-21' AND id > 21)
OR update_date > '2017-12-21'
ORDER BY update_date,id LIMIT 5
Cơ bản là ta sẽ chỉ lấy các bài viết có cùng thời gian đăng như bài viết cuối và id > , hoặc thời gian lớn hơn bài viết cuối
Tổng kết
Phương pháp keyset pagination and seek giúp tối ưu việc phân trang cho các bài toán lớn hơn rất nhiều (Tham khảo biểu đồ trên)