Redis进阶编程知识点详解
5.1 Redis 乐观锁
乐观锁是一种并发控制机制,它假设多用户并发操作时冲突很少发生,因此不在操作数据时加锁,而是在更新数据时检查数据是否被其他线程修改过。Redis通过WATCH、MULTI、EXEC和DISCARD命令实现乐观锁。
5.1.1 核心语法
- WATCH key [key ...]:监视一个或多个key,如果在执行事务之前被其他客户端修改,则事务将被中断。
- MULTI:标记一个事务块的开始。后续的命令会被排队,直到执行EXEC。
- EXEC :执行所有事务块内的命令。如果WATCH的key被修改,EXEC将返回
(nil)。 - 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,通过SessionCallback或execute方法可以执行事务操作,实现乐观锁。
5.3.1 核心组件
- RedisTemplate:Spring Data Redis的核心模板类,封装了Redis操作。
- SessionCallback:回调接口,可以在一个连接中执行多个操作,实现事务。
- @Transactional :配合
RedisTemplate的setEnableTransactionSupport(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 核心命令
- SUBSCRIBE channel [channel ...]:订阅一个或多个频道。
- PUBLISH channel message:向指定频道发布消息。
- PSUBSCRIBE pattern [pattern ...] :订阅一个或多个模式(支持通配符
*、?等)的频道。 - UNSUBSCRIBE [channel ...]:退订频道。
- 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 消息处理核心命令
- XADD key ID field value [field value ...] :向Stream添加消息。
- ID:可以使用
*让Redis自动生成。
- ID:可以使用
- XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] ID [ID ...]:从一个或多个Stream中读取消息。
- XGROUP [CREATE key groupname id-or-][SETIDkeygroupnameid−or−] [SETID key groupname id-or-][SETIDkeygroupnameid−or−] [DESTROY key groupname] [DELCONSUMER key groupname consumername]:管理消费者组。
- XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] ID [ID ...]:从消费者组中读取消息。
- XACK key group ID [ID ...]:确认消息已被处理。
- XPENDING key group [[IDLE idle] start end count [consumer]]:查看待处理消息。
- 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通过StreamOperations、StreamListener和StreamMessageListenerContainer来简化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服务器端执行原子性操作。通过EVAL或EVALSHA命令执行Lua脚本,可以保证脚本中的多个Redis命令要么全部执行,要么全部不执行,并且不会被其他客户端的命令打断。
5.10.1 Lua 核心语法
- 变量 :
local name = "Redis" - 控制流 :
if ... then ... elseif ... then ... else ... end - 循环 :
while ... do ... end,for i=1,10 do ... end - 函数 :
function add(a,b) return a+b end - 表 :
local user = {name="Tom", age=20},user.age或user["age"] - 调用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服务器中,并提供更强大的管理能力,如库管理、函数列表、复制等,是对EVAL和SCRIPT命令的改进。
5.12.1 核心命令
- FUNCTION LOAD:加载函数库。
- FCALL:调用一个函数。
- FUNCTION LIST:列出所有函数。
- FUNCTION DELETE:删除一个函数。
- 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的高级编程特性,主要内容包括:
- 并发控制:通过乐观锁(WATCH/MULTI/EXEC)和Lua脚本实现原子性操作,保证数据一致性。
- 消息队列:深入探讨了发布订阅模式(Pub/Sub)和Stream数据结构,并对比了它们的优缺点。Stream提供了持久化、消费组、消息确认等特性,适用于可靠的消息处理场景。
- Lua脚本:作为Redis实现复杂原子操作的核心工具,详细讲解了Lua语法、脚本加载和执行,并给出了抢购、限流等实战案例。
- Redis Functions:作为Lua脚本的升级版,提供了更强大的函数管理能力。
- 综合实战:结合抢红包和灰度发布两个完整案例,展示了如何综合运用Redis的各种特性来解决实际业务问题。
通过本章的学习,读者应能掌握Redis的高级应用技巧,能够独立设计并实现高性能、高可用的Redis解决方案。