Tuesday, March 30, 2021

Software Architect: Bad practices

 Aka Những yếu tố giúp bạn đoạt giải mâm xôi vàng trong làng Software Architect (SA)

Nhân những ngày nóng nực bực mình, mình ngồi hệ thống lại tất cả những ổ gà ổ voi mà mình và những người xung quanh đã vấp ngã không những 1 mà rất nhiều lần. Hy vọng rằng bất kể bạn là 1 SA, hay là 1 Developer, hay là Devops đi chăng nữa gì cũng có thể tránh được những cái bẫy này.

alt text

First things first

Để dạo đầu, mình lại sẽ tự giới thiệu (lần thứ n) bản thân thường được gọi là Minh Monmen. Và nghề nghiệp của mình là làm tạp vụ trong cái ngành công nghiệp không khói fancy bậc nhất này.

Vâng, các bạn đọc đúng rồi đấy, tạp vụ ạ, bởi vì rằng thì là mà có rất nhiều thứ mà mình cần phải lau dọn. Những cái vũng sình lầy này tất nhiên không tự nhiên sinh ra cũng không tự nhiên mất đi, đơn giản là nó sẽ chuyển từ người này qua người khác mà thôi. Hy vọng khi tới lượt mình đưa cây chổi cho bạn thì bạn sẽ không cần phải quét dọn lại bãi chiến trường của chính mình trong những năm tháng lập trình.

Để các bạn có 1 chút ý niệm về những điều mình sắp nói ra đây, vui lòng tự vấn bản thân xem mình đã bao giờ gặp những pha xử lý đi vào lòng đất khiến cho ứng dụng của mình khốn đốn chưa? Đã bao giờ các bạn đọc lại code, xem lại thiết kế của mình và chỉ ước được đập nó đi xây mới hoàn toàn chưa? Đã bao giờ các bạn thử xem trong 6 tháng vừa rồi mình đã làm được gì, hay chỉ loanh quanh đập đi làm lại 1 chức năng tới 3 lần mà vẫn chưa hoàn thành chưa?

Bên dưới đây là những pha xử lý như thế, đi thẳng vào lòng đất mà không thèm quay đầu lại chút nào. Hãy cùng theo bước chân đầy đau thương của mình tiếp nhé.

Chưa có bài toán đã tìm lời giải

Đây chắc chắn phải là thứ được nhắc đến đầu tiên, và sẽ phải là thứ được các bạn chú ý cả khi đọc chính bài viết này.

Đến đây rồi, các bạn đã hiểu rõ mục đích của bản thân khi đọc bài viết này chưa? Và bài viết này sinh ra để làm gì vậy?

Rất nhiều người chỉ vì cái title Software Architect mà vội vã đi ngồi vẽ ra cả 1 bản thiết kế hệ thống phần mềm khủng bố, những giải pháp và công nghệ trending, ước lượng data hàng triệu record, rồi áp dụng Marchine LearningBig DataAI,... này nọ trong khi đến bài toán là gì còn chưa xác định được.

Mình đang gặp phải vấn đề gì? Mình đang ở đâu trong quá trình phát triển của 1 sản phẩm?

Đây là 2 câu hỏi buộc phải trả lời trước khi có thể bắt tay vào vẽ vời hay xây dựng cái gì đó. Nếu bài toán của bạn chỉ là 1 trang blog công nghệ, việc setup 1 hệ thống micro-services với vài chục ứng dụng nhỏ là không cần thiết. Nếu bạn đang ở giai đoạn startup và test thị trường thì 1 hệ thống cồng kềnh nhiều thành phần là tối kị.

alt text

1 số kinh nghiệm đau thương của mình và 1 số tiền bối xung quanh:

  • Startup 1 nền tảng kết nối (dạng platform) với 1 thị trường ngách khá tiềm năng, số lượng sản phẩm trên thị trường không nhiều, tuy nhiên chưa xác định được sự chấp nhận của thị trường và mô hình kinh doanh hiệu quả mà đã lên giải pháp nào micro-services, nào container, nào cloud, nào hệ thống đáp ứng được hàng triệu booking,... và kết quả là cả năm trời mới ra 1 sản phẩm mà đáng nhẽ có thể làm xong từ rất sớm với 1 monolith nhẹ nhàng, hay thậm chí chỉ là setup 1 trang wordpress cũng thành hình. ~> Đừng đổ công sức vào công nghệ sớm trừ khi business của bạn dựa hoàn toàn vào ứng dụng công nghệ, hãy tìm ra mô hình kinh doanh hiệu quả trước đã

  • Để mấy ông SA nghĩ tính năng cho sản phẩm. Thử nào mã voucher, mua sắm online, dropship, booking, affiliate,... nhưng chẳng cái nào work. Tất nhiên thử nhiều thứ là điều cần thiết và chắc chắn phải làm, nhưng cái đáng nói ở đây là cứ làm tính năng mãi, làm mãi, làm mãi, rồi lâu quá chưa kịp release đã quay xe làm tính năng khác, và lại làm mãi, làm mãi,... chưa kịp đưa ra dùng lại nghĩ ra cái khác. ~> Bài toán mình tự nghĩ ra có thể không đúng, hoặc không phải bài toán luôn

  • Hăm hở đi tìm 1 công thức thần thánh cho mối quan hệ biện chứng giữa 2 thực thể. Sử dụng nào là hàm mũ, logarit, rồi vẽ vời các kiểu các kiểu trong khi còn chưa phân tích xem trong thực tế 2 thực thể đó quan hệ với nhau thế nào, có đặc điểm gì không, có lượng hóa được không. ~> Lời giải có thể rất hay nhưng chỉ tiếc là chưa tìm được đề bài

  • Nghĩ giải pháp smart content, những hệ thống ứng dụng công nghệ cao liên quan tới nhiều giả định, nhiều model siêu cấp được training hàng ngày, sao cho có thể suggest cho user những lựa chọn xịn xò nhất. Nhưng cuối cùng chỉ cần traffic hơi cao hơn bình thường 1 tý là đã lăn ra chết. Bọn mình thường gọi là hưởng dương. ~> Trước khi con người có thể thông minh thì họ phải sống và lớn được đã

