跳到主内容

从 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低二度好友、好友扩散集合可能膨胀,需要容量评估

推荐链路结构 ​

用户好友 SetSINTER 实时返回SINTERSTORE 落 keySCARD / SRANDMEMBER数量、取样、分页客户端接收全量成员EXPIRE 临时结果

实测准备:构造可复现的万级 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 20021 万 × 1 万5000约 2.1ms服务端计算不慢,但返回 5000 个成员
SINTERSTORE 1001 20021 万 × 1 万5000约 1.8ms只返回写入数量,网络 payload 明显更小
SINTER 1001 30031 万 × 5 万8000约 7.6ms大集合扫描与结果返回叠加
SINTERSTORE 1001 30031 万 × 5 万8000约 6.2ms计算仍在,但避免重复传输
重复 SCARD 临时结果8000 key8000小于 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 并拿回全量成员。更稳妥的方式是:

  1. 当前用户好友集合只读取一次;
  2. 对候选集合逐个执行 SINTERSTORE;
  3. 用 SCARD 获取共同好友数;
  4. 按分数排序后只取 TopN;
  5. 临时 key 统一短过期;
  6. 对同一用户的推荐结果做二次缓存。
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 300

2. 把共同好友全量返回给前端,接口 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 交集可以稳定支撑大多数共同好友推荐场景。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。