Gửi email cho một triệu user
I. Lời nói đầu Giả sử bạn cần gửi email tới tất cả các users trong hệ thống. Vì vấn đề chi phí, ta cần phải sử dụng Transactional email thay vì dùng Marketing email . Ban đầu, mọi việc diễn ra suôn sẻ vì số lượng users chỉ tầm vài trăm đến vài nghìn người. Nhưng nếu bạn có lượng users ...
I. Lời nói đầu
Giả sử bạn cần gửi email tới tất cả các users trong hệ thống.
Vì vấn đề chi phí, ta cần phải sử dụng Transactional email thay vì dùng Marketing email.
Ban đầu, mọi việc diễn ra suôn sẻ vì số lượng users chỉ tầm vài trăm đến vài nghìn người.
Nhưng nếu bạn có lượng users lớn hơn nữa thì sao, vài trăm ngàn, vài triệu ?
II. Giải pháp
1. Giải pháp sida:
Hãy thử implement giải pháp gửi email thông thường. Ta sẽ tạo ra một Job - vòng lặp qua tất cả các users để đưa việc gửi email vào queue.
class MassEmailJob < ApplicationJob queue_as :default def perform User.find_each do |user| Notifier.some_email(user).deliver_later end end end
Giờ hãy thử xem xét về nhược điểm của giải pháp trên.
Job có thể bị kill
Vòng lặp qua hàng triệu users sẽ mất rất nhiều thời gian.
Giả dụ trong khoảng thời gian đó, bạn deploy hay reset Job manager, và kill cái job đó, thì sẽ không thể biết được users nào đã được gửi mail và chưa gửi mail.
Một cách đơn giản để fix đó là nhét đoạn code ra ngoài Job, có thể sử dụng rake task. Nhưng bạn phải đảm bảo rằng cái rake đó không thể bị kill, hoặc nếu có thì vẫn có thể resume được.
Bị liệt vào blacklist
Email provider không hề muốn bị spam. Nếu bạn gửi hàng ngàn email từ 1 IP trong khoảng thời gian ngắn, các bác sẽ bị bóp lại hoặc tệ hơn là liệt vào blacklist.
Vì vậy, ta cần phải nhẹ nhàng một chút, ví dụ như thêm khoảng thời gian delay sau mỗi 100 mail được gửi.
Nghẽn Job queue
Mỗi email được gửi đồng nghĩa với việc 1 job được chạy từ trong queue. Nếu bạn đưa vào hàng đợi hàng triệu jobs trên cùng queue mà đang sử dụng cho việc khác, bạn sẽ tạo ra rất nhiều cổ chai.
Vì vậy, bạn có thể tạo ra loại queue đặc biệt, và chỉ dùng nó để chứa job gửi mail.
2. Giải pháp ngon hơn:
Đầu tiên, hãy list ra các yêu cầu:
- Ta muốn đưa vào queue vài email để test trước, sau đó dần dần mới tăng lên.
- Ta muốn có danh sách các users đã và đang chờ được gửi mail.
- Ta có thể dừng việc gửi mail nếu gặp phải sai sót, và resume tiếp tục chạy.
Redis
Redis là một công cụ tuyệt vời, nó có thể sử dụng để lưu trữ data trong thời gian ngắn, lưu trữ sessions, ...
Với vô số data structure hữu ích, lần này ta sẽ sử dụng 1 trong số chúng - Sorted set.
Sorted set gần giống như kiểu Hash, Dictionary, hay associative array. Nó chưa dánh sách các values, và với mỗi value đó đều được đánh score.
ZRANGEBYSCORE
Function này của Redis sẽ trả về range của n elements lấy từ Sorted set, với score trong khoảng min và max.
Ta sẽ lưu toàn bộ user ids vào một Sorted set, với score là 0, và đổi giá trị thành 1 khi email được đưa vào trong hàng đợi.
Điều đó sẽ giúp việc lấy ra số users đã và chưa được đưa vào hàng đợi ez.
Build Sorted set
Hãy tạo một rake task để thiết lập Sorted set từ list các user id trong DB.
task :populate_users_zset => :environment do redis = Redis.new(YOUR_CONFIG) User.select('id').find_each.each_slice(100) do |users| redis.multi do users.each do |user| redis.zadd('mass_email_user_ids', 0, user.id) end end end end
Ở đây, ta sử dụng multil để add từng khối 100 user id một, giảm tải cho redis CPU.
Mặc dù việc chạy task này mất một chút thời gian, tuy nhiên nó có thể chạy lại an toàn nếu bị chẳng may kill.
Đưa vào hàng đợi số email gửi
Sau khi chạy xong task trên, ta đã có được Sorted set. Giờ ta viết tiếp task này, truyền vào số lượng user id đưa vào hàng đợi để gửi mail.
task :send_email_batch, [:batch_size] => :environment do |t, args| redis = Redis.new(YOUR_CONFIG) ids = redis.zrangebyscore('mass_email_user_ids', 0, 0, limit: [0, args.batch_size]) delay = 30.seconds ids.each_slice(100) do |ids_slice| ids_slice.each do |id| Notifier.some_email(User.find(id)).deliver_later(wait: delay) redis.zadd('mass_email_user_ids', 1, id, xx: true) end delay += 30.seconds end end
Tại đây, ta đưa jobs vào hàng đợi, và delay ở mỗi lần gửi 100 mail là 30 giây.
Đơn giản vậy thôi.
Nhờ hệ thống này mà bạn có thể từ từ tăng số lượng email lên, và kiểm tra xem việc gửi mail có vấn đề gì không.
Để gửi 100 mails, ta chạy rake
rake 'your_namespace:send_email_batch[100]'
Mọi việc có vẻ nuột? Tăng lên gửi 1000, rồi 10000, ...
Sau đó, để biết còn bao nhiêu email để gửi, chỉ việc mở redis console và sử dụng zcount để show nó ra.
ZCOUNT mass_email_user_ids 0 0
hoặc
ZCOUNT mass_email_user_ids 1 1
Túm cái váy lại
Tuy còn tồn tại nhược điểm, nhưng giải pháp trên về cơ bản đã giải quyết tốt yêu cầu bài toán đặt ra.
Gửi hàng triệu email rất khó, nhưng là một vấn đề thú vị để giải quyết.
Nguồn:
- https://redis.io/commands/zcount
- https://drivy.engineering/sending-mass-emails/