Quá ham hố công nghệ mới

Chắc chắn đây là điều mà phần đông những người đọc bài viết này đều đã ít nhất 1 lần trải qua: ham hố sự mới mẻ. Tất nhiên khi mà đó là đức tính tự nhiên của con người rồi thì chẳng ai có thể trách chúng ta được. Những cái mới, những cái hiện đại đều có những thứ được cải thiện, nhưng lại thiếu đi bề dày kinh nghiệm và sự ổn định cần có ở môi trường production.

alt text

Và sau đây lại là 1 số pha tấu hài đến từ người thiết kế giải pháp:

  • Mấy ông PHP đi dựng 1 hệ thống thương mại điện tử hoành tráng như shopify, bắt đầu học 1 nền tảng mới (nodeJS), học 1 kiến trúc mới (Micro-services), học những pattern mới (CQRS, 2FC, Event sourcing...), học những công nghệ mới (Message queue, API Gateway, K8S, Docker). Trải qua rất nhiều bài toán về vận hành như quản lý kho, quản lý ship, quản lý doanh thu, quản lý người bán,... tá lả rồi cuối cùng mô hình chưa work lại phải đập bớt đi tiếp cận những cách kinh doanh khác. ~> Again, những mô hình trên không sai, nó phù hợp cho 1 bài toán thương mại điện tử lớn, mở rộng rất dễ dàng, chỉ tiếc là chưa tới lúc mở rộng

  • Thử sức với micro-frontend sau khi đọc 1 vài bài viết trên mạng. Cuối cùng khách hàng kêu như cháy nhà vì giao diện người dùng quá phân mảnh, không nhất quán và thời gian phát triển quá lâu, trang này không kết nối được trang kia, rồi trang này user nọ trang kia user kia,... ~> Frontend vẫn là thứ có tính thống nhất rất cao, đừng dại mà đập nó ra quá sớm để rồi hối hận

  • Sử dụng 1 chiếc database key-value chưa ai nghe tới khi còn chưa có kinh nghiệm thiết kế cũng như vận hành. Kết quả là làm khó cho dev khi 1 chức năng đơn giản khi làm trên những database khác lại cần phải xây mới lại toàn bộ data model và xử lý những bài toán sâu bên dưới chưa phải của mình. Người setup, vận hành đều chưa có kinh nghiệm với db này dẫn tới việc backup, debug, trace,... đều khó như lên giời. ~> Hãy nhìn vào độ phổ biến của 1 công nghệ trước khi sử dụng nó. 99% chúng ta không phải là người có thể tự bước trên con đường mới mẻ này đâu.

  • Đưa 1 ngôn ngữ không những mới với toàn bộ team mà còn mới với cả thế giới vào xử lý những bài toán chưa bị ảnh hưởng bởi sự nhanh chậm của ngôn ngữ. Và cuối cùng là 1 bài toán rất đơn giản cũng gặp rất nhiều lỗi và mất rất nhiều thời gian mặc dù đã được senior xử lý. ~> Trăm hay không bằng tay quen. Chỉ nên tính tới việc sử dụng 1 ngôn ngữ mới với 1 team cũ nếu bài toán bắt buộc phải vậy

Bỏ quên sự phù hợp

Sự phù hợp là yếu tố quan trọng trong việc quyết định có áp dụng một giải pháp vào 1 bài toán hay không. Bỏ quên sự phù hợp khi giải 1 bài toán là biểu hiện của 1 SA tồi.

Dữ liệu có dạng thế nào? Số lượng bao nhiêu? Việc đọc/ghi có tương quan thế nào? Đặc điểm của đọc ghi ra sao?

alt text

