Redis

一、redis的安装

1. redis的单机安装

  1. redis是基于C语言编写的,首先需要安装redis所需要的gcc依赖
yum install -y gcc tcl
  1. 下载redis的.tar.gz安装包,找一个位置存放并进行解压
tar -xzf redis-6.2.6.tar.gz
  1. 进入解压好的目录,运行编译命令
make && make install

该目录已经默认配置到环境变量,因此可以在任意目录下运行这些命令:

  • redis-cli:是redis提供的命令行客户端
  • redis-server:是redis的服务端启动脚本
  • redis-sentinel:是redis的哨兵启动脚本
  1. 启动
    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

  1. 客户端
    可以在任意目录通过 redis-cli [-a 密码] [-h 要连接redis节点的IP地址] [-p 端口]

二、redis的项目应用

0. Redis自定义ID生成器

拿订单的业务场景来举例,如果我们使用数据库自带的自增id,可能会有以下两个问题:

  • id的规律太明显
  • 受表单数据量的限制

所以我们需要引入自定义的ID生成器 ---> 全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:

  1. 唯一性
  2. 高性能
  3. 高可用
  4. 递增性
  5. 安全性
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. 分布式锁

  1. 前提引入

在单体系统中,出现的并发安全问题可以通过锁去解决,确保串行执行。
如果现在有多台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);
    }
}

  1. 分布式锁的概念
    分布式锁是满足分布式系统或集群模式下多线程可见并且互斥的锁。
  2. 利用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;所以这里我们应该使用BLPOPBLPOP来实现阻塞效果。

优点:

  • 利用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中第一个消息开始

优点:

  • 消息可回溯
  • 可以多消费者争抢消息,加快消费速度
  • 可以阻塞读取
  • 没有消息漏读的风险
  • 有消息确认机制,保证消息至少被消费一次
posted @ 2024-11-02 16:43  普信小林  阅读(25)  评论(0)    收藏  举报