Redis之秒杀活动

目录

全局唯一ID:

为什么 count 不可能为 null?

为什么返回值是 timestamp << COUNT_BITS | count?

整体的逻辑 

(1) 生成时间戳

(2) 生成序列号

(3) 拼接时间戳和序列号

超卖问题:

基于版本号的乐观锁

CAS思想

一人一单:

不建议在方法上直接加锁

更合理的加锁方式

问题:toString 无法保证锁唯一性

解决方法:使用 String.intern()

将锁移动到外部

注意事务失效问题

分布式锁: 

什么是分布式锁?

分布式锁的关键特性

分布式锁的选择

ILOCK接口:

 锁类的代码:

为什么需要面向接口编程?

解耦,提高代码灵活性

为什么不能仅仅使用 Thread.currentThread().getId() 作为锁的唯一标识存储在 Redis 中?

1. 存在冲突和混淆的风险

2. 不同 JVM 的线程 ID 可能重复

解决方案

问题描述

 解决Redis误删锁的问题:

用 Lua 解决锁判断和释放的原子性问题

背景问题

Lua 脚本解决

Redisson

异步秒杀


全局唯一ID:

 为什么采用64-bit long 类型,时间戳为 32-bit,序列号为 31-bit?

采用 64-bit long 类型

  • Java 的 long 类型是 64-bit,能够表示的整数范围非常大,适合生成全局唯一的 ID。
  • 使用一个整数类型作为唯一 ID,比字符串等其他类型更加高效,节省存储空间和计算资源。

时间戳 32-bit

  • 时间戳占据 32 位,足够表示未来很长一段时间的秒数。以 BEGIN_TIMESTAMP = 1736121600 为起点(约为 2024 年 12 月 1 日),加上 2³² 秒(约 136 年),可以覆盖到 2160 年。

序列号 31-bit

  • 序列号占据 31 位,支持在同一秒内生成 2³² - 1个唯一的 ID(约 21 亿个),足够应对大多数高并发场景。

保留 1-bit 未使用

  • 通常,最高位(第 64 位)保留不用,以防止负数的情况(符号位)。如果需要,也可以将它用于其他目的。一般第一位为0保证ID值不为负数

使用Redis实现全局唯一ID:

package com.hmdp.utils;

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;

@Component
@RequiredArgsConstructor
public class RedisIdWorker {
    final StringRedisTemplate stringRedisTemplate;
    /**
     * 开始时时间戳
     *
     */
    private  static final long BEGIN_TIMESTAMP = 1736121600L;
    private static  final  int COUNT_BITS = 32; // 表示时间戳需要往左移动几位
    public long nextId(String keyPrefix){
         // 生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowEpochSecond = now.toEpochSecond(ZoneOffset.UTC);
        // 表示和初始时间相差多少
        long timestamp = nowEpochSecond - BEGIN_TIMESTAMP;

        // 生成序列号
        // 获取生成ID号的时间
        String data = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + data);
        // 为什么count不可能是null呢?
        // 如果不加上时间作为key的话,Redis的自增值会达到上限
        // 拼接并且返回
        return timestamp << COUNT_BITS | count;
    }

    public static void main(String[] args) {
        LocalDateTime time = LocalDateTime.of(2025, 1, 6, 0, 0, 0);
        long epochSecond = time.toEpochSecond(ZoneOffset.UTC);
        System.out.println(epochSecond);
    }

}

为什么 count 不可能为 null

count 的值来源于 stringRedisTemplate.opsForValue().increment() 方法:

increment() 是 Redis 的自增操作:

  • 如果指定的键不存在,Redis 会自动初始化键值为 0,然后执行自增操作,结果是 1。
  • 因此,increment() 的返回值不可能为 null

为什么返回值是 timestamp << COUNT_BITS | count

timestamp << COUNT_BITS

  • 将时间戳左移 32 位(COUNT_BITS),腾出低 32 位给序列号。时间戳占高位,保证生成的 ID 随时间递增。

| count

  • 使用按位或操作,将序列号填入低 32 位。序列号是每秒递增的,用于区分同一秒内的多个 ID。
  • 按位或操作(|)在二进制中逐位比较两个数的每一位,只要有一个为 1,结果就为 1。这个特性使得它可以将某些位的值“合并”到一个数字中,而不改变原来其他位的值。
  • 这两个部分结合起来形成一个唯一的 64-bit 整数 ID。

