Redis入门学习教程,从入门到精通,Redis进阶编程知识点详解(5)

Redis进阶编程知识点详解

5.1 Redis 乐观锁

乐观锁是一种并发控制机制,它假设多用户并发操作时冲突很少发生,因此不在操作数据时加锁,而是在更新数据时检查数据是否被其他线程修改过。Redis通过WATCHMULTIEXECDISCARD命令实现乐观锁。

5.1.1 核心语法

  1. WATCH key [key ...]:监视一个或多个key,如果在执行事务之前被其他客户端修改,则事务将被中断。
  2. MULTI:标记一个事务块的开始。后续的命令会被排队,直到执行EXEC。
  3. EXEC :执行所有事务块内的命令。如果WATCH的key被修改,EXEC将返回(nil)
  4. DISCARD:取消事务,放弃执行事务块内的所有命令,并取消对所有key的WATCH。

5.1.2 案例代码:模拟库存扣减

java 复制代码
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Transaction;
import redis.clients.jedis.Response;

import java.util.List;

public class RedisOptimisticLockExample {

    private static final String STOCK_KEY = "product:1001:stock";
    private static final Jedis jedis = new Jedis("localhost", 6379);

    public static void main(String[] args) {
        // 初始化库存为10
        jedis.set(STOCK_KEY, "10");

        // 模拟10个线程并发扣减库存
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                boolean success = decreaseStock(1);
                if (success) {
                    System.out.println(Thread.currentThread().getName() + " 扣减成功,剩余库存: " + jedis.get(STOCK_KEY));
                } else {
                    System.out.println(Thread.currentThread().getName() + " 扣减失败,库存不足或并发冲突");
                }
            }).start();
        }
    }

    /**
     * 使用乐观锁扣减库存
     * @param quantity 要扣减的数量
     * @return 是否扣减成功
     */
    private static boolean decreaseStock(int quantity) {
        // 重试机制,当因并发冲突失败时,可以重试
        int retryCount = 3;
        while (retryCount-- > 0) {
            // 1. 监视库存key
            jedis.watch(STOCK_KEY);

            // 2. 获取当前库存
            String stockStr = jedis.get(STOCK_KEY);
            if (stockStr == null) {
                jedis.unwatch();
                return false;
            }
            int stock = Integer.parseInt(stockStr);

            // 3. 检查库存是否充足
            if (stock < quantity) {
                jedis.unwatch();
                return false; // 库存不足,直接返回失败
            }

            // 4. 开启事务
            Transaction transaction = jedis.multi();
            // 5. 扣减库存
            transaction.decrBy(STOCK_KEY, quantity);
            // 6. 执行事务,如果在此期间key被其他客户端修改,exec会返回null
            List<Object> results = transaction.exec();

            // 7. 检查事务是否执行成功
            if (results != null && !results.isEmpty()) {
                // 执行成功
                return true;
            } else {
                // 执行失败,说明有其他客户端修改了key,进行重试
                System.out.println(Thread.currentThread().getName() + " 发生冲突,重试中... 剩余重试次数: " + retryCount);
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return false;
                }
            }
        }
        return false;
    }
}

5.2 Lettuce 实现乐观锁

Lettuce是一个高级的Redis客户端,支持同步、异步和响应式编程模型。其实现乐观锁的核心原理与Jedis类似,都是通过WATCH命令。

5.2.1 核心组件

  • StatefulRedisConnection:有状态的Redis连接,可以开启事务。
  • RedisCommands:同步或异步执行Redis命令的接口。

5.2.2 案例代码

java 复制代码
import io.lettuce.core.RedisClient;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.api.sync.RedisCommands;
import io.lettuce.core.transaction.TransactionResult;
import io.lettuce.core.transaction.TransactionalCommand;

import java.util.List;

public class LettuceOptimisticLockExample {

    private static final String STOCK_KEY = "product:2002:stock";
    private static RedisClient redisClient = RedisClient.create("redis://localhost:6379");

    public static void main(String[] args) {
        try (StatefulRedisConnection<String, String> connection = redisClient.connect()) {
            RedisCommands<String, String> syncCommands = connection.sync();
            syncCommands.set(STOCK_KEY, "10");

            // 模拟并发
            for (int i = 0; i < 10; i++) {
                new Thread(() -> {
                    boolean success = decreaseStock(connection, 1);
                    if (success) {
                        System.out.println(Thread.currentThread().getName() + " 扣减成功,剩余库存: " + syncCommands.get(STOCK_KEY));
                    } else {
                        System.out.println(Thread.currentThread().getName() + " 扣减失败");
                    }
                }).start();
            }
        }
    }

    private static boolean decreaseStock(StatefulRedisConnection<String, String> connection, int quantity) {
        RedisCommands<String, String> sync = connection.sync();
        int retryCount = 3;
        while (retryCount-- > 0) {
            // 1. 监视key
            sync.watch(STOCK_KEY);
            String stockStr = sync.get(STOCK_KEY);
            if (stockStr == null) {
                sync.unwatch();
                return false;
            }
            int stock = Integer.parseInt(stockStr);

            if (stock < quantity) {
                sync.unwatch();
                return false;
            }

            // 2. 开启事务
            sync.multi();
            // 3. 扣减库存
            sync.decrby(STOCK_KEY, quantity);

            // 4. 执行事务
            TransactionResult result = sync.exec();
            // 5. 检查结果
            if (result.wasDiscarded()) {
                // 事务被丢弃,说明有并发冲突
                System.out.println(Thread.currentThread().getName() + " 发生冲突,重试中...");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return false;
                }
            } else {
                // 执行成功
                return true;
            }
        }
        return false;
    }
}