Đây là 1 số câu hỏi cơ bản phải trả lời được trước khi thiết kế giải pháp về dữ liệu. Hãy xem 1 số cách thiết kế mang tính hài hước sau đây:

  • Cố đưa những cái distributed như distributed database, distributed code, distributed system vào những hệ thống chưa có vấn đề về độ lớn dữ liệu. Mặc dù chỉ là 1 hệ thống quản lý vài trăm ngàn bản ghi đơn giản, ấy vậy mà lại sử dụng những chiếc db distributed rất fancy, cuối cùng hệ thống giống như 1 cái blackbox, ngoại trừ đọc bằng code ra thì không thể check data trực tiếp trong db được. ~> Đây là 1 căn bệnh của nhiều SA khi muốn mọi thứ phải được distributed, horizontal scaling nhưng với... vài trăm ngàn bản ghi và 1-2 node

  • Thiết kế giải pháp partition trên 1 node với dữ liệu dạng phân bố đềuaccess số lượng ít row và heavy-read. Partition trên 1 node sẽ rất hiệu quả với data dạng hot-cold, tức là phân bố không đều để có thể tận dụng được phần cứng giới hạn của 1 node (như ram cho index hot-partition), ngoài ra nó cũng phù hợp với quá trình aggregate dạng table-scan khi giới hạn được khoảng tác động của query. Việc partition trên 1 node và phân bố đều sẽ gây gánh nặng ngược trở lại phần cứng và không hiệu quả. ~ Không phải cái gì lớn thì partition cũng hiệu quả, chia như thế nào phụ thuộc rất nhiều vào tính chất của từng loại dữ liệu và cách chúng ta sử dụng chúng

  • Thiết kế giải pháp có sự phát triển liên tục về schema db nhưng lại dùng thuần SQL. Kết quả là việc migrate ngày càng làm bảng thêm nặng nề và gây downtime cho hệ thống khi phải tác động tới schema của toàn bộ bảng hàng triệu record. Việc dùng SQL đơn thuần chỉ vì muốn vậy chứ không dựa trên việc phân tích đặc điểm dữ liệu và các yêu cầu trong tương lai. NoSQL mặc dù phù hợp hơn, cũng đã được dùng rất nhiều trong hệ thống nhưng lại không được xem xét sử dụng. ~> Sự phù hợp về dữ liệu, 1 lần nữa là điều cần xem xét và ưu tiên hàng đầu trước khi lựa chọn giải pháp. 1 giải pháp không phù hợp sẽ gây ra rất nhiều technical debt

Thích ăn lẩu

Và cuối cùng, một SA tồi là người thích ăn lẩu.

Nói thì có vẻ mê tín nhưng nếu SA cho phép quá nhiều công nghệ, quá nhiều giải pháp tham gia vào dự án của mình thì cuối cùng sản phẩm sẽ không khác gì 1 nồi lẩu thập cẩm và chắc chắn sẽ chẳng ai muốn ăn.

alt text

Món lẩu này sẽ đem lại cho các bạn 3 không?

  • No sharing: Mỗi người 1 ngôn ngữ sẽ triệt tiêu khả năng share kinh nghiệm lẫn nhau giữa các dev.
  • No replacement: Hãy tưởng tượng nếu 1 ngày người duy nhất làm ngôn ngữ X nghỉ ốm, hoặc tệ hơn là nghỉ việc.
  • No inheritance: Mỗi service mới sẽ trở thành 1 hành trình hoàn toàn mới, giải quyết lại những bài toán mà những người khác đã gặp phải, đơn giản vì nó được xây dựng trên 1 công nghệ mới.

Và cuối cùng, những người mệt nhất sẽ là team Devops-System. Đây là team vận hành sản phẩm về mặt kỹ thuật, phụ trách quản lý tất cả những công nghệ, những ngôn ngữ mà người thiết kế giải pháp đã đưa ra. Thiếu kinh nghiệm về dev đã đành, nhưng thiếu kinh nghiệm về vận hành mới là mối nguy thực sự đối với bất kỳ sản phẩm nào. Bạn chắc chắn sẽ không muốn phải là người thưởng thức nồi lẩu này đâu.

Nói vậy không có nghĩa là 1 dự án chỉ nên sử dụng 1 công nghệ. Áp dụng những công nghệ thực sự cần thiết, code gói gọn trong 1-2 ngôn ngữ sẽ là chìa khóa giúp tránh được sự hổ lốn quá mức của nồi lẩu.

Tổng kết

Người ta thường nói người thành công thì sẽ phải trải qua rất nhiều thất bại. Đây mình nhìn thấy thất bại hơi nhiều mà chưa thấy thành công mấy, nên không dám chia sẻ là tránh những thứ trên các bạn sẽ thành công. Tuy nhiên các bạn hãy cứ ghi nhớ 1 vài nguyên tắc cơ bản trong đầu để không mắc lại những sai lầm của bọn mình là được.

Nên nhớ rằng, 1 hệ thống thiết kế tốt thực sự sẽ không phải là hệ thống 100k req/s hello world mà sẽ là hệ thống 1k req/s nhưng thực sự trả về thông tin gì đó có ích. Cân bằng được giữa các yếu tố nguồn lực kinh tế - business phù hợp - chịu tải tương đối - mở rộng khi cần sẽ giúp các bạn có những giải pháp công nghệ hiệu quả.


Nghệ thuật xử lý background job phần 3: Push hàng triệu notification mỗi giờ

 Quá trình lột xác ngoạn mục của một hệ thống cổ lỗ sĩ khi được thiết kế cẩn thận: 1 usecase thành công của việc áp dụng triệt để các phương pháp xử lý background job.

alt text

First things first