整体的逻辑 

(1) 生成时间戳

long nowEpochSecond = now.toEpochSecond(ZoneOffset.UTC);

long timestamp = nowEpochSecond - BEGIN_TIMESTAMP;

  • 当前时间 now 转换为秒级时间戳。
  • 减去初始时间戳 BEGIN_TIMESTAMP,得到相对时间戳 timestamp
(2) 生成序列号

long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + data);

  • 基于 Redis 的 increment() 操作生成序列号。
  • 每秒一个 Redis 键("icr:<keyPrefix>:<data>"),保证同一秒内序列号从 1 开始递增。
  • 如果 Redis 键不存在,increment() 会初始化为 0,然后返回 1。
(3) 拼接时间戳和序列号

return timestamp << COUNT_BITS | count;

  • 将时间戳左移 32 位(COUNT_BITS),腾出低 32 位。
  • 通过按位或操作将序列号填入低 32 位。
  • 得到一个 64-bit 的唯一整数 ID。

UUID:

雪花算法(snowflake) :

超卖问题:

基于版本号的乐观锁

原理:在数据表中新增一个版本号字段(如 version),每次更新数据时,要求 version 字段值与当前数据库中的版本号匹配,只有匹配成功时才允许更新。更新后,version 自动加一。

流程

  1. 读取数据时,获取当前版本号。
  2. 更新数据时,带上该版本号作为条件。
  3. 数据库执行更新时会检查版本号是否一致,若一致,则更新成功;否则更新失败。
-- 查询数据和版本号
SELECT id, name, version FROM user WHERE id = 1;

-- 更新数据时,使用版本号作为条件
UPDATE user 
SET name = 'new_name', version = version + 1 
WHERE id = 1 AND version = 1;

优点:简单直观,可防止并发修改。

缺点:需要在表中增加版本号字段。

CAS思想

CAS 是一种基于比较和交换的机制,用于确保数据的原子性更新。

基于版本号的乐观锁虽然是一种常见实现方式,但其缺点是需要在数据库表中额外新增一个字段(如 version 字段)。然而,我们可以利用数据库中已有的字段(例如价格、库存数量、更新时间等),通过在执行 SQL 语句时将这些字段的值与执行前查询到的值进行比较,实现类似的乐观锁功能,从而避免新增字段的开销。

CAS 操作包含以下三个核心要素:

执行前查询到的值

  • 这是在操作开始之前,从数据库中读取的字段值(例如,某个商品的库存数量 stock = 100)。
  • 它反映了我们未被修改之前数据的状态,在后续更新时用于与数据库中的当前值进行比较。

当前值(Current Value)

  • 这是在执行 SQL 更新时,数据库中该字段的实际值。
  • 如果数据库中的当前值与预期值一致,说明数据在操作期间未被其他事务修改,可以安全地执行更新操作。

判断之前是否已经被修改了

  • 如果预期值与当前值相等,则执行更新操作,将字段值更新为目标值。
  • 如果不相等,说明数据在操作期间已经被其他事务修改,放弃修改数据库,返回失败。

使用乐观锁存在一个缺陷,就是只要发现别人修改了就放弃执行sql语句,会导致请求的大量失败。

所以还有一个更简单的办法,就是在更新的时候,查看库存是否大于0即可

​
@Service
@RequiredArgsConstructor
@Transactional
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
    @Resource
    private ISeckillVoucherService seckillVoucherService;
    final RedisIdWorker redisIdWorker;
    @Override

    public Result seckillVoucher(Long voucherId) {
        // 查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        // 判断秒杀是否开始
        LocalDateTime beginTime = voucher.getBeginTime();
        if(LocalDateTime.now().isBefore(beginTime)){
            return Result.fail("秒杀还未开始");
        }
        // 判断秒杀是否已经结束
        LocalDateTime endTime = voucher.getEndTime();
        if(LocalDateTime.now().isAfter(endTime)){
            return Result.fail("秒杀已经结束");
        }
        // 判断库存是否充足
        if (voucher.getStock() < 1) {
            return Result.fail("库存不足!");
        }
        // 扣减库存
        boolean success = seckillVoucherService.update().setSql("stock = stock - 1").
                eq("voucher_id", voucherId).
//                eq("stock",voucher.getStock()). // 加个乐观锁,如果现在的库存和我之前查询的库存不相同,说明在我之前就有线程修改了数据库
        gt("stock", 0).
                update();
        if (!success) {
            return Result.fail("库存不足!");
        }
        // 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        // 订单id
        long orderID = redisIdWorker.nextId("order");
        voucherOrder.setId(orderID);
        // 用户id
        voucherOrder.setUserId(UserHolder.getUser().getId());
        // 代金券id
        voucherOrder.setVoucherId(voucherId);
        // 写入数据库
        save(voucherOrder);
        // 返回订单id
        return Result.ok(orderID);

    }
}

