
1. 从“会写业务”到“会测中间件”的分水岭“Redis 怎么测试”这个问题我在带新人和做技术评审时被问到的频率极高。大部分人第一次接触 Redis 测试脑子里蹦出来的画面是本地起一个 Redis 服务写个set再get一下断言取出来的值跟存进去的一样收工。这种测法不能说错但它测的是“Redis 本身有没有坏”而不是“你的代码在 Redis 参与下有没有坏”。这两件事差着十万八千里。真正让测试同学头疼的是下面这些场景缓存穿透了怎么办、并发扣库存时 Redis 和数据库怎么保持一致、分布式锁在锁过期后业务还没跑完会怎样、主从切换的瞬间写请求会不会丢、大 key 把整个实例拖慢时你的降级逻辑有没有生效。这些才是“Redis 测试”的核心战场。它属于中间件测试和集成测试的交叉地带既要求你懂 Redis 的数据结构、持久化、过期策略、集群模式又要求你会写可重复、可断言、可自动化的测试代码。这篇文章适合三类人看一是刚接手缓存相关模块、想把测试做扎实的后端开发二是需要设计缓存层测试用例的测试工程师三是准备技术面试、想搞清楚“Redis 相关测试到底考什么”的同学。我会从测试思路、环境搭建、核心场景、工具选型、问题排查几个维度把这件事掰开揉碎讲清楚。文中所有代码和配置都是可以直接抄作业的参数选择我也会说明为什么这么定。2. 测试思路的整体设计与方案选型2.1 先想清楚你要测的是哪一层Redis 测试不是一个单一动作它至少分四层每层的目标、手段、成本都不一样。我习惯用下面这张表来跟团队对齐认知测试层级测试目标典型手段是否需要真实 Redis单元测试验证业务逻辑对 Redis 客户端的调用是否正确Mock 客户端否集成测试验证代码与真实 Redis 交互是否正常本地/容器化 Redis是场景测试验证缓存穿透、击穿、雪崩、锁等特定场景真实 Redis 并发工具是性能与稳定性验证高并发下延迟、吞吐、内存表现压测工具 监控是很多人一上来就想做第四层结果连第二层都没跑通。我的建议是自下而上先把单元测试的 Mock 写对再用容器把集成测试跑起来然后针对核心场景补用例最后才谈压测。顺序反了排查问题时你会分不清是代码 bug、环境问题还是 Redis 配置问题。2.2 为什么优先选容器化而不是本地安装本地装一个 Redis 当然最快apt install redis或者下载个压缩包解压就能跑。但我在实际项目里几乎不用本地安装原因有三个。第一版本不可控你本地是 6.x测试环境是 7.x某些命令行为不一样测出来的结论不可信。第二状态不干净本地 Redis 跑久了里面全是脏数据测试结果时好时坏。第三难以模拟集群主从、哨兵、Cluster 这些模式本地装起来很折腾。容器化方案我用得最多的是 Docker Compose一个文件把单机、主从、哨兵都能描述清楚。下面是一个我常用的单机加密码的最小配置version: 3.8 services: redis: image: redis:7.2-alpine container_name: test-redis ports: - 6379:6379 command: [redis-server, --requirepass, test123456, --appendonly, yes] volumes: - ./redis-data:/data这里有几个参数值得说。--appendonly yes打开 AOF 持久化是为了测试重启后数据是否还在如果你只测缓存逻辑可以关掉能快不少。--requirepass加上密码是为了让测试环境和生产环境在认证这一环上保持一致避免出现“本地没密码能跑、线上有密码就挂”的尴尬。镜像选alpine版本体积小、启动快适合频繁销毁重建的测试流程。提示测试用的 Redis 一定要和被测代码使用同一个大版本。Redis 6 到 7 在部分命令的返回结构上有变化跨大版本测试容易产生误判。2.3 Mock 还是真实实例边界在哪里这是被问得最多的问题。我的判断标准很简单测“调用逻辑”用 Mock测“交互结果”用真实实例。举个例子你的代码里有个方法getUserById逻辑是“先查 Redis命中就返回没命中查数据库再回写 Redis”。如果你要测的是“当 Redis 返回 null 时代码有没有去查数据库”那用 Mock 就够了让 Mock 客户端返回 null断言数据库查询方法被调用了一次。但如果你要测的是“回写 Redis 时设置的过期时间对不对”“序列化后的值能不能被正确反序列化回来”那就必须用真实 Redis因为 Mock 不会帮你验证序列化协议和 TTL 的真实行为。我见过一个典型的坑某同学用 Mock 测缓存回写Mock 里set永远成功结果上线后发现真实 Redis 因为内存满了触发了maxmemory-policy的淘汰回写的数据立刻被删了缓存形同虚设。这种问题只有真实实例才测得出来。3. 核心细节解析与实操要点3.1 环境隔离别让测试数据污染你的开发库测试 Redis 最容易犯的错就是直接连开发环境的库。跑一遍测试把同事正在调试的数据冲掉了这种事发生一次就够你被念叨半年。我的做法是每个测试进程用独立的 database index 或者独立的 key 前缀。Redis 默认有 16 个 database0 到 15你可以在测试配置里指定用第 15 个跟业务用的 0 号库隔开。但要注意Cluster 模式下没有多 database 的概念只有 0 号库这时候就得靠 key 前缀隔离。我通常会在测试基类里生成一个随机前缀比如test:20240517:abc123:所有测试 key 都带上它测试结束再统一清理。import redis import uuid class RedisTestBase: def setup_method(self): self.prefix ftest:{uuid.uuid4().hex[:8]}: self.client redis.Redis( host127.0.0.1, port6379, db15, passwordtest123456, decode_responsesTrue ) def teardown_method(self): # 只删自己前缀下的 key避免误删 cursor 0 while True: cursor, keys self.client.scan(cursor, matchf{self.prefix}*, count100) if keys: self.client.delete(*keys) if cursor 0: break这里用scan而不是keys是关键。keys *在数据量大时会阻塞 Redis 主线程测试环境虽然数据少但养成好习惯很重要而且scan的游标遍历逻辑本身也值得在测试里验证一遍。decode_responsesTrue让返回的是字符串而不是 bytes断言时写起来更直观。3.2 数据结构的测试重点各不同Redis 有 String、Hash、List、Set、ZSet、Stream 等结构测试时关注点完全不一样。我整理了一份速查表数据结构测试重点容易踩的坑String序列化/反序列化、TTL、原子自增存对象时序列化方式不一致Hash字段增删、批量操作、大 key字段过多导致单 key 过大List左右插入顺序、阻塞弹出阻塞命令在测试里容易卡死Set去重、集合运算大数据量下smembers阻塞ZSet分值排序、范围查询、排行榜分值相同导致排序不稳定Stream消费组、消息确认未确认消息堆积拿 String 的序列化来说很多团队用 JSON 存对象测试时一定要覆盖“存进去再取出来对象是否完全相等”。我遇到过用pickle序列化本地 Python 版本一致没问题换了个环境反序列化直接报错。测试用例里加一条“跨序列化协议兼容性”的断言能提前拦住这类问题。ZSet 的排序稳定性是个隐蔽的坑。当两个元素分值相同时Redis 会按成员的字典序排列但这个行为在不同版本、不同配置下不一定完全一致。如果你的排行榜业务依赖“分值相同时按时间先后排”那就不能只靠 ZSet 的分值得把时间戳编码进分值里测试时专门构造分值相同的场景来验证。3.3 TTL 和过期策略怎么测才靠谱TTL 测试的难点在于时间不可控。你设一个 10 秒过期的 key难道测试就真的 sleep 10 秒那用例跑起来慢得没法接受。我的做法是分两步短 TTL 用真实等待长 TTL 用TTL命令断言剩余时间范围。def test_ttl_setting(self): key f{self.prefix}ttl_key self.client.set(key, value, ex3600) ttl self.client.ttl(key) # 断言 TTL 在合理范围内而不是精确等于 3600 assert 3595 ttl 3600 def test_key_expire(self): key f{self.prefix}short_ttl self.client.set(key, value, px500) # 500 毫秒 import time time.sleep(0.8) assert self.client.get(key) is None注意ttl返回的是秒级pttl返回毫秒级。断言时给一个容差范围因为命令执行本身有耗时。短 TTL 用毫秒级设置加真实等待控制在 1 秒以内对整体测试时长影响可接受。还有一个容易忽略的点Redis 的过期删除是惰性加定期结合的。一个 key 过期了get它返回 null但exists在某些时刻可能还返回 1因为还没被真正删除。所以测试过期行为时用get判断而不是exists更贴近业务真实读取路径。4. 实操过程与核心环节实现4.1 缓存穿透、击穿、雪崩的测试用例怎么写这三个词面试常考但真正写测试用例的人不多。我逐个说怎么落地。缓存穿透指的是查询一个数据库里也不存在的 key每次请求都打到数据库。测试思路是构造一个不存在的 key连续查询多次断言数据库查询方法只被调用了一次因为第二次应该被空值缓存拦住。def test_cache_penetration(self, mocker): db_mock mocker.patch(myapp.db.query_user, return_valueNone) key f{self.prefix}not_exist_user for _ in range(5): result self.service.get_user(key) assert result is None # 关键断言数据库只被查了一次后续被空值缓存拦截 assert db_mock.call_count 1 # 验证空值确实被缓存了 assert self.client.get(key) __NULL__这里空值缓存的标记我用__NULL__而不是空字符串因为空字符串在某些序列化方案里和“未设置”难以区分。测试时要断言这个标记被正确写入并且它的 TTL 通常比正常数据短避免长期占用内存。缓存击穿是热点 key 过期瞬间大量请求打到数据库。测试思路是并发发起多个请求断言只有一个线程真正查了数据库其他线程等待或走了互斥逻辑。这个测试需要用到多线程和锁写起来稍复杂import threading def test_cache_breakdown(self, mocker): db_mock mocker.patch(myapp.db.query_hot, return_value{id: 1}) key f{self.prefix}hot_key results [] errors [] def worker(): try: results.append(self.service.get_hot(key)) except Exception as e: errors.append(e) threads [threading.Thread(targetworker) for _ in range(20)] for t in threads: t.start() for t in threads: t.join() assert not errors assert len(results) 20 # 理想情况下数据库只被查一次 assert db_mock.call_count 1实测下来如果互斥锁实现有 bugcall_count会大于 1这个断言就能抓住问题。注意线程数不要设太大20 到 50 足够暴露问题太多反而让测试不稳定。缓存雪崩是大量 key 同时过期。测试思路是批量设置相同 TTL 的 key等待过期后并发查询观察数据库压力。这个更适合在压测环境做单元测试层面可以退而求其次断言“TTL 是否加了随机抖动”。def test_ttl_random_jitter(self): key f{self.prefix}jitter_key self.service.set_with_jitter(key, value, base_ttl3600) ttl self.client.ttl(key) # 基础 3600 秒抖动范围假设是正负 300 秒 assert 3300 ttl 39004.2 分布式锁的测试最容易出问题的地方分布式锁的测试是重灾区。我见过太多锁的实现在单线程测试下完美一上并发就锁不住或者死锁。核心要测三个点互斥性、可重入性、锁超时释放。互斥性测试两个线程同时抢锁断言只有一个成功。def test_lock_mutex(self): lock_key f{self.prefix}lock acquired [] def try_lock(name): got self.lock_service.acquire(lock_key, ttl5, ownername) acquired.append((name, got)) t1 threading.Thread(targettry_lock, args(A,)) t2 threading.Thread(targettry_lock, args(B,)) t1.start(); t2.start(); t1.join(); t2.join() success [name for name, got in acquired if got] assert len(success) 1可重入性测试同一个 owner 连续获取两次锁都应该成功且释放一次后锁还在释放两次后才真正释放。这个测试能抓住“用 setnx 实现锁但没记录 owner”的经典 bug。锁超时释放测试获取锁后不主动释放等待 TTL 过期断言另一个线程能拿到锁。这里 TTL 设短一点比如 1 秒测试等待 1.5 秒后验证。注意测试分布式锁时一定要用真实的 Redis 实例不能用 Mock。锁的互斥性依赖 Redis 的原子命令如set key value nx pxMock 无法模拟这种原子性。4.3 主从与持久化的验证方法如果你的业务对数据可靠性有要求主从同步和持久化必须测。单机环境下可以用 Docker Compose 起一主一从version: 3.8 services: redis-master: image: redis:7.2-alpine ports: - 6380:6379 command: [redis-server, --requirepass, test123456, --masterauth, test123456] redis-slave: image: redis:7.2-alpine ports: - 6381:6379 command: [redis-server, --requirepass, test123456, --masterauth, test123456, --replicaof, redis-master, 6379]测试主从同步往 master 写数据等待一小段时间从 slave 读断言数据一致。等待时间取决于网络和同步频率本地环境 100 到 200 毫秒通常够用但为了稳定我一般用轮询加重试def test_master_slave_sync(self): master redis.Redis(host127.0.0.1, port6380, passwordtest123456) slave redis.Redis(host127.0.0.1, port6381, passwordtest123456) key f{self.prefix}sync_key master.set(key, sync_value) import time for _ in range(20): if slave.get(key) sync_value: break time.sleep(0.1) else: raise AssertionError(主从同步超时) assert slave.get(key) sync_value持久化测试写入数据后重启 Redis 容器断言数据还在。AOF 和 RDB 两种模式都要覆盖。这个测试在 CI 里跑要注意容器重启的时间通常给 5 到 10 秒的等待窗口。5. 工具选型与自动化集成5.1 测试框架和客户端库怎么选Python 生态里pytest加redis-py是我最常用的组合。pytest的 fixture 机制特别适合管理 Redis 连接和清理逻辑redis-py对各类命令的封装也比较完整。Java 生态用 JUnit 加 Jedis 或 Lettuce思路一样。Mock 库方面Python 用unittest.mock或pytest-mock就够不需要引入太重的框架。有些团队用fakeredis来模拟 Redis我的看法是fakeredis 适合单元测试不适合集成测试。它对部分命令的支持和真实 Redis 有差异尤其是涉及 Lua 脚本、事务、过期策略的场景用它测出来的结论不可靠。压测工具我用过redis-benchmark、memtier_benchmark和 JMeter。redis-benchmark是 Redis 自带的上手最快适合快速摸底redis-benchmark -h 127.0.0.1 -p 6379 -a test123456 -n 100000 -c 50 -t set,get -q参数含义-n是总请求数-c是并发连接数-t指定测试的命令类型-q只输出关键结果。实测下来单机 Redis 在 50 并发下 get 的 QPS 通常能到几万如果你的业务预期远低于这个数那瓶颈大概率在应用层而不是 Redis。5.2 把 Redis 测试接进 CI 流水线CI 里跑 Redis 测试核心是环境准备要快、清理要干净。我通常用 GitLab CI 或 GitHub Actions 的 service 功能起一个 Redis 容器测试跑完容器自动销毁。# GitHub Actions 示例 jobs: test: runs-on: ubuntu-latest services: redis: image: redis:7.2-alpine ports: - 6379:6379 options: - --health-cmd redis-cli ping --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkoutv4 - name: Run tests run: pytest tests/ -v env: REDIS_HOST: localhost REDIS_PORT: 6379health-cmd那段很关键它确保 Redis 真正就绪后测试才开始跑避免“连接被拒绝”的偶发失败。测试代码里通过环境变量读取连接信息本地开发和 CI 用同一套代码只是配置不同。5.3 测试数据的构造与清理策略数据构造我推荐用 factory 模式把常用的测试数据封装成函数需要时调用避免每个用例里重复写一堆set。清理策略上前面提到的“随机前缀加 scan 删除”是最稳妥的。不要用flushdb万一配置错了连到生产库一条命令下去就是事故。对于需要大量数据的性能测试可以用 pipeline 批量写入比逐条set快几十倍def seed_data(client, prefix, count10000): pipe client.pipeline(transactionFalse) for i in range(count): pipe.set(f{prefix}key:{i}, fvalue:{i}) if i % 1000 0: pipe.execute() pipe client.pipeline(transactionFalse) pipe.execute()transactionFalse关闭事务纯粹为了批量发送命令提升吞吐。每 1000 条执行一次避免单次 pipeline 过大占用过多内存。6. 常见问题与排查技巧实录6.1 测试跑着跑着就卡住了这是最高频的问题。原因通常有三个一是用了阻塞命令blpop、brpop、subscribe但没设超时测试线程一直等二是连接池耗尽前面的连接没释放三是分布式锁死锁某个线程拿了锁不释放。排查思路先看是哪个用例卡住用pytest -v能看到卡在哪个测试。如果是阻塞命令给它加timeout参数比如blpop(key, timeout1)。如果是连接池问题检查是否每个测试都正确关闭了连接用 fixture 的yield机制确保清理。锁的问题检查释放逻辑是否放在finally里。6.2 本地能过 CI 挂或者反过来环境差异导致的。常见原因Redis 版本不同、密码配置不同、database index 不同、网络延迟导致超时断言失败。解决办法是把所有环境相关的配置都抽到环境变量里测试代码不硬编码。超时类的断言给足容差CI 环境通常比本地慢。还有一个隐蔽的原因测试执行顺序。有些用例依赖前一个用例留下的数据本地跑单个文件时顺序固定能过CI 里并行跑就挂了。每个用例必须自包含不依赖外部状态。6.3 断言总是差一点时好时坏这类“flaky test”最让人抓狂。典型场景TTL 断言精确相等、并发测试的计数断言、时间相关的断言。解决原则是用范围代替精确值用轮询代替固定等待。问题现象根本原因解决手段TTL 断言偶尔失败命令执行耗时用范围断言如 3595 到 3600并发计数偶尔多一个线程调度不确定断言范围或改用同步屏障主从同步偶尔超时网络抖动轮询重试给足超时窗口锁测试偶尔双成功时钟或网络延迟检查锁实现确认用了原子命令6.4 内存相关的问题怎么测Redis 内存问题在生产上很致命测试阶段能提前发现最好。要测的点包括大 key 的识别、内存淘汰策略、内存碎片率。大 key 可以用redis-cli --bigkeys扫描测试时构造一个超大 Hash 或 List断言你的代码有没有做拆分或限制。内存淘汰策略测试把maxmemory设小比如 10MBmaxmemory-policy设为allkeys-lru然后不断写入数据断言旧数据被淘汰、新数据可读。这个测试能验证你的业务在内存紧张时的降级行为是否符合预期。redis-cli -a test123456 config set maxmemory 10mb redis-cli -a test123456 config set maxmemory-policy allkeys-lru注意config set修改的是运行时配置重启后失效。测试环境可以这么用但要在测试结束后恢复原配置避免影响后续用例。6.5 独家避坑心得最后分享几条我踩过坑才总结出来的经验。第一测试 Redis 的密码不要用空哪怕本地环境因为很多连接池在密码为空时的行为和预期不一致早点暴露问题比上线后好。第二不要在测试里用keys *哪怕数据少养成习惯用scan。第三并发测试的线程数要固定不要用 CPU 核数动态计算否则不同机器上结果不可复现。第四Lua 脚本的测试要单独覆盖因为脚本里的逻辑不走客户端序列化容易和普通命令的行为产生差异。第五测试结束一定要验证连接已关闭连接泄漏在长时间跑的 CI 里会累积成大问题。这套东西持续更新下来我发现 Redis 测试的本质不是“测 Redis”而是“测你的系统在 Redis 参与下的行为边界”。把穿透、击穿、锁、主从、内存这几个场景的用例补齐你对缓存层的信心会完全不一样。后面如果遇到新的场景比如 Redis 7 的新特性或者 Cluster 的槽位迁移测试我再继续补进来。