Redis实战--黑马点评

文章目录


SSM: https://cyborg2077.github.io/2022/09/10/SSMIntegration/
全文的参考链接: https://cyborg2077.github.io/2022/10/22/RedisPractice/
优快云

关键代码

用户UserDTO线程

UserDTO:Long id , nickname, icon

public class UserHolder {
    private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();

    public static void saveUser(UserDTO user){
        tl.set(user);
    }

    public static UserDTO getUser(){
        return tl.get();
    }

    public static void removeUser(){
        tl.remove();
    }
}

验证码登录

redis数据本身就是共享的,就可以避免session共享的问题了

  1. 根据phone从redis获取验证码并校验,不存在创建用户;
  2. 为该用户生成一个唯一的 token,并将 UserDTO 转换为 Map 后与该 token 一起存储在 Redis 中。并设置有效期
public Result login(LoginFormDTO loginForm, HttpSession session) {
        // 1.校验手机号
        String phone = loginForm.getPhone();
        if (RegexUtils.isPhoneInvalid(phone)) {
            // 2.如果不符合,返回错误信息
            return Result.fail("手机号格式错误!");
        }
        // 3.从redis获取验证码并校验
        String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
        String code = loginForm.getCode();
        if (cacheCode == null || !cacheCode.equals(code)) {
            // 不一致,报错
            return Result.fail("验证码错误");
        }

        // 4.一致,根据手机号查询用户 select * from tb_user where phone = ?
        User user = query().eq("phone", phone).one();

        // 5.判断用户是否存在
        if (user == null) {
            // 6.不存在,创建新用户并保存
            user = createUserWithPhone(phone);
        }

        // 7.保存用户信息到 redis中
        // 7.1.随机生成token,作为登录令牌
        String token = UUID.randomUUID().toString(true);
        // 7.2.将User对象转为HashMap存储
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
        Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                CopyOptions.create()
                        .setIgnoreNullValue(true)
                        .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
        // 7.3.存储
        String tokenKey = LOGIN_USER_KEY + token;
        stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
        // 7.4.设置token有效期
        stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);

        // 8.返回token
        return Result.ok(token);
    }

对应的发送验证码:

 public Result sendCode(String phone, HttpSession session) {
        // 1.校验手机号
        if (RegexUtils.isPhoneInvalid(phone)) {
            // 2.如果不符合,返回错误信息
            return Result.fail("手机号格式错误!");
        }
        // 3.符合,生成验证码
        String code = RandomUtil.randomNumbers(6);

        // 4.保存验证码到redis
        stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);//和上面 3.从redis获取验证码并校验 对应

        // 5.发送验证码
        log.debug("发送短信验证码成功,验证码:{}", code);
        // 返回ok
        return Result.ok();
    }

登录凭证保存在前端浏览器,所以不能用phone之类的个人数据
在这里插入图片描述
Redis情况:
在这里插入图片描述

拦截器token判断

public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    // 1.获取请求头中的token
    String token = request.getHeader("authorization");
    if (StrUtil.isBlank(token)) {
        return true;
    }
    // 2.基于TOKEN获取redis中的用户
    String key  = LOGIN_USER_KEY + token;
    Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
    // 3.判断用户是否存在
    if (userMap.isEmpty()) {
        return true;
    }
    // 5.将查询到的hash数据转为UserDTO
    UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
    // 6.存在,保存用户信息到 ThreadLocal
    UserHolder.saveUser(userDTO);
    // 7.刷新token有效期
    stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
    // 8.放行
    return true;
}

点赞

内存肯定够用,而且redis有持久化机制,数据可以被持久化到磁盘

Blog.userId和Use.id关联
给Blog类中添加一个isLike字段,标示是否被当前用户点赞
判断这篇笔记是否被点赞过:stringRedisTemplate.opsForZSet().score(key, userId.toString())

点赞功能的实现:

public Result likeBlog(Long id) {
        // 1.获取登录用户
        Long userId = UserHolder.getUser().getId();
        // 2.判断当前登录用户是否已经点赞
        String key = "blog:liked:" + id;
        Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
        if (score == null) {
            // 3.如果未点赞,可以点赞
            // 3.1.数据库点赞数 + 1
            boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
            // 3.2.保存用户到Redis的set集合  zadd key value score
            if (isSuccess) {
                stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());
            }
        } else {
            // 4.如果已点赞,取消点赞
            // 4.1.数据库点赞数 -1
            boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
            // 4.2.把用户从Redis的set集合移除
            if (isSuccess) {
                stringRedisTemplate.opsForZSet().remove(key, userId.toString());
            }
        }
        return Result.ok();
    }

两个地方需要用到:首页搜索推荐的分页查询 和 点进该笔记
判断是否点赞过,只需要笔记ID即博客ID,获取当前用户,查Redis
// 1.查询top5的点赞用户 zrange key 0 4

  Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);

这样并没解决频繁操作数据库,可以弄个异步任务,定时去同步redis的笔记的点赞数量,同步到mysql。现在的是点赞一次操作一次数据

lua实现一人一单,发送订单消息到队列

@Res**加粗样式**ource
private StringRedisTemplate stringRedisTemplate;
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;

static {
    SECKILL_SCRIPT = new DefaultRedisScript<>();
    SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
    SECKILL_SCRIPT.setResultType(Long.class);
}

public Result seckillVoucher(Long voucherId) {
    Long userId = UserHolder.getUser().getId();
    long orderId = redisIdWorker.nextId("order");//生成分布式ID
    // 1.执行lua脚本
    Long result = stringRedisTemplate.execute(
            SECKILL_SCRIPT,
            Collections.emptyList(),
            voucherId.toString(), userId.toString(), String.valueOf(orderId)
    );
    int r = result.intValue();
    // 2.判断结果是否为0
    if (r != 0) {
        // 2.1.不为0 ,代表没有购买资格
        return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
    }
    // 3.返回订单id
    return Result.ok(orderId);
}

在这里插入图片描述
seckill.lua内容:

-- 1. 参数列表 
-- 1.1. 优惠券 ID
local voucherId = ARGV[1]
-- 1.2. 用户 ID
local userId = ARGV[2]
-- 1.3. 订单 ID
local orderId = ARGV[3]

-- 2. 数据 key
-- 2.1. 库存 key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2. 订单 key
local orderKey = 'seckill:order:' .. voucherId

-- 3. 脚本业务
-- 3.1. 判断库存是否充足 get stockKey
if(tonumber(redis.call('get', stockKey)) <= 0) then
    -- 3.2. 库存不足,返回 1
    return 1