​

一人一单:

也就是需要在在判断库存充足之后还需要根据优惠券的id和用户的id来查询订单。如果订单存在就返回异常(说明该用户之前已经购买过了) 如果订单不存在我们就可以扣减库存并且可以创建订单。

// 前面代码一致。。。。 
// 实现一人一单,我们需要先判断该用户是否已经抢过了
        // 根据优惠券id和用户id查询订单
        Long userId = UserHolder.getUser().getId();
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if (count > 0) {
            return Result.fail("已经购买过,不可重复购买!");
        }
        // 扣减库存
        boolean success = seckillVoucherService.update().setSql("stock = stock - 1").
                eq("voucher_id", voucherId).
//                eq("stock",voucher.getStock()). // 加个乐观锁,如果现在的库存和我之前查询的库存不相同,说明在我之前就有线程修改了数据库
        gt("stock", 0).
                update();
        if (!success) {
            return Result.fail("库存不足!");
        }
        // 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        // 订单id
        long orderID = redisIdWorker.nextId("order");
        voucherOrder.setId(orderID);
        // 用户id

        voucherOrder.setUserId(UserHolder.getUser().getId());
        // 代金券id
        voucherOrder.setVoucherId(voucherId);
        // 写入数据库
        save(voucherOrder);
        // 返回订单id
        return Result.ok(orderID);

 在以上我们在查询用户是否已经下单在并发的情况下存在问题:

当线程 A 查询到 count 的值为 0(表示用户之前没有下过单),但还未完成插入订单数据的操作时,线程 B 可能在此时抢到了 CPU 的执行权,并且同样查询到 count 的值为 0。由于这两个线程的操作是并发的,线程 B 也会插入订单数据,导致用户可以多次下单。这种情况违反了“一人一单”的业务需求。

为了避免这种问题的发生,我们需要在 查询用户是否已经下单插入订单数据 这整个过程中使用一把锁进行保护,确保同一时间只有一个线程能够执行这一逻辑。这样,即使多个线程同时进入方法,也只有第一个线程能够完成操作,其余线程会被阻塞或直接返回,达到线程安全的目的。

可以将这段逻辑提取成一个方法,使用事务管理和锁机制,保证查询和插入操作的原子性。具体代码实现如下:

    @Transactional
    public synchronized Result createVoucherOrder(Long voucherId) {
        // 实现一人一单,我们需要先判断该用户是否已经抢过了
        // 根据优惠券id和用户id查询订单
        Long userId = UserHolder.getUser().getId();
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if (count > 0) {
            return Result.fail("已经购买过,不可重复购买!");
        }
        // 扣减库存
        boolean success = seckillVoucherService.update().setSql("stock = stock - 1").
                eq("voucher_id", voucherId).
//                eq("stock",voucher.getStock()). // 加个乐观锁,如果现在的库存和我之前查询的库存不相同,说明在我之前就有线程修改了数据库
        gt("stock", 0).
                update();
        if (!success) {
            return Result.fail("库存不足!");
        }
        // 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        // 订单id
        long orderID = redisIdWorker.nextId("order");
        voucherOrder.setId(orderID);
        // 用户id
        voucherOrder.setUserId(UserHolder.getUser().getId());
        // 代金券id
        voucherOrder.setVoucherId(voucherId);
        // 写入数据库
        save(voucherOrder);
        // 返回订单id
        return Result.ok(orderID);
    }

不建议在方法上直接加锁

将锁对象加在方法上会导致以下问题:

  1. 锁的范围过大:锁定整个方法,降低并发性能。
  2. 锁对象为 this:无论哪个用户访问,都需要获取同一把锁,导致线程串行执行,性能较差。

更合理的加锁方式