5.3 Spring Data Redis 实现乐观锁

Spring Data Redis提供了RedisTemplate,通过SessionCallbackexecute方法可以执行事务操作,实现乐观锁。

5.3.1 核心组件

  • RedisTemplate:Spring Data Redis的核心模板类,封装了Redis操作。
  • SessionCallback:回调接口,可以在一个连接中执行多个操作,实现事务。
  • @Transactional :配合RedisTemplatesetEnableTransactionSupport(true)可以使用声明式事务,但乐观锁通常用编程式事务更灵活。

5.3.2 案例代码

java 复制代码
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.SessionCallback;
import org.springframework.stereotype.Service;

@Service
public class RedisOptimisticLockService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    private static final String STOCK_KEY = "product:3003:stock";

    /**
     * 使用乐观锁扣减库存
     * @param productId 商品ID
     * @param quantity 扣减数量
     * @return 是否成功
     */
    public boolean decreaseStock(String productId, int quantity) {
        String key = STOCK_KEY + ":" + productId;
        // 使用SessionCallback确保所有操作在同一个连接中执行,这是实现事务和WATCH的关键
        return Boolean.TRUE.equals(redisTemplate.execute(new SessionCallback<Boolean>() {
            @Override
            @SuppressWarnings("unchecked")
            public Boolean execute(org.springframework.data.redis.core.RedisOperations operations) {
                // 1. 监视key
                operations.watch(key);

                // 2. 获取当前库存
                String stockStr = (String) operations.opsForValue().get(key);
                if (stockStr == null) {
                    operations.unwatch();
                    return false;
                }
                int stock = Integer.parseInt(stockStr);

                if (stock < quantity) {
                    operations.unwatch();
                    return false;
                }

                // 3. 开启事务
                operations.multi();
                // 4. 扣减库存
                operations.opsForValue().increment(key, -quantity);

                // 5. 执行事务
                List<Object> results = operations.exec();
                // 6. 检查执行结果
                return results != null && !results.isEmpty();
            }
        }));
    }
}

5.4 Redis 发布订阅模式

发布订阅(Pub/Sub)是一种消息通信模式。发送者(Pub)发送消息到频道(Channel),订阅者(Sub)订阅频道并接收消息。Redis提供了这种模式,但消息不会持久化,即如果订阅者断开连接,则会错过消息。

5.4.1 核心命令

  1. SUBSCRIBE channel [channel ...]:订阅一个或多个频道。
  2. PUBLISH channel message:向指定频道发布消息。
  3. PSUBSCRIBE pattern [pattern ...] :订阅一个或多个模式(支持通配符*?等)的频道。
  4. UNSUBSCRIBE [channel ...]:退订频道。
  5. PUNSUBSCRIBE [pattern ...]:退订模式。

5.5 Lettuce 实现发布订阅模式

Lettuce通过RedisPubSubCommands接口和RedisPubSubListener监听器来实现发布订阅。

5.5.1 案例代码:消息订阅者

java 复制代码
import io.lettuce.core.RedisClient;
import io.lettuce.core.pubsub.RedisPubSubListener;
import io.lettuce.core.pubsub.StatefulRedisPubSubConnection;
import io.lettuce.core.pubsub.api.sync.RedisPubSubCommands;

public class LettuceSubscriber {

    public static void main(String[] args) throws InterruptedException {
        RedisClient redisClient = RedisClient.create("redis://localhost:6379");

        // 1. 创建一个支持发布订阅的有状态连接
        try (StatefulRedisPubSubConnection<String, String> connection = redisClient.connectPubSub()) {
            // 2. 添加监听器,处理接收到的消息
            connection.addListener(new RedisPubSubListener<String, String>() {
                @Override
                public void message(String channel, String message) {
                    System.out.println("收到消息 [频道:" + channel + "] -> 内容:" + message);
                }

                @Override
                public void subscribed(String channel, long count) {
                    System.out.println("订阅成功:" + channel + ",当前订阅总数:" + count);
                }

                @Override
                public void unsubscribed(String channel, long count) {
                    System.out.println("退订成功:" + channel + ",当前订阅总数:" + count);
                }

                @Override
                public void psubscribed(String pattern, long count) {
                    System.out.println("模式订阅成功:" + pattern);
                }

                @Override
                public void punsubscribed(String pattern, long count) {
                    System.out.println("模式退订成功:" + pattern);
                }

                @Override
                public void message(String pattern, String channel, String message) {
                    System.out.println("收到模式匹配消息 [模式:" + pattern + ", 频道:" + channel + "] -> 内容:" + message);
                }
            });

            // 3. 获取同步命令接口并订阅频道
            RedisPubSubCommands<String, String> sync = connection.sync();
            sync.subscribe("news", "sports");

            // 保持主线程运行,以便接收消息
            Thread.currentThread().join();
        }
    }
}

5.5.2 案例代码:消息发布者

java 复制代码
import io.lettuce.core.RedisClient;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.api.sync.RedisCommands;

public class LettucePublisher {