end
-- 3.2. 判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
    -- 3.3. 存在,说明是重复下单,返回 2
    return 2
end
-- 3.4. 扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5. 下单(保存用户)sadd orderKey userId
redis.call('sadd', orderKey, userId)
-- 3.6. 发送消息到队列中, XADD stream.orders * k1 v1 k2 v2 ...
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0

主要功能:

  1. 参数接收:通过 ARGV 参数接收优惠券 ID(voucherId)、用户 ID(userId)和订单 ID(orderId)。
    即Long result = stringRedisTemplate.execute(
    SECKILL_SCRIPT,
    Collections.emptyList(),
    voucherId.toString(), userId.toString(), String.valueOf(orderId)
    );的参数
  2. 库存判断:使用 Redis 的 get 命令检查库存是否充足。如果库存为 0 或更少,返回 1 表示库存不足。
  3. 重复下单检查:使用 sismember 检查用户是否已经下单。如果是,返回 2 表示重复下单。//SISMEMBER 是 Redis 的一个原生命令,用于检查一个元素是否存在于 Redis 的 集合(set)中。
  4. 扣除库存:通过 incrby 命令扣减库存,每次秒杀成功后,库存减 1。
  5. 记录订单:使用 sadd 将用户 ID 加入到 orderKey 中,表示该用户已成功下单。
  6. 发送消息到队列:使用 xadd 将订单信息发送到 Redis 流(stream.orders),用于后续的异步处理,如创建订单、扣减库存等。

Controller:提供一个 REST API 接口,接受用户请求并调用业务逻辑。

@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {

    @Resource
    private IVoucherOrderService voucherOrderService;

    @PostMapping("seckill/{id}")
    public Result seckillVoucher(@PathVariable("id") Long voucherId) {
        return voucherOrderService.seckillVoucher(voucherId);
    }
}

从队列中获取订单信息,创建订单

代码工作流程
初始化:
@PostConstruct 注解的 init() 方法会在应用启动后自动执行,并启动一个单线程的 ExecutorService(SECKILL_ORDER_EXECUTOR),提交 VoucherOrderHandler 任务,开始处理秒杀订单。
消费者从 Redis Stream 读取订单消息:

VoucherOrderHandler 类中的 run() 方法循环读取 Redis Stream 中的订单信息,并通过 XREADGROUP 命令获取新消息。每次获取一条订单消息,处理后确认该消息。
异常处理和重试:

在处理订单过程中出现异常时,会调用 handlePendingList 方法,重新处理那些未确认的消息。
分布式锁确保顺序性:

每个用户的订单创建都通过分布式锁 RLock 来保证,避免同一个用户多次下单。

import java.util.concurrent.Executors;

private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

    @PostConstruct
    private void init() {
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }

    private class VoucherOrderHandler implements Runnable {

        @Override
        public void run() {
            while (true) {
                try {
                    // 1.获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 >
     
                    List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
        Consumer.from("g1", "c1"),  // 消费者组 g1 中的消费者 c1
        StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)), // 读取 1 条消息,最多阻塞 2 秒
        StreamOffset.create("stream.orders", ReadOffset.lastConsumed())  // 从上次消费的位置开始读取
);

                  // 2.判断订单信息是否为空
                    if (list == null || list.isEmpty()) {
                        // 如果为null,说明没有消息,继续下一次循环
                        continue;
                    }
                    // 解析数据
                    MapRecord<String, Object, Object> record = list.get(0);
                    Map<Object, Object> value = record.getValue();
                    VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
                    // 3.创建订单
                    createVoucherOrder(voucherOrder);
                    // 4.确认消息 XACK
                    stringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId());
                } catch (Exception e) {
                    log.error("处理订单异常", e);
                    handlePendingList();
                }
            }
        }

XREADGROUP:这个命令是消费者组专用的,它允许消费者组的成员从指定流中读取数据。每次读取后,Redis 会记录每个消费者读取到的最后一条消息的 ID,确保每个消费者不会重复消费消息。

List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
        Consumer.from("g1", "c1"),  // 消费者组 g1 中的消费者 c1
        StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)), // 读取 1 条消息,最多阻塞 2 秒
        StreamOffset.create("stream.orders", ReadOffset.lastConsumed())  // 从上次消费的位置开始读取
);

XACK:该命令用于在消费者成功处理消息后,向 Redis 表示确认该消息已被成功消费。确认的消息会从消费者组的 pending list 中移除。

stringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId());

这段代码在成功处理订单后,通过 XACK 命令确认已经消费了流中的消息

handlePendingList 方法重新处理那些未确认的消息。

 private void handlePendingList() {
            while (true) {
                try {
                    // 1.获取pending-list中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 0
                    List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                            Consumer.from("g1", "c1"),
                            StreamReadOptions.empty().count(1),
                            StreamOffset.create("stream.orders", ReadOffset.from("0"))
                   );
                    // 2.判断订单信息是否为空
                    if (list == null || list.isEmpty()) {
                        // 如果为null,说明没有异常消息,结束循环
                        break;
                    }
                    // 解析数据
                    MapRecord<String, Object, Object> record = list.get(0);
                    Map<Object, Object> value = record.getValue();
                    VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
                    // 3.创建订单
                    createVoucherOrder(voucherOrder);
                    // 4.确认消息 XACK
                    stringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId());
                } catch (Exception e) {
                    log.error("处理订单异常", e);
                }
            }
        }

StreamOffset.create(“stream.orders”, ReadOffset.from(“0”)): 这个部分非常关键,ReadOffset.from(“0”) 表示从流的最开始位置读取消息。通常情况下,XREADGROUP 会从 上次消费的位置(ReadOffset.lastConsumed())开始读取,但在 handlePendingList 中,它是从流的 最开始位置 读取消息,意味着它将读取所有 未确认的挂起消息

创建订单

private void createVoucherOrder(VoucherOrder voucherOrder) {
        Long userId = voucherOrder.getUserId();
        Long voucherId = voucherOrder.getVoucherId();
        // 创建锁对象
        RLock redisLock = redissonClient.getLock("lock:order:" + userId);
        // 尝试获取锁
        boolean isLock = redisLock.tryLock();
        // 判断
        if (!isLock) {
            // 获取锁失败,直接返回失败或者重试
            log.error("不允许重复下单!");
            return;
        }

        try {
            // 5.1.查询订单
            int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
            // 5.2.判断是否存在
            if (count > 0) {
                // 用户已经购买过了
                log.error("不允许重复下单!");
                return;
            }

            // 6.扣减库存
            boolean success = seckillVoucherService.update()
                    .setSql("stock = stock - 1") // set stock = stock - 1
                    .eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
                    .update();
            if (!success) {
                // 扣减失败
                log.error("库存不足!");
                return;
            }

            // 7.创建订单
            save(voucherOrder);
        } finally {
            // 释放锁
            redisLock.unlock();
        }
    }

