千万用户画像,如何使用Redis的Set结构实现交并差?

用户画像是基于用户的真实信息创建的虚拟模型,用于个性化推荐、精准营销等场景。例如,淘宝的千人千面就是用户画像的应用实例,通过标签如'car', 'student', 'rich', 'guangdong', 'dog'等存储并使用Redis进行去重。用户画像不仅针对个体,也可用于群体或行业分析。

还在用SQL做人群圈选?难怪你的营销预算都在打水漂

那场P0级故障会议,气氛凝重得能拧出水。

CMO把两份报表摔在桌上,一份是我们的,一份是竞品的。“同样是‘新晋奶爸’的营销活动,我们的邮件打开率1.2%,人家15%。用户反馈,收到的全是婴儿纸尿裤的推荐,问题是,我一个单身汉,孩子在哪儿?!”

数据团队的负责人脸色煞白:“要圈出‘已婚、男性、年龄28-35、有孩子’的用户,需要JOIN七八张表,昨晚的ETL任务延迟,数据是T+2的……”

CMO打断他:“我不管过程!现在,我要给所有‘身在广东、养了狗、但没养猫’的用户,发一张宠物美容院的优惠券,多久能给到我名单?”

“……明天早上。”

会议室里,我默默打开了终端。敲下几行命令后,抬起头:“名单已经生成,总计128,451人,随时可以推送。”

全场寂静。那一刻,我知道,旧时代结束了。

错误的战场:在“数据仓库”里打“闪电战”

他们最初的系统,是一套典型的“仓库式”架构。用户的所有标签都存在MySQL的宽表里。这个架构,回答“用户A有什么标签?”很快,但回答“哪些用户有标签B?”,只能SELECT uid FROM user_profile WHERE tags LIKE '%B%' —— 全表扫描,一场灾难。

后来,他们学聪明了,上了Redis,但只做了一半。他们只建了正向索引:

user:tags:{uid} -> Set<String> (一个用户有哪些标签)

当CMO提出圈人需求时,他们的代码是这样的:

public Set<Long> findUsers(String tagA, String tagB) {
    // 1. 从DB或缓存里,捞出所有用户ID
    List<Long> allUids = getAllUids(); // 灾难的开始,可能是几千万
    
    Set<Long> result = new HashSet<>();
    for (Long uid : allUids) {
        // 2. 对每个用户,查询他是否同时有tagA和tagB
        // N次Redis查询,或者N次本地判断,慢到令人发指
        if (redis.opsForSet().isMember("user:tags:" + uid, tagA) &&
            redis.opsForSet().isMember("user:tags:" + uid, tagB)) {
            result.add(uid);
        }
    }
    return result;
}

这套操作,要么因为N+1查询拖死Redis,要么因为拉取全量用户压垮网络,最终在JVM里循环判断,CPU原地爆炸。这是典型的用应用的“慢思考”,去代替数据库的“快计算”

思想钢印的破除:“反向索引”的降维打击

我为系统增加了第二个索引,一个看似简单,却蕴含着搜索引擎核心思想的结构——反向索引

tag:users:{tag} -> Set<String> (一个标签被哪些用户拥有)

有了它,CMO的问题瞬间被翻译成了另一门语言:
“广东的用户集合” “养狗的用户集合” “养猫的用户集合”。

这,是集合运算,是Redis的绝对主场。

别把大军开进Java内存!在Redis原地开战

天真的人会这么写代码:

// 另一个版本的“内存炸弹”
Set<String> guangdongUsers = redis.opsForSet().members("tag:users:{loc:gd}");
Set<String> dogOwners = redis.opsForSet().members("tag:users:{pet:dog}");
Set<String> catOwners = redis.opsForSet().members("tag:users:{pet:cat}");

guangdongUsers.retainAll(dogOwners); // 交集
guangdongUsers.removeAll(catOwners); // 差集
return guangdongUsers;

如果每个集合有数百万用户,这三行代码足以触发一次GC,然后是OutOfMemoryError,最后是服务雪崩。你把三个庞大的军团(Set)千里迢迢地从Redis战场拉到你那个小小的Java内存指挥部里搞合并,不炸才怪!

计算必须在数据所在地发生

我们使用SINTERSTORESDIFFSTORE,让Redis在自己的地盘上完成所有运算,只把最终结果的“钥匙”(一个临时Key)告诉我们。

@Service
public class AudienceService {
    private final StringRedisTemplate srt;
    // ...
    public String findAudience(List<String> mustHaveTags, List<String> mustNotHaveTags) {
        // 1. 生成一个唯一的临时Key,用于存放最终结果
        // {hash_tag} 保证临时Key和参与运算的Key在同一个Redis Cluster槽位
        String tempResultKey = "aud:tmp:{" + mustHaveTags.get(0) + "}:" + UUID.randomUUID();

        List<String> mustHaveKeys = mustHaveTags.stream().map(this::tagKey).collect(Collectors.toList());

        // 2.【核心】执行服务器端交集运算,结果存入临时Key
        srt.opsForSet().intersectAndStore(mustHaveKeys.get(0), mustHaveKeys.subList(1, mustHaveKeys.size()), tempResultKey);

        // 3. 如果有排除标签,继续在临时Key上做差集
        if (mustNotHaveTags != null && !mustNotHaveTags.isEmpty()) {
            List<String> mustNotHaveKeys = mustNotHaveTags.stream().map(this::tagKey).collect(Collectors.toList());
            srt.opsForSet().differenceAndStore(tempResultKey, mustNotHaveKeys, tempResultKey);
        }

        // 4. 为结果设置过期时间,防止垃圾数据堆积
        srt.expire(tempResultKey, 1, TimeUnit.HOURS);
        
        // 5. 返回这个Key,让调用方可以分页读取(SSCAN)或获取总数(SCARD)
        return tempResultKey;
    }

    private String tagKey(String tag) { return "tag:users:{" + tag + "}"; }
}

这套操作,无论用户基数多大,你的Java应用内存占用都接近于零。这才是真正的“四两拨千斤”。

数据洪峰来袭:用 Pipeline 武装你的写入链路

系统上线后,产品经理又来了:“我们要实时捕捉用户行为,比如‘浏览了母婴商品’,立刻给他打上‘准父母’标签。”

这意味着,写入请求量将从每天一次的ETL,暴增到每秒上万次。如果还是简单的sadd循环,网络IO会成为新的瓶颈。

Pipeline——从“单发步枪”到“加特林”:Pipeline(管道)允许我们将成百上千条命令打包,一次性发给Redis,极大减少了网络往返的耗时。

@Service
public class UserTagService {
    private final StringRedisTemplate srt;
    // ...
    public void addTagsInBatch(Map<Long, Set<String>> userTags) {
        // 使用Pipeline,将所有SADD操作打包发送
        srt.executePipelined((RedisCallback<Object>) connection -> {
            userTags.forEach((uid, tags) -> {
                tags.forEach(tag -> {
                    // 双写:正向索引和反向索引
                    connection.sAdd(userKey(uid).getBytes(), tag.getBytes());
                    connection.sAdd(tagKey(tag).getBytes(), String.valueOf(uid).getBytes());
                });
            });
            return null;
        });
    }
}

executePipelined,这个由Spring Data Redis提供的“弹药输送带”,是你的系统能否扛住写入洪峰的关键。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

CV大魔王

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值