Meu primeiro ponto seria observar que 4 GB são apertados para armazenar 20 milhões de conjuntos classificados. Uma tentativa rápida mostra que 20 milhões de usuários, cada um com 20 tags, levariam cerca de 8 GB em uma caixa de 64 bits (e é responsável pelas otimizações de memória ziplist do conjunto classificado fornecidas com o Redis 2.4 - nem tente isso com versões anteriores) .
Os conjuntos ordenados são a estrutura de dados ideal para dar suporte ao seu caso de uso. Eu usaria exatamente como você descreveu.
Como você apontou, KEYS não pode ser usado para iterar em chaves. É mais um comando de depuração. Para dar suporte à iteração de chave, você precisa adicionar uma estrutura de dados para fornecer esse caminho de acesso. As únicas estruturas no Redis que podem suportar iteração são a lista e o conjunto ordenado (através dos métodos de intervalo). No entanto, eles tendem a transformar algoritmos de iteração O(n) em O(n^2) (para lista) ou O(nlogn) (para zset). Uma lista também é uma má escolha para armazenar chaves, pois será difícil mantê-la à medida que as chaves são adicionadas/removidas.
Uma solução mais eficiente é adicionar um índice composto por conjuntos regulares. Você precisa usar uma função de hash para associar um usuário específico a um bucket e adicionar o ID do usuário ao conjunto correspondente a esse bucket. Se o ID do usuário for valores numéricos, uma função de módulo simples será suficiente. Se não forem, uma função de hashing de string simples fará o truque.
Então, para dar suporte à iteração em user:1000, user:2000 e user:1001, vamos escolher uma função de módulo 1000. user:1000 e user:2000 serão colocados no bucket index:0 enquanto user:1001 será colocado no bucket index:1.
Então, além dos zsets, agora temos as seguintes chaves:
index:0 => set[ 1000, 2000 ]
index:1 => set[ 1001 ]
Nos conjuntos, o prefixo das chaves não é necessário e permite que o Redis otimize o consumo de memória serializando os conjuntos desde que sejam mantidos pequenos o suficiente (otimização de conjuntos inteiros proposta por Sripathi Krishnan).
A iteração global consiste em um loop simples nos buckets de 0 a 1000 (excluídos). Para cada bucket, o comando SMEMBERS é aplicado para recuperar o conjunto correspondente e o cliente pode iterar nos itens individuais.
Aqui está um exemplo em Python:
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# ----------------------------------------------------
import redis, random
POOL = redis.ConnectionPool(host='localhost', port=6379, db=0)
NUSERS = 10000
NTAGS = 500
NBUCKETS = 1000
# ----------------------------------------------------
# Fill redis with some random data
def fill(r):
p = r.pipeline()
# Create only 10000 users for this example
for id in range(0,NUSERS):
user = "user:%d" % id
# Add the user in the index: a simple modulo is used to hash the user id
# and put it in the correct bucket
p.sadd( "index:%d" % (id%NBUCKETS), id )
# Add random tags to the user
for x in range(0,20):
tag = "tag:%d" % (random.randint(0,NTAGS))
p.zincrby( user, tag, 1 )
# Flush the pipeline every 1000 users
if id % 1000 == 0:
p.execute()
print id
# Flush one last time
p.execute()
# ----------------------------------------------------
# Iterate on all the users and display their 5 highest ranked tags
def iterate(r):
# Iterate on the buckets of the key index
# The range depends on the function used to hash the user id
for x in range(0,NBUCKETS):
# Iterate on the users in this bucket
for id in r.smembers( "index:%d"%(x) ):
user = "user:%d" % int(id)
print user,r.zrevrangebyscore(user,"+inf","-inf", 0, 5, True )
# ----------------------------------------------------
# Main function
def main():
r = redis.Redis(connection_pool=POOL)
r.flushall()
m = r.info()["used_memory"]
fill(r)
info = r.info()
print "Keys: ",info["db0"]["keys"]
print "Memory: ",info["used_memory"]-m
iterate(r)
# ----------------------------------------------------
main()
Ajustando as constantes, você também pode usar este programa para avaliar o consumo de memória global dessa estrutura de dados.
IMO esta estratégia é simples e eficiente, porque oferece complexidade O(1) para adicionar/remover usuários e complexidade O(n) verdadeira para iterar em todos os itens. A única desvantagem é que a ordem de iteração da chave é aleatória.