Khi csdl của chúng ta phát triển. Các kết quả của của các câu truy vấn cũng trở nên cồng kềnh và chậm dần đi. Việc trả về quá nhiều dữ liệu trong API của chúng ta không đáp ứng được vấn đề hiệu suất. Đồng thời rất lãng phí vì nhu cầu thật sự không cần đến. Việc chia dữ liệu (pagination) của chúng ta thành nhiều phần giúp ích rất nhiều. Cũng như các giải pháp cuộn vô hạn dần xuất hiện từ rất lâu rồi. Trong bài viết này, chúng ta cùng nhau tìm hiểu một số cách phân trang cũng như ưu và nhược điểm của nó.

Theo dõi source code tại đâybranch dev nha

Link postman để test cho dễ.

Pagination by Offset and Limit

Để mà phân trang, chúng ta sẽ tuân thủ theo một quy định hay theo 1 thứ tự sắp xếp cố định.

Cách phân trang đơn giản và phổ biến nhất là chúng ta sẽ cung cấp số lượng document mà chúng ta muốn nhận (Limit). Và đồng thời bỏ qua số lượng mà không cần thiết (Skip).

Skip sẽ sử dụng như sau: Với page = 1 thì đương nhiên ta không cần skip gì hết. Và những page tiếp theo tất nhiên skip = (page – 1 ) * limit.

Ta bắt tay viết nhẹ vài function. Các bạn nhớ clone source về để theo dõi, nhiều khi những function mình viết các bạn không biết từ đâu ra:

Ta thêm 1 route bên trong PostController để test cho phần này:

@Get()
  getAllPosts(@Query() { page, limit }: PaginationPostDto) {
    return this.postService.getAllPosts(page, limit);
  }

phần dto, những file này đều có sẵn hết rồi, các bạn theo dõi nha.

export class PaginationPostDto {
  @IsNotEmpty()
  page?: number;

  @IsNotEmpty()
  limit?: number;
}

Viết nhẹ function getAllPosts bên trong PostService

async getAllPosts(page: number, limit: number) {
    const count = await this.postRepository.countDocuments({});
    const count_page = (count / limit).toFixed();
    const posts = await this.postRepository.getByCondition(
      {},
      null,
      {
        sort: {
          _id: 1,
        },
        skip: (Number(page) - 1) * Number(limit),
        limit: Number(limit),
      },
    );
    return { count_page, posts };
  }

Chỗ skip và limit mình dùng Number để ép kiểu, query truyền lên hiểu là type string. Còn tham số truyền vô phải là number nên mình phải ép kiểu nó. Typescript là vậy đó.

Bổ sung countDocuments vào PostRepository nữa để trả về số lượng page khi pagination

  async countDocuments(filter) {
    return this.postModel.countDocuments(filter);
  }

Lưu ý bộ lọc giữa 2 hàm phải giống nhau nha.

Nhược điểm

Pagination được sử dụng nhiều trong thằng MySql (Laravel) và cũng như MongoDB.

Hiệu suất cần được cải thiện. Khi query, dù ở page bao nhiều thì nó cũng lấy hết những dữ liệu mà thỏa điều kiện. Sau đó mới SKIP và lấy số lượng được chỉ định LIMIT. Điều này cho thấy khối lượng công việc lớn khi xử lý dữ liệu lớn. Ví dụ như Message, có hàng triệu hàng tỉ tin nhắn mà query kiểu này thì chết toi.

Bên cạnh vấn đề hiệu suất, chúng ta gặp nhiều vấn đề về việc dữ liệu thay đổi liên tục, như thêm, xóa, … Khi đang ở trang 1, mà dữ liệu được thêm vào. Sâu đó ta load trang 2 lên, thì dữ liệu của trang 1 bị đẩy lên đầu ở trang 2. Gây ra hiện tượng trùng lặp dữ liệu. Giả sử với dữ liệu ít thay đổi như bài viết, sản phẩm sẽ không ảnh hưởng gì. Nhưng dữ liệu là lịch sử truy cập hay lịch sử giao dịch. Hoặc đơn giản hơn là tin nhắn thì việc lặp lại dữ liệu hiển thị cho người dùng gây ra nhiều kết quả không tốt.

Ưu điểm

Mặc dù phương pháp này có nhược điểm về mặt hiệu suất nhưng nó rất phổ biến và được nhiều người biết đến và tiếp cận. Dễ dàng bỏ qua nhiều trang dữ liệu, hay dễ dàng thay đổi column dùng để sắp xếp. Do đó nó là giải pháp nhanh chóng cho những dữ liệu không quá lớn. Và tính chính xác dữ liệu như mình nói phía trên ở mức chấp nhận được.

Keyset pagination

Mặc dù phân trang dựa trên offset (limit, skip) có thể hữu ích và tiện lợi nhưng hiệu suất thì lại không tốt với dữ liệu lớn. Đôi khi một số tác vụ / task cần phải có hiệu suất tốt,