    public static void main(String[] args) {
        RedisClient redisClient = RedisClient.create("redis://localhost:6379");
        try (StatefulRedisConnection<String, String> connection = redisClient.connect()) {
            RedisCommands<String, String> sync = connection.sync();

            // 发布消息到频道
            Long result1 = sync.publish("news", "今天天气真好!");
            System.out.println("发布到 news 频道,收到消息的订阅者数量: " + result1);

            Long result2 = sync.publish("sports", "中国队获得金牌!");
            System.out.println("发布到 sports 频道,收到消息的订阅者数量: " + result2);
        }
    }
}

5.6 Spring Data Redis 实现发布订阅模式

Spring Data Redis通过RedisMessageListenerContainer容器和MessageListener接口实现发布订阅。

5.6.1 配置类

java 复制代码
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.PatternTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;

@Configuration
public class RedisPubSubConfig {

    // 1. 创建消息监听容器
    @Bean
    public RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory,
                                                   MessageListenerAdapter listenerAdapter) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        // 订阅一个或多个频道或模式
        container.addMessageListener(listenerAdapter, new PatternTopic("news"));
        container.addMessageListener(listenerAdapter, new PatternTopic("sports"));
        return container;
    }

    // 2. 创建消息监听器适配器,将消息委托给指定的bean的方法
    @Bean
    public MessageListenerAdapter listenerAdapter(RedisMessageReceiver receiver) {
        // 参数1:接收消息的bean,参数2:处理消息的方法名
        return new MessageListenerAdapter(receiver, "handleMessage");
    }
}

5.6.2 消息接收者

java 复制代码
import org.springframework.stereotype.Component;

@Component
public class RedisMessageReceiver {

    /**
     * 处理接收到的消息
     * @param message 消息内容
     * @param channel 频道名称
     */
    public void handleMessage(String message, String channel) {
        System.out.println("从频道 [" + channel + "] 接收到消息: " + message);
    }
}

5.6.3 消息发布者

java 复制代码
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

@Service
public class RedisMessagePublisher {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    /**
     * 发布消息到指定频道
     * @param channel 频道名
     * @param message 消息内容
     */
    public void publish(String channel, String message) {
        redisTemplate.convertAndSend(channel, message);
    }
}

5.7 Stream

Redis Stream是Redis 5.0引入的一种新的数据结构,它是一个消息队列,支持持久化、消费者组、消息确认等高级特性,解决了发布订阅模式消息不可靠的问题。

5.7.1 Stream 消息处理核心命令

  1. XADD key ID field value [field value ...] :向Stream添加消息。
    • ID:可以使用*让Redis自动生成。
  2. XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] ID [ID ...]:从一个或多个Stream中读取消息。
  3. XGROUP [CREATE key groupname id-or-][SETIDkeygroupnameid−or−] [SETID key groupname id-or-][SETIDkeygroupnameid−or−] [DESTROY key groupname] [DELCONSUMER key groupname consumername]:管理消费者组。
  4. XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] ID [ID ...]:从消费者组中读取消息。
  5. XACK key group ID [ID ...]:确认消息已被处理。
  6. XPENDING key group [[IDLE idle] start end count [consumer]]:查看待处理消息。
  7. XCLAIM key group consumer min-idle-time ID [ID ...] [IDLE ms] [TIME ms-unix-time] [RETRYCOUNT count] [FORCE]:转移未确认消息的所有权。

5.7.2 Stream 消费组

消费组允许多个消费者分摊Stream中的消息,每条消息只会被组内的一个消费者处理。这实现了消息的负载均衡和容错。

5.8 Lettuce 实现 Stream 机制

Lettuce提供了RedisStreamCommands接口来操作Stream。

5.8.1 案例代码:生产者

java 复制代码
import io.lettuce.core.RedisClient;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.api.sync.RedisCommands;
import io.lettuce.core.XAddArgs;
import io.lettuce.core.models.stream.StreamMessage;

public class LettuceStreamProducer {

    public static void main(String[] args) {
        RedisClient redisClient = RedisClient.create("redis://localhost:6379");
        try (StatefulRedisConnection<String, String> connection = redisClient.connect()) {
            RedisCommands<String, String> sync = connection.sync();
            String streamKey = "mystream";

            // 发送10条消息
            for (int i = 1; i <= 10; i++) {
                // XADD mystream * field1 value1 field2 value2
                XAddArgs args = XAddArgs.Builder.maxlen(1000); // 可选,限制stream最大长度
                String messageId = sync.xadd(streamKey, args, "orderId", String.valueOf(i), "amount", String.valueOf(i * 100));
                System.out.println("发送消息成功,ID: " + messageId);
            }
        }
    }
}

5.8.2 案例代码:消费者(使用消费组)

java 复制代码
import io.lettuce.core.RedisClient;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.api.sync.RedisCommands;
import io.lettuce.core.models.stream.StreamMessage;

import java.util.List;

public class LettuceStreamConsumer {