g1 是消费者组的名字,表示一组消费者一起读取流中的消息。
c1 是消费者的名字,在消费者组 g1 中的一个消费者实例。每个消费者组可以包含多个消费者,且每个消费者负责读取流中的一部分数据。
消费者组和消费者的工作方式:
消费者组:Redis 的 Stream 使用消费者组来管理消息的消费。消费者组的目的是让多个消费者共享消息,但每个消息只被组内的一个消费者消费。
消费者:每个消费者会从消息流中获取消息,并进行处理。在你的代码中,c1 是 g1 消费者组中的一个消费者。c1 会读取流中的消息,处理订单,并确认消息。

签到

public Result signCount() {
        // 1.获取当前登录用户
        Long userId = UserHolder.getUser().getId();
        // 2.获取日期
        LocalDateTime now = LocalDateTime.now();
        // 3.拼接key
        String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
        String key = USER_SIGN_KEY + userId + keySuffix;
        // 4.获取今天是本月的第几天
        int dayOfMonth = now.getDayOfMonth();
        // 5.获取本月截止今天为止的所有的签到记录,返回的是一个十进制的数字 BITFIELD sign:5:202203 GET u14 0
        List<Long> result = stringRedisTemplate.opsForValue().bitField(
                key,
                BitFieldSubCommands.create()
                        .get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0)
        );
        if (result == null || result.isEmpty()) {
            // 没有任何签到结果
            return Result.ok(0);
        }
        Long num = result.get(0);
        if (num == null || num == 0) {
            return Result.ok(0);
        }
        // 6.循环遍历
        int count = 0;
        while (true) {
            // 6.1.让这个数字与1做与运算,得到数字的最后一个bit位  // 判断这个bit位是否为0
            if ((num & 1) == 0) {
                // 如果为0,说明未签到,结束
                break;
            }else {
                // 如果不为0,说明已签到,计数器+1
                count++;
            }
            // 把数字右移一位,抛弃最后一个bit位,继续下一个bit位
            num >>>= 1;
        }
        return Result.ok(count);
    }

Session登录

1.2 基于Session实现登录流程

手机或者app端发起请求,请求我们的nginx服务器,nginx基于七层模型走的事HTTP协议,可以实现基于Lua直接绕开tomcat访问redis,也可以作为静态资源服务器,轻松扛下上万并发, 负载均衡到下游tomcat服务器,打散流量,我们都知道一台4核8G的tomcat,在优化和处理简单业务的加持下,大不了就处理1000左右的并发, 经过nginx的负载均衡分流后,利用集群支撑起整个项目,同时nginx在部署了前端项目后,更是可以做到动静分离,进一步降低tomcat服务的压力,这些功能都得靠nginx起作用,所以nginx是整个项目中重要的一环。

校验登录状态:
用户在请求时候,会从cookie中携带者JsessionId到后台,后台通过JsessionId从session中拿到用户信息,如果没有session信息,则进行拦截,如果有session信息,则将用户信息保存到threadLocal中,并且放行
在这里插入图片描述

如果将 Token 存放在请求头中并希望在每次请求时刷新 Token 的存活时间,选择在拦截器中改造是一个合理的设计。

1.7 Redis代替session的业务流程

在这里插入图片描述
String结构,是JSON形式的,占用多一点空间,如果使用哈希,则他的value中只会存储他数据本身。
在设计这个key的时候,我们之前讲过需要满足两点
1、key要具有唯一性
2、key要方便携带
在后台生成一个随机串token,然后让前端带来这个token就能完成我们的整体逻辑了
在这里插入图片描述

 // 7.1.随机生成token,作为登录令牌
    String token = UUID.randomUUID().toString(true);

在这里插入图片描述

public class RefreshTokenInterceptor implements HandlerInterceptor {

    private StringRedisTemplate stringRedisTemplate;

    public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.获取请求头中的token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {
            return true;
        }
        // 2.基于TOKEN获取redis中的用户
        String key  = LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
        // 3.判断用户是否存在
        if (userMap.isEmpty()) {
            return true;
        }
        // 5.将查询到的hash数据转为UserDTO
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        // 6.存在,保存用户信息到 ThreadLocal
        UserHolder.saveUser(userDTO);
        // 7.刷新token有效期
        stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 8.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 移除用户
        UserHolder.removeUser();
    }
}
public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.判断是否需要拦截(ThreadLocal中是否有用户)
        if (UserHolder.getUser() == null) {
            // 没有,需要拦截,设置状态码
            response.setStatus(401);
            // 拦截
            return false;
        }
        // 有用户,则放行
        return true;
    }
}

缓存

缓存(Cache),就是数据交换的缓冲区,俗称的缓存就是缓冲区内的数据,一般从数据库中获取,存储于本地代码(例如:

1:Static final ConcurrentHashMap<K,V> map = new ConcurrentHashMap<>(); 本地用于高并发

例2:static final Cache<K,V> USER_CACHE = CacheBuilder.newBuilder().build(); 用于redis等缓存

例3:Static final Map<K,V> map =  new HashMap(); 本地缓存

由于其被Static修饰,所以随着类的加载而被加载到内存之中,作为本地缓存,由于其又被final修饰,所以其引用(例3:map)和对象(例3:new HashMap())之间的关系是固定的,不能改变,因此不用担心赋值(=)导致缓存失效;

应用层缓存:可以分为tomcat本地缓存,比如之前提到的map,或者是使用redis作为缓存
数据库缓存:在数据库中有一片空间是 buffer pool,增改查数据都会先加载到mysql的缓存中

2.2 添加商户缓存

如果缓存有,则直接返回,如果缓存不存在,则查询数据库,然后存入redis。
在这里插入图片描述

2.3 缓存更新策略

缓存更新是redis为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们向redis插入太多数据,此时就可能会导致缓存中的数据过多,所以redis会对部分数据进行更新,或者把他叫为淘汰更合适。
内存淘汰:redis自动进行,当redis内存达到咱们设定的max-memery的时候,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式)
超时剔除:当我们给redis设置了过期时间ttl之后,redis会将超时的数据进行删除,方便咱们继续使用缓存
主动更新:我们可以手动调用方法把缓存删掉,通常用于解决缓存和数据库不一致问题
在这里插入图片描述

