性能优化

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延迟复杂度
基础 INCR100,0001ms低
本地缓存500,0000.1ms中
Pipeline300,0000.5ms中
Lua 脚本200,0000.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 方案后,我们学习雪花算法。

👉 下一章:雪花算法

发现内容有问题?

告诉我们哪里需要改进。