Computer >> Hướng Dẫn Máy Tính >  >> Lập Trình >> Ruby

Ngăn chặn Job Sidekiq Trùng lặp: 3 Chiến lược Đã Chứng minh

Có khả năng cao nếu bạn viết code Ruby, bạn đang dùng Sidekiq để xử lý background processing. Nếu bạn đến từ ActiveJob hay nền tảng khác, hãy theo dõi, một số mẹo dưới đây vẫn áp dụng được.

Lập trình viên dùng (Sidekiq) background job cho nhiều trường hợp khác nhau. Một số crunch số liệu, một số gửi email chào mừng user, một số lên lịch đồng bộ dữ liệu. Dù trường hợp của bạn là gì, bạn có thể sẽ gặp yêu cầu tránh job trùng lặp. Bằng job trùng lặp, tôi hiểu là hai job làm chính xác cùng một việc. Hãy đi sâu vào vấn đề này.

Tại sao Cần De-duplicate Job?

Hãy tưởng tượng kịch bản job của bạn trông như sau:

class BookSalesWorker include Sidekiq::Worker def perform(book_id) crunch_some_numbers(book_id) upload_to_s3 end ... end

BookSalesWorker luôn làm cùng một việc — truy vấn DB lấy sách dựa trên book_id và lấy dữ liệu bán hàng mới nhất để tính toán một số con số. Sau đó, upload lên dịch vụ lưu trữ. Hãy nhớ rằng mỗi khi một cuốn sách được bán trên website, job này sẽ được enqueue.

Bây giờ, nếu bạn có 100 đơn hàng cùng lúc? Bạn sẽ có 100 job làm chính xác cùng một việc. Có thể bạn chấp nhận được. Bạn không quan tâm lắm đến ghi S3, và queue không quá tắc nghẽn, nên bạn chịu đựng được tải. Nhưng, "có scale không?"™️

Chắc chắn là không. Nếu bạn bắt đầu nhận nhiều đơn hàng cho nhiều cuốn sách hơn, queue sẽ nhanh chóng chồng chất với công việc không cần thiết. Nếu bạn có 100 job làm cùng một việc cho một cuốn sách, và có 10 cuốn sách bán song song, bạn hiện đang có 1000 job sâu trong queue, mà thực tế bạn chỉ cần 10 job cho mỗi cuốn sách.

Bây giờ, hãy xem qua một vài tùy chọn để ngăn job trùng lặp chồng chất queue của bạn.

1. Cách Tự Làm (DIY)

Nếu bạn không thích dependency bên ngoài và logic phức tạp, bạn có thể thêm giải pháp tùy chỉnh vào codebase. Tôi tạo một repo mẫu để thử các ví dụ. Sẽ có link trong mỗi cách tiếp cận đến ví dụ.

1.1 Cách Một Flag (One Flag Approach)

Bạn có thể thêm một flag quyết định có enqueue job hay không. Có thể thêm sales_enqueued_at vào bảng Book và maintain nó. Ví dụ:

module BookSalesService def schedule_with_one_flag(book) # Kiểm tra xem job có được enqueue hơn 10 phút trước không if book.sales_enqueued_at < 10.minutes.ago book.update(sales_enqueued_at: Time.current) BookSalesWorker.perform_async(book.id) end end end

Điều đó có nghĩa là không job mới sẽ được enqueue cho đến khi 10 phút trôi qua kể từ lần job cuối được enqueue. Sau 10 phút, chúng ta update sales_enqueued_at và enqueue job mới.

Điều khác bạn có thể làm là đặt một flag boolean, ví dụ crunching_sales. Bạn đặt crunching_sales = true trước khi job đầu tiên được enqueue. Sau đó, đặt nó = false khi job hoàn thành. Tất cả job khác cố gắng được schedule sẽ bị từ chối cho đến khi crunching_sales = false.

Bạn có thể thử cách này trong repo ví dụ tôi tạo.

1.2 Cách Hai Flag (Two Flags Approach)

Nếu "khóa" job không được enqueue trong 10 phút nghe quá đáng sợ, nhưng bạn vẫn ổn với việc thêm flag vào code, thì gợi ý sau có thể thú vị.

Bạn có thể thêm flag thứ hai cho sales_enqueued_at hiện có — là sales_calculated_at. Code của chúng ta sẽ trông như sau:

module BookSalesService def schedule_with_two_flags(book) # Kiểm tra xem sales có đang được tính toán ngay bây giờ không if book.sales_enqueued_at <= book.sales_calculated_at book.update(sales_enqueued_at: Time.current) BookSalesWorker.perform_async(book.id) end end end class BookSalesWorker include Sidekiq::Worker def perform(book_id) crunch_some_numbers(book_id) upload_to_s3 # Thêm mới book.update(sales_calculated_at: Time.current) end ... end