    public static void main(String[] args) {
        RedisClient redisClient = RedisClient.create("redis://localhost:6379");
        try (StatefulRedisConnection<String, String> connection = redisClient.connect()) {
            RedisCommands<String, String> sync = connection.sync();
            String streamKey = "mystream";
            String groupName = "mygroup";
            String consumerName = "consumer1";

            // 1. 创建消费组(如果不存在)
            try {
                sync.xgroupCreate(streamKey, groupName, "0", true); // 从开始位置创建
                System.out.println("消费组创建成功");
            } catch (Exception e) {
                System.out.println("消费组可能已存在: " + e.getMessage());
            }

            // 2. 循环读取消息
            while (true) {
                // XREADGROUP GROUP mygroup consumer1 BLOCK 1000 STREAMS mystream >
                // ">" 表示只读取从未投递给任何消费者的新消息
                List<StreamMessage<String, String>> messages = sync.xreadgroup(io.lettuce.core.Consumer.from(groupName, consumerName),
                        io.lettuce.core.XReadArgs.Builder.block(1000), io.lettuce.core.XReadArgs.StreamOffset.lastConsumed(streamKey));

                for (StreamMessage<String, String> msg : messages) {
                    System.out.println("消费者 " + consumerName + " 收到消息: ID=" + msg.getId() + ", 内容=" + msg.getBody());

                    // 3. 处理消息(模拟业务处理)
                    processMessage(msg);

                    // 4. 确认消息已处理 XACK mystream mygroup message-id
                    sync.xack(streamKey, groupName, msg.getId());
                    System.out.println("消息 " + msg.getId() + " 已确认");
                }
            }
        }
    }