Chào quý vị và các anh em bạn bè gần xa, mình là Minh Monmen - một blogger nghiệp dư thi thoảng hứng chí chia sẻ về những công việc mình làm hàng ngày trong lĩnh vực công nghệ. Hôm nay nhân dịp mình đọc được bài viết của Go Jek mô tả về hệ thống push notification của họ ở đây: How We Manage a Million Push Notifications an Hour thì mình cũng nổi hứng chia sẻ về những thứ tương tự nhưng dưới những góc nhìn sâu sát hơn về mặt giải pháp công nghệ cũng như cách giải quyết vấn đề.

Lưu ý: Đây không phải là bài dịch từ bài viết trên. Dưới ngòi bút của mình thì các bạn có thể yên tâm đọc tiếp.

1 số kiến thức bạn có thể cần để hiểu được trọn vẹn bài viết này:

Nhào vô nào.

The problem

Chỉ ít ngày trước mình được giao tiếp quản 1 hệ thống push notification cũ được viết bằng python + mysql đang gặp nhiều vấn đề về performance như:

  • Latency lớn gây chậm các service yêu cầu.
  • Service quá tải vào các thời gian cao điểm dẫn tới mất push.
  • Failed rate liên quan tới network với firebase lớn do tần suất call dày đặc.
  • Quản lý token không hiệu quả, xuất hiện duplicate token và nhiều token inactive gây chậm quá trình push.
  • Thiết kế DB dạng normalized không phù hợp dẫn tới việc tất cả các request push đều cần join 3 table.
  • Sử dụng nhiều tài nguyên CPU và RAM

Ngoài ra hệ thống cũ này chưa track được tỷ lệ thành công của mỗi lần push, cũng như truy vết hành động push từ các service nội bộ khác.

Kiến trúc hiện tại của hệ thống này như sau:

alt text

Như vậy với mỗi request nhận được, Push API sẽ gọi list token của user từ Token store và gửi yêu cầu push lên Firebase API. Rất đơn giản phải không nào?

Sau khi nhận đề bài, mình đã nghiên cứu lại hệ thống và đưa ra 1 giải pháp thiết kế hoàn toàn mới phù hợp hơn với tính chất dữ liệu và tính chất hoạt động của bài toán.

alt text

Thiết kế DB và API

Về cơ bản, database sẽ lưu 2 loại dữ liệu chính:

  • Push token (FCM token) của user: Update ít, đọc theo list nhiều, nhiều data bên lề như thông tin thiết bị, thông tin hệ điều hành cần lưu cùng.
  • Push log chứa log và kết quả push: Insert + update rất nhiều, đọc ít.

Dựa vào tính chất những dữ liệu trên mình đã chuyển database từ Mysql sang MongoDB để phù hợp với dữ liệu dạng document và tận dụng performance tốt hơn của MongoDB trong các operation insert/update bản ghi.

Phần Push API sẽ chủ yếu hứng traffic request push từ các service nội bộ. Để quá trình này diễn ra nhanh chóng và không bắt các service khác phải chờ thì flow đơn giản như sau:

  • Nhận yêu cầu push từ service nội bộ
  • Tạo job với unique job-id
  • Push job vào Job Queue xây dựng trên redis (implement bằng thư viện go-workers)
  • Trả lại job-id cho service để trace về sau

Bằng việc đơn giản hóa phần việc của Push API và thiết kế lại DB mình đã giải quyết được 3 vấn đề: latency của API, các vấn đề liên quan tới quản lý token và performance của việc tìm token cho từng request.

Thiết kế job worker

Các bạn đọc bài viết của Go Jek mình đề cập ở trên thì sẽ thấy phần thiết kế job worker của họ khá sơ sài khi chỉ nhắc tới việc xử lý mỗi yêu cầu push bằng 1 job. Với cách implement truyền thống 1 job dạng:

  • Insert Push log vào DB
  • Filter token của user từ DB
  • Build push request dựa vào list token
  • Call Firebase API
  • Update kết quả push vào DB

thì 1 job push sẽ mất trung bình 200 ~ 300ms để xử lý. Hệ thống sẽ cần scale số lượng worker lên tương đối nhiều để đạt hiệu suất sử lý song song lớn.

Tuy nhiên trong hệ thống này các bạn sẽ thấy 2 điểm bottleneck ảnh hưởng tới performance của hệ thống như sau:

  • Insert log và update kết quả làm tăng load database
  • Call API tới bên thứ 3 (ở đây là Firebase API) có giới hạn và độ trễ lớn do network

Để giải quyết được 2 vấn đề này, mình đã sử dụng kỹ thuật batch processing nội bộ trong từng instance Job worker. Tức là gộp chung 1 loạt những request giống nhau vào 1 lần call DB, API thông qua batch. Muster là 1 thư viện dành cho Golang giúp việc xử lý batch dễ dàng hơn bằng cách tạo ra batch dạng bucket. Các bạn sẽ fill dần bucket này và nó sẽ thực thi khi đầy dựa trên số lượng hoặc timeout.

Bằng cách này mình đã tách việc xử lý 1 job ra làm các step khác nhau như sau. Để các bạn dễ hình dung thì mình thêm cả thông tin thời gian xử lý ở từng step với n là số lượng request push hệ thống nhận vào.