2.3.1 数据库缓存不一致解决方案:

数据库中数据发生变化,而缓存却没有同步,此时就会有一致性问题存在
Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库后再去更新缓存,也称之为双写方案

Read/Write Through Pattern : 由系统本身完成,数据库与缓存的问题交由系统本身去处理

Write Behind Caching Pattern :调用者只操作缓存,其他线程去异步处理数据库,实现最终一致
如果采用第一个方案,那么假设我们每次操作数据库后,都操作缓存,但是中间如果没有人查询,那么这个更新动作实际上只有最后一次生效,中间的更新动作意义并不大,我们可以把缓存删除,等待再次查询时,将缓存中的数据加载出来

  • 删除缓存还是更新缓存?

    • 更新缓存:每次更新数据库都更新缓存,无效写操作较多
    • 删除缓存:更新数据库时让缓存失效,查询时再更新缓存
  • 如何保证缓存与数据库的操作的同时成功或失败?

    • 单体系统,将缓存与数据库操作放在一个事务
    • 分布式系统,利用TCC等分布式事务方案
先操作缓存还是先操作数据库?

先操作缓存还是先操作数据库?

  • 先删除缓存,再操作数据库-----会导致> 这样缓存里面就是未更新数据库的值
  • 先操作数据库,再删除缓存

要先操作数据库,再删除缓存
在这里插入图片描述
在分布式系统或高并发场景中,先操作数据库,再删除缓存是一种常见的缓存一致性策略,主要是为了避免数据不一致问题。以下是原因及详细解释:

  1. 操作顺序的重要性

    • 如果您 先删除缓存,再更新数据库,可能会导致缓存和数据库的数据不一致。
    • 假设以下场景:
      1. 线程 A 读取缓存,发现缓存已被删除。
      2. 线程 A 查询数据库,获取旧数据,并重新写入缓存。
      3. 此时,线程 B 更新数据库,使得缓存中的数据变成了过期数据。
    • 这会导致缓存和数据库之间的数据不一致问题。
  2. 为什么要先操作数据库,再删除缓存
    先更新数据库,确保数据是最新的,然后删除缓存,防止旧数据继续被使用。

  • 数据库是权威数据来源:通过先更新数据库,保证持久化存储中的数据始终是正确的。
  • 删除缓存避免脏数据:在更新完数据库之后,再删除缓存,确保后续的读取操作会触发缓存重新加载,从数据库获取最新数据。
  1. 常见的时序问题
    即使采用“先操作数据库,再删除缓存”的策略,也可能出现并发问题,比如:
  • 线程 A 更新数据库并删除缓存;
  • 同时,线程 B 读取缓存发现为空,去数据库读取旧数据并写入缓存(因为线程 A 的事务未提交)。
  1. 解决时序问题的进一步优化
    为了解决并发导致的缓存不一致问题,可以使用以下方法:
    (1)延时双删策略
    • 在删除缓存后,短时间内再次删除缓存,确保缓存中没有旧数据:
 updateDatabase();
 deleteCache();
 Thread.sleep(delay);  // 等待数据库事务提交
deleteCache();        // 再次删除缓存

(2)异步消息队列更新

  • 使用消息队列确保缓存和数据库的一致性:
    1. 更新数据库后,发送删除缓存的消息到消息队列;
    2. 消费者监听消息队列,执行缓存删除操作。
      (3)分布式锁
  • 在操作缓存和数据库前,使用分布式锁,确保同一时间只有一个线程操作数据库和缓存。
  1. 如果反过来操作(先删缓存再改数据库)
    可能会导致缓存数据被提前写回,出现脏数据问题:
    1. 缓存被删除后,其他线程可能立即查询数据库并重建缓存。
    2. 如果这时数据库还没更新,查询的将是旧数据,导致脏数据写回缓存。
  2. 总结
  • 先操作数据库,再删除缓存 是为了避免缓存中出现脏数据。
  • 常见优化策略包括:延时双删、消息队列同步、分布式锁等。
  • 此方法的核心思想是:以数据库为准,确保缓存一致性,同时减少并发问题的发生。

2.4 实现商铺和缓存与数据库双写一致

核心思路如下:

修改ShopController中的业务逻辑,满足下面的需求:
根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间
根据id修改店铺时,先修改数据库,再删除缓存
在这里插入图片描述

2.5 缓存穿透问题的解决思路

缓存穿透 :指用户请求的数据既不在缓存中,也不在数据库中,这样的请求绕过了缓存,直接访问了数据库,造成了不必要的数据库查询压力。缓存穿透的原因通常是由于某些请求访问了不存在的数据,导致每次都访问数据库,从而导致缓存的失效。

常见的解决方案有两种:

  • 缓存空对象:即使某个数据在数据库中不存在,我们也把这个数据的“空值”缓存到 Redis 或其他缓存中,这样下一次请求该数据时,就不需要再查询数据库,而是直接从缓存中获取空值,减少不必要的数据库压力。
    • 优点:实现简单,维护方便
    • 缺点:
      • 额外的内存消耗
      • 可能造成短期的不一致
  • 布隆过滤:通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在
    • 优点:内存占用较少,没有多余key
    • 缺点:
      • 实现复杂
      • 存在误判可能(Hash值一样,好的Hash函数可能性为0)
        在这里插入图片描述
        在这里插入图片描述

缓存穿透的解决方案有哪些?

  • 缓存null值
  • 布隆过滤
  • 增强id的复杂度,避免被猜测id规律
  • 做好数据的基础格式校验
  • 加强用户权限校验
  • 做好热点参数的限流

2.7 缓存雪崩问题及解决思路

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。

解决方案:

  • 给不同的Key的TTL添加随机值
  • 利用Redis集群提高服务的可用性
  • 给缓存业务添加降级限流策略
  • 给业务添加多级缓存
    在这里插入图片描述

2.8 缓存击穿问题及解决思路

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

常见的解决方案有两种:

  • 互斥锁
  • 逻辑过期

