这是 Beta 探索课程,内容结构、实验步骤和示例可能会继续调整。
性能优化
Redis 方案虽然性能不错,但在超高并发场景下仍需优化。
1. 本地缓存
原理: 在本地缓存一批 ID,减少 Redis 访问。
2. Pipeline 批量操作
Pipeline 把多条命令一次性写进网络缓冲区,再统一读回结果。它不改变命令本身的原子性,但把 N 次网络往返压缩成 1 次,在延迟敏感的场景里收益非常直接。
一次发 1000 条 INCR:
- 不使用 Pipeline:1000 次往返,按 0.5ms 一次算,耗时约 500ms
- 使用 Pipeline:1 次往返,耗时约 0.5ms 加上服务端处理时间
实现:
/**
* 用 Pipeline 一次申请一批 ID。
*/
public class RedisPipelineIdGenerator {
private static final String KEY = "id:order";
private static final int BATCH_SIZE = 1000;
private final JedisPool jedisPool;
public RedisPipelineIdGenerator(String host, int port) {
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(16);
this.jedisPool = new JedisPool(config, host, port);
}
/**
* 一次 Pipeline 发出 BATCH_SIZE 条 INCR,返回这一批 ID。
*/
public List<Long> nextIds() {
try (Jedis jedis = jedisPool.getResource()) {
Pipeline pipeline = jedis.pipelined();
for (int i = 0; i < BATCH_SIZE; i++) {
pipeline.incr(KEY);
}
List<Object> results = pipeline.syncAndReturnAll();
List<Long> ids = new ArrayList<>(results.size());
for (Object result : results) {
ids.add((Long) result);
}
return ids;
}
}
}
Pipeline 有三条使用纪律:
- 一批命令的数量要控制住。命令太多会把一次读缓冲撑大,服务端和客户端都要等,收益反而变小;常用取值是几百到几千条。
- Pipeline 里的命令按顺序执行,但中间可能被其他客户端的命令穿插,所以不要在同一个 Pipeline 里放互相依赖的命令。
- Pipeline 只省往返,不省服务端的执行时间。命令本身很慢时,Pipeline 帮不上太多忙。
3. Lua 脚本
当一次取号需要「判断 + 初始化 + 自增」这几步、而且这几步必须是一个整体时,Pipeline 就帮不上忙了——多条命令之间还是可能被别的客户端插进来。这时用 Lua 脚本:Redis 把整个脚本作为一个原子单位执行。
典型的场景是「带初始值批量取号」:键不存在时先初始化,键存在时直接自增,还要一次返回整段的左右端点。
脚本:
-- KEYS[1]:号段键,例如 id:order
-- ARGV[1]:批大小
-- ARGV[2]:初始值,只在键不存在时生效
local batch = tonumber(ARGV[1])
local start = tonumber(ARGV[2])
if redis.call('EXISTS', KEYS[1]) == 0 then
-- 预置成 start - batch,第一次 INCRBY 之后拿到的正好是 start
redis.call('SET', KEYS[1], start - batch)
end
local endId = redis.call('INCRBY', KEYS[1], batch)
return { endId - batch + 1, endId }
实现:
/**
* 用 Lua 脚本一次预占一段 ID。
*/
public class RedisLuaIdGenerator {
private static final String KEY = "id:order";
private static final String ALLOC_SCRIPT = """
local batch = tonumber(ARGV[1])
local start = tonumber(ARGV[2])
if redis.call('EXISTS', KEYS[1]) == 0 then
redis.call('SET', KEYS[1], start - batch)
end
local endId = redis.call('INCRBY', KEYS[1], batch)
return { endId - batch + 1, endId }
""";
private final JedisPooled jedis;
public RedisLuaIdGenerator(String host, int port) {
this.jedis = new JedisPooled(host, port);
}
/**
* 预占一段 ID,返回区间的左右端点。
*/
public long[] allocate(int batchSize, long start) {
Object result = jedis.eval(ALLOC_SCRIPT, Collections.singletonList(KEY),
String.valueOf(batchSize), String.valueOf(start));
List<?> range = (List<?>) result;
return new long[] { ((Number) range.get(0)).longValue(),
((Number) range.get(1)).longValue() };
}
}
用 Lua 脚本取号有三点注意:
- 脚本是原子执行的,但执行期间会阻塞这个 Redis 实例上的其他命令,所以脚本必须短,通常控制在几十微秒到几毫秒之内。
- 脚本会被缓存到服务端,用
SCRIPT LOAD加EVALSHA可以省掉每次传输脚本内容的开销;但 Redis 重启或执行SCRIPT FLUSH之后缓存失效,需要重新加载。 - 脚本出错不会留下半截状态:Redis 在加载阶段就会检查脚本语法,语法错误在第一次执行时就被拦下,不会写到一半才失败。
| 优化方案 | QPS | 延迟 | 复杂度 |
|---|---|---|---|
| 基础 INCR | 100,000 | 1ms | 低 |
| 本地缓存 | 500,000 | 0.1ms | 中 |
| Pipeline | 300,000 | 0.5ms | 中 |
| Lua 脚本 | 200,000 | 0.8ms | 中 |
1. 使用本地缓存
本地缓存是 Redis 方案里性价比最高的一招。做法是启动时或首次调用时用一次 INCRBY 一次取回一批 ID 缓存在本地内存里,本地逐个发放,发放到一定比例再批量申请下一批。
实例启动 → INCRBY id:order 1000 → 拿到 [1001, 2000]
本地发放 1001, 1002, ... 期间完全不访问 Redis
剩余量低于阈值 → INCRBY id:order 1000 → 拿到 [2001, 3000]
它把”每个 ID 一次网络往返”压缩成”每批 ID 一次网络往返”,代价和号段模式一样——实例异常退出时,本地还没发放的 ID 全部作废,产生空洞。所以批大小要按可接受的空洞量来定,并且要用监控盯住缓存命中率。
批大小也不是越大越好:1000 个实例各缓存 1000 个 ID,一次启动就要预取 100 万个 ID,浪费会被实例数成倍放大。
2. 监控 Redis 性能
验证要点
- 命令只用于验证系统状态,读者不需要记具体参数。
Redis 方案通过优化可以达到很好的性能,适合中高并发场景。
了解了 Redis 方案后,我们学习雪花算法。
👉 下一章:雪花算法
发现内容有问题?
告诉我们哪里需要改进。
