Ежедневная активность и retention миллионов пользователей
Предпосылки: Клиенты и соединения, Bitmap и Bitfield.
Продукт отслеживает DAU/WAU/MAU и retention (какая доля пользователей вернулась через 7 дней). Пользователей — 10 миллионов. Нужно быстро отвечать на два типа вопросов: «сколько уникальных пользователей были активны за период?» и «был ли конкретный пользователь активен в конкретный день?».
HyperLogLog отвечает на первый вопрос, но не на второй. SET — 10 миллионов user_id × ~50 байт = 500 МБ на один день. Bitmap — 10 миллионов бит = 1.25 МБ на один день. При 365 днях: SET = 180 ГБ, Bitmap = 456 МБ.
Побитовые операции BITOP AND/OR вычисляют retention и WAU/MAU на сервере за O(n) байт — без передачи данных в Ruby:
class ActivityTracker
def initialize(redis_pool)
@redis = redis_pool
end
def mark_active(user_id)
key = "active:#{Date.today.iso8601}"
@redis.with { |r| r.setbit(key, user_id, 1) }
end
def active?(user_id, date)
key = "active:#{date.iso8601}"
@redis.with { |r| r.getbit(key, user_id) == 1 }
end
def dau(date)
@redis.with { |r| r.bitcount("active:#{date.iso8601}") }
end
def wau(start_date)
keys = (0..6).map { |i| "active:#{(start_date + i).iso8601}" }
result_key = "wau:#{start_date.iso8601}"
@redis.with do |r|
r.bitop("OR", result_key, *keys)
count = r.bitcount(result_key)
r.del(result_key)
count
end
end
# Retention: из тех, кто был активен в day_a, сколько вернулось в day_b?
def retention(day_a, day_b)
result_key = "retention:#{day_a}:#{day_b}"
@redis.with do |r|
r.bitop("AND", result_key, "active:#{day_a.iso8601}", "active:#{day_b.iso8601}")
returned = r.bitcount(result_key)
total = r.bitcount("active:#{day_a.iso8601}")
r.del(result_key)
total.zero? ? 0.0 : (returned.to_f / total * 100).round(2)
end
end
endBITOP AND вычисляет пересечение двух дней за O(n) байт (1.25 МБ при 10 миллионах пользователей) без передачи данных клиенту. BITOP OR объединяет 7 дней для WAU. Bitmap эффективен при плотных числовых ID. Если ID разреженные (UUID), бо́льшая часть бит останется нулевой — для таких случаев лучше HyperLogLog (без ответа на «был ли конкретный?») или SET.
BITFIELD: компактные числовые поля
BITFIELD хранит несколько целых чисел разной разрядности в одной строке — экономит ключи, когда для сущности нужно несколько небольших счётчиков:
REDIS.with do |r|
# Хранить level (8 бит, 0-255) и score (16 бит, 0-65535) в одной строке
r.bitfield("player:#{user_id}", "SET", "u8", 0, level)
r.bitfield("player:#{user_id}", "SET", "u16", 8, score)
# Прочитать оба поля за один вызов
level, score = r.bitfield("player:#{user_id}", "GET", "u8", 0, "GET", "u16", 8)
# Атомарный инкремент с защитой от переполнения
r.bitfield("player:#{user_id}", "OVERFLOW", "SAT", "INCRBY", "u16", 8, 100)
end