+30

Xây dựng hệ thống push hàng triệu notification mỗi giờ

Giới thiệu cơ bản về hệ thống push

Chắc bạn đã quen với những tin notification trên các ứng dụng ngân hàng khi bạn chuyển tiền, hay ứng ứng dụng gọi xe khi bạn đặt hay hoàn thành cuốc xe, đến những ứng dụng gọi đồ ăn gửi tin giảm giá mỗi ngày cho bạn
Push notification system trở thành một hệ thống cốt lõi và cần thiết với hầu hết các ứng dụng , bài toán chúng ta đang hướng đến là một ứng dụng gửi tin quảng cáo
Với hệ thống này ta cần gửi thông báo cho số lượng lớn người dùng tùy thuộc vào số lượng user có trong hệ thống và có thể lên tới hàng triệu users.

Bài toán

Bạn có bao giờ tự hỏi một hệ thống push notifcation đã xử lý thế nào mà có thể push tin cho hàng triệu người dùng ?
Câu hỏi có thể tựa tựa bài toán làm sao để ăn hết một con voi ? Rõ ràng là không thể ăn liền được cả một con voi, mà cần chia nhỏ ra rồi ăn dần.
Cơ chế này cũng tương tự cách giải của bài toán trên, chúng ta sẽ không thể push cùng lúc hàng triệu tin đi được vì vậy mỗi lần ta cũng chỉ lấy một số lượng tin nhất định gửi chúng đi, rồi tiếp tục quá trình này đến khi gửi hết
Vì vậy khi đi vào chi tiết cách xử lý gửi tin cho hàng triệu khách hàng như thế nào, hãy quay lại câu chuyện làm sao để gửi tin notification cho một khách hàng

Làm sao gửi tin cho 1 user

Hệ thống của bạn phải gọi api vào bên thứ ba như Firebase Cloud Messaging (FCM). FCM sẽ gửi tin đến điện thoại của bạn

image.png

Vậy cần những thông tin gì khi gọi api ?

  • Message: Nội dung của thông báo hiển thị ở phía client
  • Token: FCM sẽ định danh mỗi thiết bị bằng token, như vậy khi có token FCM có thể gửi chính xác thiết bị. Nhưng FCM là system định danh thiết bị, làm sao để App của mình có được token mà gọi sang FCM ?

image.png

FCM sẽ có api để client có thể đăng kí lấy token và gửi lại App để lưu. Về luồng đi thì nó đơn giản như sau:

  1. Client gọi api đăng kí token của FCM
  2. FCM sinh token, và lưu thông tin lại vào cache, hoặc db ( không chính xác thực sự cơ chế lưu của FCM nhưng mình vẽ ra để mọi người cũng hình dung service họ cũng sẽ lưu lại thông tin này ), sau đó thì trả lại thông tin cho client
  3. Client gọi api gửi token đó cho App để lưu lại có thể trong cache, hoặc db

    Do token có thể dùng lại được nhiều lần để gửi tin, nên quá trình đăng ký token thường thì chỉ khi lần đầu login vào app, hoặc khi token đã hết hạn ( không thể gửi tin push đến bằng token đó ) và cần quá trình đăng ký lại.

Gửi tin đến nhiều người

Để tạo ra một chiến dịch quảng cáo, ta cần có web backend lựa chọn các tập người dùng và nội dung gửi đi, sau đó hệ thống push phía dưới sẽ xử lý việc gửi tin đi.

image.png

Đây là kiến trúc tổng quan của hệ thống

  1. Web Backend: Sẽ tạo chiến dịch và lưu thông tin của chiến dịch bao gồm nội dung tin, tập người dùng, thời gian gửi....
  2. Builder Service : Polling những campaign cần gửi, dựa vào những điều kiện để build ra tập set các user cần push trong redis, cập nhật lại trạng thái của campaign đã hoàn tất việc build
  3. Push Worker: Các push worker sẽ polling từ db để lấy ra những campaign hoàn tất việc build tập set. Lấy token của user trong redis, build message và gọi api của FCM, đẩy response tới queue để async việc lưu trạng thái. Khi hoàn tất việc xử lý sẽ cập nhật trạng thái hoàn tất quá trình push.

Những vấn đề

Có thể thấy service quan trọng nhất của hệ thống là Push Worker, làm sao để nó xử lý hiệu quả việc tương tác với hệ thống bên trong cũng như là với FCM là điều quan trọng nhất. Những vấn đề khiến hệ thống push chậm:

  1. Cách xử lý worker tương tác với các component bên trong không hiệu quả
  2. Việc gọi api FCM để xử lý push cho từng user không hiệu quả với số lượng lớn user.
  3. Xử lý các token không hợp lệ, hoặc hết hạn sẽ làm chậm khả năng push

Push worker xử lý sao cho hiệu quả

Mỗi worker làm sao để xử lý thật nhanh ?
Mỗi khi quét được 1 list các campaign cần push thì dùng chính thread đó để handle luôn chuyện push ? Đương nhiên là không.
Một thread để xử lý tất cả các chuyện từ polling đến push thì hệ thống không thể nhanh được.
Vì vậy mỗi worker sẽ chỉ có 1 thread chịu trách nhiệm cho việc polling lấy ra những campaign mới. Tiếp theo gán task push cho thread pool gồm nhiều threads xử lý việc push
Để xử lý tốt chúng ta cần quản lý được số lượng thread đang active nếu tất cả thread đã active cho việc push rồi thì không thêm task cho thread pool nữa.
Nếu muốn push nhiều campaign cùng lúc cần kiểm soát thêm số lượng thread đang xử lý 1 campaign