a.数据库与缓存数据不一致,导致多线程安全问题
根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间
根据id修改店铺时,先修改数据库,再删除缓存。等下一次再来查询数据时,再从数据库中加载到缓存中。
b.缓存穿透:布隆过滤器实现较为复杂所以就用了缓存空对象的方法。
先从前台提交要查询的商户id,打到redis中判断是否命中缓存,如果redis中命中缓存了,再判断缓存是否是空值,如果是的话,直接结束查询,不是的话就返回数据。如果redis没有命中的话,就来到数据库中查询,在数据库中查询到了数据,便将数据写入缓存,同时返回数据。如果没有的话,直接将空数据写入缓存,结束查询。
c.缓存击穿热key问题–>逻辑过期的实现:把过期时间设置在 redis的value中,注意:这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去处理。Redisson 提供了分布式锁机制,可以在缓存失效后,确保只有一个线程能够更新缓存,从而避免大量请求直接击中数据库

3、优惠卷秒杀

每个店铺都可以发布优惠券:
当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题:

3.1 -SnowFlake算法来生成分布式环境下的全局唯一ID

在这里插入图片描述
自己JAVA实现SnowFlake

getId()方法就是生成一个唯一ID的入口,每次调用这个方法时,都会返回一个由SnowFlake实例生成的ID。实现了SnowFlake算法的单例模式,保证了ID生成器在多线程环境下的唯一性和线程安全。

3.3 添加优惠卷

每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀抢购:
tb_voucher:优惠券的基本信息,优惠金额、使用规则等
tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息

平价卷由于优惠力度并不是很大,所以是可以任意领取

而代金券由于优惠力度大,所以像第二种卷,就得限制数量,从表结构上也能看出,特价卷除了具有优惠卷的基本信息以外,还具有库存,抢购时间,结束时间等等字段
在这里插入图片描述

3.4 实现秒杀下单

当用户开始进行下单,我们应当去查询优惠卷信息,查询到优惠卷信息,判断是否满足秒杀条件

比如时间是否充足,如果时间充足,则进一步判断库存是否足够,如果两者都满足,则扣减库存,创建订单,然后返回订单id,如果有一个条件不满足则直接结束。

3.5 库存超卖问题分析

超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:而对于加锁,我们通常有两种解决方案:
在这里插入图片描述

悲观锁:

悲观锁可以实现对于数据的串行化执行,比如syn,和lock都是悲观锁的代表,同时,悲观锁中又可以再细分为公平锁,非公平锁,可重入锁,等等

乐观锁:

乐观锁:会有一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功,这套机制的核心逻辑在于,如果在操作过程中,版本号只比原来大1 ,那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过,当然乐观锁还有一些变种的处理方式比如cas

乐观锁的典型代表:就是cas,利用cas进行无锁化机制加锁,var5 是操作前读取的内存值,while中的var1+var2 是预估值,如果预估值 == 内存值,则代表中间没有被人修改过,此时就将新值去替换 内存值

其中do while 是为了在操作失败时,再次进行自旋操作,即把之前的逻辑再操作一次。
在这里插入图片描述
在这里插入图片描述

3.6 优惠券秒杀-一人一单

让一个用户只能下一个单

4、分布式锁

可见性:多个线程都能看到相同的结果,注意:这个地方说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化的意思

互斥:互斥是分布式锁的最基本的条件,使得程序串行执行

高可用:程序不易崩溃,时时刻刻都保证较高的可用性

高性能:由于加锁本身就让性能降低,所有对于分布式锁本身需要他就较高的加锁性能和释放锁性能
Zookeeper:zookeeper也是企业级开发中较好的一个实现分布式锁的方案

4.2 、Redis分布式锁的实现核心思路

实现分布式锁时需要实现的两个基本方法:

  • 获取锁:

    • 互斥:确保只能有一个线程获取锁
    • 非阻塞:尝试一次,成功返回true,失败返回false
  • 释放锁:

    • 手动释放
    • 超时释放:获取锁时添加一个超时时间

核心思路:
我们利用redis 的setnx方法,当有多个线程进入时,我们就利用该方法,第一个线程进入时,redis 中就有这个key 了,返回了1,如果结果是1,则表示他抢到了锁,那么他去执行业务,然后再删除锁,退出锁逻辑,没有抢到锁的哥们,等待一定时间后重试即可
在这里插入图片描述

在这里插入图片描述

setnx

SETNX(Set if Not Exists)是 Redis 提供的一种原子操作,常用来实现分布式锁。通过 Redis 的 SETNX 命令,可以确保只有一个客户端能成功设置某个 key,从而实现分布式锁的效果。它是通过设置一个特定的 key 来锁定一个资源,其他客户端在尝试获取锁时会发现该 key 已经存在,因此无法获取锁。

使用 SETNX 实现分布式锁

在分布式系统中,多个服务实例可能同时尝试访问共享资源。通过分布式锁,可以保证同一时间只有一个实例能访问该资源。Redis 的 SETNX 操作常常用于这种场景。

基本思路:

  1. 客户端尝试通过 SETNX 设置一个唯一的锁标识符(通常是一个随机值或者唯一标识符)。
  2. 如果 SETNX 返回 1,表示获取锁成功;如果返回 0,表示锁已经存在,获取锁失败。
  3. 锁的有效期一般会设置为一个合理的超时(如 10 秒),以防止由于服务故障或异常导致锁永远无法释放。

1. 使用 SETNX 实现分布式锁的基本步骤

  1. 客户端尝试获取锁

    • 使用 SETNX 命令设置一个唯一的锁标识符(key)。
    • 如果 SETNX 返回 1,表示成功获取锁。
    • 如果返回 0,表示锁已存在,尝试重新获取锁或失败。
  2. 设置锁的有效期

    • 使用 EXPIRE 或者 SET 命令来设置锁的超时,防止死锁。例如,如果没有在规定时间内释放锁,则自动过期。
  3. 释放锁

    • 使用 DEL 删除锁的 key 来释放锁。需要注意的是,在释放锁时必须保证只有持有锁的客户端能删除该锁的 key,以防止其他客户端错误地释放锁。

2. Redis 实现分布式锁的代码示例(使用 Java)

假设我们要使用 Java 来实现 Redis 分布式锁,首先确保你有一个 Redis 客户端库,像是 Jedis 或者 Lettuce

使用 Jedis 实现分布式锁

首先需要在项目中添加 Jedis 依赖:

<!-- pom.xml -->
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>3.6.0</version>
</dependency>

接下来是分布式锁的实现代码:

import redis.clients.jedis.Jedis;
import java.util.UUID;

public class RedisLock {
    private Jedis jedis;
    private static final String LOCK_KEY = "distributed_lock";

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