Step 1: Insert DB job < 1ms x n

  • Fill insert db bucket

Step 2: Batch processing ~ 10ms x n/1000

  • Trigger insert db batch
  • Schedule push job

Step 3: Push job ~ 5ms x n

  • Filter token của user từ DB
  • Build push request dựa vào list token
  • Fill call Firebase bucket

Step 4: Batch processing ~ 500ms x n/500

  • Trigger call Firebase batch
  • Fill update db bucket

Step 5: Batch processing ~ 20ms x n/1000

  • Trigger update db batch

Với việc MongoDB hỗ trợ Bulk write operation và Firebase API cũng hỗ trợ batch request lên tới 500 message/call thì việc tận dụng batch đã giảm đi rất nhiều thời gian chờ API call (do giảm số lượng request) cũng như tài nguyên của database (do giảm số query đơn lẻ). Điều này đã giúp mình có thể đáp ứng trên 1000 push/s, tức là 3,6 triệu push / giờ chỉ với 1 worker và tiêu thụ lượng tài nguyên rất khiêm tốn.

Tổng kết

Tất nhiên vinh quang nào cũng phải trả giá bằng máu và nước mắt. Batch processing là một kỹ thuật khó và xử lý được nó đòi hỏi các bạn phải tính toán thật cẩn thận về các vấn đề liên quan tới errorretryreport. Mình cũng phải trả giá cho việc đạt performance như trên bằng khả năng xử lý lỗi khi 1 step nào đó có vấn đề. Giờ thay vì sự cố chỉ ảnh hưởng tới 1 push riêng lẻ của bạn thì nó sẽ có thể ảnh hưởng tới vài trăm tiến trình push khác nếu các bạn không xử lý retry cẩn thận.

Nhưng sau tất cả thì mọi thứ bạn đánh đổi sẽ đều xứng đáng nếu các bạn biết sử dụng nó đúng cách. Hiện tại hệ thống push notification cho mạng xã hội của mình đang chạy với khoảng 1 triệu push mỗi giờ trên 5 worker, tiêu thụ trung bình 0.25 vCPU và < 100MB RAM. Theo mình đánh giá về số lượng request gửi tới firebase (100-150/min) cũng như load của DB (< 5% / 2 core) ít tẹo thì đây mới chỉ là 1 phần rất nhỏ workload mà hệ thống có thể chịu được.


Monday, March 29, 2021

Nghệ thuật xử lý background job

 Đây thực chất là phần tiếp theo của câu chuyện anh chàng buôn chuối trong bài viết này

alt text

First things first

Yeah, lại là mình đây, Minh Monmen trong vai trò chàng trai buôn chuối rảnh rỗi ngồi viết lách linh tinh. Sau khi thu thập được rất nhiều kinh nghiệm từ việc bán chuối bán chuối, mình tự nhận thấy một số người coi trọng những kỹ sư thực thụ hơn những con buôn trái nghề. Nên là trong lần này mình sẽ hóa thân thành 1 kỹ sư phần mềm giả trang để tìm hiểu về background job và tiếp tục câu chuyện còn dang dở lần trước ở mức độ sâu hơn.

Trong bài viết này, ngoài việc tổng hợp thông tin từ một số nguồn tin chính thống, mình cũng sẽ chia sẻ thêm về những cách thiết kế và xử lý job, queue, batch processing,... mà mình đã thực hiện sau nhiều thương vụ buôn chuối của mình.

Tuy nhiên, để có thể đọc hiểu trôi chảy những thứ mà mình nêu ra ở đây thì các bạn nên có 1 số kiến thức nền tảng về:

  • Background job
  • Queue
  • Event-driven
  • Cronjob
  • Batch processing
  • Concurrency and lock

Nhiêu đó đã, giờ bắt đầu nào.

Các loại job và usecase của chúng

Trong 1 bài viết rất chi tiết và cụ thể của bác Bill về vấn đề này đã đề cập rõ từng loại job cũng như usecase của chúng rồi, mình sẽ chỉ tóm tắt lại cho các bạn thôi. (Nhưng hãy đọc bài viết kia để có cái nhìn chi tiết hơn)

Trên khía cạnh trigger thì background job có thể xuất phát từ 2 loại trigger sau:

  • Event-driven trigger: Là job được khởi chạy dựa trên 1 event nào đó xảy ra trong hệ thống. Có thể là việc 1 API được gọi, 1 Object được lưu vào DB,...
  • Schedule-driven trigger: Là job khởi chạy dựa trên thời gian. Đó có thể là job định kỳ (hàng ngày, hàng giờ,...) hoặc job vào một thời điểm hay sau 1 thời điểm nhất định nào đó.

Event-driven job

Bạn sẽ sử dụng event-driven job khi nó phụ thuộc vào việc xuất hiện của những sự kiện không biết sẽ xảy ra khi nào như:

  • Gửi email cho user khi họ đăng ký
  • Xử lý video sau khi user upload lên
  • Tạo report cho user sau khi họ submit yêu cầu ...

Event-driven job thường được trigger thông qua hệ thống job queue và worker. Mỗi khi có event, job,... được đẩy vào job queue thì worker sẽ lắng nghe và xử lý lần lượt.

