还在用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内存指挥部里搞合并,不炸才怪!
计算必须在数据所在地发生
我们使用SINTERSTORE
和SDIFFSTORE
,让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提供的“弹药输送带”,是你的系统能否扛住写入洪峰的关键。