    // 获取锁
    public boolean acquireLock(String lockValue, int lockTimeout) {
        // 通过 SETNX 命令获取锁
        long result = jedis.setnx(LOCK_KEY, lockValue);
        
        if (result == 1) {
            // 如果设置成功,锁被获取,设置锁的超时时间
            jedis.expire(LOCK_KEY, lockTimeout);
            return true;
        }
        
        // 如果锁已经被其他客户端获取,则返回 false
        return false;
    }

    // 释放锁
    public boolean releaseLock(String lockValue) {
        // 获取锁的值
        String currentValue = jedis.get(LOCK_KEY);

        // 判断锁是否被当前客户端持有
        if (currentValue != null && currentValue.equals(lockValue)) {
            jedis.del(LOCK_KEY); // 删除锁
            return true;
        }

        return false; // 锁不属于当前客户端,无法释放
    }

    public static void main(String[] args) {
        // 创建 Jedis 客户端
        Jedis jedis = new Jedis("localhost", 6379);

        // 创建锁实例
        RedisLock lock = new RedisLock(jedis);
        
        // 使用 UUID 作为唯一锁标识
        String lockValue = UUID.randomUUID().toString();

        // 获取锁(超时设置为 10 秒)
        if (lock.acquireLock(lockValue, 10)) {
            System.out.println("Successfully acquired lock!");
            // 执行某些操作...

            // 释放锁
            if (lock.releaseLock(lockValue)) {
                System.out.println("Successfully released lock!");
            } else {
                System.out.println("Failed to release lock!");
            }
        } else {
            System.out.println("Failed to acquire lock!");
        }

        jedis.close(); // 关闭 Redis 连接
    }
}

3. 锁的超时问题和改进

  • 锁的超时: 上述代码设置了一个超时来避免死锁。当获取锁成功后,设置了 expire 来自动释放锁。如果客户端在处理资源时发生异常或服务崩溃,锁将在超时后自动释放,避免死锁。
  • 锁值的唯一性: 使用 UUID 作为锁的值,以确保每个客户端的锁都是唯一的,避免其他客户端误释放锁。
  • 检测锁是否超时: 如果你的业务需要获取锁的客户端对锁进行检查,也可以在每次获取锁时,使用 GET 命令检查当前锁的值。

4. Redis 分布式锁的注意事项

  • 死锁: 如果客户端获取锁后没有及时释放,且没有设置超时机制,可能会导致死锁。因此,锁应该有有效期,避免由于服务宕机等问题导致无法释放锁。
  • 锁粒度: 锁的粒度不应太粗或太细。锁的粒度过大可能导致性能下降,粒度过小则可能导致多次锁争用。
  • 分布式环境下的原子操作: 使用 Redis 提供的原子操作(如 SETNX)保证分布式锁的正确性和性能。

总结:

  • SETNX 是实现分布式锁的基础命令,通过它可以实现原子性操作来控制锁的获取。
  • 确保设置锁的有效期,防止死锁。
  • 使用 UUID 确保锁值的唯一性,防止误释放锁。

4.4 Redis分布式锁误删情况说明

在这里插入图片描述

4.5 解决Redis分布式锁误删问题

需求:修改之前的分布式锁实现,满足:在获取锁时存入线程标示(可以用UUID表示)
在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致

  • 如果一致则释放锁
  • 如果不一致则不释放锁

核心逻辑:在存入锁时,放入自己线程的标识,在删除锁时,判断当前这把锁的标识是不是自己存入的,如果是,则进行删除,如果不是,则不进行删除。
uuid是用来区分不同jvm的,同一个jvm使用一个uuid
具体代码如下:加锁

private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {
   // 获取线程标示
   String threadId = ID_PREFIX + Thread.currentThread().getId();
   // 获取锁
   Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
   return Boolean.TRUE.equals(success);
}

在这里插入图片描述

4.7 Lua脚本解决多条命令原子性问题

因为线程1的拿锁,比锁,删锁,实际上并不是原子性的

redis.call(‘命令名称’, ‘key’, ‘其它参数’, …)

JAVA:RedisTemplate中,可以利用execute方法去执行lua脚本

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;

public class RedisService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    public void executeLuaScript() {
        // 创建Lua脚本
        String script = "if redis.call('exists', KEYS[1]) == 0 then redis.call('set', KEYS[1], ARGV[1]) end return redis.call('incrby', KEYS[2], ARGV[2])";
        
        // 创建一个DefaultRedisScript对象来指定返回值类型
        RedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);

        // 执行Lua脚本
        Long result = redisTemplate.execute(redisScript, Arrays.asList("user:123", "user:123:score"), "John", 10);

        // 输出执行结果
        System.out.println("Result: " + result);
    }
}

5、分布式锁-redisson

以后用分布式锁直接用redisson
在这里插入图片描述

在这里插入图片描述

