缓存性能测试实战——Redis 压测、缓存穿透场景、缓存效果验证
缓存性能测试实战——Redis 压测、缓存穿透场景、缓存效果验证
适读人群:后端开发工程师、系统架构师 | 阅读时长:约14分钟 | 核心价值:掌握针对缓存层的专项性能测试,学会验证缓存效果和模拟缓存异常场景
加了缓存,压测数据反而更差了
2021年我接手一个商品详情接口的性能优化任务。接口P99是520ms,业务要求降到200ms以内。
我评估后觉得很简单:商品数据变化不频繁,加个Redis缓存,问题解决。
加完Redis缓存,我压测了一下,P99从520ms变成了……680ms。
比加缓存前还慢了160ms。
这个结果让我困惑了。检查代码,缓存逻辑没有错误,命中率监控显示是89%。
后来DBA帮我看了一下,发现问题:Redis的get操作本身是0.5ms,但我用的是StringRedisTemplate加上自定义的序列化,每次缓存命中都要做一次JSON反序列化,这个操作花了8ms,比原来直接查数据库(走了Buffer Pool全是内存读)的5ms还慢。
加了缓存,反而引入了额外的序列化开销,让接口变慢了。
这次经历告诉我:缓存优化需要测量,不能假设"加缓存一定变快"。
Redis 基准测试
在评估缓存方案之前,先测试Redis本身的性能上限。
redis-benchmark 基准测试
# Redis自带测试工具,不需要额外安装
# 基础测试:100个并发,100万次操作
redis-benchmark -h 127.0.0.1 -p 6379 \
-c 100 \ # 100并发
-n 1000000 \ # 100万次请求
-t set,get,lpush,lpop,sadd,spop,zadd,zrange,hset,hget # 测试的命令
# 测试特定数据大小的性能
redis-benchmark -h 127.0.0.1 -p 6379 \
-c 100 -n 100000 \
-t set \
-d 1024 # 1KB的value
redis-benchmark -h 127.0.0.1 -p 6379 \
-c 100 -n 100000 \
-t set \
-d 10240 # 10KB的value
# pipeline测试(批量命令)
redis-benchmark -h 127.0.0.1 -p 6379 \
-c 100 -n 100000 \
-t set \
--pipe # 启用pipeline模式典型基准结果
====== SET ======
100000 requests completed in 0.97 seconds
100 parallel clients
3 bytes payload
keep alive: 1
99.95% <= 1 milliseconds
100.00% <= 2 milliseconds
103092.78 requests per second ← 约10万 QPS关键数字参考(单机Redis,数据在内存中):
- SET/GET:10-15万 QPS(小value)
- value大小增加到10KB:5-8万 QPS
- 使用pipeline:可达50-80万 QPS
缓存命中率测试
测试缓存命中的效果
用k6写一个测试,对比有缓存和无缓存的性能差异:
// cache_effectiveness_test.js
import http from 'k6/http';
import { check, group } from 'k6';
import { Trend } from 'k6/metrics';
const withCacheDuration = new Trend('with_cache_duration', true);
const withoutCacheDuration = new Trend('without_cache_duration', true);
export const options = {
vus: 50,
duration: '5m',
};
const BASE_URL = 'https://api.example.com';
export default function() {
// 测试有缓存的请求(固定100个商品ID,一定会命中缓存)
group('cached request', function() {
const productId = Math.floor(Math.random() * 100) + 1; // 1-100,全在缓存
const start = Date.now();
const res = http.get(`${BASE_URL}/api/product/${productId}`);
withCacheDuration.add(Date.now() - start);
check(res, {
'status 200': (r) => r.status === 200,
'from cache': (r) => r.headers['X-Cache-Hit'] === 'true', // 服务端需要返回这个header
});
});
// 测试无缓存的请求(随机10万个商品ID,大概率不在缓存)
group('uncached request', function() {
const productId = Math.floor(Math.random() * 100000) + 10001; // 10001-110000,不在缓存
const start = Date.now();
const res = http.get(`${BASE_URL}/api/product/${productId}`);
withoutCacheDuration.add(Date.now() - start);
check(res, { 'status 200': (r) => r.status === 200 });
});
}通过对比两个Trend指标,可以量化缓存带来的性能提升:
with_cache_duration:
p50: 12ms
p99: 45ms
without_cache_duration:
p50: 180ms
p99: 520ms缓存命中时P99提升了11.6倍,这才是有意义的缓存效果验证数据。
缓存穿透场景测试
什么是缓存穿透
缓存穿透:查询一个不存在的数据,缓存里没有,每次都穿透到数据库查询,数据库也没有,但每次都要查一遍。
如果恶意用户或爬虫大量请求不存在的productId(比如productId=999999999),每个请求都穿透缓存直打数据库,可以轻易把数据库打崩。
测试接口对缓存穿透的防御能力
// cache_penetration_test.js
import http from 'k6/http';
import { check } from 'k6';
import { Counter } from 'k6/metrics';
const penetrationRequests = new Counter('cache_penetration_attempts');
export const options = {
scenarios: {
// 正常流量
normal_traffic: {
executor: 'constant-arrival-rate',
rate: 500,
timeUnit: '1s',
duration: '5m',
preAllocatedVUs: 100,
exec: 'normalRequest',
},
// 缓存穿透攻击
penetration_attack: {
executor: 'constant-arrival-rate',
rate: 200, // 每秒200个穿透请求
timeUnit: '1s',
duration: '5m',
preAllocatedVUs: 50,
exec: 'penetrationRequest',
},
},
thresholds: {
// 正常流量的P99不应该因为穿透攻击而升高
'http_req_duration{scenario:normal_traffic}': ['p(99)<500'],
'http_req_failed{scenario:normal_traffic}': ['rate<0.005'],
},
};
export function normalRequest() {
const productId = Math.floor(Math.random() * 10000) + 1;
const res = http.get(`https://api.example.com/api/product/${productId}`);
check(res, { 'success': (r) => r.status === 200 });
}
export function penetrationRequest() {
// 使用永远不存在的商品ID(比如负数或超大数)
const fakeProductId = -1 * Math.floor(Math.random() * 1000000);
penetrationRequests.add(1);
const res = http.get(`https://api.example.com/api/product/${fakeProductId}`);
// 期望返回404,但不期望这些请求让正常流量变慢
check(res, { '404 for non-exist': (r) => r.status === 404 });
}如果有布隆过滤器(Bloom Filter)或空对象缓存防御穿透攻击,正常流量的P99应该不受穿透攻击影响。如果正常流量P99升高,说明防御方案失效,需要排查。
缓存雪崩场景测试
模拟大量缓存同时失效时的场景:
// cache_avalanche_test.js
// 模拟缓存雪崩:大量请求同时打向未命中缓存的商品
import http from 'k6/http';
import { check } from 'k6';
export const options = {
scenarios: {
// 模拟缓存全部失效后的瞬间冲击
avalanche: {
executor: 'ramping-arrival-rate',
startRate: 0,
timeUnit: '1s',
stages: [
{ duration: '10s', target: 1000 }, // 10秒内从0到1000 RPS
{ duration: '30s', target: 1000 }, // 保持1000 RPS 30秒
{ duration: '10s', target: 0 },
],
preAllocatedVUs: 500,
},
},
thresholds: {
// 即使在雪崩场景下,错误率也不应该超过5%
'http_req_failed': ['rate<0.05'],
},
};
export default function() {
// 大量请求命中相同的一批商品(缓存刚失效)
const hotProductIds = [1001, 1002, 1003, 1004, 1005];
const productId = hotProductIds[Math.floor(Math.random() * hotProductIds.length)];
const res = http.get(`https://api.example.com/api/product/${productId}`);
check(res, { 'success or degraded': (r) => r.status === 200 || r.status === 503 });
}Java 端缓存序列化性能对比
用JMH测试不同序列化方案的性能差异,解决文章开头的问题:
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Thread)
@Warmup(iterations = 5)
@Measurement(iterations = 10)
@Fork(2)
public class CacheSerializationBenchmark {
private ProductVO testProduct;
private ObjectMapper objectMapper;
private Kryo kryo;
@Setup
public void setup() {
testProduct = createTestProduct();
objectMapper = new ObjectMapper();
kryo = new Kryo();
kryo.register(ProductVO.class);
}
@Benchmark
public byte[] jacksonSerialization() throws Exception {
return objectMapper.writeValueAsBytes(testProduct);
}
@Benchmark
public byte[] kryoSerialization() {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
Output output = new Output(bos);
kryo.writeObject(output, testProduct);
output.flush();
return bos.toByteArray();
}
@Benchmark
public ProductVO jacksonDeserialization(DeserializationState state) throws Exception {
return objectMapper.readValue(state.jacksonBytes, ProductVO.class);
}
@Benchmark
public ProductVO kryoDeserialization(DeserializationState state) {
Input input = new Input(state.kryoBytes);
return kryo.readObject(input, ProductVO.class);
}
}典型结果:
| 序列化方案 | 序列化 ops/ms | 反序列化 ops/ms | 序列化后大小 |
|---|---|---|---|
| JSON (Jackson) | 450 | 380 | 856 bytes |
| Kryo | 2100 | 1800 | 312 bytes |
| Protobuf | 1900 | 2200 | 198 bytes |
Kryo的序列化/反序列化性能是Jackson的4-5倍,序列化后体积也更小(影响网络传输)。
对于缓存场景,如果对象序列化/反序列化是热点,可以考虑从Jackson换为Kryo。
踩坑实录
坑1:Redis Cluster模式下批量命令命中多个节点
现象: 把单机Redis换成Redis Cluster后,之前的mget批量查询接口P99从15ms涨到了280ms。
原因: Redis Cluster里,不同的key分布在不同的slot(16384个slot分布到多个节点)。mget如果包含分布在不同节点的key,会被拆分成多个请求分别发到各个节点,无法利用pipeline。
解法: 用Hash Tag强制让同一批商品的key落在同一个slot:stock:{productId},{}里的内容是Hash Tag,只有Hash Tag参与hash计算,保证stock:{1001}和image:{1001}在同一个slot,可以用pipeline批量读取。
坑2:热key导致单个Redis节点CPU 100%
现象: 全局Banner图接口压测时,Redis Cluster其中一个节点CPU 100%,其他节点只有10%。
原因: 所有请求都查同一个key(全局Banner的缓存key是固定的),而Redis按slot路由,这个固定key永远落在同一个节点,形成热key。
解法: 把热key拆分成多个:banner:list:0, banner:list:1, ... banner:list:9,10个key均匀分布到不同节点,客户端随机选一个读取。
总结
缓存性能测试的三个核心测试场景:
- 缓存效果测试:量化缓存带来的性能提升,验证命中率
- 缓存穿透测试:验证防御方案是否有效,确保穿透不影响正常流量
- 缓存雪崩测试:验证大量缓存同时失效时系统的降级能力
加缓存不是万能的,加之前要先了解你的瓶颈真的是数据库读,而不是序列化、网络或代码逻辑。