加锁的对象应该基于业务需求选择更细粒度的对象,例如用户ID(userId),以减小锁的范围。

    @Transactional
    public  Result createVoucherOrder(Long voucherId) {
        // 实现一人一单,我们需要先判断该用户是否已经抢过了
        // 根据优惠券id和用户id查询订单
        Long userId = UserHolder.getUser().getId();
        synchronized (userId.toString()) {
            //具体的业务逻辑
        }
    }
问题:toString 无法保证锁唯一性

userId.toString() 每次都会生成一个新的字符串对象,因此不能保证锁定的是同一个对象,这会导致 synchronized 失效,从而无法有效控制并发。

解决方法:使用 String.intern()

可以通过 userId.toString().intern() 来保证锁的唯一性。intern() 方法会将字符串存储到 JVM 的字符串常量池中,相同的字符串值会返回相同的引用,确保锁对象唯一。

synchronized (userId.toString().intern()) { //具体的业务逻辑}

调用 intern() 方法后,userId.toString().intern() 能够保证唯一性,是因为 String.intern() 方法将字符串存储到 JVM 的字符串常量池(String Pool)中。对于相同的字符串值,intern() 方法确保返回的引用是同一个对象,从而实现锁对象的唯一性。

在方法内部加锁还存在一个问题:

将锁移动到外部

在方法内部加锁存在另一个问题:
方法结束时锁会释放,但事务提交是延后的。如果在事务提交前,其他线程查询到数据库发现没有订单,仍可能导致“一人多单”的问题。

因此,锁应放在方法外部:

// seckillVoucher方法
public Result seckillVoucher(Long voucherId) {
        // 查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        // 判断秒杀是否开始
        LocalDateTime beginTime = voucher.getBeginTime();
        if(LocalDateTime.now().isBefore(beginTime)){
            return Result.fail("秒杀还未开始");
        }
        // 判断秒杀是否已经结束
        LocalDateTime endTime = voucher.getEndTime();
        if(LocalDateTime.now().isAfter(endTime)){
            return Result.fail("秒杀已经结束");
        }
        // 判断库存是否充足
        if (voucher.getStock() < 1) {
            return Result.fail("库存不足!");
        }
    Long userId = UserHolder.getUser().getId(); 
    synchronized (userId.toString().intern()){
       return createVoucherOrder(voucherId);
    }
}

注意事务失效问题

在上述代码中,直接调用 createVoucherOrder(voucherId) 可能导致事务失效问题。这是因为 Spring 的事务管理基于动态代理,直接调用会绕过代理,导致事务功能失效。

解决方法是通过 AopContext 获取当前类的代理对象来调用方法:

  IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);

如果有多个JVM的存在,每个JVM都会有自己的锁导致每一个锁,因此对于集群来说,还是会导致一人多单的。(还是锁对象不唯一导致的)

在分布式系统中,每个 JVM 都有自己的内存空间,因此即使在单机环境中通过 synchronizedString.intern() 保证了锁对象的唯一性,到了集群环境中,不同的 JVM 实例仍然可能生成不同的锁对象,从而导致 分布式环境下并发控制失效

分布式锁: 

基于Redis的分布式锁:

什么是分布式锁?

分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。

分布式锁是一种跨多个进程或服务器实例的机制,用于在分布式系统中对共享资源实现同步控制。它是为了解决多个进程或服务访问同一资源时的并发问题而设计的,确保在同一时间只有一个进程能够访问某个共享资源。

在单机环境中,synchronizedReentrantLock 等 JVM 内部的锁机制可以很好地解决并发问题,但在分布式系统中,多个实例运行在不同的 JVM 上,内存不共享,传统的锁机制就无法满足需求。这时,就需要 分布式锁

分布式锁的关键特性

  1. 互斥性:同一时间只有一个客户端能够获得锁,确保对共享资源的独占访问。
  2. 容错性:即使一个客户端因故障未释放锁,系统也能通过机制(如超时)保证锁最终释放。
  3. 高可用性:分布式锁的获取和释放需要快速响应,且系统中的单点故障不应导致锁机制不可用

分布式锁的选择

实现方式适用场景优点缺点
Redis 锁高性能、高并发场景简单高效,性能优越锁过期或释放需要谨慎处理
ZooKeeper 锁高可靠性和一致性要求场景可靠性高,支持自动释放性能较低,维护成本高
数据库锁并发量小的场景简单易用,无需额外基础设施性能受限,可能出现锁争用

