Redis Set与IntSet编码

Redis Set与IntSet编码

Redis 的五种数据结构各有特色,用对了才能发挥它的优势。很多人只用到了 String 和 Hash,却不知道 List、Set、ZSet 在特定场景下更合适。本文从应用场景出发,讲什么时候用什么类型。

一、Set的编码方式

#

1.1 两种编码

编码 结构 适用条件
intset 整数数组 元素都是整数且数量少
hashtable 哈希表 元素非整数或数量多

#

1.2 编码转换条件

# 默认配置
set-max-intset-entries 512 # intset最多512个元素

转换规则:

  • 元素都是整数且数量<=512:使用intset
  • 任一条件不满足:转换为hashtable
  • 转换单向:intset → hashtable后不回退

二、IntSet详解

#

2.1 结构设计

IntSet是紧凑的整数数组:

typedef struct intset {
uint32_t encoding; // 编码类型(int16/int32/int64)
uint32_t length; // 元素数量
int8_t contents[]; // 柔性数组,实际存储整数
} intset;
内存布局(存储3个int16整数):
┌──────────┬──────────┬───────────────────────────────┐
│ encoding │ length │ contents │
│ 2字节 │ 4字节 │ 2字节 │ 2字节 │ 2字节 │
│ INT16 │ 3 │ 100 │ 200 │ 300 │
└──────────┴──────────┴───────────────────────────────┘
总大小:4 + 4 + 2*3 = 14字节

#

2.2 编码升级

当插入一个超出当前编码范围的整数时,触发升级:

当前状态:encoding=INT16,存储 [100, 200]

插入 40000(超出int16范围):

升级过程:
1. 将encoding改为INT32
2. 重新分配内存(4 * 3 = 12字节)
3. 从后向前迁移数据(防止覆盖)
contents[2] = 40000 (int32)
contents[1] = 200 (int32)
contents[0] = 100 (int32)

升级后:
┌──────────┬──────────┬───────────────────────────────┐
│ INT32 │ 3 │ 100 │ 200 │ 40000 │
└──────────┴──────────┴───────────────────────────────┘
总大小:4 + 4 + 4*3 = 20字节

升级特点

  • 升级是单向的(int16 → int32 → int64),不会降级
  • 升级后不再降级,即使删除大值
  • 升级操作O(n),但发生频率低

#

2.3 有序性保证

IntSet始终保持有序:

// 查找使用二分查找
static uint8_t intsetSearch(intset *is, int64_t value, uint32_t *pos) {
int min = 0, max = intrev32ifbe(is->length)-1, mid = -1;
int64_t cur = -1;

while (max >= min) {
mid = ((unsigned int)min + (unsigned int)max) >> 1;
cur = _intsetGet(is, mid);
if (value > cur) {
min = mid + 1;
} else if (value < cur) {
max = mid - 1;
} else {
break;
}
}
// ...
}

有序性优势

  • 查找:O(log n) 二分查找
  • 去重:天然有序,相等元素相邻

#

2.4 插入操作

intset *intsetAdd(intset *is, int64_t value, uint8_t *success) {
uint8_t valenc = _intsetValueEncoding(value);
uint32_t pos;

// 是否成功(默认)
if (success) *success = 1;

// 如果需要,升级编码
if (valenc > intrev32ifbe(is->encoding)) {
return intsetUpgradeAndAdd(is, value);
}

// 查找插入位置(如果不存在)
if (intsetSearch(is, value, &pos)) {
if (success) *success = 0; // 已存在
return is;
}

// 扩容
is = intsetResize(is, intrev32ifbe(is->length)+1);

// 移动pos之后的元素(为插入腾出位置)
if (pos < intrev32ifbe(is->length))
intsetMoveTail(is, pos, pos+1);

// 插入元素
_intsetSet(is, pos, value);
is->length = intrev32ifbe(intrev32ifbe(is->length)+1);
return is;
}

三、Hashtable实现

#

3.1 编码转换

当Set使用hashtable编码时,value存储什么?

// Set的hashtable中,key是元素值,value是NULL
// 只用哈希表的key来存储和去重

dictEntry *entry = dictFind(set->ptr, element);
// entry->key = element
// entry->v.val = NULL

#

3.2 查找过程

robj *setTypeIsMember(robj *subject, robj *value) {
long long llval;

if (subject->encoding == OBJ_ENCODING_HT) {
// hashtable编码,直接查哈希表
return dictFind(subject->ptr, value) != NULL;
}

if (subject->encoding == OBJ_ENCODING_INTSET) {
// intset编码
if (isObjectRepresentableAsLongLong(value, &llval) == C_OK) {
return intsetFind(subject->ptr, llval);
}
}

return 0;
}