Thay vì sử dụng Offset thì ta dùng match (where bên sql) để chọn vùng dữ liệu chúng ta cần tìm.

Ý tưởng chính của kĩ thuật pagination này cũng khá tương tự như ở trên thôi, chỉ thêm 1 điều kiện là id cuối cùng của trang trước là điểm bắt đầu của trang sau.

Sau khi get data từ page 1. Giả sử ta đang lấy chiều tăng dần theo id: Ta lấy id cuối cùng của page 1 (theo chiều tăng dần tức là id lớn nhất), ở page 2, ta sẽ lấy với điều kiện id lớn hơn id ở page 1 ta vừa lấy trước đó. Như vậy, database không cần phải query tới dữ liệu ở page 1 nữa. Từ đó tăng hiệu năng khá nhiều. Và chúng ta sẽ thấy sự chênh lệch hiệu năng giữa 2 cách khi làm việc với dữ liệu lớn.

Lưu ý rằng việc phân trang phải tuân theo 1 sự sắp xếp nhất định, từ đó mới bảo đảm được tính đúng đắng của dữ liệu.

Thực hiện

Tới đây mình edit nhẹ lại cái code 1 xíu để thực hiện việc truy vấn theo cách mới.

Đầu tiên sửa lại cái DTO, nhận thêm 1 param là start nữa. Chính là id cuối cùng, và tùy theo cách sắp xếp tăng hay giảm mà phần điều kiện với start này cho hợp lý.

export class PaginationPostDto {
  start?: string;
  page?: number;

  @IsNotEmpty()
  limit?: number;
}

Sau đó sửa lại câu query 1 xíu:

async getAllPosts(start: string, page: number, limit: number) {
    const count = await this.postRepository.countDocuments({});
    const count_page = (count / limit).toFixed();
    const posts = await this.postRepository.getByCondition(
      {
        _id: {
          $gt: isValidObjectId(start) ? start : '000000000000000000000000',
        },
      },
      null,
      {
        sort: {
          _id: 1,
        },
        //skip: Number(page) * Number(limit),
        limit: Number(limit),
      },
    );
    return { count_page, posts };
  }

Ta sửa lại chỗ controller khai báo thêm cái start và truyền qua service luôn hì.

Ở code trên mình dùng thêm isValidObjectId để kiểm tra thêm, với page 1 thì ta đâu có start đâu. Lúc đó nó sẽ sinh ra lỗi á. Và đồng thời ta không dùng page nên mình comment lại phần skip, nhưng mình không xóa đi. Lý do thì xíu nữa mình sẽ nói đến.

Và mình đang sắp xếp theo chiều tăng dần, nên phần match mình sử dụng $gt nghĩa là lớn hơn start.

Nhược điểm

Hạn chễ rõ ràng nhất khi nhìn vào ta thấy đó là việc xác định id cuối cùng để làm điều kiện cho page tiếp theo.

Khi dùng như vậy, ta khó có thể bỏ qua nhiều trang dữ liệu cùng 1 lúc, nghĩa là từ trang 1 sang trang 3. Lúc này ta không xác định được đâu là id start của page 3 ( Tức là id cuối cùng trong page 2).

Thì để giải quyết được vấn đề này, ta sử dụng mẹo nhỏ mình có nhắc ở phía trên. Trong đoạn code mình có comment phần skip. Không tự nhiên mình comment lại mà không bỏ đi đâu, có ý đồ cả đấy. Chúng ta dùng skip để pass qua một số page không cần thiết. Thì các bạn có thể định nghĩa lại tên biến cho phù hợp, do mình dùng trong 2 trường hợp luôn nên mình giữ nguyên biến. Trong trường hợp này ta hiểu nó là số trang skip qua. Là từ trang 1 qua trang 3, ta bỏ qua trang 2, thì ta phải truyền page = 1 vào, để nó skip qua trang số 2.

Ưu điểm

Từ lúc nãy đến giờ mình đều nói, thì mặt chúng ta quan tâm đến chính là hiệu suất. Và tất nhiên hiệu suất của cách phân trang này sẽ tốt hơn với cách trước đó. Nhưng chúng ta khó có thể nhận ra khi làm việc với những dữ liệu vừa và nhỏ.

Thêm vào đó, việc thêm xóa dữ liệu liên tục không ảnh hưởng đến việc load page. Mặc dù nói là page những cái nó quan tâm là id cuối của page trước chứ không quan tâm là page thứ mấy.

Kết luận

Qua bài hôm nay mình chia sẻ những cách mà mình đã thực hiện Pagination. Đồng thời cũng chia sẻ ưu-nhược điểm của từng loại phân trang. Từ đó các bạn áp dụng cho phù hợp với nhu cầu của mình.

Rất mong được sự ủng hộ của mọi người để mình có động lực ra những bài viết tiếp theo.
{\__/}
( ~.~ )
/ > ♥️ I LOVE YOU 3000

JUST DO IT!