GPT. Redis 分布式锁的改进(使用 Redisson

如果需要更为健壮的分布式锁管理,可以使用 Redisson,它是一个基于 Redis 的 Java 客户端,提供了丰富的分布式锁 API。Redisson 提供了更高级的功能,比如可重入锁、锁的等待超时等,适合大规模分布式应用。

<!-- pom.xml -->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.16.1</version>
</dependency>
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.redisson.client.codec.StringCodec;
import org.redisson.config.Config;

public class RedissonLockExample {

    public static void main(String[] args) throws InterruptedException {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://127.0.0.1:6379");

        RedissonClient redisson = Redisson.create(config);

        RLock lock = redisson.getLock("distributed_lock");
        lock.lock();

        try {
            // 执行任务
            System.out.println("Lock acquired, executing task...");
            Thread.sleep(5000);
        } finally {
            lock.unlock();
        }

        redisson.shutdown();
    }
}

Redisson 封装了很多 Redis 锁的高级功能,推荐用于需要更高并发和可靠性的应用。

5.1 分布式锁-redission功能介绍

分布式锁–redisson中:
Redisson提供了.分布式锁的多种多样功能
可重入锁(Reentrant Lock)
公平锁(Fair Lock)…

5.3 分布式锁-redission可重入锁原理

在Lock锁中,他是借助于底层的一个voaltile的一个state变量来记录重入的状态的,比如当前没有人持有这把锁,那么state=0,假如有人持有这把锁,那么state=1,如果持有这把锁的人再次持有这把锁,那么state就会+1 ,如果是对于synchronized而言,他在c语言代码中会有一个count,原理和state类似,也是重入一次就加一,释放一次就-1 ,直到减少成0 时,表示当前这把锁没有被人持有

5.4 分布式锁-redission锁重试和WatchDog机制

在分布式系统中,为了避免多个节点同时执行相同任务,常常会使用分布式锁来确保任务的唯一性和顺序性。Redisson 是一种常见的分布式锁实现,它基于 Redis 提供了分布式锁的功能,并且支持一些高级特性,例如锁重试和 WatchDog 机制。

1. Redisson 锁重试机制 (Lock Retry Mechanism)

Redisson 提供了锁重试机制,允许在尝试获取锁失败时进行重试。这个机制可以有效防止因为锁竞争而导致的长时间阻塞,并且避免了死锁的发生。

  • tryLock(long waitTime, long leaseTime, TimeUnit unit)
    Redisson 的 tryLock 方法可以设置两个重要的时间参数:

    • waitTime:表示如果锁不可用时,最多等待的时间。过了这个时间锁就会放弃。
    • leaseTime:表示锁被持有的最大时间,即锁的过期时间。即使锁的持有者没有释放锁,过期时间到了,锁也会被自动释放。

    通过合理设置这些参数,Redisson 可以避免在锁不可用时永久阻塞。若等待时间内锁可用,Redisson 将会成功获得锁,并且在指定的过期时间后自动释放锁。

  • 重试机制
    在调用 tryLock 时,如果锁不可用,Redisson 会继续尝试获取锁,直到重试超时或获取到锁为止。重试的间隔时间通常是随机的,这样可以有效避免多个请求同时争抢锁时造成的“锁碰撞”问题。

    RLock lock = redisson.getLock("myLock");
    // 设定最多等待5秒,每隔1秒重试一次,锁过期时间为10秒
    boolean isLocked = lock.tryLock(5, 10, TimeUnit.SECONDS);
    

2. Redisson WatchDog 机制

Redisson 提供了 WatchDog 机制,它可以确保在持有锁的客户端未显式释放锁的情况下,自动延长锁的有效期,避免因锁超时释放而导致的并发问题。

  • WatchDog 的作用
    WatchDog 主要是为了防止客户端在持有锁的过程中崩溃或者网络中断,导致锁提前释放,造成其他客户端误认为锁已经释放,从而导致并发问题。它会在客户端持有锁时定期更新锁的过期时间。

  • 如何使用 WatchDog
    在 Redisson 中,WatchDog 默认启用。当你通过 RLock.lock() 方法获取锁时,如果持有锁的时间超过设置的过期时间,Redisson 会自动延长锁的过期时间(称为 WatchDog 机制)。如果你手动释放锁(unlock()),WatchDog 也会停止工作。

    RLock lock = redisson.getLock("myLock");
    // 获取锁时会自动启用 WatchDog
    lock.lock();
    try {
        // 执行任务
    } finally {
        // 释放锁
        lock.unlock();
    }
    

    如果锁被持续持有而没有显式释放,Redisson 会自动延长锁的有效期,直到锁被释放。

  • 配置 WatchDog 的时间间隔
    默认情况下,Redisson 会以默认的时间间隔(通常是30秒)更新锁的过期时间。你可以通过配置 WatchDog 的心跳时间,控制它检查并延长锁有效期的频率。

    Config config = new Config();
    config.useSingleServer().setAddress("redis://127.0.0.1:6379");
    config.setLockWatchdogTimeout(10000); // 设置 WatchDog 超时时间为 10 秒
    RedissonClient redisson = Redisson.create(config);
    

小结

  • 锁重试机制可以有效避免锁竞争导致的阻塞,并允许在锁不可用时设置重试策略。
  • WatchDog 机制可以避免因客户端崩溃或者锁超时导致的锁提前释放问题,确保锁的过期时间在持有期间得到自动延长。

这两个机制的结合使得 Redisson 在高并发的分布式环境中非常可靠,能够确保锁的公平性和有效性。

5.5 分布式锁-redission锁的MutiLock原理

原理:多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功
为了提高Redis的可用性,我们会搭建集群或者主从,现在以主从为例

此时我们去写命令,写在主机上,主机会将数据同步给从机,但是假设主机还没来得及把数据写入到从机去的时候,主机宕机了

哨兵会发现主机宕机了,于是选举一个slave(从机)变成master(主机),而此时新的master(主机)上并没有锁的信息,那么其他线程就可以获取锁,又会引发安全问题

为了解决这个问题。Redisson提出来了MutiLock锁,使用这把锁的话,那我们就不用主从了,每个节点的地位都是一样的,都可以当做是主机,那我们就需要将加锁的逻辑写入到每一个主从节点上,只有所有的服务器都写入成功,此时才是加锁成功,假设现在某个节点挂了,那么他去获取锁的时候,只要有一个节点拿不到,都不能算是加锁成功,就保证了加锁的可靠性

6.秒杀优化

当用户发起请求,此时会先请求Nginx,Nginx反向代理到Tomcat,而Tomcat中的程序,会进行串行操作,分为如下几个步骤

查询优惠券
判断秒杀库存是否足够
查询订单
校验是否一人一单
扣减库存
创建订单

在这六个步骤中,有很多操作都是要去操作数据库的,而且还是一个线程串行执行,这样就会导致我们的程序执行很慢,所以我们需要异步程序执行,那么如何加速呢?

优化方案:我们将耗时较短的逻辑判断放到Redis中,例如:库存是否充足,是否一人一单这样的操作,只要满足这两条操作,那我们是一定可以下单成功的,不用等数据真的写进数据库,我们直接告诉用户下单成功就好了。然后后台再开一个线程,后台线程再去慢慢执行队列里的消息,这样我们就能很快的完成下单业务。
在这里插入图片描述
但是这里还存在两个难点

  • 我们怎么在Redis中快速校验是否一人一单,还有库存判断
  • 我们校验一人一单和将下单数据写入数据库,这是两个线程,我们怎么知道下单是否完成。
    我们需要将一些信息返回给前端,同时也将这些信息丢到异步queue中去,后续操作中,可以通过这个id来查询下单逻辑是否完成

我们现在来看整体思路:当用户下单之后,判断库存是否充足,只需要取Redis中根据key找对应的value是否大于0即可,如果不充足,则直接结束。如果充足,则在Redis中判断用户是否可以下单,如果set集合中没有该用户的下单数据,则可以下单,并将userId和优惠券存入到Redis中,并且返回0,整个过程需要保证是原子性的,所以我们要用Lua来操作,同时由于我们需要在Redis中查询优惠券信息,所以在我们新增秒杀优惠券的同时,需要将优惠券信息保存到Redis

6.2 秒杀优化-Redis完成秒杀资格判断

需求:

  • 新增秒杀优惠券的同时,将优惠券信息保存到Redis中

  • 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功

  • 如果抢购成功,将优惠券id和用户id封装后存入阻塞队列

  • 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能

6.3基于阻塞队列实现秒杀优化

在这里插入图片描述

修改下单的操作,我们在下单时,是通过Lua表达式去原子执行判断逻辑,如果判断结果不为0,返回错误信息,如果判断结果为0,则将下单的逻辑保存到队列中去,然后异步执行

阻塞队列

需求:

  • 如果秒杀成功,则将优惠券id和用户id封装后存入阻塞队列
  • 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
    在这里插入图片描述

步骤二:实现异步下单功能
先创建一个线程池

private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

创建线程任务,秒杀业务需要在类初始化之后,就立即执行,所以这里需要用到@PostConstruct注解

@PostConstruct
private void init() {
    SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}

private class VoucherOrderHandler implements Runnable {
    @Override
    public void run() {
        while (true) {
            try {
                //1. 获取队列中的订单信息
                VoucherOrder voucherOrder = orderTasks.take();
                //2. 创建订单
                handleVoucherOrder(voucherOrder);
            } catch (Exception e) {
                log.error("订单处理异常", e);
            }
        }
    }
}

编写创建订单的业务逻辑

private IVoucherOrderService proxy;
private void handleVoucherOrder(VoucherOrder voucherOrder) {
    //1. 获取用户
    Long userId = voucherOrder.getUserId();
    //2. 创建锁对象,作为兜底方案
    RLock redisLock = redissonClient.getLock("order:" + userId);
    //3. 获取锁
    boolean isLock = redisLock.tryLock();
    //4. 判断是否获取锁成功         
    if (!isLock) {
        log.error("不允许重复下单!");
        return;
    }
    try {
        //5. 使用代理对象,由于这里是另外一个线程,
        proxy.createVoucherOrder(voucherOrder);
    } finally {
        redisLock.unlock();
    }
}

查看AopContext源码,它的获取代理对象也是通过ThreadLocal进行获取的,由于我们这里是异步下单,和主线程不是一个线程,所以不能获取成功

private static final ThreadLocal<Object> currentProxy = new NamedThreadLocal("Current AOP proxy");

但是我们可以将proxy放在成员变量的位置,然后在主线程中获取代理对象

@Override
public Result seckillVoucher(Long voucherId) {
    Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,
            Collections.emptyList(), voucherId.toString(),
            UserHolder.getUser().getId().toString());
    if (result.intValue() != 0) {
        return Result.fail(result.intValue() == 1 ? "库存不足" : "不能重复下单");
    }
    long orderId = redisIdWorker.nextId("order");
    //封装到voucherOrder中
    VoucherOrder voucherOrder = new VoucherOrder();
    voucherOrder.setVoucherId(voucherId);
    voucherOrder.setUserId(UserHolder.getUser().getId());
    voucherOrder.setId(orderId);
    //加入到阻塞队列
    orderTasks.add(voucherOrder);
    //主线程获取代理对象
    proxy = (IVoucherOrderService) AopContext.currentProxy();
    return Result.ok(orderId);
}