Redis 储存的实际上是锁的唯一值。这个唯一值的主要作用是区分哪个客户端线程持有了锁

ILOCK接口:

package com.hmdp.utils;

public interface ILock {
    /**
     * 尝试获取锁
     * @param timeoutSec 锁持有的超时时间,过期后自动释放
     * @return true代表获取锁成功;false代表获取锁失败
     */
    boolean tryLock(long timeoutSec);

    /**
     * 释放锁
     */
    void unlock();
}

 锁类的代码:

package com.hmdp.utils;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.concurrent.TimeUnit;

public class SimpleRedisLock implements  ILock{
    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    private static  final String KEY_PREFIX  = "lock";
    private String name; // 锁的名称
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public boolean tryLock(long timeoutSec) {
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, Thread.currentThread().getId()+"",timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success) ; // 自动拆箱可能会存在空指针
    }

    @Override
    public void unlock() {
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}
 public Result seckillVoucher(Long voucherId) {
        // 查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        // 判断秒杀是否开始
        LocalDateTime beginTime = voucher.getBeginTime();
        if(LocalDateTime.now().isBefore(beginTime)){
            return Result.fail("秒杀还未开始");
        }
        // 判断秒杀是否已经结束
        LocalDateTime endTime = voucher.getEndTime();
        if(LocalDateTime.now().isAfter(endTime)){
            return Result.fail("秒杀已经结束");
        }
        // 判断库存是否充足
        if (voucher.getStock() < 1) {
            return Result.fail("库存不足!");
        }

        Long userId = UserHolder.getUser().getId();

        // 创建锁对象
        SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
        // 获取锁
        boolean isLock = lock.tryLock(1200);
        if(! isLock){
            // 说明没有获取到锁
            return Result.fail("不可重复下单!");
        }
        // 我们需要获取代理(事务)对象才行
        //try  finally  事务可以生效,因为没有捕获异常。
        // 如果catch捕获了异常,需要抛出RuntimeException类型异常,不然事务失效。
        // 这里加了catch事务也能生效。因为事务失效的场景是在事务方法内部try catch消化掉异常,而这里try catch是在事务方法外部(可以自己抛异常测试一下)
        try {
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        } finally {
            lock.unlock();
        }
    }

为什么需要面向接口编程?

解耦,提高代码灵活性
  • 问题
    如果直接依赖具体类(例如 SimpleRedisLock),系统会被绑定到 Redis 实现上。如果未来需求变化(如改用 ZooKeeper 或数据库实现分布式锁),就需要修改所有依赖锁的代码,增加维护成本。

  • 解决
    使用接口(例如 ILock)定义分布式锁的行为。业务代码只依赖 ILock 接口,与具体实现无关。这样,当需要更换锁的实现时,只需新增实现类(如 ZookeeperLock),无需修改调用代码,极大提升了灵活性和可扩展性。


为什么不能仅仅使用 Thread.currentThread().getId() 作为锁的唯一标识存储在 Redis 中?

1. 存在冲突和混淆的风险
  • 线程 ID 是数字,直接使用 Thread.currentThread().getId() 可能导致锁值与其他业务逻辑的 Redis 键值冲突或混淆,特别是在调试和监控时,难以区分锁的来源。
2. 不同 JVM 的线程 ID 可能重复
  • 在分布式系统中,不同 JVM 实例可能生成相同的线程 ID。例如,一个 JVM 的线程 ID 为 1,另一个 JVM 的线程也可能是 1,这会导致锁的唯一性失效,从而引发并发问题。

解决方案

使用 UUID + 线程 ID 的组合作为锁的唯一值:

  • UUID 保证跨 JVM 的唯一性。
  • 线程 ID 提供额外的信息,用于定位锁的具体来源。
    String threadId = UUID.randomUUID().toString() + "-" + Thread.currentThread().getId();
    

以上代码还存在一个问题,让我们来看以下的场景

