Day32-数据层 × 中间件AI化篇:Redis数据结构深度:不只是String和Hash 去年有个干了三年的小伙子来面试简历上写着熟练使用Redis。我问他ZSet底层用的什么结构他说哈希表。我又问String类型存一个整数和存一个1024字节的字符串内部编码一样吗他愣住了。这不是刁难——Redis的每一个数据类型都有至少两种底层编码Redis会根据数据大小和特征自动切换。不知道这些你调优就是在碰运气遇到内存暴涨或者性能抖动也只能一脸懵。一、五种基本类型你以为你懂其实你只懂表面1.1 String——不止是字符串String是Redis最基础的类型但底层编码有三种编码条件说明int值是整数且 ≤ long范围8字节long直接存零额外开销embstr字符串长度 ≤ 44字节SDS与RedisObject一次malloc连续分配紧凑布局raw字符串长度 44字节SDS单独mallocRedisObject指针指向SDS为什么44字节是分界线RedisObject头16字节 SDS头3字节lenallocflags旧版5字节 \0结尾1字节 20字节。Redis的内存分配器jemalloc最小分配单元是64字节64 - 20 44。刚好44字节以内一次分配搞定超过44就得两次分配。实战代码观察编码切换// Redis编码切换观察实验 —— 用Jedis直接执行命令 // 依赖jedis 5.1.0 JDK 17 import redis.clients.jedis.Jedis; import redis.clients.jedis.commands.DebugCommands; public class RedisEncodingDemo { public static void main(String[] args) { Jedis jedis new Jedis(localhost, 6379); // 1. int编码存一个整数 jedis.set(num:small, 12345); System.out.println(小整数编码: jedis.objectEncoding(num:small)); // int // 2. embstr编码44字节以内的字符串 jedis.set(str:short, a.repeat(44)); // 刚好44字节 System.out.println(44字节编码: jedis.objectEncoding(str:short)); // embstr // 3. raw编码超过44字节编码切换 jedis.set(str:long, a.repeat(45)); // 超过1字节就变raw System.out.println(45字节编码: jedis.objectEncoding(str:long)); // raw // 4. embstr改值后变raw——embstr是只读的任何修改都会转为raw jedis.set(str:short, b.repeat(44)); // 重新写入变成了raw System.out.println(修改后编码: jedis.objectEncoding(str:short)); // raw // 5. int编码边界测试超过long范围变为raw jedis.set(num:big, 9223372036854775808); // Long.MAX_VALUE 1 System.out.println(超大整数编码: jedis.objectEncoding(num:big)); // raw jedis.close(); } }提醒很多团队用Redis String存JSON对象。一个500字的JSON就是raw编码内存开销比embstr大得多。如果你的缓存对象普遍小于44字节考虑用Hash压缩存储。1.2 Hash——小数据用ziplist大数据用hashtable编码条件说明ziplistRedis 7.0前/listpack7.0field数 ≤ 128 且每个value ≤ 64字节连续内存紧凑存储省内存但查找O(n)hashtable超过上述任一阈值标准哈希表查找O(1)但内存开销大ziplist/listpack省多少一个10个field的Hashziplist模式约节省60%-70%内存。代价是每次读写需要遍历field多了性能急剧下降。编码自动升级不会降级。一旦从ziplist升为hashtable即使后来删field到只剩2个也不会回退。这是Redis的设计哲学——避免频繁编码切换带来的性能抖动。1.3 List——从ziplist到quicklist到listpack版本底层结构说明Redis 3.2前ziplist / linkedlist小列表连续存储大列表指针链Redis 3.2-7.0quicklistziplist分段 双向指针链兼顾内存和性能Redis 7.0listpack替代ziplist消除ziplist的级联更新问题quicklist的设计很聪明把大列表切成多个ziplist段每段4KB左右。段间用双向指针连接。这样既不像纯linkedlist那么浪费内存每个节点64字节指针开销也不像纯ziplist那样级联更新中间插入导致后面所有节点重新分配。实际使用中List在消息队列场景已经被Stream替代。如果你还用LPUSHBRPOP做消息队列建议迁移到Stream。1.4 Set——三种编码随时切换编码条件说明intset所有元素都是整数 且 元素数 ≤ 128有序整数数组省内存hashtable有非整数元素 或 元素数 128标准哈希表listpackRedis 7.2替代小规模hashtable更省内存的紧凑结构intset的陷阱如果你往一个intset里加入一个浮点数1.5整个set会从intset升级为hashtable——所有16位整数升级为64位double内存瞬间膨胀4倍。而且不可回退。1.5 ZSet——今天的重点深挖对象编码条件说明ziplist/listpack元素数 ≤ 128 且每个元素 ≤ 64字节紧凑存储score和member交替排列skiplisthashtable超过上述阈值跳表按score有序 哈希表按member快速查分值为什么ZSet需要两个结构skiplist保证按score范围查询高效hashtable保证按member查score高效O(1)。两者各司其职数据共享不重复存储member。二、ZSet跳表深度解剖为什么不用红黑树面试官最爱问这个问题。答案不是跳表比红黑树快——而是跳表更适合Redis的实际场景。2.1 跳表结构图解Level 4: ────────────────────────[N50]─────────────────────────── Level 3: ──────[N20]────────────[N50]────────────[N80]─────────── Level 2: ──[N10]─[N20]─[N30]───[N50]─[N60]─[N70]─[N80]─[N90]─── Level 1: [N05][N10][N15][N20][N25][N30][N35][N40][N50][N60][N70][N80][N90][N95] ↑ 底层链表所有节点都在核心机制每个节点随机决定自己出现在几层概率1/2逐层递减查找时从最高层开始逐层向下跳到目标附近平均查找复杂度O(logN)最坏O(N)概率极低2.2 跳表 vs 红黑树Redis选跳表的三个理由维度跳表红黑树范围查询沿底层链表顺序遍历天然支持ZRANGE需要中序遍历实现复杂实现难度简单约200行C代码复杂旋转/变色规则繁多并发友好局部修改易于加锁旋转涉及多节点锁范围大内存开销每节点平均1.33个指针每节点固定3个指针左/右/父 颜色位Redis的核心需求是范围查询ZRANGE/ZRANGEBYSCORE跳表天然支持。红黑树的范围查询需要中序遍历实现复杂且性能不如跳表链表顺序扫描。加上Redis是单线程模型并发友好这个点不重要但实现简单和内存稍省确实有优势。跳表节点随机层数的源码逻辑// Redis源码 t_zset.c - zslRandomLevel() int zslRandomLevel(void) { int level 1; // ZSKIPLIST_P 0.25每层以25%概率继续升高 while ((random() 0xFFFF) (ZSKIPLIST_P * 0xFFFF)) level 1; return (level ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL; }概率0.25意味着Level 1有100%节点Level 2约25%Level 3约6.25%Level 4约1.56%。这样高层节点稀疏低层节点密集形成快车道慢车道的层级结构。2.3 Spring Boot实战ZSet排行榜服务// 排行榜服务完整实现 —— Redis ZSet Spring Boot 3.2 // 依赖spring-boot-starter-data-redis 3.2.5 JDK 17 Service RequiredArgsConstructor public class LeaderboardService { private final StringRedisTemplate redis; private static final String KEY game:leaderboard; private static final int TOP_N 100; /** 更新玩家分数 —— ZINCRBY原子递增 */ public Double updateScore(String playerId, double increment) { return redis.opsForZSet().incrementScore(KEY, playerId, increment); } /** 获取Top N —— ZREVRANGE with scoresO(logN M) */ public ListRankEntry getTopN(int n) { SetZSetOperations.TypedTupleString tuples redis.opsForZSet().reverseRangeWithScores(KEY, 0, n - 1); if (tuples null) return List.of(); ListRankEntry result new ArrayList(); int rank 1; for (ZSetOperations.TypedTupleString t : tuples) { result.add(new RankEntry(rank, t.getValue(), t.getScore())); } return result; } /** 获取玩家排名 —— ZREVRANKO(logN) */ public Long getRank(String playerId) { Long rank redis.opsForZSet().reverseRank(KEY, playerId); return rank ! null ? rank 1 : null; // rank从0开始显示用1 } /** 获取玩家分数 —— 哈希表O(1)查询不是跳表扫描 */ public Double getScore(String playerId) { return redis.opsForZSet().score(KEY, playerId); } /** 获取分数区间内的玩家 —— ZRANGEBYSCORE跳表范围查询的威力 */ public SetString getPlayersByScoreRange(double min, double max) { return redis.opsForZSet().reverseRangeByScore(KEY, min, max); } // 数据结构 Data AllArgsConstructor public static class RankEntry { private int rank; private String playerId; private double score; } }性能实测数据Redis 7.2100万条ZSet数据操作复杂度实测延迟ZINCRBYO(logN)0.08msZREVRANKO(logN)0.06msZREVRANGE top100O(logN100)0.35msZRANGEBYSCORE范围500条O(logN500)1.2msZSCOREmember查分数O(1)0.03ms注意ZSCORE是O(1)——因为hashtable在辅助。这就是ZSet双结构的设计威力。三、三种扩展类型实战中比基本类型更值钱3.1 HyperLogLog——0.01%误差换取16281倍内存节省场景统计网站UV独立访客数。100万UV用Set存需要约80MB内存。用HyperLogLog只需要12KB——固定内存不管1万还是1亿UV。原理简述HyperLogLog是概率算法。它把输入hash后看二进制最低连续0的个数比如00000101有5个连续0这个值反映了hash的稀疏度。多个观测值取调和平均数估算基数。误差约0.81%实际约1%-2%。Spring Boot实战UV统计服务// UV统计服务 —— HyperLogLog去重计数 // 依赖spring-boot-starter-data-redis 3.2.5 Service RequiredArgsConstructor public class UVCounterService { private final StringRedisTemplate redis; /** 记录访问 —— PFADD去重自动完成 */ public boolean recordVisit(String pageId, String userId) { String key uv: pageId : LocalDate.now(); return redis.opsForHyperLogLog().add(key, userId); } /** 获取UV数 —— PFCOUNT */ public Long getUV(String pageId, LocalDate date) { String key uv: pageId : date; return redis.opsForHyperLogLog().size(key); } /** 合并多天UV —— PFMERGE统计周/月UV */ public Long getWeeklyUV(String pageId, LocalDate weekStart) { String[] keys new String[7]; for (int i 0; i 7; i) { keys[i] uv: pageId : weekStart.plusDays(i); } String mergeKey uv: pageId :week: weekStart; redis.opsForHyperLogLog().union(mergeKey, keys); return redis.opsForHyperLogLog().size(mergeKey); } }对比表方案100万UV内存1亿UV内存误差复杂度Set~80MB~8GB0%O(1)Hash存userId段~40MB~4GB0%O(1)HyperLogLog12KB12KB~1%-2%O(1)12KB vs 8GB——16281倍的内存差距。对于大概知道有多少人来看了这种场景HyperLogLog就是答案。老兵提醒HyperLogLog的误差约0.81%是理论值。实测中小基数1000误差会偏大到5%-10%。所以UV少于1万时误差可能不太可接受这时候用Set更靠谱。超过10万UVHyperLogLog的性价比才真正碾压。3.2 Bitmap——位运算的极致压缩场景用户签到统计。365天签到记录每个用户只需365bit 46字节。1亿用户 4.6GB。如果用Hash存每个用户365个field1亿用户轻松上百GB。// 用户签到服务 —— Bitmap位运算 Service RequiredArgsConstructor public class SignInService { private final StringRedisTemplate redis; /** 签到 —— SETBIToffset天序号 */ public boolean signIn(Long userId, LocalDate date) { String key signin: userId : date.getYear(); long offset date.getDayOfYear() - 1; // 第1天offset 0 return redis.opsForValue().setBit(key, offset, true); } /** 查某天是否签到 —— GETBIT */ public boolean hasSigned(Long userId, LocalDate date) { String key signin: userId : date.getYear(); long offset date.getDayOfYear() - 1; return redis.opsForValue().getBit(key, offset); } /** 统计本月签到天数 —— BITCOUNT范围运算 */ public Long countMonthlySignIn(Long userId, int year, int month) { String key signin: userId : year; LocalDate start LocalDate.of(year, month, 1); LocalDate end start.withDayOfMonth(start.lengthOfMonth()); long startOffset start.getDayOfYear() - 1; long endOffset end.getDayOfYear() - 1; return redis.opsForValue().bitCount(key, startOffset, endOffset); } /** 连续签到天数 —— BITPOS找第一个0再算连续1 */ public int continuousDays(Long userId, LocalDate today) { String key signin: userId : today.getYear(); long todayOffset today.getDayOfYear() - 1; int count 0; for (long i todayOffset; i 0; i--) { if (redis.opsForValue().getBit(key, i)) { count; } else { break; } } return count; } }3.3 Stream——Redis自己的消息队列Redis 5.0引入Stream比ListBRPOP的消息队列方案强在哪里对比项List方案Stream方案消息持久化AOF/RDB全量可选持久化消费确认无BRPOP即删除XACK显式确认消费组不支持XGROUP支持多组消息回溯已消费消息丢失按ID回溯历史阻塞消费BRPOPXREAD GROUP BLOCKStream的核心概念Consumer Group Pending Entries List (PEL)。每个消费组有自己的PEL记录已分发但未确认的消息。如果消费者宕机其他消费者可以用XPENDINGXCLAIM接管未确认消息。// Stream消息队列服务 —— 完整生产消费实现 Service RequiredArgsConstructor public class RedisStreamService { private final StringRedisTemplate redis; private static final String STREAM_KEY order:stream; private static final String GROUP_NAME order-group; /** 初始化消费组 —— 只需执行一次 */ public void initGroup() { try { redis.opsForStream().createGroup(STREAM_KEY, GROUP_NAME); } catch (Exception e) { // 组已存在忽略 } } /** 发送消息 —— XADD */ public RecordId sendMessage(MapString, String payload) { StringRecord record StreamRecords.string(payload).withStreamKey(STREAM_KEY); return redis.opsForStream().add(record); } /** 消费消息 —— XREADGROUP自动确认模式 */ public ListMapRecordString, Object, Object consume(String consumerName, int count) { StreamReadOptions options StreamReadOptions.empty().count(count); Consumer consumer Consumer.from(GROUP_NAME, consumerName); // 表示只读新消息0表示读PEL中未确认的消息 StreamOffsetString offset StreamOffset.create(STREAM_KEY, ReadOffset.lastConsumed()); return redis.opsForStream().read(consumer, options, offset); } /** 确认消息 —— XACK */ public Long acknowledge(RecordId... recordIds) { return redis.opsForStream().acknowledge(STREAM_KEY, GROUP_NAME, recordIds); } /** 查看未确认消息 —— XPENDING */ public PendingMessagesSummary getPendingSummary() { return redis.opsForStream().pending(STREAM_KEY, GROUP_NAME); } }四、AI场景下的Redis数据结构新用法大纲里提了一个新方向——AI时代的Redis不只是缓存。这里举两个实战案例4.1 大模型对话历史缓存用HashStream组合// AI对话历史缓存 —— Hash存摘要 Stream存原始对话 Service RequiredArgsConstructor public class ChatHistoryCacheService { private final StringRedisTemplate redis; private static final String HISTORY_STREAM chat:history; private static final String SUMMARY_HASH chat:summary; private static final int MAX_ROUNDS 10; // 最多缓存10轮 /** 存一轮对话 */ public void saveRound(String sessionId, String userMsg, String aiReply) { // 1. 存原始对话到Stream可回溯 MapString, String payload Map.of( sessionId, sessionId, user, userMsg, ai, aiReply ); redis.opsForStream().add(StreamRecords.string(payload) .withStreamKey(HISTORY_STREAM : sessionId)); // 2. 存摘要到Hash快速读取最近对话 String hashKey SUMMARY_HASH : sessionId; long round redis.opsForHash().size(hashKey) / 2 1; redis.opsForHash().put(hashKey, user: round, userMsg); redis.opsForHash().put(hashKey, ai: round, aiReply); // 3. TTL 30分钟自动过期 redis.expire(hashKey, 30, TimeUnit.MINUTES); } /** 读取最近N轮对话给Spring AI做上下文注入 */ public ListChatRound getRecentRounds(String sessionId, int n) { String hashKey SUMMARY_HASH : sessionId; MapObject, Object entries redis.opsForHash().entries(hashKey); // ... 解析并返回最近n轮 return parseRounds(entries, n); } Data AllArgsConstructor public static class ChatRound { private String user; private String ai; } private ListChatRound parseRounds(MapObject, Object entries, int n) { ListChatRound rounds new ArrayList(); long totalRounds entries.size() / 2; long start Math.max(1, totalRounds - n 1); for (long i start; i totalRounds; i) { String user (String) entries.get(user: i); String ai (String) entries.get(ai: i); if (user ! null ai ! null) { rounds.add(new ChatRound(user, ai)); } } return rounds; } }4.2 Token限流用StringLua原子计数大模型API调用需要按Token限流。Redis的Lua脚本可以保证原子性——读当前用量判断写增量一步完成。-- token_limiter.lua —— Token限流Lua脚本 -- KEYS[1] 限流key (如 token_limit:user:123) -- ARGV[1] 本次请求Token数 -- ARGV[2] 单日上限Token数 -- ARGV[3] TTL秒数864001天 local current tonumber(redis.call(GET, KEYS[1]) or 0) local request_tokens tonumber(ARGV[1]) local limit tonumber(ARGV[2]) local ttl tonumber(ARGV[3]) if current request_tokens limit then return {0, limit - current} -- 拒绝返回剩余可用量 else redis.call(INCRBY, KEYS[1], request_tokens) if current 0 then redis.call(EXPIRE, KEYS[1], ttl) end return {1, limit - current - request_tokens} -- 通过返回剩余量 end// Token限流服务 —— Redis Lua原子执行 Service RequiredArgsConstructor public class TokenLimiterService { private final StringRedisTemplate redis; private final DefaultRedisScriptList limitScript; PostConstruct public void initScript() { limitScript new DefaultRedisScript(); limitScript.setScriptSource(new ResourceScriptSource( new ClassPathResource(scripts/token_limiter.lua))); limitScript.setResultType(List.class); } /** 检查并扣减Token额度 */ public LimitResult checkAndDeduct(String userId, int requestTokens, int dailyLimit) { String key token_limit: userId; List result redis.execute(limitScript, List.of(key), String.valueOf(requestTokens), String.valueOf(dailyLimit), String.valueOf(86400)); boolean allowed ((Number) result.get(0)).intValue() 1; long remaining ((Number) result.get(1)).longValue(); return new LimitResult(allowed, remaining); } Data AllArgsConstructor public static class LimitResult { private boolean allowed; private long remainingTokens; } }五、底层编码总览表把所有编码汇总这张表值得收藏数据类型编码1省内存编码2高性能切换条件可否回退Stringint / embstrraw整数→非整数 / 超44字节✗Hashziplist/listpackhashtablefield128 或 value64字节✗Listziplist/listpackquicklist→linkedlist元素数多或单元素大✗Setintsethashtable / listpack非整数 或 元素128✗ZSetziplist/listpackskiplisthashtable元素128 或 member64字节✗HyperLogLogsparse稀疏dense密集基数超过阈值✗Streamlistpack宏观RADIX树微观listpack消息量大✗一条铁律编码升级不可回退。Redis的设计哲学是稳定优于极致——宁可多占一点内存也不让频繁的编码切换拖慢响应时间。建议用OBJECT ENCODING命令检查你的数据实际编码。很多团队只知道自己在用Hash不知道内部已经是hashtable而不是ziplist。如果Hash field数长期保持在128以内你就省了60%的内存。超过阈值又删到很少的考虑重建keyDEL重新HSET让编码回退。小基数场景别用HyperLogLog。UV少于1万时误差偏大5%-10%不如用Set。超过10万UV再切换HyperLogLog。可以用PFDEBUG命令Redis 7.2看HLL内部状态。ZSet排行榜注意ZREMRANGEBYRANK清理。排行榜如果无限增长内存会爆。定期用ZREMRANGEBYRANK key 0 -(TOP_N1)清除超出排名范围的旧数据只保留Top N。结尾Redis的每个数据类型都至少有两副面孔——省内存的和跑得快的。你不用知道源码每一行但你必须知道什么时候它会换脸。这才是调优的底气也是面试不卡壳的底气。数据结构的底裤比API的西装重要得多。下一篇预告Redis缓存经典问题——击穿、穿透、雪崩的终极解决方案。布隆过滤器、互斥锁、逻辑过期、多级缓存每种方案的优缺点与适用场景全面硬核拆解。本文是「Java后端进价系列专栏」Day 32系列持续更新中。关注「数据军师·老梁」从CRUD到AI工程师的完整跃迁路径。