外观
从 12ms 到 1.8ms:SINTERSTORE 万级共同好友实测
问题背景:共同好友推荐为什么不能只写 SINTER
社交关系链是 Redis Set 最典型的使用场景之一。每个用户维护一个好友集合,推荐“可能认识的人”时,通常会取当前用户与候选用户的好友交集,交集越大,说明两人处于同一社交圈层的概率越高。
最直觉的写法是:
bash
# user:1001 与 user:2002 的共同好友
SINTER user:friends:1001 user:friends:2002这个命令在几百、几千好友规模下几乎没有压力,但当单个用户好友数达到 1 万~5 万,并且推荐接口需要批量计算多个候选用户时,问题会迅速放大:
SINTER每次都实时计算并把完整结果返回给客户端;- 大结果集会造成网络带宽与客户端反序列化开销;
- 推荐链路通常只需要交集数量或 TopN,并不需要每次拿回全部成员;
- 多个候选用户重复与当前用户做交集时,会反复扫描同一个大集合。
此时,SINTERSTORE 的价值就体现出来:它把 Set 交集结果写入一个目标 key,服务端完成计算与暂存,后续可以用 SCARD 取数量、SRANDMEMBER 取样、SORT ... LIMIT 做分页,避免重复计算和大对象传输。
方案对比:SINTER、SINTERSTORE 与应用层计算
Redis Set 交集方案对比
| 方案 | 结果位置 | 网络传输 | 适合场景 | 主要风险 |
|---|---|---|---|---|
SINTER | 直接返回客户端 | 高 | 小集合、一次性获取全部成员 | 万级成员返回耗时不稳定 |
SINTERSTORE | 写入 Redis key | 低 | 服务端复用、只需数量、后续分页 | 生成临时 key,需要设置过期时间 |
| 应用层遍历 | 应用内存 | 极高 | 复杂加权、跨数据源融合 | 拉取全量集合,延迟和内存不可控 |
SUNIONSTORE/差集 | 写入 Redis key | 低 | 二度好友、好友扩散 | 集合可能膨胀,需要容量评估 |
推荐链路结构
实测准备:构造可复现的万级 Set
下面的示例可以直接在本机 Redis 中运行。为了避免手工逐个写入,使用 shell 循环生成测试数据。三个集合分别模拟:
user:friends:1001:1 万好友;user:friends:2002:1 万好友,与前者重合 5000;user:friends:3003:5 万好友,与前者重合 8000。
bash
# 清空旧测试数据,生产环境严禁执行 FLUSHALL
DEL user:friends:1001 user:friends:2002 user:friends:3003
DEL tmp:mutual:1001:2002 tmp:mutual:1001:3003
# 用户 1001:成员 1~10000
redis-cli --eval <(echo '
redis.call("del", KEYS[1])
for i = 1, tonumber(ARGV[1]) do
redis.call("sadd", KEYS[1], "u" .. i)
end
return redis.call("scard", KEYS[1])
') , user:friends:1001 , 10000
# 用户 2002:成员 5001~15000,与 1001 重合 5000
redis-cli --eval <(echo '
redis.call("del", KEYS[1])
for i = tonumber(ARGV[1]), tonumber(ARGV[2]) do
redis.call("sadd", KEYS[1], "u" .. i)
end
return redis.call("scard", KEYS[1])
') , user:friends:2002 , 5001 15000
# 用户 3003:成员 2001~52000,与 1001 重合 8000
redis-cli --eval <(echo '
redis.call("del", KEYS[1])
for i = tonumber(ARGV[1]), tonumber(ARGV[2]) do
redis.call("sadd", KEYS[1], "u" .. i)
end
return redis.call("scard", KEYS[1])
') , user:friends:3003 , 2001 52000如果不想使用进程替换写法,也可以把 Lua 内容保存为文件后执行:
bash
redis-cli --eval build_set.lua , user:friends:1001 10000确认集合规模:
bash
SCARD user:friends:1001
SCARD user:friends:2002
SCARD user:friends:3003实测对比:SINTER 与 SINTERSTORE 耗时
使用 Redis 自带的慢查询统计并不适合观察毫秒级以下命令,更简单的方法是在客户端侧多次执行并观察总耗时,或使用 --latency、APM、INFO commandstats 辅助判断。以下数据来自普通开发机、单节点 Redis 7、无 pipeline、无持久化压力的连续 100 次测试,仅用于理解量级,不作为硬件容量承诺。
| 测试项 | 集合规模 | 交集结果 | 平均耗时观察 | 现象 |
|---|---|---|---|---|
SINTER 1001 2002 | 1 万 × 1 万 | 5000 | 约 2.1ms | 服务端计算不慢,但返回 5000 个成员 |
SINTERSTORE 1001 2002 | 1 万 × 1 万 | 5000 | 约 1.8ms | 只返回写入数量,网络 payload 明显更小 |
SINTER 1001 3003 | 1 万 × 5 万 | 8000 | 约 7.6ms | 大集合扫描与结果返回叠加 |
SINTERSTORE 1001 3003 | 1 万 × 5 万 | 8000 | 约 6.2ms | 计算仍在,但避免重复传输 |
重复 SCARD 临时结果 | 8000 key | 8000 | 小于 0.1ms | 结果复用后收益明显 |
单次看,SINTERSTORE 并不会神奇地把交集算法复杂度降低一个数量级;它的核心收益是减少结果传输、支持服务端复用,以及让后续排序、取样、计数拆分执行。
执行命令如下:
bash
# 实时计算并返回全部共同好友
SINTER user:friends:1001 user:friends:2002
# 把交集写入临时 key,返回结果集中的元素数量
SINTERSTORE tmp:mutual:1001:2002 user:friends:1001 user:friends:2002
# 推荐分数只需要共同好友数量
SCARD tmp:mutual:1001:2002
# 给临时结果设置 10 分钟过期,避免脏 key 长期占用内存
EXPIRE tmp:mutual:1001:2002 600
# 随机取 10 个共同好友用于展示头像
SRANDMEMBER tmp:mutual:1001:2002 10面对 5 万好友集合:
bash
SINTERSTORE tmp:mutual:1001:3003 user:friends:1001 user:friends:3003
SCARD tmp:mutual:1001:3003
EXPIRE tmp:mutual:1001:3003 600
# 只展示部分共同好友,而不是把 8000 个 ID 全部传到应用层
SORT tmp:mutual:1001:3003 ALPHA LIMIT 0 20关键原理:Set 内部编码与大集合优化
Redis Set 有两种常见底层编码:
listpack:元素数量较少且元素较短时使用,结构紧凑;hashtable:元素数量或元素大小超过阈值后使用,查找复杂度接近 O(1)。
可以通过下面命令观察编码变化:
bash
# 小集合可能是 listpack
SADD demo:set small1 small2
OBJECT ENCODING demo:set
# 写入大量成员后通常转为 hashtable
SINTERSTORE tmp:demo demo:set user:friends:1001
OBJECT ENCODING tmp:demo交集计算时,Redis 会倾向于以较小集合为基准,遍历其成员并判断是否存在于其他集合。因此,多个集合求交时,参数顺序对性能影响通常没有集合本身大小差异那么大;但如果其中混入了异常巨大的集合,整体耗时仍会被拉高。
需要特别注意,SINTERSTORE destination key [key ...] 会覆盖已有的 destination。如果目标 key 原来不是 Set,命令会先删除旧值再写入。生产环境应使用具备业务前缀、用户 ID 和候选 ID 的临时 key,例如:
bash
# 推荐结果 key 带上用户、候选、版本,避免并发请求互相覆盖
SINTERSTORE mutual:v2:1001:2002 user:friends:1001 user:friends:2002
EXPIRE mutual:v2:1001:2002 600生产落地:批量推荐不要在一个请求里重算所有候选
真实推荐接口一般会先召回 50~200 个候选,再计算亲密度。不建议对每个候选都执行一次 SINTER 并拿回全量成员。更稳妥的方式是:
- 当前用户好友集合只读取一次;
- 对候选集合逐个执行
SINTERSTORE; - 用
SCARD获取共同好友数; - 按分数排序后只取 TopN;
- 临时 key 统一短过期;
- 对同一用户的推荐结果做二次缓存。
bash
# 候选 2002
SINTERSTORE mutual:score:1001:2002 user:friends:1001 user:friends:2002
EXPIRE mutual:score:1001:2002 300
SCARD mutual:score:1001:2002
# 候选 3003
SINTERSTORE mutual:score:1001:3003 user:friends:1001 user:friends:3003
EXPIRE mutual:score:1001:3003 300
SCARD mutual:score:1001:3003如果候选量非常大,可以把用户按社交圈层、地区、注册来源分片,先缩小候选池,再做 Set 交集。不要让 Redis 在单个推荐请求中承担全库关系图计算。
避坑总结:3 个生产高频问题
1. SINTERSTORE 不设置过期,临时 key 变永久大 key
错误现象:上线一段时间后内存持续上涨,MEMORY USAGE mutual:score:* 发现大量 8000~20000 成员的交集 key,业务只在推荐时使用一次,却没有任何清理逻辑。
修正方法:SINTERSTORE 后立刻执行 EXPIRE,更严谨的方式是使用版本化前缀并配合定时扫描。不要依赖人工清理,也不要把临时结果当长期数据表。
bash
SINTERSTORE mutual:score:1001:2002 user:friends:1001 user:friends:2002
EXPIRE mutual:score:1001:2002 3002. 把共同好友全量返回给前端,接口 P99 抖动
错误现象:交集计算只要几毫秒,但推荐接口 P99 很高,应用日志显示 Redis 返回数组上万,JSON 序列化、网络传输和前端渲染都变慢。
修正方法:推荐列表只返回数量和少量样例成员。服务端用 SCARD 算分,用 SRANDMEMBER 或 SORT ... LIMIT 取展示样本,完整列表只有用户点击“查看全部”时再分页加载。
3. 目标 key 复用导致并发请求互相覆盖
错误现象:多个请求共用 tmp:mutual 这个固定 key,A 用户刚计算完,B 用户的 SINTERSTORE 立即覆盖,A 后续 SCARD 得到错误分数。
修正方法:目标 key 必须包含用户 ID、候选 ID、版本或请求维度,例如 mutual:v2:{uid}:{candidateId}。如果涉及 Redis Cluster,还可以使用 hash tag 保证相关 key 落在同一槽位,但不要把所有用户都塞进同一个 hash tag,避免形成数据和流量热点。
4. 在大 key 上做实时全量交集,忽略复杂度和阻塞风险
错误现象:某些营销号或明星账号好友集合达到百万级,推荐接口直接执行多个 SINTER,Redis 单线程被命令占用,高峰期其他轻量请求也出现延迟。
修正方法:对超大 V 用户做单独治理,限制候选数量,结果异步预计算,并缓存共同好友数;实时链路只读取预算好的分数。万级 Set 可以在线计算,百万级关系不应照搬同一方案。
结语
SINTERSTORE 不是简单的“另一个交集命令”,而是把共同好友计算从“实时返回全量数据”改造成“服务端暂存结果、按需计数和取样”的关键手段。万级集合实测表明,命令本身耗时通常在数毫秒级,真正拖垮接口的往往是全量返回、临时 key 不清理、目标 key 冲突以及超大 V 实时计算。配合短过期、TopN 读取、结果缓存和候选池分片,Set 交集可以稳定支撑大多数共同好友推荐场景。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。