redis 客户端介绍
jedis 的使用
快速入门
引入依赖
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>4.3.0</version>
</dependency>
连接步骤--
- 引入依赖
- 建立连接
- 释放资源
private Jedis jedis;
@BeforeEach
void before() {
/*建立连接*/
jedis = new Jedis("192.168.122.1", 6379);
/*输入密码*/
jedis.auth("1234");
/*选择库*/
jedis.select(0);
}
@Test
void contextLoads() {
/*设置值,返回ok表示成功*/
String names = jedis.set("names", "123");
/*得到值*/
String names1 = jedis.get("names");
System.out.println("names = " + names);
System.out.println("names1 = " + names1);
}
建立连接
// 参数1 表示连接地址,参数2 是端口号
private Jedis new Jedis("192.168.122.1", 6379);
// 输入密码
jedis.auth("1234");
// 选择库第 0个库
jedis.select(0);
Hset的使用
//添加值
jedis.hset("user:1","name","lack");
jedis.hset("user:1", "age","18");
// 打印出来
Map<String, String> stringStringMap = jedis.hgetAll("user:1");
System.out.println(stringStringMap);
jedis 连接池
- 首先创建公共静态类,类里配置信息,并返回jedis对象
import java.time.Duration;
public class JedisConnectionFactory {
// 创建对象
private static final JedisPool JEDIS_POOL;
static {
// 配置连接池
JedisPoolConfig poolConfig = new JedisPoolConfig();
// 最大连接数
poolConfig.setMaxTotal(2);
// 最大空闲连接
poolConfig.setMaxIdle(2);
// 最小空闲连接
poolConfig.setMinIdle(0);
// 设置最长等待时间,ms
// poolConfig.setMaxWaitMillis(200); 弃用
poolConfig.setMaxWait(Duration.ofSeconds(5));
// 创建连接池对象, 参数1:连接池,参数2:端口号,参数3,超时时间,连接密码
JEDIS_POOL = new JedisPool(poolConfig, "192.168.122.1", 6379, 1000, "1234");
}
public static Jedis getJedis() {
// 获取jedis对象
return JEDIS_POOL.getResource();
}
}
SpringDataRedis
SpringData是Spring中数据操作的模块,包含对各种数据库的集成,其中对Redis的集成模块就叫做SpringDataRedis,官网地址:spring.io/projects/sp…
提供了对不同Redis客户端的整合(Lettuce和Jedis)
提供了RedisTemplate统一API来操作Redis
支持Redis的发布订阅模型
支持Redis哨兵和Redis集群
支持基于Lettuce的响应式编程
支持基于JDK、JSON、字符串、Spring对象的数据序列化及反序列化
支持基于Redis的JDKCollection实现
快速入门
引入依赖
redis 依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
连接池依赖
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
SpringDataRedis 默认使用的是 lettuce 客户端
需要改变要先导入 jedis 依赖
注意:必须关掉 lettuce-core包
spring:
redis:
host: 192.168.122.1
port: 6379
password: 1234
jedis:
pool:
# 最大连接
max-active: 8
# 最大空闲连接
max-idle: 8
# 最小空闲
min-idle: 0
# 连接等待时间
max-wait: 100ms
# 选择库
database: 0
测试类
@Autowired
private RedisTemplate redisTemplate;
@Test
void contextLoads() {
// 存储一个值 string
redisTemplate.opsForValue().set("name","胡歌");
Object name = redisTemplate.opsForValue().get("name");
System.out.println("name = " + name);
}
RedisSerializer 序列化
上面快速入门写完,发现在数据库里存入的数据并没有被修改。
序列化方式,会把java对象转换成字节数组,存入到 redis。 序列化默认是jdk的 ObjectOutputStream
- ObjectOutputStream:就是把java对象转成字节
那么大家思考一下,这种方式有什么问题吗?
缺点可读性差、内存占用较大
所以我们必须要改变序列化方式
序列化方式 | |
---|---|
StringRedisSerializer | key 都是字符串的时候用它 |
GenericJackson2JsonRedisSerializer | 转 json 字符串的工具,值用这个 |
修改Serializer(方式一)
首先创建类,配置好 Serializer
@Configuration
public class redisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
// 创建 RedisTemplate 对象
RedisTemplate<String, Object> template = new RedisTemplate<>();
// 设置连接工程
template.setConnectionFactory(connectionFactory);
// 创建JSON序列化工具
GenericJackson2JsonRedisSerializer jsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
// 设置key的序列化,分别是Hash 和 string
template.setKeySerializer(RedisSerializer.string());
template.setHashKeySerializer(RedisSerializer.string());
// 设置value的序列化,分别是Hash 和 string
template.setValueSerializer(jsonRedisSerializer);
template.setHashValueSerializer(jsonRedisSerializer);
return template;
}
}
这里会报错因为没有引用 jackson-databind 依赖啊 使用了这个 GenericJackson2JsonRedisSerializer
发现这个时候,可以存进去了,存到 redis里是这样的
@Test
void testSaveUser() {
// 写入数据
redisTemplate.opsForValue().set("user:100", new User("胡歌", 22));
// 获取数据
User o = (User) redisTemplate.opsForValue().get("user:100");
System.out.println("o = " + o);
}
正是因为有@class行才可以反序列成对应的类型
修改Serializer(方式二)
方式一是有缺点的,因为数据库会存入java对象的类型,会占用大量的内存空间。
我们并不会使用JSON序列化来处理value,所以会手动完成对象的序列化。key和value都使用string进行存储
使用 StringRedisTemplate,它默认key和value都是string类型 不用写配置类了。程序员自己知道自己转什么类型即可
@Test
void testSaveUser() throws JsonProcessingException {
// 写入数据
User user = new User("胡歌", 22);
// 转JSON
String json = MAPPER.writeValueAsString(user);
redisTemplate.opsForValue().set("user:200", json);
// 获取数据
String jsonUser = redisTemplate.opsForValue().get("user:200");
// 手动反序列化
User user1 = MAPPER.readValue(jsonUser, User.class);
System.out.println("user1 = " + user1);
}
Hash存值取值
@Test
void testHash(){
// 存值
redisTemplate.opsForHash().put("user:400","name","胡歌");
redisTemplate.opsForHash().put("user:400","age","18");
// 取值
Map<Object, Object> map = redisTemplate.opsForHash().entries("user:400");
System.out.println("map = " + map);
}
redis 实战演练
短信登录的实现
基于 Session 的实现
ThreadLocal通常用于解决线程安全问题,特别是在多线程共享数据的情况下。通过将共享数据存储在ThreadLocal中,每个线程都有自己独立的副本,从而避免了多线程之间的竞争和同步问题。(使用 Map 存储数据)
使用拦截器,在请求之前先进行拦截处理
直接从 userHolder 中的 Threadlocal 中获取当前线程的用户对象
session共享问题:多台Tomcat并不共享session存储空间
当请求切换到不同tomcat服务时导致数据丢失的问题。
session的替代方案应该满足:
- 数据共享
- 内存存储
- key、value结构
注入对象
@Autowired
private StringRedisTemplate stringRedisTemplate;
存储字符串
- 参数1:key
- 参数2:value
- 参数3:时间
- 参数4:时间单位
// 设置
stringRedisTemplate.opsForValue().set(phone, numbers, 2, TimeUnit.MINUTES);
获取字符串
- 参数:key
// 获取
String code = stringRedisTemplate.opsForValue().get("code");
存储 hash
putAll: 根据map对象,将 key 和 value 全部存储为hash
- 参数1:key
- 参数2:map集合
Map<String, Object> map = BeanUtil.beanToMap(userDTO);
stringRedisTemplate.opsForHash().putAll(token, map);
给 key 设置有效期
- 参数1:key
- 参数2:时间
- 参数3:时间单位
// 将 token key 设置 30 分钟有效期
stringRedisTemplate.expire(token, 30, TimeUnit.MINUTES);
细节:应该在用户每次访问请求的时候,刷新用户的key有效期。
根据 key 获取 hash返回 map
如果为空,自动返回一个空 map,并不是 null
- 参数:key
Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(token);
删除 key
- 参数 key
stringRedisTemplate.delete(key);
锁,不存在则设置 key(setIfAbsent)
当 key 在 redis 中不存在时,则设置 key,否则 false
- 参数1:key
- 参数2:值
- 参数3:时间
- 参数4:时间单位
Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
经常用于分布式锁
商户缓存
缓存作用:降低后端负载,提高读写效率,降低响应时间
缓存成本;数据一致性成本,代码维护成本,运维成本
业务场景:
低一致性需求:使用内存淘汰机制。例如店铺类型的查询缓存
高一致性需求:主动更新,并以超时剔除作为兜底方案。例如店铺详情查询的缓存
-
- 开发者编码
-
- 使用服务
-
- 最终一致,操作缓存
- 最终一致,操作缓存
我们操作数据库和操作缓存,需要注意的三个问题?
- 删除缓存还是更新缓存?
- 更新缓存:每次更新数据库都更新缓存,无效写操作较多
- 删除缓存:更新数据库时让缓存失效,查询时再更新缓存
- 如何保证缓存与数据库的操作的同时成功或失败?
- 单体系统,将缓存与数据库操作放在一个事务
- 分布式系统,利用TCC等分布式事务方案
- 先操作缓存还是先操作数据库?
- 先删除缓存,再操作数据库
- 先操作数据库,再删除缓存(线程问题出现性低)
容错性大,删除缓存和更新数据库速度慢,而查询和写入缓存速度快
容错性低,三个条件:缓存失效,并发执行,在查询缓存微秒中更新数据库。
缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
缓存空对象
- 优点:实现简单,维护方便
- 缺点:额外的内存消耗,可能造成短期的不一致
布隆过滤
- 优点:内存占用较少,没有多余key
- 缺点:实现复杂,存在误判可能
也可以把 ID 增加复杂度,格式验证。进行主动拦截,还有热点参数限流
缓存雪崩
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
- 给不同的Key的TTL添加随机值
- 利用Redis集群提高服务的可用性
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
缓存击穿
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问,并且会在瞬间给数据库带来巨大的冲击。
常见的解决方案有两种:
- 互斥锁
- 逻辑过期
缓存击穿:多个请求,进行查询为命中,去数据库查询进行重建业务。
给 key 设置一个路基过期时间,如果时间已经过期,开启新线程获取锁,并去重建,其他线程去获取不到锁,不进行重建,使用旧数据。
互斥锁
流程
private Shop mutualExclusion(Long id) {
// 从 redis 查询商铺缓存
String key = CACHE_SHOP_KEY + id;
String cacheShop = stringRedisTemplate.opsForValue().get(key);
// 判断是否存在
if (StrUtil.isNotBlank(cacheShop)) {
// 不为空,转对象返回
return JSONUtil.toBean(cacheShop, Shop.class);
}
if (Objects.equals(cacheShop, "")) {
return null;
}
// 不存在从数据库查询0
// 4 未命中,从这里开始写互斥锁
String lockKey = "lock:shop:" + id;
Shop shop = null;
// 表示抛异常也需要释放锁
try {
// 获取锁
boolean tryLock = tryLock(lockKey);
if (!tryLock) {
// 失败
Thread.sleep(50);
// 重试
return mutualExclusion(id);
}
shop = shopService.getById(id);
if (shop == null) {
stringRedisTemplate.opsForValue().set(key, "", 2, TimeUnit.MINUTES);
return null;
}
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), 30, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unLock(lockKey);
}
return shop;
}
// 获取锁
private boolean tryLock(String key) {
Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(aBoolean);
}
// 释放锁
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
逻辑过期
流程
首先考虑逻辑过期,怎么添加到数据中
在不对源代码进行修改的情况下添加。使用以下方案
@Data
public class RedisData {
private LocalDateTime expireTime; // 逻辑过期
private Object data; // 对象
}
@Override
public Result queryById(Long id) {
Shop shop = mutualExclusion(id);
if (shop == null) {
return Result.fail("店铺不存在!");
}
return Result.ok(shop);
}
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
private Shop mutualExclusion(Long id) {
// 从 redis 查询商铺缓存
String key = CACHE_SHOP_KEY + id;
String cacheShop = stringRedisTemplate.opsForValue().get(key);
// 判断是否存在
if (StrUtil.isBlank(cacheShop)) {
// 为空 返回null
return null;
}
// 不为空
RedisData redisData = JSONUtil.toBean(cacheShop, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 判断缓存是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 没有过期直接返回
return shop;
}
// 过期进行缓存重建
// 获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 判断是否获取成功
if (isLock) {
// 成功开启线程进行重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
saveShopToRedis(id, 20L);
} finally {
// 释放锁
unLock(lockKey);
}
});
}
// 并返回过期的商铺信息, 获取失败表示已经进行重建, 返回过期商铺信息
return shop;
}
public void saveShopToRedis(Long id, Long expireSecond) {
// 查询店铺数据
Shop shop = getById(id);
// 封装逻辑过期
RedisData redisData = new RedisData();
// 模拟延迟
redisData.setData(shop);
// 当前时间的基础上加 expireSecond 秒
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSecond));
// 写入 redis(永久有效,没有过期时间)
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
private boolean tryLock(String key) {
Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(aBoolean);
}
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
封装缓存工具
使用
@Autowired
private CacheClient cacheClient;
@Override
public Result queryById(Long id) {
Shop shop = cacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY, LOCK_SHOP_KEY, id,
Shop.class, this::getById, 20L, TimeUnit.SECONDS);
if (shop == null) {
return Result.fail("店铺不存在!");
}
return Result.ok(shop);
}
工具类
@Component
@Slf4j
public class CacheClient {
@Autowired
private StringRedisTemplate stringRedisTemplate;
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 设置 key
*/
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
/**
* 逻辑过期
*/
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
// 设置逻辑过期
RedisData redisData = new RedisData(LocalDateTime.now().plusSeconds(unit.toSeconds(time)), value);
// 写入
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
/**
* 缓存穿透
*/
public <R, ID> R queryWithPassThrough(
String keyPrefix, ID id, Class<R> rClass, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
// 判断是否存在
if (StrUtil.isNotBlank(json)) {
return JSONUtil.toBean(json, rClass);
}
if (Objects.equals(json, "")) {
return null;
}
// 不存在查询数据库
R r = dbFallback.apply(id);
if (r == null) {
stringRedisTemplate.opsForValue().set(key, "", 2, TimeUnit.MINUTES);
return null;
}
// 存入最新数据
set(key, r, time, unit);
return r;
}
public <R, ID> R queryWithLogicalExpire(
String keyPrefix, String lockPrefix, ID id, Class<R> rClass, Function<ID, R> dbFallback,
Long time, TimeUnit unit) {
// 从 redis 查询商铺缓存
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
// 判断是否存在
if (StrUtil.isBlank(json)) {
// 为空 返回null
return null;
}
// 不为空
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), rClass);
LocalDateTime expireTime = redisData.getExpireTime();
// 判断缓存是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 没有过期直接返回
return r;
}
// 过期进行缓存重建
// 获取互斥锁
String lockKey = lockPrefix + id;
boolean isLock = tryLock(lockKey);
// 判断是否获取成功
if (isLock) {
// 成功开启线程进行重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
R r2 = dbFallback.apply(id);
setWithLogicalExpire(key, r2, time, unit);
} finally {
// 释放锁
unLock(lockKey);
}
});
}
// 并返回过期的商铺信息, 获取失败表示已经进行重建, 返回过期商铺信息
return r;
}
private boolean tryLock(String key) {
Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(aBoolean);
}
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
}
优惠券秒杀
全局唯一ID
唯一性,高可用,高性能,递增性,安全性 ID的组成部分:
- 符号位:1bit,永远为0
- 时间戳:31bit,以秒为单位,可以使用69年
- 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID
基于 redis 生成
/**
* 基于 Redis 的 id 生成器
*/
@Component
public class RedisIdWorker {
// 开始时间戳
private static final long BEGIN_TIMESTAMP = 1691924580;
// 序列号位数
private static final int COUNT_BITS = 32;
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* @param keyPrefix 前缀,区分不同的 key
*/
public long nextId(String keyPrefix) {
// 1. 生成时间戳
// 当前时间
LocalDateTime now = LocalDateTime.now();
// 转为秒
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
// 得到时间差
long timestamp = nowSecond - BEGIN_TIMESTAMP;
// 2. 生成序列号
// 获取天
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
// 指定自增,redis 的key 最大 2的3次方,所以我们按照时间分 key
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
// 3. 拼接返回
// 使用 位运算,序列号占用32位,我们需要将时间戳向左移动32位
return timestamp << COUNT_BITS | count;
}
public static void main(String[] args) {
// 创建一个表示日期和时间的 LocalDateTime 对象,设置为:2023年8月13日11时3分0秒
LocalDateTime of = LocalDateTime.of(2023, 8, 13, 11, 3, 0);
// 将 LocalDateTime 对象转换为以 UTC 时区为基准的秒数
long second = of.toEpochSecond(ZoneOffset.UTC);
System.out.println(second);
}
}
测试
@Test
public void ssw2() throws InterruptedException {
// 500 个线程
CountDownLatch countDownLatch = new CountDownLatch(500);
Runnable task = () -> {
for (int i = 0; i < 100; i++) {
long order = redisIdWorker.nextId("order");
System.out.println("id = " + order);
}
countDownLatch.countDown(); // 记录一个
};
long begin = System.currentTimeMillis();
for (int i = 0; i < 500; i++) {
executorService.submit(task);
}
countDownLatch.await(); // 等待
long end = System.currentTimeMillis();
System.out.println("time:" + (end - begin));
}
测试500个线程,需要 1756
全局唯一ID生成策略:
- UUID
- Redis自增
- snowflake算法
- 数据库自增
Redis自增ID策略:
- 每天一个key,方便统计订单量
- ID构造是 时间戳 + 计数器
超卖问题
我们使用下面的方式去扣减库存,100个库存,两百人线程去执行,执行结束发现库存还有76个
boolean flag = seckillVoucherService.updateById(seckillVoucher);
这种方式更好一点
解决方案:乐观锁。在更新时去判断版本是否和传入的版本一致,不一致则不更新,这就是乐观锁。
还有一种解决方案,判断库存是否和刚刚的库存一样,原理一样的,这叫CAS法
实现一个用户只能领取一个券一次
@Override
public Result seckillVoucher(Long voucherId) {
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
// 抢券未开始!
// 抢券已结束!
// 券抢完啦!
// 一人一单
/*
* 对谁加锁(对每个用户)
* 动态代理失效,事务的失效原则
*/
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
/*
* 事务问题,用this.是不会产生事务的,需要使用Spring代理
* Spring AOP提供的一个静态方法,用于获取当前代理对象。
* 开启注解支持
* 在启动类上加:@EnableAspectJAutoProxy(exposeProxy = true)
* <dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
*/
IVoucherOrderService voucherService = (IVoucherOrderService) AopContext.currentProxy();
return voucherService.getResult(voucherId, seckillVoucher, userId);
}
}
@Transactional
public Result getResult(Long voucherId, SeckillVoucher seckillVoucher, Long userId) {
/*
* intern:从常量池中检查当前变量是否存在,如果过在使用常量池变量,
* 才能保证 synchronized 加锁的正确性。
* 但是锁没有锁住事务,会导致事务没提交,就又被读取。
*/
LambdaQueryWrapper<VoucherOrder> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(VoucherOrder::getUserId, userId).eq(VoucherOrder::getVoucherId, voucherId);
if (count(queryWrapper) > 0) {
return Result.fail("当前用户已经领取过了...");
}
// 可以继续抢,扣减库存
seckillVoucher.setStock(seckillVoucher.getStock() - 1);
boolean flag = seckillVoucherService.updateById(seckillVoucher);
if (!flag) return Result.fail("券抢完啦!");
long order = redisIdWorker.nextId("order");
// 新增订单
VoucherOrder voucherOrder = new VoucherOrder(order, userId, voucherId);
boolean save = save(voucherOrder);
return Result.ok(order);
}
不可以在集群模式下使用(会有问题)
集群模式下,Synchronize 会出现问题
因为 userId.toString().intern() 是在一个 JVM 里面获取常量,判断是否可以放行,无法判断另一个JVM下的常量池,因此我们知道 一个JVM有一个锁监视器,接下来使用 分布式锁解决此问题
分布式锁
模拟一个集群,使用 nginx 负载均衡配置
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/json;
sendfile on;
keepalive_timeout 65;
server {
listen 8080;
server_name localhost;
# 指定前端项目所在的位置
location / {
root html/hmdp;
index index.html index.htm;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
# 拦截 api
location /api {
default_type application/json;
#internal;
keepalive_timeout 30s;
keepalive_requests 1000;
#支持keep-alive
proxy_http_version 1.1;
rewrite /api(/.*) $1 break;
proxy_pass_request_headers on;
#more_clear_input_headers Accept-Encoding;
proxy_next_upstream error timeout;
# proxy_pass http://127.0.0.1:8081;
# 负载均衡到下面 backend 节点
proxy_pass http://backend;
}
}
upstream backend {
server 127.0.0.1:8081 max_fails=5 fail_timeout=10s weight=1;
server 127.0.0.1:8082 max_fails=5 fail_timeout=10s weight=1;
}
}
我们应该让多个JVM使用同一个锁监视器
MySQL | Redis | Zookeeper | |
---|---|---|---|
互斥 | 利用mysql本身的互斥锁机制 | 利用setnx这样的互斥命令 | 利用节点的唯一性和有序性实现互斥 |
高可用 | 好 | 好 | 好 |
高性能 | 一般 | 好 | 一般 |
安全性 | 断开连接,自动释放锁 | 利用锁超时时间,到期释放 | 临时节点,断开连接自动释放 |
使用 redis 的setnx 只能设置一个,为了防止服务宕机,并设置过期时间。
原子性问题:设置过期时间和设置锁必须同一时间。
锁接口创建和实现
public interface ILock {
/**
* 尝试获取锁
* @param timeoutSec 锁持有时间,过期自动释放
* @return true 获取成功,false 获取失败,不会重复获取,造成资源浪费(非阻塞模式)
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}
实现
public class SimpleRedisLock implements ILock {
private final StringRedisTemplate stringRedisTemplate;
// 锁的名称不能写死,不同的业务不同的锁
private final String name;
private static final String KEY_PREFIX = "lock:";
public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
this.stringRedisTemplate = stringRedisTemplate;
this.name = name;
}
@Override
public boolean tryLock(long timeoutSec) {
// 获取锁
// value 加线程表示,表示那个线程获取了锁
long id = Thread.currentThread().getId();
Boolean aBoolean = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, String.valueOf(id), timeoutSec, TimeUnit.SECONDS);
// 自动装箱,避免空的问题,使用true 进行比较
return Boolean.TRUE.equals(aBoolean);
}
@Override
public void unlock() {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
业务实现
@Override
public Result seckillVoucher(Long voucherId) {
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) return Result.fail("抢券未开始!");
if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) return Result.fail("抢券已结束!");
if (seckillVoucher.getStock() < 1) return Result.fail("券抢完啦!");
// 一人一单
Long userId = UserHolder.getUser().getId();
// 创建锁对象
SimpleRedisLock simpleRedisLock = new SimpleRedisLock(stringRedisTemplate, "order:" + userId);
// 获取锁, 抢购时间大约 60秒,所以设置 60 秒,跟业务有关系。这里设置20分钟
boolean tryLock = simpleRedisLock.tryLock(1200);
// 因为非阻塞,所以判断返回值
if (!tryLock) {
// 获取锁失败
return Result.fail("一人只允许下一单!");
}
try {
// 获取锁成功!
IVoucherOrderService voucherService = (IVoucherOrderService) AopContext.currentProxy();
return voucherService.getResult(voucherId, seckillVoucher, userId);
} finally {
// 释放锁
simpleRedisLock.unlock();
}
}
误删问题
当程序出现意外情况
- 线程一获取锁,在执行过程中,业务阻塞了,超时锁释放了
- 线程二获取锁,执行业务
- 线程一恢复过来,将线程二的锁释放了
- 线程三,获取锁....
这就导致了误删锁问题。
解决方式:就是删除锁判断一下锁是不是自己的
代码实现
public class SimpleRedisLock implements ILock {
private final StringRedisTemplate stringRedisTemplate;
private final String name;
private static final String KEY_PREFIX = "lock:";
/*
* UUID标识 保证多个 JVM 不会重复生成
*/
private static final String ID_PREFIX = UUID.fastUUID().toString(true) + "-";
public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
this.stringRedisTemplate = stringRedisTemplate;
this.name = name;
}
@Override
public boolean tryLock(long timeoutSec) {
/*
* 将标识设置成 UUID形式存入,保证
*/
String threadID = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean aBoolean = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadID, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(aBoolean);
}
@Override
public void unlock() {
/*
* 需要将存入的锁标识和 和 当前线程标识进行判定,一样则删除
*/
String currentKey = ID_PREFIX + Thread.currentThread().getId();
String lockKey = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
if (currentKey.equals(lockKey)) {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
}
原子性问题导致的误删除(lua 脚本)
在 JVM 里有个机制,叫做 垃圾回收,可能会在业务执行完毕后,即将释放锁之前发生程序阻塞,导致锁过期。就是一种 fullGC。
因为判断锁和释放锁是两个操作,可能触发原子性问题。
我们使用 lua 脚本,将多个 redis 命令放到一块实现原子性。
那么 lua 是如何调用 redis 的呢
redis.call('命令名称', 'key', '其他参数...')
redis.call("SET", key, value)
-- 使用 local 声明变量
local key = "your_key"
使用脚本
在 redis 控制台创建脚本, Eval 执行一个脚本,后面的 0 表示没有变量参数
Eval "return redis.call('set', 'name', 'Jack')" 0
变量参数传递
在 lua 语言中,keys 数组中存入的是 key,值存入到 ARGV 里面。并且变量的下标是从 1 开始的,没有 0
下方代码中,其中 name 是key,heihei 是值
EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 name heihei
编写lua脚本
-- 锁的 key
local key = KEYS[1]
-- 当前线程标识
local threadId = ARGV[1]
-- 获取锁中的线程标识
local id = redis.call('get', key)
-- 比较线程标识与锁的标识是否一致
if(id == threadId)
then
-- 释放锁
return redis.call('del', key)
end
-- 不一致
return 0
在 Java 中使用脚本,这里使用静态方式,在类加载的时候就加载,提升性能导入脚本。DefaultRedisScript 脚本的类
// 定义一个 Redis Lua 脚本,用于解锁操作
private static final DefaultRedisScript<Long> UN_LOCK_SCRIPT;
static {
UN_LOCK_SCRIPT = new DefaultRedisScript<>();
UN_LOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua")); // 设置 Lua 脚本的位置,通常是在 resources 目录下
UN_LOCK_SCRIPT.setResultType(Long.class); // 设置脚本的返回类型为 Long
}
@Override
public void unlock() {
// 调用 lua 脚本
stringRedisTemplate.execute(
UN_LOCK_SCRIPT, // 使用之前定义的 Lua 脚本
Collections.singletonList(KEY_PREFIX + name), // 设置键值参数,这里是锁的键
ID_PREFIX + Thread.currentThread().getId() // 设置参数,这里是线程 ID,用于区分不同的锁
);
}
总结
- 利用 set nx ex 获取锁,并设置过期时间,保存线程标识
- 释放锁先判断线程标识是不否与自己一致,一致则删除锁
特性
- 利用 set nx 满足互斥
- 利用 set ex 保证故障时锁依然能释放,避免死锁,提高安全性
- 利用 redis 集群保证高可用和高并发特性。
还可以优化,还可以进一步提升
基于 Redis 分布式锁的优化
不可重入,同一个线程无法多次获取同一把锁
同一个线程 A方法获取锁后,B方法无法获取锁,B 方法会等待锁释放,因为A方法没有执行完,这就导致了死锁。
不可重试
没有重试机制,一旦发生错误就直接返回 false,应该一旦发现获取锁失败,就应该等一等,重新尝试获取
超时释放
之前的超时释放锁,虽然可以避免死锁,但如果业务执行耗时较长,导致超时释放,存在安全隐患。
主从一致性问题
读写分离模式,读操作访问从节点,写操作访问主节点,主节点需要将数据同步到从节点。
假设一个 Set 操作,假设尚未同步到从节点上,主节点宕机了,此时,会选择一个从作为主,从节点因为没有完成同步,没有锁标识,这个时候其他线程可以拿到锁,也就等于多个线程拿到锁。所以可能极端情况下出现问题。
Redisson 入门
依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
配置 Redisson
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
// 配置---org.redisson.config
Config config = new Config();
// 集群配置
// config.useClusterServers().addNodeAddress()
config.useSingleServer().setAddress("redis://127.0.0.1:6379")
.setPassword(null);
return Redisson.create(config);
}
}
使用
@Autowired
private RedissonClient redissonClient;
// 创建锁对象
RLock lock = redissonClient.getLock("lock:order:" + userId);
// 获取锁,API 默认时间是,不重试不等待,超时释放,默认是30秒
boolean tryLock = lock.tryLock();
// 因为非阻塞,所以判断返回值
if (!tryLock) {
// 获取锁失败
return Result.fail("一人只允许下一单!");
}
try {
// 获取锁成功!
IVoucherOrderService voucherService = (IVoucherOrderService) AopContext.currentProxy();
return voucherService.getResult(voucherId, seckillVoucher, userId);
} finally {
// 释放锁
lock.unlock();
}
可重入锁原理
我们之前的锁,是采用 key value 的结构,在一个线程中,由于 Redis 的 nx 原理,设置过一次就不可以再设置了,所以是不可重入的。
可重入原理
采用的是 Redis 中的 Hash 结构,在获取锁的时候,加了一个判断,判断当前锁是不是当前线程,如果是,就让数量 + 1,这样就可以重复获取锁了,在释放锁的时候也一样,让锁 -1,如果是 0 就可以删除锁了。
走到 0 也就表示业务走到了最后,才可以释放锁。
内部采用的也是 lua 脚本
获取锁
local key = KEYS[1]; -- 锁的key
local threadId = ARGV[1]; -- 线程唯一标识
local releaseTime = ARGV[2]; -- 锁的自动释放时间
-- 判断是否存在
if(redis.call('exists', key) == 0) then
-- 不存在, 获取锁
redis.call('hset', key, threadId, '1');
-- 设置有效期
redis.call('expire', key, releaseTime);
return 1; -- 返回结果
end;
-- 锁已经存在,判断threadId是否是自己
if(redis.call('hexists', key, threadId) == 1) then
-- 不存在, 获取锁,重入次数+1
redis.call('hincrby', key, threadId, '1');
-- 设置有效期
redis.call('expire', key, releaseTime);
return 1; -- 返回结果
end;
return 0; -- 代码走到这里,说明获取锁的不是自己,获取锁失败
释放锁脚本
local key = KEYS[1]; -- 锁的key
local threadId = ARGV[1]; -- 线程唯一标识
local releaseTime = ARGV[2]; -- 锁的自动释放时
-- 判断当前锁是否还是被自己持有
if (redis.call('HEXISTS', key, threadId) == 0) then
return nil; -- 如果已经不是自己,则直接返回
end;
-- 是自己的锁,则重入次数-1
local count = redis.call('HINCRBY', key, threadId, -1);
-- 判断是否重入次数是否已经为0
if (count > 0) then
-- 大于0说明不能释放锁,重置有效期然后返回
redis.call('EXPIRE', key, releaseTime);
return nil;
else -- 等于0说明可以释放锁,直接删除
redis.call('DEL', key);
return nil;
end;
异步秒杀思路
看下图,我们查询优惠券,查询订单,都需要查询数据库,以及判断操作,这就导致频繁查询,在数据量大的情况下,浪费时间。
我们将信息提前存入 Redis 里,从Redis中查询,提高速度。预先指定。
将抢购信息存入 redis,当抢购开始会直接判断,那大家想,都已经反馈给用户了。那存入数据库,是不是就不是那么着急存入了,就可以通过异步的方式将数据,存入数据库。
那我们改进一下,提高并发性能
需求:
- 新增秒杀优惠券的同时,将优惠券信息保存到Redis中
- 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
- 如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
- 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
第二步的lua脚本,参考如下(传递时需要将类型转换成 String)
-- 参数列表
local voucherId = ARGV[1]
-- 用户ID
local userId = ARGV[2]
-- 数据 key
-- 库存key .. 是拼接符
local stockKey = 'seckill:stock:' .. voucherId
-- 订单 key
local orderKey = 'seckill:order:' .. voucherId
-- 脚本业务
--3.1 判断库存是否充足 tonumber 转成数字
local stoke = tonumber(redis.call('get', stockKey))
if(stoke == nil or stoke <= 0) then
-- 库存不足
return 1
end
-- 判断用户是否已经下单
local user = tonumber(redis.call('sismember', orderKey, userId))
if(user == nil or user == 1) then
-- 存在,说明重复
return 2
end
-- 扣库存
redis.call('incrby', stockKey, -1)
-- 下单
redis.call('sadd', orderKey, userId)
return 0
阻塞队列
阻塞队列,当一个线程从阻塞队列中获取元素时,如果没有元素,该线程会被阻塞,直到队列中有元素,才会被唤醒。
创建一个 1024 * 1024 的阻塞队列,数组列表
private final BlockingQueue<VoucherOrder> orderTask = new ArrayBlockingQueue<>(1024 * 1024);
// 将信息放入 阻塞队列
orderTask.add(voucherOrder);
实战异步下单
// 用于存放秒杀订单的阻塞队列,设置容量为 1024 * 1024
private final BlockingQueue<VoucherOrder> orderTask = new ArrayBlockingQueue<>(1024 * 1024);
// 单线程的线程池,用于处理秒杀订单
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
// 在构造函数执行后,使用 @PostConstruct 标记的方法
// 用于初始化秒杀订单处理逻辑
@PostConstruct
public void init() {
// 提交一个任务到单线程的线程池
SECKILL_ORDER_EXECUTOR.submit(() -> {
// 无限循环,从阻塞队列中取出订单并处理
while (true) {
try {
// 从阻塞队列中取出订单,如果队列为空则阻塞等待
VoucherOrder take = orderTask.take();
// 处理秒杀订单
voucherOrderService.getResult(take);
} catch (InterruptedException e) {
// 处理中断异常,可以根据实际情况进行处理
Thread.currentThread().interrupt();
}
}
});
}
订单业务逻辑
@Transactional
public void getResult(VoucherOrder voucherOrder) {
RLock lock = redissonClient.getLock("lock:order:" + voucherOrder.getUserId());
boolean tryLock = lock.tryLock();
if (!tryLock) {
log.error("当前用户已经领取过了...");
return;
}
Long count = lambdaQuery().eq(VoucherOrder::getUserId, voucherOrder.getUserId())
.eq(VoucherOrder::getVoucherId, voucherOrder.getVoucherId())
.count();
if (count > 0) {
log.error("当前用户已经领取过了...");
return;
}
// 可以继续抢,扣减库存
boolean flag = seckillVoucherService.lambdaUpdate().setSql("stock = stock - 1")
.eq(SeckillVoucher::getVoucherId, voucherOrder.getVoucherId())
.gt(SeckillVoucher::getStock, 0)
.update();
if (!flag) {
log.error("券抢完啦!");
return
}
// 新增订单
save(voucherOrder);
}
秒杀业务的优化思路是什么?
- 先利用Redis完成库存余量、一人一单判断,完成抢单业务
- 再将下单业务放入阻塞队列,利用独立线程异步下单
基于阻塞队列的异步秒杀存在哪些问题?使用的是 JDK 的阻塞队列,它使用的是JVM内存。
- 内存限制问题
- 数据安全问题
- 内存满了,就会溢出。
- 如果 Redis 的宕机了,就会导致数据安全问题。
消息队列(解决内存问题和数据安全问题)
消息队列(Message Queue),字面意思就是存放消息的队列。最简单的消息队列模型包括3个角色:
- 消息队列:存储和管理消息,也被称为消息代理(Message Broker)
- 生产者:发送消息到消息队列,将消息持久化,防止宕机数据消失,会将消息给消费者确认,保证消息一次。
- 消费者:从消息队列获取消息并处理消息
Redis提供了三种不同的方式来实现消息队列:
- list结构:基于List结构模拟消息队列
- PubSub:基本的点对点消息模型
- Stream:比较完善的消息队列模型
基于List结构模拟消息队列
消息队列(Message Queue),字面意思就是存放消息的队列。而Redis的list数据结构是一个双向链表,很容易模拟出队列效果。
队列是入口和出口不在一边,因此我们可以利用:LPUSH 结合 RPOP、或者 RPUSH 结合 LPOP来实现。
不过要注意的是,当队列中没有消息时RPOP或LPOP操作会返回null,并不像JVM的阻塞队列那样会阻塞并等待消息。因此这里应该使用BRPOP或者BLPOP来实现阻塞效果。
等待数据
# 阻塞等待 l1 的队列的数据,最长时间等待 20s
brpop l1 20
添加数据
# 往 l1 队列中添加两个数据
lpush l1 e2 32
优缺点
优点:
- 利用Redis存储,不受限于JVM内存上限
- 基于Redis的持久化机制,数据安全性有保证
- 可以满足消息有序性
缺点:
- 无法避免消息丢失
- 只支持单消费者(因为拿完就删掉了,无法被多个消费者获取)
基于PubSub的消息队列
PubSub(发布订阅)是Redis2.0版本引入的消息传递模型。顾名思义,消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息。
- SUBSCRIBE channel [channel] :订阅一个或多个频道
- PUBLISH channel msg :向一个频道发送消息
- PSUBSCRIBE pattern[pattern] :订阅与pattern格式匹配的所有频道
下图中,生产者发送了 order.queue msg1,那么消费者接收 order.queue 和 order.*都会收到订阅。
订阅--阻塞订阅
# 精确订阅 order.q1
subscribe order.q1
# 模式匹配订阅 order.*
psubscribe order.*
发送信息
# 向 order.q1 发送 hello
publish order.q1 hello
优缺点
如果没有订阅接收,数据发送会丢失,并且如果消费者缓存区满了,发送数据也会丢失。
优点:
- 采用发布订阅模型,支持多生产、多消费 缺点:
- 不支持数据持久化
- 无法避免消息丢失
- 消息堆积有上限,超出时数据丢失
可靠性较高不建议使用。
基于Stream的消息队列
Stream 是 Redis 5.0 引入的一种新数据类型,可以实现一个功能非常完善的消息队列。
发送信息
# 添加信息--- 添加了一个 users 的键值对信息,*表示id由redis创建,
xadd users * name jack
# 查看 users 消息队列的长度
xlen users
读消息
数据可以重复的读取,读完不会删除,但是使用 $,只会读取最新的消息,不会读取已经被消费的了。
# 读取信息,count 表示读多少,streams 表示读取那个消息队列,0表示读取第一个,也可以写 $ 表示一直读取最新的
xread count 1 streams users 0
阻塞读取
block 0 一直阻塞,0表示时间,不限制
# 阻塞方式
xread count 1 block 0 streams users $
但是会出现消息漏读的情况
比如:正在阻塞时,发来一条信息,可以正常读取到,但是,没有阻塞等待消息时,发来的信息,就会遗失。
STREAM类型消息队列的XREAD命令特点:
- 消息可回溯
- 一个消息可以被多个消费者读取
- 可以阻塞读取
- 有消息漏读的风险
基于Stream的消息队列-消费者组
消费者组(Consumer Group):将多个消费者划分到一个组中,监听同一个队列。具备下列特点:
消息分流
队列中的消息会分流给组内的不同消费者,而不是重复消费,从而加快消息处理的速度
消息标识
消费者组会维护一个标识,记录最后一个被处理的消息,哪怕消费者宕机重启,还会从标示之后读取消息。确保每一个消息都会被消费
消息确认
消费者获取消息后,消息处于pending状态,并存入一个pending-list。当处理完成后需要通过XACK来确认消息,标记消息为已处理,才会从pending-list移除。
创建消费者组
命令
XGROUP CREATE key groupName ID [MKSTREAM]
- key:队列名称
- groupName:消费者组名称
- ID:起始ID标示,$代表队列中最后一个消息,0则代表队列中第一个消息
- MKSTREAM:队列不存在时自动创建队列
# 删除指定的消费者组
XGROUP DESTORY key groupName
# 给指定的消费者组添加消费者
XGROUP CREATE CONSUMER key groupname consumername
# 删除消费者组中的指定消费者
XGROUP DELCONSUMER key groupname consumername
创建消费者组
# 在 users 队列中,创建消费者组,组名称 g1,从起始开始消费
xgroup create users g1 0
从消费者组读取信息
XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] ID [ID ...]
- group:消费组名称
- consumer:消费者名称,如果消费者不存在,会自动创建一个消费者
- count:本次查询的最大数量
- BLOCK milliseconds:当没有消息时最长等待时间
- NOACK:无需手动ACK,获取到消息后自动确认
- STREAMS key:指定队列名称
- ID:获取消息的起始ID:
- ">":从下一个未消费的消息开始 其它:根据指定id从pending-list中获取已消费但未确认的消息,例如0,是从pending-list中的第一个消息开始
使用
# group 指定组名 c1 是消费者名称,没有会自动创建 block 阻塞两秒
streams 制定队列名 > 表示读取未消费的
xreadgroup group g1 c1 count 1 block 2000 streams users >
但是我们读入消息后,并没有确认消息。
# 处理 users 队列的 g1 消费者组下的两个信息
xack users g1 1708862116252-0 1708864566336-0
获取处于未确认状态的消息的信息
# 获取 users 中 g1 消费者组的 最早到最新的,并且限制获取十条
xpending users g1 - + 10
STREAM类型消息队列的XREADGROUP命令特点:
- 消息可回溯
- 可以多消费者争抢消息,加快消费速度
- 可以阻塞读取
- 没有消息漏读的风险
- 有消息确认机制,保证消息至少被消费一次
实战
需求:
- 创建一个Stream类型的消息队列,名为stream.orders
- 修改之前的秒杀下单Lua脚本,在认定有抢购资格后,直接向stream.orders中添加消息,内容包含voucherId、userId、orderId
- 项目启动时,开启一个线程任务,尝试获取stream.orders中的消息,完成下单
创建队列
# 创建消费者组,从 0 开始,mkstream 如果不存在直接创建。stream.order 是Stream 的名称
xgroup create stream.order g1 0 mkstream
修改 lua 脚本
-- 参数列表
local voucherId = ARGV[1]
-- 用户ID
local userId = ARGV[2]
-- 订单ID
local orderId -= ARGV[3]
-- 数据 key
-- 库存key .. 是拼接符
local stockKey = 'seckill:stock:' .. voucherId
-- 订单 key
local orderKey = 'seckill:order:' .. voucherId
-- 脚本业务
--3.1 判断库存是否充足 tonumber 转成数字
local stoke = tonumber(redis.call('get', stockKey))
if(stoke == nil or stoke <= 0) then
-- 库存不足
return 1
end
-- 判断用户是否已经下单
local user = tonumber(redis.call('sismember', orderKey, userId))
if(user == nil or user == 1) then
-- 存在,说明重复
return 2
end
-- 扣库存
redis.call('incrby', stockKey, -1)
-- 下单
redis.call('sadd', orderKey, userId)
-- 添加信息
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0
代码实现
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
private static final ExecutorService SECKILL_ORDER_EXECURTOR = Executors.newSingleThreadExecutor();
@PostConstruct
public void init() {
SECKILL_ORDER_EXECURTOR.submit(() -> {
while (true) {
try {
// 1. 获取消息队列中的消息 xreadgroup group g1 c1 count 1 block 2000 stream streams.order >
String quickName = "streams.order";
List<MapRecord<String, Object, Object>> mapRecordList = stringRedisTemplate.opsForStream().read(
// org.springframework.data.redis.connection.stream
// 设置消费者组和消费者
Consumer.from("g1", "c1"),
// 每次消费1个,阻塞两秒钟
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
// 读取最新的未消费的
StreamOffset.create(quickName, ReadOffset.lastConsumed())
);
// 2. 判断消息是否获取成功
if (mapRecordList == null || mapRecordList.isEmpty()) {
continue;
}
// 3. 解析消息
MapRecord<String, Object, Object> entries = mapRecordList.get(0);
Map<Object, Object> value = entries.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
// 4. 如果获取成功,可以下单
voucherOrderService.getResult(voucherOrder);
// 5. ACK 确认
stringRedisTemplate.opsForStream().acknowledge(quickName, "g1", entries.getId());
} catch (Exception e) {
// 出现异常,就去 pendingList中查询
log.error("处理订单异常");
handlePendingList();
}
}
});
}
private void handlePendingList() {
while (true) {
try {
// 1. 获取消息队列中的消息 pending-list group g1 c1 count 1 block 2000 stream streams.order >
String quickName = "streams.order";
List<MapRecord<String, Object, Object>> mapRecordList = stringRedisTemplate.opsForStream().read(
// org.springframework.data.redis.connection.stream
// 设置消费者组和消费者
Consumer.from("g1", "c1"),
// 每次消费1个,阻塞两秒钟
StreamReadOptions.empty().count(1),
// 读取最新的未消费的
StreamOffset.create(quickName, ReadOffset.from("0"))
);
// 2. 判断消息是否获取成功,说明pending 中没有消息
if (mapRecordList == null || mapRecordList.isEmpty()) {
break;
}
// 3. 解析消息
MapRecord<String, Object, Object> entries = mapRecordList.get(0);
Map<Object, Object> value = entries.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
// 4. 如果获取成功,可以下单
voucherOrderService.getResult(voucherOrder);
// 5. ACK 确认
stringRedisTemplate.opsForStream().acknowledge(quickName, "g1", entries.getId());
} catch (Exception e) {
// 出现异常
log.error("处理pend-list订单异常");
}
}
}
总结
List | PubSub | Stream | |
---|---|---|---|
消息持久化 | 支持 | 不支持 | 支持 |
阻塞读取 | 支持 | 支持 | 支持 |
消息堆积处理 | 受限于内存空间,可以利用多消费者加快处理 | 受限于消费者缓冲区 | 受限于队列长度,可以利用消费者组提高消费速度,减少堆积 |
消息确认机制 | 不支持 | 不支持 | 支持 |
消息回溯 | 不支持 | 不支持 | 支持 |