Để thử, xem hướng dẫn trong repo ví dụ.

Bây giờ chúng ta kiểm soát một phần thời gian giữa lúc job được enqueue và lúc kết thúc. Trong phần thời gian đó, không job nào có thể được enqueue. Khi job đang chạy, sales_enqueued_at sẽ lớn hơn sales_calculated_at. Khi job kết thúc, sales_calculated_at sẽ lớn hơn (mới hơn) sales_enqueued_at và job mới sẽ được enqueue.

Dùng hai flag có thể thú vị, vì bạn có thể hiển thị lần cuối số liệu bán hàng được cập nhật trên UI. User đọc chúng có thể biết dữ liệu mới đến đâu. Win-win.

Tóm tắt Flag

Có thể cám dỗ tạo giải pháp như thế khi cần thiết, nhưng tôi thấy chúng hơi cồng kềnh và thêm overhead. Tôi khuyên dùng nếu use case đơn giản, nhưng ngay khi nó phức tạp hoặc không đủ, tôi khuyên bạn thử các tùy chọn khác.

Một con lớn của cách flag: bạn sẽ mất tất cả job cố gắng enqueue trong 10 phút đó. Một pro lớn: không kéo dependency, và giảm nhanh số lượng job trong queue.

1.3 Duyệt Queue (Traversing The Queue)

Cách khác là tạo custom locking mechanism để ngăn cùng job được enqueue. Chúng ta sẽ check queue Sidekiq chúng ta quan tâm và xem job (worker) đã có đó chưa. Code trông như sau:

module BookSalesService def schedule_unique_across_queue(book) queue = Sidekiq::Queue.new('default') queue.each do |job| return if job.klass == BookSalesWorker.to_s && job.args == [book.id] end BookSalesWorker.perform_async(book.id) end end class BookSalesWorker include Sidekiq::Worker def perform(book_id) crunch_some_numbers(book_id) upload_to_s3 end ... end

Trong ví dụ trên chúng ta kiểm tra xem queue 'default' có job với class name BookSalesWorker không. Cũng kiểm tra xem argument của job có match book ID không. Nếu job BookSalesWorker với cùng Book ID đã có trong queue, chúng ta return sớm và không schedule thêm.

Lưu ý một số job vẫn có thể được schedule nếu bạn schedule quá nhanh vì queue rỗng. Điều chính xác xảy ra với tôi khi test local với:

100.times { BookSalesService.schedule_unique_across_queue(book) }

Bạn có thể thử trong repo ví dụ.

Điều tốt: bạn có thể duyệt tất cả queue để tìm job hiện có nếu cần. Con: vẫn có thể duplicate job nếu queue rỗng và bạn schedule nhiều job cùng lúc. Ngoài ra, bạn đang có thể duyệt qua tất cả job trong queue trước khi schedule một job, nên có thể tốn kém tùy kích thước queue.

2. Nâng cấp lên Sidekiq Enterprise

Nếu bạn hoặc tổ chức có tiền "nằm rỗi", bạn có thể nâng cấp lên phiên bản Enterprise của Sidekiq. Bắt đầu từ $179/tháng, và nó có tính năng cool giúp tránh job trùng lặp. Rất tiếc tôi không có Sidekiq Enterprise, nhưng tôi tin tài liệu của họ đủ. Bạn dễ dàng có job unique (không trùng lặp) với code sau:

class BookSalesWorker include Sidekiq::Worker sidekiq_options unique_for: 10.minutes def perform(book_id) crunch_some_numbers(book_id) upload_to_s3 end ... end

Và xong. Bạn có implementation job tương tự như phần 'One Flag Approach'. Job sẽ unique trong 10 phút, nghĩa là không job nào khác với cùng argument có thể được schedule trong khoảng thời gian đó.

One-liner khá cool, phải không? Vâng, nếu bạn có Enterprise Sidekiq và mới biết tính năng này, tôi thực sự vui vì đã giúp được. Đa số chúng ta không dùng nó, nên hãy nhảy sang giải pháp tiếp theo.

3. sidekiq-unique-jobs Cứu Tinh

Vâng, tôi biết chúng ta sắp nhắc đến gem. Vâng, nó có file Lua có thể khiến một số người ngại. Nhưng hãy kiên nhẫn, đây là "đ món hời" bạn nhận được. Gem sidekiq-unique-jobs đi kèm nhiều tùy chọn locking và cấu hình khác — có thể nhiều hơn bạn cần.