问题描述

  1. 线程 A 获取锁:线程 A 成功获取锁并设置了过期时间。
  2. 线程 A 阻塞:由于业务逻辑执行时间较长,线程 A 被阻塞,锁的过期时间已到,Redis 自动释放了锁。
  3. 线程 B 获取锁:此时线程 B 成功获取到锁并开始执行逻辑。
  4. 线程 A 释放锁:线程 A 在执行完逻辑后尝试释放锁,但此时锁已经被线程 B 重新获取,A 无法确认锁是否属于自己,直接删除了锁。
  5. 线程 C 获取锁:因为锁被 A 误删,线程 C 也获得锁,导致锁的控制完全失效,进而出现并发问题。

为了解决锁误删问题,我们需要在释放锁时确认锁的持有者,确保只有锁的持有者才能释放锁。也就是需要在释放锁之前还需要判断是否是自己的锁。这可以通过以下方法实现: 

 解决Redis误删锁的问题:

添加线程标识,并在删除之前判断线程标识

package com.hmdp.utils;
import cn.hutool.core.lang.UUID;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.concurrent.TimeUnit;

public class SimpleRedisLock implements  ILock{
    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 String name; // 锁的名称
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public boolean tryLock(long timeoutSec) {
        // 不能使用Thread.currentThread().getId()+""作为唯一的key的标识,因为不同的JVM可能会创建出相同的id
        String threadId = ID_PREFIX + Thread.currentThread().getId(); // 使用当前线程的唯一标识(线程ID)作为锁的归属标识。
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name,threadId,timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success) ; // 自动拆箱可能会存在空指针
    }
// 这里还有问题:get和delete不是原子性的。
// 假如时间到期了,另一个线程获取到了锁,这时候你又给删除了
    @Override
    public void unlock() {
        // 判断锁是不是自己的
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        if ( stringRedisTemplate.opsForValue().get(KEY_PREFIX+name).equals(threadId)) {
            // 是自己的锁
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }
}

用 Lua 解决锁判断和释放的原子性问题

背景问题

在 Redis 分布式锁中,可能会出现以下场景:

  1. 线程 A 持有锁并因阻塞导致锁超时。
  2. 线程 B 获得了锁。
  3. 线程 A 恢复运行,并认为锁仍是它的(因为判断逻辑和释放逻辑不是原子性的)。
  4. 线程 A 错误地释放了线程 B 的锁。
Lua 脚本解决

解决方案是针对Java jvm 的阻塞问题的解决。以下是一个示例脚本:

-- Lua 脚本用于判断锁是否属于当前线程并释放
local lockKey = KEYS[1]  -- 锁的 key
local lockValue = ARGV[1]  -- 当前线程的唯一标识

-- 判断锁是否属于当前线程
if redis.call("GET", lockKey) == lockValue then
    -- 如果是,释放锁
    return redis.call("DEL", lockKey)
else
    -- 如果不是,返回 0,不执行删除
    return 0
end

 

-- 比较线程标示与锁中的标示是否一致
if(redis.call('get',KEYS[1])==ARGV[1]) then
-- 释放锁 del key
return redis.call('del', KEYS[1])
end
return 0
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }    
public void unlock() {
        /**
// 判断锁是不是自己的
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        if ( stringRedisTemplate.opsForValue().get(KEY_PREFIX+name).equals(threadId)) {
            // 是自己的锁
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }*/
        // 调用lua脚本
        stringRedisTemplate.execute(UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX + name),
                ID_PREFIX + Thread.currentThread().getId()
                );
    }

DefaultRedisScript 是 Spring Data Redis 提供的类,用于封装 Lua 脚本。

setLocation 指定 Lua 脚本文件的位置,这里脚本文件名为 unlock.lua,存放在类路径(classpath)下。

setResultType 设置 Lua 脚本的返回值类型,此处为 Long,表示 Lua 脚本会返回一个整数(如 01)。

unlock.lua 脚本文件的作用应该是:

  • 判断当前线程是否持有锁。
  • 如果是,则释放锁。
  • 如果不是,则不进行任何操作。

通过 Lua 脚本,将锁的判断和释放操作合并为一个原子性操作,能有效解决线程 A 错误释放线程 B 的锁的问题。Redis 的单线程特性保证了脚本的执行顺序和一致性,是解决此类问题的最佳选择之一。 

Redisson

详细的Redisson的介绍请看

分布式锁Redisson详解,Redisson如何解决不可重入,不可重试,超时释放,主从一致问题的分析解决(包括源码简单分析)-CSDN博客

异步秒杀