在这里插入图片描述

7.5 Redis消息队列-基于Stream的消息队列-消费者组

在这里插入图片描述

9.3 好友关注-Feed流实现方案

Feed流有两种模式:

  1. 拉模式:也叫做读扩散

该模式的核心含义就是:当张三和李四和王五发了消息后,都会保存在自己的邮箱中,假设赵六要读取信息,那么他会从读取他自己的收件箱,此时系统会从他关注的人群中,把他关注人的信息全部都进行拉取,然后在进行排序
推模式:也叫做写扩散。

  1. 推模式是没有写邮箱的,当张三写了一个内容,此时会主动的把张三写的内容发送到他的粉丝收件箱中去,假设此时李四再来读取,就不用再去临时拉取了

优点:时效快,不用临时拉取

缺点:内存压力大,假设一个大V写信息,很多人关注他, 就会写很多分数据到粉丝那边去

推拉模式是一个折中的方案,站在发件人这一段,如果是个普通的人,那么我们采用写扩散的方式,直接把数据写入到他的粉丝中去,因为普通的人他的粉丝关注量比较小,所以这样做没有压力,如果是大V,那么他是直接将数据先写入到一份到发件箱里边去,然后再直接写一份到活跃粉丝收件箱里边去,现在站在收件人这端来看,如果是活跃粉丝,那么大V和普通的人发的都会直接写入到自己收件箱里边来,而如果是普通的粉丝,由于他们上线不是很频繁,所以等他们上线时,再从发件箱里边去拉信息。
在这里插入图片描述

9.4 好友关注-推送到粉丝收件箱

需求:

  • 修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱
  • 收件箱满足可以根据时间戳排序,必须用Redis的数据结构实现
  • 查询收件箱数据时,可以实现分页查询

传统了分页在feed流是不适用的,因为我们的数据会随时发生变化:因为List查询只能按照角标查,所以不用List结构
在这里插入图片描述
数据有变化的,不用List,而采用sortedSet来做,可以进行范围查询,并且还可以记录当前获取数据时间戳最小值,就可以实现滚动分页了

8. redis实现签到等

利用redis中的bitmap数据结构存储签到信息,把每一个bit位对应当月的每一天,形成了映射关系。
zset实现点赞排行榜

Zdd zincrby zscore
https://blog.youkuaiyun.com/m0_68657832/article/details/139177872

 String key = BLOG_LIKED_KEY + id;
        Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
        if (score == null) {
            // 3.如果未点赞,可以点赞
            // 3.1.数据库点赞数 + 1
            boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
            // 3.2.保存用户到Redis的set集合  zadd key value score
            if (isSuccess) {
                stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());
            }

String key = FOLLOW_KEY + userId;
if (isFollow) {
    // 用户为关注,则关注
    Follow follow = new Follow();
    follow.setUserId(userId);
    follow.setFollowUserId(followUserId);
    boolean isSuccess = this.save(follow);
    if (isSuccess) {
        // 用户关注信息保存成功,把关注的用户id放入Redis的Set集合中,
        stringRedisTemplate.opsForSet().add(key, followUserId.toString());
    }
        
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = USER_SIGN_KEY + userId + keySuffix;
// 获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
// 写入Redis SETBIT key offset 1
stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值