Mô hình của event-driven job là xử lý hàng loạt cùng lúc dựa trên nhiều worker chạy song song. Do đó loại job này có tính scalable

Schedule-driven job

Schedule-driven được sử dụng cho các tác vụ thường xuyên, xác định được trước thời gian chạy hoặc lặp đi lặp lại như:

  • Publish bài post đã được lên lịch sẵn
  • Dọn dẹp file tạm hàng ngày
  • Gửi email báo cáo hàng tuần ...

Schedule-driven job thường được trigger thông qua crontabinterval hay forever repeat code.

Mô hình của schedule-driven job thường là một job được xử lý tại 1 thời điểm theo thời gian được đặt sẵn. Vì vậy loại job này KHÔNG có tính scalable

Cách giao lưu phối kết hợp

Chắc vậy là đủ để các bạn hình dung sơ sơ về ứng dụng của 2 loại hình background job này rồi nhỉ? Trong khuôn khổ hạn hẹp của bài viết này thì mình sẽ giới thiệu cho các bạn 1 vài cách kết hợp 2 loại background job trên và tình huống sử dụng cụ thể khi mình xây dựng các ứng dụng.

Bài toán 1: Đếm số lượng view trang web / sản phẩm

Đây tưởng chừng là một bài toán có yêu cầu đơn giản mà việc thực hiện cũng lại đơn giản luôn. Cứ mỗi lần có 1 lượt view trang web hay 1 sản phẩm của bạn thì cộng cho sản phẩm đó 1 lượt view.

Điểm quan trọng nhất của việc scale background job chính là xử lý đồng thời nhiều job cũng 1 lúc. Do vậy vấn đề về Atomic operation phải được đặt lên hàng đầu. Chi tiết bạn có thể google search thêm. Ở đây mình sẽ bỏ qua việc các vấn đề liên quan tới Atomic operation trong việc lưu trữ data của các bạn.

1. Cách nông dân

Đơn giản là mỗi khi API view product được gọi thì bạn cộng thêm 1 view vào database

alt text

Vấn đề gặp phải:

  • Blocking IO: Việc +1 view vào database làm chậm response của người dùng, mặc dù người dùng không cần thiết phải chờ hành động này
  • Performance: Khi số lượng người dùng sản phẩm lớn, ví dụ có 1000 người cùng view tại 1 thời điểm thì DB của bạn sẽ phải chịu 1000 câu query update 1 lúc. Oh...

2. Sử dụng event-driven job

Giờ thay vì API gọi thẳng vào DB thì ta đẩy nó vào 1 cái Job Queue. Sẽ có 1 cơ số worker ở phía sau chờ sẵn để xử lý những cái job này.

alt text

Vấn đề đã giải quyết:

  • Non-blocking IO: Việc +1 view bây giờ đã gần như không ảnh hưởng tới thời gian response của người dùng do thời gian để đẩy job queue thường nhỏ hơn nhiều so với thời gian query update
  • Throttling: Giờ nếu bạn có 10 worker, tại 1 thời điểm 1 worker chỉ xử lý 1 job. Vậy thì cùng lúc bạn sẽ chỉ có 10 job chạy song song, tức là dù bạn có 1000 view sản phẩm cùng lúc thì tại 1 thời điểm cũng chỉ có 10 câu lệnh update db được chạy.

Vấn đề còn tồn tại:

  • Performance: Vâng vẫn là cái vấn đề về performance, chỉ là ở 1 cấp độ khác mà thôi. Thay vì DB của các bạn phải chịu tải lớn, thì các bạn đã đánh đổi điều đó bằng việc xử lý được ít job hơn. Và vì xử lý ít job hơn nên các bạn sẽ dễ dẫn tới trường hợp bị dồn job do worker không xử lý kịp.
  • Busy IO: 1 vấn đề mà giải pháp này vẫn còn đó là nó vẫn còn rất gánh nặng về mặt IO cho DB. Với 1000 view, DB của các bạn vẫn phải chịu 1000 câu lệnh update liên tục. Điều đó làm ảnh hưởng rất nhiều tới hiệu năng của những tác vụ khác.

3. Sử dụng kết hợp 2 loại job

Để giải quyết vấn đề về DB bottle neck thì ta sẽ nghĩ ngay tới tầng đệm (caching). Tầng caching là tầng xử lý tốt phần IO hơn rất nhiều so với các loại DB cơ bản. Do vậy chúng ta sẽ đẩy gánh nặng này cho tầng caching bằng cách tạo ra 1 scheduled job lặp đi lặp lại để ghi data từ cache xuống DB.

alt text

Như các bạn thấy, khi các event job +1 view cho sản phẩm thì kết quả này được ghi vào tầng cache. Sau đó các scheduled job sẽ định kỳ lấy tổng số view chưa được đếm này (n view) từ trong cache ra để ghi vào DB (+n view)