    private static void processMessage(StreamMessage<String, String> msg) {
        try {
            // 模拟业务处理耗时
            Thread.sleep(100);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

5.9 Spring Data Redis 实现 Stream 机制

Spring Data Redis通过StreamOperationsStreamListenerStreamMessageListenerContainer来简化Stream操作。

5.9.1 配置类

java 复制代码
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.stream.*;
import org.springframework.data.redis.stream.StreamMessageListenerContainer;

import java.time.Duration;

@Configuration
public class RedisStreamConfig {

    @Bean
    public StreamMessageListenerContainer<String, ObjectRecord<String, String>> streamMessageListenerContainer(
            RedisConnectionFactory connectionFactory) {

        // 1. 配置容器选项
        StreamMessageListenerContainer.StreamMessageListenerContainerOptions<String, ObjectRecord<String, String>> options =
                StreamMessageListenerContainer.StreamMessageListenerContainerOptions.builder()
                        .pollTimeout(Duration.ofSeconds(1)) // 轮询超时时间
                        .build();

        // 2. 创建容器
        StreamMessageListenerContainer<String, ObjectRecord<String, String>> container =
                StreamMessageListenerContainer.create(connectionFactory, options);

        // 3. 配置订阅(使用消费组)
        Subscription subscription = container.receiveAutoAck(
                Consumer.from("mygroup", "consumer1"), // 消费组和消费者
                StreamOffset.create("mystream", ReadOffset.lastConsumed()), // 从上次消费的位置开始
                message -> {
                    System.out.println("收到消息: " + message);
                    // 业务处理
                }
        );
        subscription.start();

        return container;
    }
}

5.9.2 生产者

java 复制代码
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StreamOperations;
import org.springframework.data.redis.connection.stream.RecordId;
import org.springframework.data.redis.connection.stream.StreamRecords;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.Map;

@Service
public class RedisStreamProducer {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    public void sendMessage(String orderId, String amount) {
        StreamOperations<String, String, String> streamOps = redisTemplate.opsForStream();

        // 创建消息记录
        Map<String, String> messageMap = new HashMap<>();
        messageMap.put("orderId", orderId);
        messageMap.put("amount", amount);

        // XADD mystream * orderId 1001 amount 100
        RecordId recordId = streamOps.add(StreamRecords.newRecord().in("mystream").ofMap(messageMap));
        System.out.println("消息发送成功,ID: " + recordId);
    }
}

5.10 Lua 脚本

Lua脚本允许在Redis服务器端执行原子性操作。通过EVALEVALSHA命令执行Lua脚本,可以保证脚本中的多个Redis命令要么全部执行,要么全部不执行,并且不会被其他客户端的命令打断。

5.10.1 Lua 核心语法

  1. 变量local name = "Redis"
  2. 控制流if ... then ... elseif ... then ... else ... end
  3. 循环while ... do ... endfor i=1,10 do ... end
  4. 函数function add(a,b) return a+b end
  5. local user = {name="Tom", age=20}user.ageuser["age"]
  6. 调用Redis命令redis.call('SET', key, value)redis.pcall('GET', key)

5.10.2 Redis 执行 Lua 程序

使用 EVAL 命令

bash 复制代码
# EVAL script numkeys key [key ...] arg [arg ...]
EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey "Hello Lua"

使用 EVALSHA 命令 (先通过SCRIPT LOAD加载脚本获取SHA1值,提高效率)

bash 复制代码
SCRIPT LOAD "return redis.call('SET', KEYS[1], ARGV[1])"
# 返回: "d5c2b3b6c2f1c0a3b1f7e8d1b9c2f6a3e4d5c7b9"
EVALSHA d5c2b3b6c2f1c0a3b1f7e8d1b9c2f6a3e4d5c7b9 1 mykey "Hello SHA"

5.10.3 Redis 实现商品定时抢购案例

使用Lua脚本保证扣减库存和记录订单的原子性,并支持定时开始和结束(通过判断时间戳)。

Lua脚本:seckill.lua

lua 复制代码
-- seckill.lua
-- KEYS[1]: 商品库存key
-- KEYS[2]: 商品已售key
-- KEYS[3]: 用户已购买记录key (set)
-- ARGV[1]: 商品ID
-- ARGV[2]: 用户ID
-- ARGV[3]: 抢购开始时间戳 (毫秒)
-- ARGV[4]: 抢购结束时间戳 (毫秒)
-- ARGV[5]: 当前时间戳 (毫秒)
-- ARGV[6]: 限购数量

local stock_key = KEYS[1]
local sold_key = KEYS[2]
local user_key = KEYS[3]

local product_id = ARGV[1]
local user_id = ARGV[2]
local start_time = tonumber(ARGV[3])
local end_time = tonumber(ARGV[4])
local now_time = tonumber(ARGV[5])
local limit_qty = tonumber(ARGV[6])

-- 1. 检查抢购时间
if now_time < start_time then
    return -1  -- 未开始
end
if now_time > end_time then
    return -2  -- 已结束
end

-- 2. 检查用户是否已购买过
local user_buy_count = redis.call('SCARD', user_key)  -- 假设一个用户只能抢一次
if user_buy_count >= limit_qty then
    return -3  -- 超过限购
end

-- 3. 检查库存并扣减
local stock = tonumber(redis.call('GET', stock_key))
if not stock or stock <= 0 then
    return -4  -- 库存不足
end

-- 扣减库存
redis.call('DECR', stock_key)
-- 增加已售数量
redis.call('INCR', sold_key)
-- 记录用户购买
redis.call('SADD', user_key, user_id)

-- 返回成功标志和剩余库存
return {0, redis.call('GET', stock_key)}

Java调用代码

java 复制代码
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;

import java.util.Arrays;
import java.util.List;

public class SeckillService {

    private static final String LUA_SCRIPT =
            "local stock_key = KEYS[1]\n" +
            "local sold_key = KEYS[2]\n" +
            "local user_key = KEYS[3]\n" +
            "local product_id = ARGV[1]\n" +
            "local user_id = ARGV[2]\n" +
            "local start_time = tonumber(ARGV[3])\n" +
            "local end_time = tonumber(ARGV[4])\n" +
            "local now_time = tonumber(ARGV[5])\n" +
            "local limit_qty = tonumber(ARGV[6])\n" +
            "if now_time < start_time then\n" +
            "    return -1\n" +
            "end\n" +
            "if now_time > end_time then\n" +
            "    return -2\n" +
            "end\n" +
            "local user_buy_count = redis.call('SCARD', user_key)\n" +
            "if user_buy_count >= limit_qty then\n" +
            "    return -3\n" +
            "end\n" +
            "local stock = tonumber(redis.call('GET', stock_key))\n" +
            "if not stock or stock <= 0 then\n" +
            "    return -4\n" +
            "end\n" +
            "redis.call('DECR', stock_key)\n" +
            "redis.call('INCR', sold_key)\n" +
            "redis.call('SADD', user_key, user_id)\n" +
            "return {0, redis.call('GET', stock_key)}";

    private JedisPool jedisPool;

    public SeckillService(JedisPool jedisPool) {
        this.jedisPool = jedisPool;
    }

    public int seckill(String productId, String userId, long startTime, long endTime, int limitQty) {
        try (Jedis jedis = jedisPool.getResource()) {
            String stockKey = "product:" + productId + ":stock";
            String soldKey = "product:" + productId + ":sold";
            String userKey = "product:" + productId + ":users";

            long nowTime = System.currentTimeMillis();

            List<Object> result = (List<Object>) jedis.eval(LUA_SCRIPT,
                    Arrays.asList(stockKey, soldKey, userKey),
                    Arrays.asList(productId, userId, String.valueOf(startTime), String.valueOf(endTime),
                            String.valueOf(nowTime), String.valueOf(limitQty)));

            if (result.get(0) instanceof Long) {
                int code = ((Long) result.get(0)).intValue();
                if (code == 0) {
                    System.out.println("抢购成功!剩余库存: " + result.get(1));
                } else {
                    System.out.println("抢购失败,错误码: " + code);
                }
                return code;
            } else {
                return -999; // 脚本执行异常
            }
        }
    }
}

5.11 Redis 流量限制(漏桶算法)

使用Lua脚本实现一个简单的漏桶算法,限制API的访问频率。

5.11.1 Lua 脚本:rate_limit.lua

lua 复制代码
-- rate_limit.lua
-- KEYS[1]: 限流key
-- ARGV[1]: 漏桶容量 (最大请求数)
-- ARGV[2]: 漏水速率 (每秒处理请求数)
-- ARGV[3]: 当前时间戳 (秒)
-- ARGV[4]: 本次请求数量 (通常为1)
-- 返回值: 0-拒绝, 1-允许

local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

-- 获取当前桶中的水量和上次请求时间
local last_time = redis.call('HGET', key, 'last_time')
local water = redis.call('HGET', key, 'water')

-- 初始化
if last_time == false then
    water = 0
    last_time = now
else
    water = tonumber(water)
    last_time = tonumber(last_time)
end

-- 计算漏掉的水量
local elapsed = now - last_time
local leaked = elapsed * rate
if leaked > 0 then
    water = math.max(0, water - leaked)
end

-- 更新上次请求时间
redis.call('HSET', key, 'last_time', now)

-- 判断是否允许请求
if water + requested <= capacity then
    water = water + requested
    redis.call('HSET', key, 'water', water)
    redis.call('EXPIRE', key, 60)  -- 设置过期时间,避免无限占用内存
    return 1
else
    return 0
end

Java调用代码

java 复制代码
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;

import java.util.Arrays;

public class RateLimiter {

    private JedisPool jedisPool;
    private String luaSha; // 脚本的SHA1值

    public RateLimiter(JedisPool jedisPool) {
        this.jedisPool = jedisPool;
        loadLuaScript();
    }

    private void loadLuaScript() {
        try (Jedis jedis = jedisPool.getResource()) {
            String script = 
                "local key = KEYS[1]\n" +
                "local capacity = tonumber(ARGV[1])\n" +
                "local rate = tonumber(ARGV[2])\n" +
                "local now = tonumber(ARGV[3])\n" +
                "local requested = tonumber(ARGV[4])\n" +
                "local last_time = redis.call('HGET', key, 'last_time')\n" +
                "local water = redis.call('HGET', key, 'water')\n" +
                "if last_time == false then\n" +
                "    water = 0\n" +
                "    last_time = now\n" +
                "else\n" +
                "    water = tonumber(water)\n" +
                "    last_time = tonumber(last_time)\n" +
                "end\n" +
                "local elapsed = now - last_time\n" +
                "local leaked = elapsed * rate\n" +
                "if leaked > 0 then\n" +
                "    water = math.max(0, water - leaked)\n" +
                "end\n" +
                "redis.call('HSET', key, 'last_time', now)\n" +
                "if water + requested <= capacity then\n" +
                "    water = water + requested\n" +
                "    redis.call('HSET', key, 'water', water)\n" +
                "    redis.call('EXPIRE', key, 60)\n" +
                "    return 1\n" +
                "else\n" +
                "    return 0\n" +
                "end";
            luaSha = jedis.scriptLoad(script);
        }
    }

    public boolean allowRequest(String key, int capacity, int rate) {
        try (Jedis jedis = jedisPool.getResource()) {
            long now = System.currentTimeMillis() / 1000;
            Object result = jedis.evalsha(luaSha, 1, key, 
                    String.valueOf(capacity), String.valueOf(rate), 
                    String.valueOf(now), "1");
            return (Long) result == 1;
        }
    }
}

5.12 Function

Redis 7.0引入了Functions特性,允许将Lua脚本作为函数存储在Redis服务器中,并提供更强大的管理能力,如库管理、函数列表、复制等,是对EVALSCRIPT命令的改进。

5.12.1 核心命令

  1. FUNCTION LOAD:加载函数库。
  2. FCALL:调用一个函数。
  3. FUNCTION LIST:列出所有函数。
  4. FUNCTION DELETE:删除一个函数。
  5. FUNCTION KILL:终止正在运行的函数。

5.12.2 案例代码:使用 Function

创建并加载一个Function(Lua脚本)

lua 复制代码
# mylib.lua
# 定义一个名为 'add' 的函数,接收两个参数,返回它们的和
#!lua name=mylib

local function add(keys, args)
    return args[1] + args[2]
end

-- 注册函数到Redis
redis.register_function('add', add)

在Redis客户端加载

bash 复制代码
# 加载函数库
redis-cli FUNCTION LOAD REPLACE "$(cat mylib.lua)"
# 输出: mylib

调用函数

bash 复制代码
# 调用函数,keys参数为空,args为两个数字
redis-cli FCALL add 0 10 20
# 输出: 30

5.13 抢红包案例分析

5.13.1 红包拆分

将总金额拆分成多个小红包,存入Redis List中。

java 复制代码
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;

public class RedPacketSplitter {

    /**
     * 将总金额拆分成指定数量的红包
     * @param totalAmount 总金额(单位:分)
     * @param num 红包数量
     * @return 红包金额列表(单位:分)
     */
    public static List<Integer> splitRedPacket(int totalAmount, int num) {
        List<Integer> amounts = new ArrayList<>();
        int remainingAmount = totalAmount;
        int remainingNum = num;
        Random random = new Random();

        for (int i = 0; i < num - 1; i++) {
            // 保证每个红包至少1分,且剩余金额足够
            int max = remainingAmount - (remainingNum - 1);
            int amount = random.nextInt(max) + 1;
            amounts.add(amount);
            remainingAmount -= amount;
            remainingNum--;
        }
        // 最后一个红包拿剩余的所有金额
        amounts.add(remainingAmount);
        return amounts;
    }

    public static void main(String[] args) {
        int total = 10000; // 100元 = 10000分
        int count = 10;
        List<Integer> amounts = splitRedPacket(total, count);
        System.out.println("红包金额列表: " + amounts);
        int sum = amounts.stream().mapToInt(Integer::intValue).sum();
        System.out.println("总金额: " + sum + " 分");
    }
}

5.13.2 红包创建

将拆分好的红包列表存入Redis的List结构中。

java 复制代码
import redis.clients.jedis.Jedis;

public class RedPacketCreator {

    private Jedis jedis;

    public RedPacketCreator(Jedis jedis) {
        this.jedis = jedis;
    }

    /**
     * 创建红包
     * @param redPacketId 红包ID
     * @param amounts 红包金额列表
     */
    public void createRedPacket(String redPacketId, List<Integer> amounts) {
        String key = "redpacket:" + redPacketId + ":list";
        // 将红包金额存入List
        for (Integer amount : amounts) {
            jedis.lpush(key, String.valueOf(amount));
        }
        // 记录红包总数
        jedis.set("redpacket:" + redPacketId + ":total", String.valueOf(amounts.size()));
        // 记录红包初始状态
        jedis.set("redpacket:" + redPacketId + ":status", "active");
        // 设置过期时间,例如24小时
        jedis.expire(key, 24 * 3600);
        System.out.println("红包创建成功,ID: " + redPacketId + ", 红包个数: " + amounts.size());
    }
}

5.13.3 红包争抢(Lua脚本保证原子性)

使用Lua脚本从List中弹出一个红包并记录抢红包记录。

lua 复制代码
-- grab_redpacket.lua
-- KEYS[1]: 红包列表key
-- KEYS[2]: 红包记录key (hash)
-- KEYS[3]: 红包已抢数量key
-- ARGV[1]: 红包ID
-- ARGV[2]: 用户ID
-- ARGV[3]: 当前时间戳

local list_key = KEYS[1]
local record_key = KEYS[2]
local count_key = KEYS[3]
local redpacket_id = ARGV[1]
local user_id = ARGV[2]
local timestamp = ARGV[3]

-- 1. 检查用户是否已经抢过这个红包
local user_exists = redis.call('HEXISTS', record_key, user_id)
if user_exists == 1 then
    return -1  -- 用户已抢过
end

-- 2. 从列表中弹出一个红包金额
local amount = redis.call('LPOP', list_key)
if not amount then
    return -2  -- 红包已抢完
end

-- 3. 记录用户抢到的金额
redis.call('HSET', record_key, user_id, amount)
-- 4. 增加已抢数量
local grabbed_count = redis.call('INCR', count_key)
-- 5. 如果红包已抢完,更新状态
local total = redis.call('GET', 'redpacket:' .. redpacket_id .. ':total')
if grabbed_count >= tonumber(total) then
    redis.call('SET', 'redpacket:' .. redpacket_id .. ':status', 'finished')
end

-- 返回抢到的金额
return amount

Java调用代码

java 复制代码
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;

import java.util.Arrays;

public class RedPacketGrabber {

    private JedisPool jedisPool;
    private String luaSha;

    public RedPacketGrabber(JedisPool jedisPool) {
        this.jedisPool = jedisPool;
        loadLuaScript();
    }

    private void loadLuaScript() {
        try (Jedis jedis = jedisPool.getResource()) {
            String script = 
                "local list_key = KEYS[1]\n" +
                "local record_key = KEYS[2]\n" +
                "local count_key = KEYS[3]\n" +
                "local redpacket_id = ARGV[1]\n" +
                "local user_id = ARGV[2]\n" +
                "local timestamp = ARGV[3]\n" +
                "local user_exists = redis.call('HEXISTS', record_key, user_id)\n" +
                "if user_exists == 1 then\n" +
                "    return -1\n" +
                "end\n" +
                "local amount = redis.call('LPOP', list_key)\n" +
                "if not amount then\n" +
                "    return -2\n" +
                "end\n" +
                "redis.call('HSET', record_key, user_id, amount)\n" +
                "local grabbed_count = redis.call('INCR', count_key)\n" +
                "local total = redis.call('GET', 'redpacket:' .. redpacket_id .. ':total')\n" +
                "if grabbed_count >= tonumber(total) then\n" +
                "    redis.call('SET', 'redpacket:' .. redpacket_id .. ':status', 'finished')\n" +
                "end\n" +
                "return amount";
            luaSha = jedis.scriptLoad(script);
        }
    }

    public int grabRedPacket(String redPacketId, String userId) {
        try (Jedis jedis = jedisPool.getResource()) {
            String listKey = "redpacket:" + redPacketId + ":list";
            String recordKey = "redpacket:" + redPacketId + ":records";
            String countKey = "redpacket:" + redPacketId + ":grabbed";

            Object result = jedis.evalsha(luaSha, 3, listKey, recordKey, countKey,
                    redPacketId, userId, String.valueOf(System.currentTimeMillis()));

            if (result instanceof Long) {
                int code = ((Long) result).intValue();
                if (code == -1) {
                    System.out.println("用户 " + userId + " 已经抢过红包了!");
                } else if (code == -2) {
                    System.out.println("红包已抢完!");
                }
                return code;
            } else {
                int amount = Integer.parseInt((String) result);
                System.out.println("用户 " + userId + " 抢到 " + amount + " 分!");
                return amount;
            }
        }
    }
}

5.14 应用灰度发布案例

5.14.1 灰度发布原理

灰度发布是一种平滑过渡的发布方式,让一部分用户继续使用旧版本,一部分用户开始使用新版本,如果新版本没有问题,再逐步扩大范围,最终将所有用户迁移到新版本。使用Redis可以存储灰度规则(如白名单用户、流量比例等)。

5.14.2 OpenResty 服务安装

OpenResty是一个基于Nginx与Lua的高性能Web平台,可以结合Redis实现灰度发布。

安装步骤(Ubuntu)

bash 复制代码
# 1. 安装依赖
sudo apt-get install -y libpcre3-dev libssl-dev perl make build-essential curl

# 2. 下载OpenResty源码
wget https://openresty.org/download/openresty-1.21.4.1.tar.gz
tar -xzf openresty-1.21.4.1.tar.gz
cd openresty-1.21.4.1

# 3. 配置并编译安装
./configure --prefix=/usr/local/openresty --with-http_stub_status_module --with-http_ssl_module
make
sudo make install

# 4. 添加环境变量
echo 'export PATH=/usr/local/openresty/nginx/sbin:$PATH' >> ~/.bashrc
source ~/.bashrc

5.14.3 resty.redis 模块

OpenResty自带了resty.redis模块,用于在Lua脚本中访问Redis。

基本使用示例

lua 复制代码
local redis = require "resty.redis"
local red = redis:new()

-- 设置连接超时时间
red:set_timeout(1000)

-- 连接Redis
local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
    ngx.say("failed to connect: ", err)
    return
end

-- 执行Redis命令
local res, err = red:get("mykey")
if not res then
    ngx.say("failed to get: ", err)
    return
end

ngx.say("value: ", res)

-- 释放连接
red:set_keepalive(10000, 100)

5.14.4 灰度发布实现

场景:根据用户ID或IP,决定转发到旧版本服务器还是新版本服务器。

OpenResty 配置:nginx.conf

nginx 复制代码
http {
    # 定义上游服务器组
    upstream old_version {
        server 192.168.1.10:8080;
    }
    upstream new_version {
        server 192.168.1.11:8080;
    }

    server {
        listen 80;
        server_name myapp.com;

        location / {
            # 灰度判断逻辑
            access_by_lua_block {
                -- 引入redis模块
                local redis = require "resty.redis"
                local red = redis:new()
                red:set_timeout(1000)

                -- 连接Redis
                local ok, err = red:connect("127.0.0.1", 6379)
                if not ok then
                    ngx.log(ngx.ERR, "Redis连接失败: ", err)
                    -- 连接失败,默认走旧版本
                    ngx.ctx.use_new_version = false
                    return
                end

                -- 1. 获取用户标识,例如Cookie中的userId或IP
                local user_id = ngx.var.remote_addr -- 使用IP作为示例,实际可用Cookie
                
                -- 2. 从Redis获取灰度规则
                -- 规则1:白名单用户直接走新版本
                local is_whitelist, err = red:sismember("gray:whitelist", user_id)
                if is_whitelist == 1 then
                    ngx.ctx.use_new_version = true
                    red:set_keepalive(10000, 100)
                    return
                end

                -- 规则2:根据百分比灰度(例如20%的用户走新版本)
                local ratio_key = "gray:ratio"
                local ratio, err = red:get(ratio_key)
                if ratio then
                    ratio = tonumber(ratio)
                    -- 使用用户ID的哈希值取模
                    local hash = ngx.crc32_short(user_id)
                    if hash % 100 < ratio then
                        ngx.ctx.use_new_version = true
                        red:set_keepalive(10000, 100)
                        return
                    end
                end

                -- 默认走旧版本
                ngx.ctx.use_new_version = false
                red:set_keepalive(10000, 100)
            }

            # 根据灰度结果选择上游
            set $backend "old_version";
            if (ngx.ctx.use_new_version) {
                set $backend "new_version";
            }
            proxy_pass http://$backend;
        }
    }
}

Redis 灰度规则设置

bash 复制代码
# 设置灰度比例为20%
redis-cli SET gray:ratio 20

# 添加白名单用户
redis-cli SADD gray:whitelist "192.168.1.100"
redis-cli SADD gray:whitelist "10.0.0.50"

本章概览

本章系统介绍了Redis的高级编程特性,主要内容包括:

  1. 并发控制:通过乐观锁(WATCH/MULTI/EXEC)和Lua脚本实现原子性操作,保证数据一致性。
  2. 消息队列:深入探讨了发布订阅模式(Pub/Sub)和Stream数据结构,并对比了它们的优缺点。Stream提供了持久化、消费组、消息确认等特性,适用于可靠的消息处理场景。
  3. Lua脚本:作为Redis实现复杂原子操作的核心工具,详细讲解了Lua语法、脚本加载和执行,并给出了抢购、限流等实战案例。
  4. Redis Functions:作为Lua脚本的升级版,提供了更强大的函数管理能力。
  5. 综合实战:结合抢红包和灰度发布两个完整案例,展示了如何综合运用Redis的各种特性来解决实际业务问题。

通过本章的学习,读者应能掌握Redis的高级应用技巧,能够独立设计并实现高性能、高可用的Redis解决方案。

相关推荐
MekoLi292 小时前
MongoDB 新手完全指南:从入门到精通的实战手册
数据库·后端
cyforkk2 小时前
Spring AOP 进阶:揭秘 @annotation 参数绑定的底层逻辑
java·数据库·spring
夏日听雨眠2 小时前
Linux学习1
linux·服务器·学习
朗迹 - 张伟2 小时前
UE5 C++学习笔记
c++·学习·ue5
今天减肥吗2 小时前
前端面试学习流程
学习
2401_884970612 小时前
用Pygame开发你的第一个小游戏
jvm·数据库·python
麦聪聊数据2 小时前
快速将Oracle数据库发布为 API:使用 QuickAPI 实现 SQL2API
数据库·sql·低代码·oracle·restful
arvin_xiaoting2 小时前
OpenClaw学习总结_I_核心架构系列_AgentLoop详解
java·学习·架构·llm·ai-agent·飞书机器人·openclaw
2501_918126912 小时前
学习所有用c语言定义stm32的语句
c语言·stm32·嵌入式硬件·学习·个人开发