Sử dụng batch push thay vì push đơn lẻ

Hãy xem việc push đơn lẻ xử lý như thế nào ?

image.png

Giả sử bạn gửi 100 tin push cho FCM, với mỗi lần gọi:

  1. Gửi 1 request lên FCM
  2. FCM mất 1 chút thời gian xử lý ( chắc chắn là rất nhanh, mình sẽ giải thích phần này phía dưới )
  3. Gửi lại response cho Push Worker

Vậy FCM xử lý như thế nào ? Ta cần chú ý response của FCM trả về gồm mã code chỉ ra rằng token có hợp lệ hay không và tại thời điểm đó tin push chưa hề gửi đến client.

image.png

Điều đó có ý nghĩa ra sao ? Có nghĩa là khi nhận được request gửi đến, FCM sẽ chỉ validate request và token sau đó gửi notification vào queue để xử lý sau và trả lại response ngay. Quá trình xử lý đó rất là nhanh.
Vậy thì thứ chậm nhất khi mình gọi api hóa ra lại chính là việc mở kết nối và gửi từng request qua internet đến Firebase.
Để tối ưu điều này thì FCM có 1 api khác cho phép gửi cùng lúc tối đa 500 messages. Điều này cải thiện tốc độ xử lý rất nhiều.
Với những tin push có nội dung chung ta có thể dùng send multicast cho phép gửi chung một nội dung kèm nhiều tokens điều này sẽ tiết kiệm được băng thông.

Xử lý các token rác

Không phải tất cả người dùng sử dụng app của bạn đều là người dùng active, có những người họ chỉ dùng một vài lần và xóa app, hoặc cả năm họ mới vào app một lần.
Điều đó dẫn đến một vấn đề là có một số lượng lớn các token hết hạn, và không thể gửi tin đến được.
Nếu cứ tiếp tục gửi tin thì cũng chỉ khiến hệ thống bạn tốn thêm tài nguyên cũng như thời gian xử lý, nhất là những hệ thống nhiều người dùng không active
Vậy làm sao để xác định được 1 user không còn active ? Ta sẽ dựa vào mã lỗi mà response từ FCM trả về, ta sẽ xác định được rằng token không còn hợp lệ nữa. Ta sẽ đánh dấu rằng token đó phải được làm mới khi mà người dùng login lại app, để client tự đăng ký một token mới.
Đồng thời Push Worker khi check trạng thái token không hợp lệ thì sẽ không gửi notification của người dùng đó nữa.

Các phần tối ưu khác

Xử lý việc lấy user trong tập set và tokens như thế nào ?

Thường thì chúng ta sẽ bị quen với tư duy xử lý vòng for. Ví dụ khi bạn muốn lấy 100 user từ tập set trong redis bạn sẽ làm gì ?
Bạn sẽ viết vòng for để lấy tin:

int BATCH_SIZE = 100;
List<String> users = new ArrayList<>(BATCH_SIZE);
for(int i = 0;i < BATCH_SIZE; i++) { 
    String user = redis.sPop(key);
    users.add(user);
 }

Việc làm đó sẽ tương đương :

image.png

Bạn sẽ thấy điều này giống hệt với việc sử dụng batch push được đề cập phía trên. Tránh điều này đương nhiên Redis cũng cung cấp giải pháp để tiết kiệm RTT & tránh context switching.

  1. Redis pipelining: cho phép gửi batch commands tức là nhiều commands cùng lúc mà không cần chờ kết quả trả về như phía trên.
  2. Các câu lệnh hỗ trợ việc lấy cùng lúc nhiều kết quả: chẳng hạn nếu muốn pop 100 users bạn có thể sử dụng sPop(key, count)

Và đương nhiên bạn có thể áp dụng để lấy token tùy vào việc bạn lưu token dưới dạng hash hay key value. Có thể lựa chọn hmget hoặc hget để lấy cùng lúc nhiều user

Xử lý việc lưu Push Response

Với việc các Push Worker gửi push response qua queue, đã giúp các Worker tránh được việc phải tốn thêm thời gian lưu vào db giúp khả năng push tốt hơn.
Nhưng nói đi cũng phải nói lại đằng nào bạn cũng phải lưu, nhưng lưu thế nào cho tối ưu.
Câu chuyện này đã lặp lại đến 3 lần, vẫn là batch. Hệ thống bên mình dùng MongoDB cho phép batch update cơ chế thì cũng tương tự câu chuyện của FCM, và Redis...
Và khi các bạn áp dụng nó các bạn sẽ thấy tốc độ cải thiện đáng kinh ngạc...

Kết luận

Đọc xong bài viết chắc hẳn mọi người thấy sức mạnh của batch là lớn đến như thế nào rồi. Chỉ với một concept duy nhất đã được áp dụng ở rất nhiều chỗ khác nhau.
Và bài toán làm sao ăn được con voi có lẽ các bạn cũng đã có lời giải chính xác. Chia nhỏ con voi thành những phần vừa phải (batch), và ăn dần!
Chú ý là vừa phải thôi nhé. Nếu muốn hiểu sâu hơn, mọi người hãy tự đặt câu hỏi tại sao FCM chỉ cho phép batch 500, tại sao db chỉ batch 1000 mới hiệu quả, với Redis pipelining bao nhiêu commands là hiệu quả ?

Bài viết đến đây là hết rồi. Hẹn gặp lại các bạn trong những bài viết mới ❤️


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí