Redis之秒杀活动

目录

全局唯一ID:

[为什么 count 不可能为 null?](#为什么 count 不可能为 null?)

[为什么返回值是 timestamp << COUNT_BITS | count?](#为什么返回值是 timestamp << COUNT_BITS | count?)

整体的逻辑

[(1) 生成时间戳](#(1) 生成时间戳)

[(2) 生成序列号](#(2) 生成序列号)

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

超卖问题:

基于版本号的乐观锁

CAS思想

一人一单:

不建议在方法上直接加锁

更合理的加锁方式

[问题:toString 无法保证锁唯一性](#问题:toString 无法保证锁唯一性)

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

将锁移动到外部

注意事务失效问题

分布式锁:

什么是分布式锁?

分布式锁的关键特性

分布式锁的选择

ILOCK接口:

锁类的代码:

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

解耦,提高代码灵活性

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

[1. 存在冲突和混淆的风险](#1. 存在冲突和混淆的风险)

[2. 不同 JVM 的线程 ID 可能重复](#2. 不同 JVM 的线程 ID 可能重复)

解决方案

问题描述

解决Redis误删锁的问题:

[用 Lua 解决锁判断和释放的原子性问题](#用 Lua 解决锁判断和释放的原子性问题)

背景问题

[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:

java 复制代码
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. 数据库执行更新时会检查版本号是否一致,若一致,则更新成功;否则更新失败。
sql 复制代码
-- 查询数据和版本号
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即可

java 复制代码
​
@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来查询订单。如果订单存在就返回异常(说明该用户之前已经购买过了) 如果订单不存在我们就可以扣减库存并且可以创建订单。

java 复制代码
// 前面代码一致。。。。 
// 实现一人一单,我们需要先判断该用户是否已经抢过了
        // 根据优惠券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 也会插入订单数据,导致用户可以多次下单。这种情况违反了"一人一单"的业务需求。

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

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

java 复制代码
    @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),以减小锁的范围。

java 复制代码
    @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 的字符串常量池中,相同的字符串值会返回相同的引用,确保锁对象唯一。

java 复制代码
synchronized (userId.toString().intern()) { //具体的业务逻辑}

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

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

将锁移动到外部

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

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

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

java 复制代码
// 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 获取当前类的代理对象来调用方法:

java 复制代码
  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接口:

java 复制代码
package com.hmdp.utils;

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

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

锁类的代码:

java 复制代码
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);
    }
}
java 复制代码
 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 提供额外的信息,用于定位锁的具体来源。

    java 复制代码
    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误删锁的问题:

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

java 复制代码
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 复制代码
-- 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

Lua 复制代码
-- 比较线程标示与锁中的标示是否一致
if(redis.call('get',KEYS[1])==ARGV[1]) then
-- 释放锁 del key
return redis.call('del', KEYS[1])
end
return 0
java 复制代码
    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博客

相关推荐
MiniFlyZt1 小时前
省市区三级联动(后端)
数据库·spring boot
背太阳的牧羊人1 小时前
用于与多个数据库聊天的智能 SQL 代理问答和 RAG 系统(2) —— 从 PDF 文档生成矢量数据库 (VectorDB),然后存储文本的嵌入向量
数据库·人工智能·sql·langchain·pdf
程序员谷美1 小时前
Redis 性能优化:利用 MGET 和 Pipeline 提升效率
java·redis·性能优化
zhangxueyi2 小时前
MySQL之企业面试题:InnoDB存储引擎组成部分、作用
java·数据库·mysql·面试·innodb
代码代码快快显灵2 小时前
Redis 优化秒杀(异步秒杀)
数据库·redis·缓存
一条小小yu2 小时前
java 从零开始手写 redis(六)redis AOF 持久化原理详解及实现
java·redis·spring
极客先躯2 小时前
Redis 安装与配置指南
数据库·redis·数据验证·安装说明·编译和安装·redis 集群配置·查看集群
小湿哥2 小时前
RedisDB双机主从同步性能测试
redis·nosql·性能测试·同步性能
YaenLi3 小时前
MySQL 安装部署
linux·数据库·mysql
乄北城以北乀3 小时前
一.MySQL程序简介
数据库·mysql