我们还可以使用异步秒杀来优化秒杀。详细在以下博客

Redis 优化秒杀(异步秒杀)-CSDN博客

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/951601.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

Linux环境(Ubuntu)上搭建MQTT服务器(EMQX )网络环境部署

支持的 Ubuntu 版本&#xff1a;Ubuntu 22.04(本人使用)、Ubuntu 20.04、Ubuntu 1 下面开始环境搭建&#xff1a; &#xff08;1&#xff09;从github在线地址克隆压缩包&#xff0c;然后进行压缩&#xff08;不好用&#xff09;&#xff0c;在对环境进行依赖包的下载 &…

JavaSE(十五)——认识进程与多线程入门

文章目录 认识进程进程的概念进程的特性进程的状态进程的优先级进程的描述进程的调度 并发与并行认识线程线程的概念Java中线程的表示认识Thread类线程的创建 线程的控制和管理线程启动-start()线程休眠-sleep()线程中断-interrupt()线程插队-join()线程让步-yield() 线程的状态…

EasyCVR视频汇聚平台如何配置webrtc播放地址?

EasyCVR安防监控视频系统采用先进的网络传输技术&#xff0c;支持高清视频的接入和传输&#xff0c;能够满足大规模、高并发的远程监控需求。平台支持多协议接入&#xff0c;能将接入到视频流转码为多格式进行分发&#xff0c;包括RTMP、RTSP、HTTP-FLV、WebSocket-FLV、HLS、W…

【免费开源】积木JimuBI大屏集成ruoyiVue

JimuBI介绍 JimuBI 是一个JAVA语言的低代码数据可视化大屏BI产品&#xff0c;将大屏、仪表盘、移动面板、图表或页面元素封装为基础组件&#xff0c;无需编写代码即可完成业务需求。 这是JeecgBoot团队出品的另外一款报表产品&#xff0c;积木报表已经成为业内报表首先&#x…

mysql binlog 日志分析查找

文章目录 前言一、分析 binlog 内容二、编写脚本结果总结 前言 高效快捷分析 mysql binlog 日志文件。 mysql binlog 文件很大 怎么快速通过关键字查找内容 一、分析 binlog 内容 通过 mysqlbinlog 命令可以看到 binlog 解析之后的大概样子 二、编写脚本 编写脚本 search_…

如何在谷歌浏览器中使用安全沙箱

谷歌浏览器的沙箱机制是一种重要的安全功能&#xff0c;可以有效隔离浏览会话中的每个标签页和插件&#xff0c;以防止恶意软件攻击用户系统。本文将详细介绍如何在谷歌浏览器中启用和使用沙箱功能。 一、什么是谷歌浏览器沙箱&#xff1f; 谷歌浏览器的沙箱是一种安全机制&am…

【C++】C++11(二)

目录 九、可变参数模板十、lambda表达式10.1 C98中的一个例子10.2 lambda表达式10.3 lambda表达式语法10.3.1 lambda表达式各部分说明10.3.2 捕获列表说明 10.4 函数对象与lambda表达式 十一、包装器11.1 function包装器11.2 bind 十二、线程库12.1 线程12.1.1 thread类的简单介…

《零基础Go语言算法实战》【题目 1-16】字符串的遍历与比较

《零基础Go语言算法实战》 【题目 1-16】字符串的遍历与比较 给出两个字符串&#xff0c;请编写程序以确定能否将其中一个字符串重新排列后变成另一个字符串&#xff0c; 并规定大小写是不同的字符&#xff0c;空格也作为字符考虑。保证两个字符串的长度小于或等于 5000。 …

Type-C单口便携显示器-LDR6021

Type-C单口便携显示器是一种新兴的显示设备&#xff0c;它凭借其便携性、高性能和广泛的应用场景等优势&#xff0c;正在成为市场的新宠。以下是Type-C单口便携显示器的具体运用方式&#xff1a; 一、连接与传输 1. **设备连接**&#xff1a;Type-C单口便携显示器通过Type-C接…

聚类系列 (二)——HDBSCAN算法详解

在进行组会汇报的时候&#xff0c;为了引出本研究动机&#xff08;论文尚未发表&#xff0c;暂不介绍&#xff09;&#xff0c;需要对DBSCAN、OPTICS、和HDBSCAN算法等进行详细介绍。在查询相关资料的时候&#xff0c;发现网络上对于DBSCAN算法的介绍非常多与细致&#xff0c;但…