四、集合运算

#

4.1 SINTER(交集)

void sinterCommand(client *c) {
robj **setkeys = c->argv + 1;
robj *dstkey = NULL;

// 1. 找到最小的Set(减少比较次数)
int minidx = 0;
for (int j = 1; j < c->argc - 1; j++) {
if (setTypeSize(setkeys[j]) < setTypeSize(setkeys[minidx])) {
minidx = j;
}
}

// 2. 遍历最小Set的元素
si = setTypeInitIterator(setkeys[minidx]);
while (setTypeNext(si, &ele, &elesize) != -1) {
// 3. 检查元素是否在其他所有Set中
for (int j = 1; j < c->argc - 1; j++) {
if (j == minidx) continue;
if (!setTypeIsMember(setkeys[j], ele)) break;
}

// 4. 如果所有Set都包含,加入结果
if (j == c->argc - 1) {
addReplyBulkCBuffer(c, ele, elesize);
}
}
}

优化策略

  • 以最小的Set作为基准遍历
  • 减少查找次数

#

4.2 SUNION(并集)

void sunionCommand(client *c) {
// 1. 创建临时结果Set
robj *dstset = setTypeCreate(NULL);

// 2. 遍历所有输入Set
for (int j = 1; j < c->argc - 1; j++) {
robj *setobj = lookupKeyRead(c->db, setkeys[j]);
if (setobj == NULL) continue;

si = setTypeInitIterator(setobj);
while (setTypeNext(si, &ele, &elesize) != -1) {
// 3. 加入结果(自动去重)
setTypeAdd(dstset, ele);
}
}

// 4. 输出结果
setTypeIterator *di = setTypeInitIterator(dstset);
while (setTypeNext(di, &ele, &elesize) != -1) {
addReplyBulkCBuffer(c, ele, elesize);
}
}

#

4.3 SDIFF(差集)

void sdiffCommand(client *c) {
robj *dstset = setTypeCreate(NULL);
robj *setobj = lookupKeyRead(c->db, setkeys[1]);

// 1. 遍历第一个Set
si = setTypeInitIterator(setobj);
while (setTypeNext(si, &ele, &elesize) != -1) {
// 2. 检查是否不在其他Set中
for (int j = 2; j < c->argc - 1; j++) {
if (setTypeIsMember(setkeys[j], ele)) break;
}

// 3. 不在其他Set中,加入结果
if (j == c->argc - 1) {
setTypeAdd(dstset, ele);
}
}
}

五、应用场景

#

5.1 共同好友

@Service
public class SocialService {

// 添加好友
public void addFriend(Long userId, Long friendId) {
redis.opsForSet().add("friends:" + userId, String.valueOf(friendId));
redis.opsForSet().add("friends:" + friendId, String.valueOf(userId));
}

// 获取共同好友
public Set<String> getCommonFriends(Long userId1, Long userId2) {
return redis.opsForSet().intersect("friends:" + userId1, "friends:" + userId2);
}

// 获取好友数量
public Long getFriendCount(Long userId) {
return redis.opsForSet().size("friends:" + userId);
}

// 是否好友
public Boolean isFriend(Long userId, Long friendId) {
return redis.opsForSet().isMember("friends:" + userId, String.valueOf(friendId));
}
}

#

5.2 标签系统

@Service
public class TagService {

// 给文章添加标签
public void tagArticle(Long articleId, List<String> tags) {
String key = "article:tags:" + articleId;
redis.opsForSet().add(key, tags.toArray(new String[0]));
redis.expire(key, 30, TimeUnit.DAYS);
}

// 获取文章标签
public Set<String> getArticleTags(Long articleId) {
return redis.opsForSet().members("article:tags:" + articleId);
}

// 获取有相同标签的文章
public Set<String> getArticlesByTag(String tag) {
return redis.opsForSet().members("tag:articles:" + tag);
}

// 获取同时有多个标签的文章
public Set<String> getArticlesByTags(List<String> tags) {
String[] keys = tags.stream()
.map(t -> "tag:articles:" + t)
.toArray(String[]::new);
return redis.opsForSet().intersect(keys);
}

// 获取文章的所有标签(反向索引)
public void indexArticleTags(Long articleId, List<String> tags) {
for (String tag : tags) {
redis.opsForSet().add("tag:articles:" + tag, String.valueOf(articleId));
}
}
}

#

5.3 IP黑名单

