Redis
一、redis的安装
1. redis的单机安装
- redis是基于C语言编写的,首先需要安装redis所需要的gcc依赖
yum install -y gcc tcl
- 下载redis的.tar.gz安装包,找一个位置存放并进行解压
tar -xzf redis-6.2.6.tar.gz
- 进入解压好的目录,运行编译命令
make && make install
该目录已经默认配置到环境变量,因此可以在任意目录下运行这些命令:
- redis-cli:是redis提供的命令行客户端
- redis-server:是redis的服务端启动脚本
- redis-sentinel:是redis的哨兵启动脚本
- 启动
redis的启动方式有很多种,例如:
- 默认启动
redis-server - 指定配置启动
我们可以修改redis.conf里的一些配置:
(1)允许访问的地址,默认是127.0.0.1,会导致只能在本地访问。修改为0.0.0.0则可以在任意IP访问,生产环境不要设置为0.0.0.0
bind 0.0.0.0
(2)守护进程,修改为yes后即可后台运行
daemonize yes
(3)密码,设置后访问Redis必须输入密码
requirepass 123321
(4)监听的端口
port 6379
(5)工作目录,默认是当前目录,也就是运行redis-server时的命令,日志、持久化等文件会保存在这个目录
dir .
(6)数据库数量,设置为1,代表只使用1个库,默认有16个库,编号0~15
databases 1
(7)设置redis能够使用的最大内存
maxmemory 512mb
(8)日志文件,默认为空,不记录日志,可以指定日志文件名
logfile "redis.log"
配置完保存之后,启动这个配置文件
redis-server /tmp/redis/redis.conf - 开机自启
我们也可以通过配置来实现开机自启。
首先,新建一个系统服务文件:
vi /etc/systemd/system/redis.service
内容如下:
[Unit]
Description=redis-server
After=network.target
[Service]
Type=forking
ExecStart=/usr/local/bin/redis-server /usr/local/src/redis-6.2.6/redis.conf
PrivateTmp=true
[Install]
WantedBy=multi-user.target
然后重载系统服务:
systemctl daemon-reload
然后可以通过下面这些命令来操作redis
- 启动:systemctl start redis
- 停止:systemctl stop redis
- 重启:systemctl restart redis
- 查看状态:systemctl status redis
执行下面的命令,可以让redis开机自启:
systemctl enable redis
- 客户端
可以在任意目录通过redis-cli [-a 密码] [-h 要连接redis节点的IP地址] [-p 端口]
二、redis的项目应用
0. Redis自定义ID生成器
拿订单的业务场景来举例,如果我们使用数据库自带的自增id,可能会有以下两个问题:
- id的规律太明显
- 受表单数据量的限制
所以我们需要引入自定义的ID生成器 ---> 全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:
- 唯一性
- 高性能
- 高可用
- 递增性
- 安全性
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
/**
* @Description: redis订单id生成器
* @Author: YccLin
* @Date: 2024/10/31
*/
@Component
public class RedisIdWorker {
/**
* 开始的时间戳
*/
private static final long BEGIN_TIMESTAMP = 1640995200L;
private static final long COUNT_BITS = 32;
@Resource
private StringRedisTemplate stringRedisTemplate;
// 业务前缀,如订单业务就是order...
public Long nextId(String keyPrefix) {
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timeStamp = nowSecond - BEGIN_TIMESTAMP;
// 2. 生成序列号
// 2.2 获取当前日期,精确到天,以便后续查询某年某月某天的业务量
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
// 2.3 自增长
Long count = stringRedisTemplate.opsForValue()
.increment("icr:" + keyPrefix + ":" + date);
return timeStamp << COUNT_BITS | count;
}
public static void main(String[] args) {
LocalDateTime time = LocalDateTime.of(2022, 1, 1, 0, 0, 0);
long second = time.toEpochSecond(ZoneOffset.UTC);
System.out.println("second: " + second);
}
}
1. 锁的一些概念
synchronized只能保证同一个JVM内多个线程之间的互斥,在分布式下依然会出现并发问题。
2. 超卖问题
拿优惠券举例,用户去抢购券时,在并发场景下可能出现超卖问题,比如表中的stock可能出现负数等情况。
为了解决这个问题,我们引入了两种概念锁,分别是悲观锁和乐观锁。
悲观锁:顾名思义,它比较“悲观”,它认为上述的超卖情况大概率存在,所以会在每次操作时加锁。由于每次操作都加锁,就会导致性能下降。
乐观锁:和悲观锁相反,它认为上述情况并不是大概率存在,只有很小概率才会导致问题,所以它只会在更新操作时才去加锁。和悲观锁相比,它的性能更好。
3. 分布式锁
- 前提引入
在单体系统中,出现的并发安全问题可以通过锁去解决,确保串行执行。
如果现在有多台JVM,每台JVM内部都维护了一个锁监视器对象,假设我们用用户的唯一id作为锁,这些id会在常量池中。如果在同一个JVM中,当id相同的情况下,多个线程获取的都是同一个锁,也就是锁的监视器都是同一个。比如当线程1去获取这个锁监视器时,会在内部记录线程1的名称,其他线程也来获取时会发现已经存在,获取失败。而如果是多台机器,就会有多个JVM,都有各自的堆、栈、方法区、常量池等,也包括了新的监视器锁,就会导致能获取成功。显然,这不是我们想看到的。
下面是引入redisson的示例:
首先需要引入依赖:
pom.xml
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
然后我们需要去配置redisson的客户端,这里推荐手动配置,如果使用配置文件配置会跟redis的掺杂。
RedissonConfig
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://192.168.159.100:6379")
.setPassword("root");
return Redisson.create(config);
}
}
redisson的使用
import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.SimpleRedisLock;
import com.hmdp.utils.UserHolder;
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.aop.framework.AopContext;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.Objects;
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService iSeckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private RedissonClient redissonClient;
@Override
public Result seckillVoucher(Long voucherId) {
// 1. 查询优惠券信息
SeckillVoucher seckillVoucher = iSeckillVoucherService.getById(voucherId);
// 2. 判断是否开始、过期
if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀尚未开始");
}
if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已经结束");
}
// 3. 判断库存是否充足
if (seckillVoucher.getStock() < 1) {
return Result.fail("库存不足");
}
// 对用户加锁
Long userId = UserHolder.getUser().getId();
/**
* 单体系统的方法:
* intern(): 从常量池找,确保同一个用户对应同一个锁
* @purpose: 这里是确保事务提交后才会释放锁,否则其他线程可能拿到锁,导致重复下单
*/
// synchronized (userId.toString().intern()) {
// IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
// return proxy.createVoucherOrder(voucherId, seckillVoucher);
// }
/**
* 创建锁对象:
* 多线程环境下的方法:
* 用自己封装的基于sexNx的锁
*/
// SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
RLock lock = redissonClient.getLock("lock:order:" + userId);
boolean isLock = lock.tryLock();
if (!isLock) {
// 重试或报错
return Result.fail("不允许重复下单");
}
try {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId, seckillVoucher);
} finally {
lock.unlock();
}
}
/**
* 事务只有在锁释放之后才会由Spring提交,再次期间,可能有其他线程进来
*/
@Transactional
public Result createVoucherOrder(Long voucherId, SeckillVoucher seckillVoucher) {
// 对用户加锁
Long userId = UserHolder.getUser().getId();
// 不允许多次下单(一人一单)
Integer count = lambdaQuery().eq(VoucherOrder::getVoucherId, voucherId)
.eq(VoucherOrder::getUserId, userId)
.count();
if (count > 0) {
return Result.fail("禁止重复下单");
}
// 4. 充足就扣减库存
boolean success = iSeckillVoucherService.lambdaUpdate()
.set(SeckillVoucher::getStock, seckillVoucher.getStock() - 1)
.eq(SeckillVoucher::getVoucherId, voucherId)
.gt(SeckillVoucher::getStock, 0) // 采用CAS,乐观锁的实现之一,避免超卖;不过乐观锁弊端是可能导致大部分没有卖出,导致业务上的错误,所以这里直接换成库存大于0即可
.update();
if (!success) {
return Result.fail("库存不足");
}
// 5. 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
Long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(orderId);
}
}
- 分布式锁的概念
分布式锁是满足分布式系统或集群模式下多线程可见并且互斥的锁。 - 利用lua脚本实现多条命令原子性
lua脚本,顾名思义,就是lua语言编写的。它可以确保一次性执行完redis命令,就可以避免多线程的安全问题。
比如现在有个工具类实现获取和释放锁:
子接口ILock
public interface ILock {
/**
* 尝试获取锁
* @param timeoutSec
* @return
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}
获取、释放锁的SimpleRedisLock
import cn.hutool.core.lang.UUID;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
public class SimpleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
// 初始化脚本
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
@Override
public boolean tryLock(long timeoutSec) {
// ID前缀 + 线程id
String threadId = ID_PREFIX + Thread.currentThread().getId();
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
// 避免拆箱带来的空指针风险
return Boolean.TRUE.equals(success);
}
// @Override
// public void unlock() {
// String threadId = ID_PREFIX + Thread.currentThread().getId();
// String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// if (Objects.equals(threadId, id)) {
// stringRedisTemplate.delete(KEY_PREFIX + name);
// }
// }
// 基于lua脚本解锁
@Override
public void unlock() {
String threadId = ID_PREFIX + Thread.currentThread().getId();
stringRedisTemplate.execute(UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
threadId);
}
}
lua脚本:
resources/unlock.lua
-- 释放锁:比较操作和解锁操作一起执行,确保线程安全
if (redis.call('get', KEYS[1]) == ARGV[1]) then
return redis.call('del', KEYS[1])
end
return 0
4.redis的消息队列
redis有三种消息队列的模式,分别是List、PubSub和Stream。
(1)List
redis的list是一种双向链表,我们可以使用LPUSH + RPOP或者RPUSH + LPOP实现,但是当队列中没有消息时,会返回null;所以这里我们应该使用BLPOP或BLPOP来实现阻塞效果。
优点:
- 利用redis存储,不受JVM内存上限
- 基于redis的持久化机制,数据安全性有保证
- 可以满足消息有序性
缺点: - 无法避免消息丢失
- 只支持单消费者
(2)PubSub
顾名思义,发布订阅,消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到消息。
SUBSCRIBE channel[channel]订阅一个或者多个频道PUBLISH channel msg向一个频道发消息PSUBSCRIBE pattern[pattern]订阅与pattern格式匹配的所有频道
优点:
- 采用发布订阅模式,支持多生产、多消费
缺点:
- 不支持消息持久化
- 无法避免消息丢失
- 消息堆积有上限,超出时数据丢失
(3)Stream
Redis5.0引入的一种新数据类型,可以实现功能完善的消息队列。
Stream又有两种模式,一种是单消费者模式,一种是消费者组的模式。
① 单消费者模式:
添加消息:
XADD [NONMKSTREAM:如果队列不存在,是否自动创建队列,默认是自动创建] [MAXLEN|MINID [=|~] thredshold [LIMIT count]:设置消息队列的最大消息数量] *|ID:消息的唯一ID,*代表由redis自动生成,格式是"时间戳-递增数字" field value [field value ...]:发送到队列中的消息,称为Entry,格式就是多个键值对
读取消息:
XREAD [COUNT count:每次读取消息的最大数量] [BLOCK milliseconds:当没有消息时,是否阻塞、阻塞时长] STREAMS key [key ...]:要从哪个队列读取消息,key就是队列名 ID [ID ...]:起始id,只返回大于该id的消息,0:代表从第一个消息开始;$:代表从最新消息开始
注意:这里读取消息如果用最新的方式,可能会出现bug。当取到最新的一条消息并正在进行处理,这时又添加了多条消息进来,可能就出现消息漏读的情况。
优点:
- 消息可回溯
- 一个消息可以被多个消费者读取
- 可以阻塞读取
缺点:
- 有消息漏读的风险
② 消费者组
创建消费者组:
XGROUP CREATE key:队列名称 groupName:消费者组名 ID:起始id标识 [MKSTREAM]:队列不存在时自动创建队列
删除指定的消费者组:
XGROUP DESTORY key groupName
给指定的消费者组添加消费者:
XGROUP CREATECONSUMER key groupName consumerName
删除消费者组中的指定消费者:
XGROUP DELCONSUMER key groupName consumerName
从消费者组读取消息:
XREADGROUP GROUP group:消费组名称 consumer:消费者名称,如果不存在会自动创建 [COUNT count]:本次查询的最大数量 [BLOCK milliseconds]:当没有消息时最长等待时间 [NOACK]:无需手动ACK,获取到消息后自动确认 STREAMS key [key ...]:指定队列名称 ID [ID ...]:获取消息的起始id,>从下一个未消费的消息开始;其他的根据指定id从pending-list中获取已消费未确认的消息,例如0,从pengding-list中第一个消息开始
优点:
- 消息可回溯
- 可以多消费者争抢消息,加快消费速度
- 可以阻塞读取
- 没有消息漏读的风险
- 有消息确认机制,保证消息至少被消费一次

浙公网安备 33010602011771号