玩转 JMeter:Random Order Controller让测试“乱”出花样

嘿&#xff0c;各位性能测试的小伙伴们&#xff01;今天咱要来唠唠 JMeter 里超级有趣又超实用的 Random Order Controller&#xff08;随机顺序控制器&#xff09;&#xff0c;它就像是性能测试这场大戏里的“魔术棒”&#xff0c;轻轻一挥&#xff0c;就能让测试场景变得千变…

L1G5000 XTuner 微调个人小助手认知

使用 XTuner 微调 InternLM2-Chat-7B 实现自己的小助手认知 1 环境配置与数据准备步骤 0. 使用 conda 先构建一个 Python-3.10 的虚拟环境步骤 1. 安装 XTuner 修改提供的数据步骤 0. 创建一个新的文件夹用于存储微调数据步骤 1. 创建修改脚本步骤 2. 执行脚本步骤 3. 查看数据…

UE5 使用内置组件进行网格切割

UE引擎非常强大&#xff0c;直接内置了网格切割功能并封装为蓝图节点&#xff0c;这项功能在UE4中就存在&#xff0c;并且无需使用Chaos等模块。那么就来学习下如何使用内置组件实现网格切割。 1.配置测试用StaticMesh 对于被切割的模型&#xff0c;需要配置一些参数。以UE5…

springmvc执行分析

步骤分析 1.浏览器客户端携带请求路径&#xff0c;本案例中是“/hello”&#xff0c;通过 web.xml 中的前端控制器配置&#xff0c;发送请求到前端控制器(DispatcherServlet)&#xff0c;并加载 SpringMVC.xml 配置文件&#xff0c;将 HelloController 加载进IOC容器当中&…

LLM - Llama 3 的 Pre/Post Training 阶段 Loss 以及 logits 和 logps 概念

欢迎关注我的CSDN&#xff1a;https://spike.blog.csdn.net/ 本文地址&#xff1a;https://spike.blog.csdn.net/article/details/145056912 Llama 3 是 Meta 公司发布的开源大型语言模型&#xff0c;包括具有 80 亿和 700 亿参数的预训练和指令微调的语言模型&#xff0c;支持…

【python基础——异常BUG】

什么是异常(BUG) 检测到错误,py编译器无法继续执行,反而出现错误提示 如果遇到错误能继续执行,那么就捕获(try) 1.得到异常:try的执行,try内只可以捕获一个异常 2.预案执行:except后面的语句 3.传入异常:except … as uestcprint(uestc) 4.没有异常:else… 5.鉴定完毕,收尾的语…

(长期更新)《零基础入门 ArcGIS(ArcMap) 》实验六----流域综合处理(超超超详细!!!)

流域综合处理 流域综合治理是根据流域自然和社会经济状况及区域国民经济发展的要求,以流域水流失治理为中心,以提高生态经济效益和社会经济持续发展为目标,以基本农田优化结构和高效利用及植被建设为重点,建立具有水土保持兼高效生态经济功能的半山区流域综合治理模式。数字高程…

设计模式与游戏完美开发(3)

更多内容可以浏览本人博客&#xff1a;https://azureblog.cn/ &#x1f60a; 该文章主体内容来自《设计模式与游戏完美开发》—蔡升达 第二篇 基础系统 第五章 获取游戏服务的唯一对象——单例模式&#xff08;Singleton&#xff09; 游戏实现中的唯一对象 在游戏开发过程中…

VSCode 在Windows下开发时使用Cmake Tools时输出Log乱码以及CPP文件乱码的终极解决方案

在Windows11上使用VSCode开发C程序的时候&#xff0c;由于使用到了Cmake Tools插件&#xff0c;在编译运行的时候&#xff0c;会出现输出日志乱码的情况&#xff0c;那么如何解决呢&#xff1f; 这里提供了解决方案&#xff1a; 当Settings里的Cmake: Output Log Encoding里设…

Solidity入门: 函数

函数 Solidity语言的函数非常灵活&#xff0c;可以进行各种复杂操作。在本教程中&#xff0c;我们将会概述函数的基础概念&#xff0c;并通过一些示例演示如何使用函数。 我们先看一下 Solidity 中函数的形式: function <function name>(<parameter types>) {in…