@Component
public class IpBlacklistService {

private static final String BLACKLIST_KEY = "blacklist:ip";

public void addToBlacklist(String ip) {
redis.opsForSet().add(BLACKLIST_KEY, ip);
}

public void removeFromBlacklist(String ip) {
redis.opsForSet().remove(BLACKLIST_KEY, ip);
}

public boolean isBlacklisted(String ip) {
return Boolean.TRUE.equals(redis.opsForSet().isMember(BLACKLIST_KEY, ip));
}

public Set<String> getAllBlacklisted() {
return redis.opsForSet().members(BLACKLIST_KEY);
}
}

#

5.4 抽奖/随机选取

@Service
public class LotteryService {

// 添加参与者
public void addParticipant(String activityId, String userId) {
redis.opsForSet().add("lottery:" + activityId, userId);
}

// 随机抽取中奖者(不重复)
public List<String> drawWinners(String activityId, int count) {
// SPOP随机弹出
List<String> winners = new ArrayList<>();
for (int i = 0; i < count; i++) {
String winner = redis.opsForSet().pop("lottery:" + activityId);
if (winner == null) break;
winners.add(winner);
}
return winners;
}

// 随机抽取中奖者(可重复)
public List<String> drawWinnersWithReplacement(String activityId, int count) {
return new ArrayList<>(redis.opsForSet()
.distinctRandomMembers("lottery:" + activityId, count));
}
}

六、性能优化

#

6.1 利用IntSet节省内存

// 存储用户ID(整数),Set会自动使用intset
for (int i = 0; i < 500; i++) {
redis.opsForSet().add("followers:" + userId, String.valueOf(i));
}
// 编码为intset,非常省内存

// 插入非整数元素,触发编码转换
redis.opsForSet().add("followers:" + userId, "not_a_number");
// 转换为hashtable

#

6.2 批量操作

// 批量添加
redis.opsForSet().add("set", "a", "b", "c", "d", "e");

// 批量判断存在性(使用Pipeline)
List<Boolean> exists = redis.executePipelined((RedisCallback<Object>) connection -> {
for (String item : items) {
connection.setCommands().sIsMember("set".getBytes(), item.getBytes());
}
return null;
}).stream()
.map(o -> (Boolean) o)
.collect(Collectors.toList());

#

6.3 大Set分片

// 如果Set很大,按hash分片
public void addToShardSet(String key, String member) {
int shard = member.hashCode() % 10;
redis.opsForSet().add(key + ":" + shard, member);
}

public boolean isInShardSet(String key, String member) {
int shard = member.hashCode() % 10;
return Boolean.TRUE.equals(redis.opsForSet()
.isMember(key + ":" + shard, member));
}

七、常见问题

#

7.1 Set最大元素数

理论限制:约40亿(2^32 - 1)
实际限制:内存大小

建议:单个Set不超过10000个元素

#

7.2 SMEMBERS的性能

# SMEMBERS返回所有元素,大Set会阻塞
SMEMBERS big:set # 危险!

# 替代方案:使用SSCAN渐进遍历
SSCAN big:set 0 COUNT 100

#

7.3 集合运算的复杂度

SINTER:O(N*M),N是最小Set大小,M是Set数量
SUNION:O(N),N是所有Set元素总数
SDIFF:O(N),N是第一个Set的元素数

建议:
- 大Set的交集避免在Redis做,应用层处理
- 或预先计算交集存入新Set

八、总结

特性 IntSet Hashtable
结构 有序整数数组 哈希表
查找 O(log n) O(1)
插入 O(n) O(1)
内存 极紧凑 有指针开销
适用 小整数集合 大集合或字符串

Set使用建议:

  1. 存储整数:尽量让Set使用intset编码
  2. 控制大小:单个Set不超过10000元素
  3. SSCAN替代SMEMBERS:避免阻塞
  4. 集合运算:注意复杂度,大Set在应用层处理
  5. 去重场景:Set是天然的去重数据结构

核心要点

  1. String:简单的键值对,适合缓存、计数器

  2. Hash:存储对象属性,适合用户信息、配置

  3. List:有序列表,适合消息队列、最新列表

  4. Set:无序去重,适合共同好友、抽奖

  5. ZSet:有序集合,适合排行榜、积分系统

总结

选择合适的数据结构是使用 Redis 的关键。在实际项目中,根据业务需求选择合适的类型,可以提升性能和开发效率。


   转载规则


《Redis Set与IntSet编码》 小乐 采用 知识共享署名 4.0 国际许可协议 进行许可。
  目录