Vấn đề đã giải quyết:

  • Performance: Do view được lưu tạm thời vào trong cache, do vậy ta có thể tận dụng sức mạnh IO của cache để nâng số worker đồng thời cũng như giảm được thời gian xử lý từng event job. Do đó tình trạng dồn queue sẽ được xử lý.
  • Throttling hơn nữa: Việc phát sinh query update vào DB chỉ xảy ra trên các scheduled job, do vậy ta đã giảm thiểu số lần update DB thành 1 con số cố định và có thể cân đối được. Ví dụ nếu scheduled job chạy 10s 1 lần thì trong 1 phút sẽ chỉ có tối đa 6 query update DB được tạo ra (thay vì cả 1000 query update như trước)

Vấn đề còn tồn tại:

  • Delay data: Dữ liệu view của sản phẩm sẽ không được update theo thời gian thực mà sẽ có độ trễ tùy theo tần suất scheduled job. Tuy nhiên độ trễ này thường là chấp nhận được khi so sánh với những lợi ích nó mang lại.

Tips: Như mình đã nói ở trên, việc xử lý atomic operation là rất quan trọng trong việc xây dựng background job. Các bạn có thể thấy trong ảnh mình sử dụng operation -n và +n do cộng và trừ thường là atomic operation trên hầu hết các loại db/cache. Đây là 1 tip cho các bạn. Không nên get rồi set counter bằng 0 mà nên get rồi trừ counter đi giá trị hiện tại của nó để đảm bảo không bị mất dữ liệu view khi đang reset counter nhé.

Tips 2: Với redis thì các bạn không cần phải chơi trick trừ như trên, vì nó có sẵn 1 cái atomic operation là GETSET để các bạn reset counter rồi.

Bài toán 2: Gửi email thông báo hàng loạt

Đây là bài toán thường gặp ở các hệ thống tin tức, báo cáo,... khi mà định kỳ (hàng ngày, hàng tuần) phải gửi nội dung được tổng hợp tới nhiều người dùng. Vậy background job sẽ xử lý trường hợp này như thế nào?

1. Cách nông dân

alt text

Trong cách này thì chúng ta sẽ có 1 scheduled job siêu to khổng lồ chạy để lấy danh sách người dùng từ trong database, sau đó dùng danh sách này để gửi email cho tất cả user.

Vấn đề gặp phải:

  • Thời gian xử lý lâu: Đương nhiên với duy nhất 1 scheduled job xử lý việc gửi email cho toàn bộ người dùng thì thời gian để xử lý hết được sẽ lâu đúng không? Tưởng tượng trong job này bạn phải lấy email, tạo content cho email đó, gửi email,... lần lượt cả ngàn lần.
  • Khó retry: Với 1 job siêu to khổng lồ như này thì việc gặp lỗi giữa quá trình chạy sẽ rất khó để giải quyết do việc chạy lại job sẽ buộc phải xử lý mọi thứ từ đầu, xử lý trùng lặp,...

2. Cách bớt nông dân

alt text

Ở đây scheduled job sẽ không đảm nhận việc thực hiện nhiệm vụ nữa mà sẽ đóng vai trò là người quản lý - tạo task cho nhiều event worker chạy song song thông qua Job queue

Vấn đề đã giải quyết:

  • Scalable: Chúng ta đã giải quyết được tính chất không scale được của scheduled job khi mà giờ đây nó chỉ đóng vai trò tạo task và đẩy vào job queue cho các event worker xử lý.
  • Thời gian xử lý nhanh:: Do có thể xử lý đồng thời qua các event worker nên thời gian xử lý tổng thể sẽ giảm xuống theo cấp số nhân
  • Dễ dàng retry: Việc xử lý lỗi giờ đây dễ dàng hơn rất nhiều vì từng job sẽ handle việc gửi email cho 1 user cụ thể. Do đó nếu có lỗi thì cũng không ảnh hưởng tới user khác. Ngoài ra từng job nhỏ còn tự retry được luôn mà không phải chạy lại toàn bộ từ đầu.

Bài toán 3: ETL process

Nói qua 1 chút về thuật ngữ ETL (Extract Transform Load) thì đây là thuật ngữ để chỉ 1 quá trình xử lý xử liệu từ hệ thống nguồn tới hệ thống đích. Mà thật ra là quá trình này thường là để chuyển dữ liệu từ các hệ thống hoạt động (Operation) sang hệ thống phân tích và báo cáo (Analytic and Reporting).

Có rất nhiều tool được sinh ra cho quá trình này tuy nhiên có thể vì kiến thức của mình lúc ấy còn hạn chế hoặc do hệ thống của bên mình chưa khủng tới mức dùng những giải pháp đồ sộ đó mà mình đã chọn giải pháp đơn giản hơn là tự viết những tiến trình đồng bộ dữ liệu từ các hệ thống Operation tới hệ thống Analytic bằng Scheduled job.

Tất cả job và dữ liệu xử lý trong quá trình ETL phải được thiết kế để có thể retry hoặc chạy lại mà không bị trùng lặp hay dẫn tới sai sót.

1. Cách nông dân

alt text

Trong cách này, mình có duy nhất 1 job được scheduled để làm cả 3 quá trình ExtractTransformLoad. Idea thì rất đơn giản, cứ 1 tiếng bạn chạy 1 cái job lấy hết data từ DB nguồn trong thời gian vừa rồi, làm 1 số thao tác magic trên đống dữ liệu đó, rồi đẩy vào 1 DB đích. Hết