Để bắt đầu nhanh, cho gem sidekiq-unique-jobs vào Gemfile, chạy bundle và cấu hình worker như sau:

class UniqueBookSalesWorker include Sidekiq::Worker sidekiq_options lock: :until_executed, on_conflict: :reject def perform(book_id) book = Book.find(book_id) logger.info "I am a Sidekiq Book Sales worker - I started" sleep 2 logger.info "I am a Sidekiq Book Sales worker - I finished" book.update(sales_calculated_at: Time.current) book.update(crunching_sales: false) end end

Có nhiều tùy chọn, nhưng tôi quyết định đơn giản hóa và dùng cái này:

sidekiq_options lock: :until_executed, on_conflict: :reject

lock: :until_executed sẽ khóa job UniqueBookSalesWorker đầu tiên cho đến khi nó được executed. Với on_conflict: :reject, chúng ta nói rằng muốn tất cả job khác cố gắng executed bị reject vào dead queue. Điều chúng ta đạt được ở đây tương tự những gì làm trong ví dụ DIY ở trên.

Cải tiến nhỏ so với ví dụ DIY: chúng ta có một loại log về những gì xảy ra. Để cảm nhận nó trông như nào, hãy thử:

5.times { UniqueBookSalesWorker.perform_async(Book.last.id) }

Chỉ một job sẽ executed hoàn toàn, và bốn job còn lại sẽ bị gửi vào dead queue, nơi bạn có thể retry chúng. Cách này khác với ví dụ trước nơi job trùng lặp chỉ bị bỏ qua.

Có nhiều tùy chọn cho locking và conflict resolving. Tôi đề nghị bạn tham khảo tài liệu gem cho use case cụ thể của bạn.

Insight Tuyệt Vời

Điều tuyệt vời của gem này: bạn có thể xem lock và lịch sử những gì xảy ra trong queue. Chỉ cần thêm các dòng sau vào config/routes.rb:

# config/routes.rb require 'sidekiq_unique_jobs/web' Rails.application.routes.draw do mount Sidekiq::Web, at: '/sidekiq' end 

Nó sẽ include Sidekiq client gốc, nhưng cũng cho bạn hai trang thêm — một cho job locks và một cho changelog. Nó trông như sau:

Chú ý hai trang mới "Locks" và "Changelogs". Tính năng khá cool.

Bạn có thể thử tất cả trong dự án ví dụ nơi gem đã cài sẵn sàng.

Tại sao Lua?

Thứ nhất, tôi không phải tác giả gem, nên tôi chỉ đang giả định. Lần đầu thấy gem, tôi thắc mắc: tại sao dùng Lua trong Ruby gem? Có thể lạ lúc đầu, nhưng Redis hỗ trợ chạy Lua script. Tôi đoán tác giả gem có điều này trong đầu và muốn làm logic nhanh gọn hơn trong Lua.

Nếu bạn xem file Lua trong repo gem, chúng không phức tạp lắm. Tất cả Lua script được gọi sau từ Ruby code trong SidekiqUniqueJobs::Script::Caller ở đây. Hãy xem source code, nó thú vị để đọc và hiểu cách mọi thứ hoạt động.

Gem Thay Thế

Nếu bạn dùng ActiveJob nhiều, bạn có thể thử gem active-job-uniqueness tại đây. Ý tưởng tương tự, nhưng thay vì custom Lua script, nó dùng [Redlock] để lock item trong Redis.

Để có unique job với gem này, bạn có thể tưởng tượng job như sau:

class BookSalesJob < ActiveJob::Base unique :until_executed def perform ... end end

Cú pháp ít verbose hơn nhưng rất tương tự gem sidekiq-unique-jobs. Có thể giải quyết case của bạn nếu bạn phụ thuộc nhiều vào ActiveJob.

Tư duy Cuối Cùng

Tôi hy vọng bạn获得一些 kiến thức về cách xử lý duplicate job trong app. Tôi chắc chắn đã vui nghiên cứu và thử nghiệm các giải pháp khác nhau. Nếu bạn không tìm thấy cái bạn cần, tôi hy vọng một số ví dụ đã truyền cảm hứng bạn tạo ra thứ của riêng bạn.

Đây là dự án ví dụ với tất cả code snippet.

Hẹn gặp bạn trong bài sau, cheers.

P.S. Nếu bạn muốn đọc bài Ruby Magic ngay khi ra mắt, hãy subscribe newsletter Ruby Magic và không bao giờ bỏ lỡ bài nào!