Cách xử lý này có 1 cái tiện là bạn có thể tạo 1 cái pipeline đơn giản để data lần lượt được xử lý qua cả 3 quá trình một cách tuần tự mà không phải lo nghĩ gì. Tuy nhiên đời không như là mơ. Bạn sẽ gặp các vấn đề tương tự như vụ gửi email ở trên, mà còn ở mức độ nghiêm trọng hơn vì:

  • Thời gian xử lý lâu: Để dữ liệu chạy qua tất cả các quá trình này 1 lúc sẽ tốn thời gian và tài nguyên. Nếu để interval dài thì dữ liệu của bạn quá outdate. Nếu interval thấp thì job sau dễ chồng chéo lên job trước do job trước chưa chạy xong,...
  • Retry: Again, vấn đề không retry được sẽ là vấn đề rất nhức nhối. Với multi-step job như này thì việc fail 1 step cuối sẽ khiến toàn bộ các step trước phải chạy lại, và... boom

Hãy cùng tìm hiểu cách tiếp cận tiếp theo

2. Cách bớt nông dân

alt text

Ở đây mình sử dụng 1 vài DB tạm để chứa các dữ liệu trong quá trình xử lý và tách 3 quá trình ETL ra thành 3 scheduled job khác nhau. Mặc dù cách này đã cải thiện về thời gian và việc xử lý lỗi trong quá trình chạy để retry từng phần được, song nó vẫn dựa trên mô hình scheduled, tức là không scale được. Cách này có thể chạy được ổn với time interval tương đối ngắn, lượng data giữa các bước sync không quá nhiều.

Đối với 1 record dữ liệu thì việc xử lý qua từng bước sẽ là tuần tự. Tuy nhiên với nhiều record dữ liệu thì 3 quá trình này trở thành song song nhau (chạy kiểu gối đầu). Do vậy thời gian tổng thể sẽ được rút ngắn kha khá

Vấn đề duy nhất bạn phải giải quyết đó chính là tracking status của dữ liệu. Tức là dữ liệu của bạn đã đi tới bước nào, được xử lý chưa, thành công hay thất bại, và quan trọng hơi là được sắp xếp xử lý 1 cách có thứ tự.

3. Cách loằng ngoằng

alt text

Phát triển tiếp mô hình phía trên và thêm yếu tố scaling bằng event job, mình sẽ có mô hình cuối cùng này. Trông có vẻ phức tạp vậy tuy nhiên về cấu trúc lại y hệt vụ gửi email ở phía trên thôi không có gì to tát cả.

Cách này đã giải quyết được gần như tất cả các vấn đề liên quan tới performance, scale, delay time,... mà ta gặp phải phía trên. Nó phù hợp với các hệ thống sync dữ liệu có độ trễ thấp do có thể giảm được thời gian delay giữa các lần chạy.

Tuy nhiên, vinh quang nào cũng phải trả giá bằng máu và nước mắt. Các bạn sẽ phải đánh đổi bằng việc:

  • Track data status: đánh dấu data đã xử lý tới bước nào
  • Data paging: Các bạn phải có 1 cột id hay time đủ tin cậy để scheduled job có thể phân chia được từng khoảng dữ liệu cho event job xử lý đồng thời. Vì thế việc dữ liệu được tổ chức thế nào sẽ khá tricky đó nhé.

alt text

Đây là minh họa cho quá trình xử lý song song nhiều bản ghi cùng lúc bởi 3 loại job.

Tips: Tận dụng lợi thế về batch processing với từng job bằng việc tìm hiểu số record ghi đồng thời hiệu quả với từng loại DB. Ví dụ mongodb sẽ có mức ghi hiệu quả nếu mỗi job xử lý 1000 record đồng thời. Tham khảo thêm tại đây

Tổng kết

Qua bài viết trên mình đã đưa ra cho các bạn cái nhìn tổng quát về 2 loại background job cũng như 3 case ứng dụng thực tế trong việc kết hợp 2 loại job này để tăng khả năng xử lý của ứng dụng.

Mặc dù bài viết không có 1 mẩu code thực nào mà trông như thuần túy lý thuyết nhưng các bạn yên tâm rằng mọi mô hình bên trên đều đã được mình áp dụng trong thực tế và chỉ tổng kết lại kết quả và hiệu quả của nó cho các bạn mà thôi.

Có những mô hình mặc dù mình có nêu ra điểm chưa tốt nhưng nó cũng có thể xử lý khối lượng công việc khá lớn rồi đó. Ví dụ như mô hình 2 bài toán ETL đang xử lý vài chục triệu record dữ liệu hàng ngày từ 5 hệ thống với hơn 20 scheduled job có độ trễ dưới 2 phút. Hay mô hình 2 bài toán view cũng đang xử lý hơn 5 triệu job 1 ngày với 10 worker cho hệ thống notification thời gian thực mà chưa gặp vấn đề gì về performance. Do đó việc bạn chọn cách nào cho dự án của mình sẽ còn tùy vào tính chất và khối lượng công việc của các worker nữa.

Cám ơn các bạn đã quan tâm theo dõi đến đây. Hẹn gặp lại trong bài viết sau.