手写分布式锁实现秒杀业务

1. 准备工作

秒杀业务首先的需求就是对于全局的id是有要求的
以秒杀业务的订单为例,思考一下订单表如果使用数据库自增的id会存在什么问题?

  • id的规律性太明显,数据库自增的id每次都是加1,这样可能会导致泄露信息给用户
  • 其次,还会受到单表数据量的限制,当这张表使用自增id,如果后期数据量变得庞大起来,对于数据库的分库分表会造成极大的麻烦

引入全局id生成器的作用:
全局id生成器是一种在分布式系统下用来生成全局唯一ID的工具,全局id生成器必须要满足几个要求:

  • 唯一性
  • 高可用
  • 高性能
  • 递增性
  • 安全性

全局id生成器的实现:

  • 雪花算法
  • UUID
  • 使用redis实现

其中雪花算法和使用redis的操作都是企业中比较常用的

我们以Redis为例
我们知道,redis是可以生成自增的数值的,但是为例增加ID的安全性,我们还应该拼接一下其他信息,我们采用和雪花算法类似的思想
在这里插入图片描述
这里我们手写一个Redis的ID生成器

// Redis的全局生成器
@Component
public class RedisIdWorker {
	/**
	*	开始时间戳
	*/
	private static final long BEGIN_TIMESTAMP = 1640995200L;  // 这里是我生成的当前时间的秒数

	/**
	*	序列号位数
	*/
	private static final int COUNT_BITS = 32;     // 这里采用的32位

	@Resource
	private StringRedisTemplate stringRedisTemplate;  // 注入spring自带的redis序列化工具,默认键值都是String类型

	public long nextId(String keyPrefix) {   // 这里的参数是业务前缀,拼接key用的,可以让你的redis缓存结构更加清晰
		// 1.生成时间戳
		LocalDateTime now = LocalDateTime.now();
		long nowSecond = now.toEpochSeconf(ZoneOffset.UTC)   // 当前秒数
		long timestamp = nowSecond - BEGIN_TIMESTAMP;

		// 2.生成序列号,Redis的自增策略
		// 2.1 获取当前日期,精确到每天,这样做可以让每一天产生的订单使用相同的key,还可以避免超过缓存数量上限
		String date = now.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
		long count = stringRedistemplate.opsForValu().increment("icr:" + keyPrefix + ":" + date);   // 拼接业务的key

		// 因为返回的是long类型,这里我们采用位运算拼接id并返回  => 时间戳左移32位,并且低位来补充count
		return timestamp << COUNT_BITS | count;
	}
}

2. 开始实现秒杀业务

2.1 初始代码

/**
*	业务场景:我们以优惠券为例,优惠券有普通优惠券和秒杀类优惠券,秒杀类优惠券是普通优惠券的一个类别,我们  * 在每次抢到一张秒杀优惠券时,都要生成一个订单来记录,同时优惠券的总数要实现减1,并且在每次抢购之前,确
*	保时间必须在有效时间范围内
*/

    @Resource
    private ISeckillVoucherService seckillVoucherService;
	// 注入我们先前定义好的ID生成器
	private RedisIdWorker redisIdWorker;

	// 秒杀
	@Override
	@Transactional   // 需要加事务
	public Result secKillVoucher(long voucherId) {
		// 1.查询优惠券
		SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
		// 2.判断秒杀是否开始
		if(voucher.getBeginTime().isAfter(LocalDateTime.now())) {   // 表示秒杀开始时间大于当前时间 => 秒杀还未开始
			// 秒杀尚未开始
			return Result.fail("秒杀尚未开始!!");
		}
		// 3.判断秒杀是否结束
		if(voucher.getEndTime().isBefore(LocalDateTime.now()) {
			// 秒杀已经结束
			return Result.fail("秒杀已经结束");
		}
		// 4.时间条件满足,再次判断是否还有库存
		if(voucher.getStock() < 1){
			// 库存不足
			return Result.fail("库存不足");
		}
		// 5.所有条件全部满足,可以购买,扣减库存
		boolean success = seckillVoucherService.update()
							.set("stock = stock - 1")
							.eq("voucher_id",voucherId).update();
		// 判断是否扣减成功
	    if(!success) {
			// 扣减失败
			return Result.fail("库存不足!");
		}
		
		// 6.成功,可以创建订单
		VoucherOrder voucherOrder = new VoucherOrder();
		// 6.1 订单id
		long orderId = redisIdWorker.nextId("order");
		voucherOrder.setId(orderId);
		// 6.2 用户id
		Long userId = UserHoler.getUser().getId();    // 这里我是将用户存在了登录拦截器的一个ThreadLocal里面,直接从里面取用户信息了
		voucherOrder.setUserId(userId);
		// 6.3 代金券id
		voucherOrder.setVoucherId(voucherId);

		// 7.把订单写入数据库
		save(voucherOrder);
		
		// 8.返回订单id
		return Result.ok(orderId);
	}

2.2 超卖问题

上面的代码看着挺合乎逻辑是吧,判断了购买时间,判断了库存,判断了是否购买成功等等。看着逻辑挺严密。但是如果你用jmeter模拟高并发场景去测试一下,你会惊奇的发现你的数据库里面的库存会变成负的。上面的代码会产生严重的超卖问题。

什么是超卖问题?
超卖问题是一种典型的多线程安全问题,假设一下,如果你的库存是100,但是有200个人同时执行了判断库存是否充足的逻辑,那他们得到的结果都是仍然有库存,然后继续执行购买逻辑,当他们执行完之后,你的库存就会变成-100了。

超卖问题的解决方案:

针对超卖问题,主要的方案就是给代码加锁

  • 加悲观锁
    悲观锁会认为线程安全问题一定会发生,因此在执行之前会先获取锁,确保线程串行执行,当我执行的时候,你就必须等待。这种方式简单粗暴,但是会降低执行效率

    • 例如 Synchronized,Lock 都是常见的悲观锁
  • 加乐观锁
    乐观锁认为线程安全问题不一定会发生,因此不进行加锁操作,只是在更新数据时去判断有没有其他线程对数据做了修改

    • 如果没有修改则认为是安全的,自己才更新数据
    • 如果已经被其他线程修改,此时可以重试或者异常

悲观锁的操作很容易实现,这里就不做赘述了,我们分析乐观锁的实现

上面我们知道,乐观锁的关键是判断之前查询得到的数据是否有被修改过,那怎么实现呢?

  • 版本号法:线程在每次得到数据时,都会得到一个版本号,在执行更新数据时,会判断当前的版本号是否和自己之前拿到的版本号一致,一致的话才执行修改操作,并且在修改之后,将版本号+1。
  • CAS法:Compare And Swap,比较并交换,这种方法不再需要版本号作为额外的条件,而是直接将自己需要修改的字段作为条件,在执行修改时,先判断此时的值是否和之前的值一致,同样,一致才执行修修改。

2.3 第一次的修改

经过了前面的分析,我们可以对前面的代码做一次简单的修改

/**
*	业务场景:我们以优惠券为例,优惠券有普通优惠券和秒杀类优惠券,秒杀类优惠券是普通优惠券的一个类别,我们  * 在每次抢到一张秒杀优惠券时,都要生成一个订单来记录,同时优惠券的总数要实现减1,并且在每次抢购之前,确
*	保时间必须在有效时间范围内
*/

    @Resource
    private ISeckillVoucherService seckillVoucherService;
	// 注入我们先前定义好的ID生成器
	private RedisIdWorker redisIdWorker;

	// 秒杀
	@Override
	@Transactional   // 需要加事务
	public Result secKillVoucher(long voucherId) {
		// 1.查询优惠券
		SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
		// 2.判断秒杀是否开始
		if(voucher.getBeginTime().isAfter(LocalDateTime.now())) {   // 表示秒杀开始时间大于当前时间 => 秒杀还未开始
			// 秒杀尚未开始
			return Result.fail("秒杀尚未开始!!");
		}
		// 3.判断秒杀是否结束
		if(voucher.getEndTime().isBefore(LocalDateTime.now()) {
			// 秒杀已经结束
			return Result.fail("秒杀已经结束");
		}
		// 4.时间条件满足,再次判断是否还有库存
		if(voucher.getStock() < 1){
			// 库存不足
			return Result.fail("库存不足");
		}
		// 5.所有条件全部满足,可以购买,扣减库存
		boolean success = seckillVoucherService.update()
							.set("stock = stock - 1")
							.eq("voucher_id",voucherId)
							.eq("stock",voucher.getStock())   // CAS:判断此时的库存是否和之前的库存一致
							.update();
		// 判断是否扣减成功
	    if(!success) {
			// 扣减失败
			return Result.fail("库存不足!");
		}
		
		// 6.成功,可以创建订单
		VoucherOrder voucherOrder = new VoucherOrder();
		// 6.1 订单id
		long orderId = redisIdWorker.nextId("order");
		voucherOrder.setId(orderId);
		// 6.2 用户id
		Long userId = UserHoler.getUser().getId();    // 这里我是将用户存在了登录拦截器的一个ThreadLocal里面,直接从里面取用户信息了
		voucherOrder.setUserId(userId);
		// 6.3 代金券id
		voucherOrder.setVoucherId(voucherId);

		// 7.把订单写入数据库
		save(voucherOrder);
		
		// 8.返回订单id
		return Result.ok(orderId);
	}

对代码做了修改之后,我们继续使用jmeter测试,测试结果会发现大多数都失败了?为什么呢?
这是由于我们并没有加自旋机制,失败之后没有重试机制,因此,继续完善代码

// 5.扣减库存
boolean success = seckillVoucherService.update()
    .setSql("stock = stock - 1")
    .eq("voucher_id",voucherId)
    .gt("stock",0)   // CAS:判断库存是否 > 0,大于0才修改
    .update();

到这里,最简单的秒杀业务就已经实现了。接下来继续深入


3. 秒杀业务拓展 => 一人一单

3.1 初始代码

在实际的业务中,我们经常会要求同一个用户只能购买一张优惠券,那么又该怎么实现呢?

    // 秒杀
    @Override
    public Result seckillVoucher(Long voucherId) {
        // 1.查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        // 2.判断秒杀是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {   // 开始时间大于当前时间
            // 尚未开始
            return Result.fail("秒杀尚未开始!!");
        }
        // 3.判断秒杀是否结束
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {    // 结束时间在当前时间之前
            // 已经结束
            return Result.fail("秒杀已经结束!!");
        }
        // 4.判断库存是否充足
        if (voucher.getStock() < 1) {
            // 库存不足
            return Result.fail("库存不足");
        }

        Long userId = UserHolder.getUser().getId();        // 从前面的登录拦截器里面获取用户从而获取id
        synchronized(userId.toString().intern()) {
            // 获取代理对象(事务)
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        }
    }

    // 加悲观锁
    @Transactional
    public Result createVoucherOrder(Long voucherId) {
        // 5.一人一单判断
        Long userId = UserHolder.getUser().getId();        // 从前面的登录拦截器里面获取用户从而获取id

        // 5.1 查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        // 5.2 判断是否存在
        if (count > 0) {
            // 用户已经下过单了
            return Result.fail("用户已经购买过一次!!");
        }

        // 6.扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock",0)   // CAS:判断库存是否 > 0,大于0才修改
                .update();
        if (!success) {
            // 扣减失败
            return Result.fail("库存不足");
        }

        // 7.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        // 7.1 订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        // 7.2 用户id
        voucherOrder.setUserId(userId);
        // 7.3 代金券id
        voucherOrder.setVoucherId(voucherId);
        // 8.把订单写入数据库
        save(voucherOrder);

        // 9.返回订单id
        return Result.ok(orderId);
    }

我们通过乐观锁和悲观锁的配合,就可以实现这个需求。
但是!!
但是!!
但是!!
这代码就没有任何问题了吗?

我们将这个服务在idea里面crtl+D,复制一份,修改端口号,启动两个同样的服务,来模拟集群环境,继续jmeter测试。
我们会发现,一个用户出现了购买多张优惠券的情况
?????

其实,这里出现的问题是由于我们使用的 synchronized 锁只能保证在一个jvm里面生效,一个jvm的锁监视器只能监视自己的工作范围内的锁,不能保证集群环境下工作。而当我们启动两个集群时,就相当于有两台jvm,它们之间互不干扰,因此会出现一个用户购买多张优惠券的情况。

那这种情况怎么解决呢?
来到我们的主角,分布式锁


4.分布式锁

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

  • 多进程可见
  • 互斥
  • 高可用
  • 高性能
  • 安全性

分布式锁的核心是多进程之间互斥,而满足这一点的方式有很多,常见的主要有三种
在这里插入图片描述
这里我们来实现Redis的分布式锁
实现分布式时需要实现的两个基本方法:

  • 获取锁
# 添加锁,利用sexnx的互斥特性
SETNX lock thread1

# 添加过期时间,避免服务宕机引起的死锁
EXPIRE lcok 10
  • 释放锁

    • 手动释放
    # 手动释放,直接删除即可
    DEL key
    
    • 超时释放,获取锁时添加一个时间,超时自动释放

4.1 分布式锁的实现

定义接口

public interface ILock {

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

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

实现接口

public class SimpleRedisLock implements Ilokc {
	private String name;    // 业务名称,后面拼接锁的key
	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) + "-";   // 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);       // 防止拆箱时可能发生的异常
    }
    @Override
    public void unlock() {
        // 获得线程标识
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        // 获取锁中的标识
        String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        // 判断标识是否一致
        if (threadId.equals(id)) {
            // 释放锁
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }
}

最基本的分布式锁就已经实现了
思考一下,还有优化的空间吗?

考虑一下这种情况:
假设当前线程在执行unlock()时,已经判断了锁中的标识就是自己的,也就是说可以执行释放锁操作了,但是此时这个线程阻塞了,直到锁自动过期了都还没有恢复运行,此时另一个线程可以获取锁了,并且此时之前那个阻塞的线程恢复运行,将要执行释放锁的操作了,但是此时的锁并不是自己的,不就又误删了吗?

简单来说,判断锁标识和释放锁时应该具有原子性的。

那怎么实现原子性呢,事务?其实这里加事务并不容易实现,因此我们提供了另一个解决思路。
我们编写一个lua脚本,里面编写多条Redis的命令,确保多条命令的原子性。
(Lua语言对于redis操作的学习成本很低,菜鸟上很多教程)

4.2 编写lua脚本

我们在resource目录下编写lua脚本,脚本内容其实就是前面unlock的内容

-- 获取锁中的线程标识 get key
local id = redis.call('get',KEYS[1])
-- 比较线程标识和锁中标识是否一致
if(id == ARGV[1]) then
    -- 释放锁 del key
    return redis.call('del',KEYS[1])
end
return 0

然后修改代码

public class SimpleRedisLock implements ILock{

    private String name;   // 业务名称,后面作为锁的key
    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) + "-";   // 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) {
        // 获取线程标识
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        // 获取锁
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name,threadId,timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);       // 防止拆箱时可能发生的异常
    }

    /**
     * 基于lua脚本
     */
    @Override
    public void unlock() {
        // 调用lua脚本,实现redis操作的原子性
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX + name),
                ID_PREFIX + Thread.currentThread().getId()
                );
    }

此时这个分布式锁已经是一个较为完善的锁了,已经可以投入使用了。只是缺少了一些功能机制,比如不可重入,不可重试,超时释放的安全隐患,主从的一致性等等,但是这个分布式锁的思想是很完善的。

其实已经有分布式锁的框架可以使用了
Redisson就提供了许多分布式服务,其中就包含了各种分布式锁的实现,感兴趣的可以去学!!

  • 3
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
手写Redis分布式锁的一个常见方法是使用Redis的SETNX命令和EXPIRE命令。SETNX用于设置一个键值对,只有在键不存在的情况下才能设置成功,用于表示获取锁的操作。EXPIRE用于设置键的过期时间,确保在获取锁的客户端崩溃或网络故障的情况下,锁最终会被释放。 下面是一个用C语言手写Redis分布式锁的简单示例代码: ```c #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/types.h> #include <sys/time.h> #include <sys/wait.h> #include <signal.h> #include <errno.h> #include <hiredis/hiredis.h> #define REDIS_HOST "localhost" #define REDIS_PORT 6379 #define LOCK_KEY "my_lock" #define LOCK_EXPIRE_TIME 10 int main() { pid_t child_pid; int status; // 创建子进程 child_pid = fork(); if (child_pid == 0) { // 子进程获取锁 redisContext *redis_conn = redisConnect(REDIS_HOST, REDIS_PORT); if (redis_conn == NULL || redis_conn->err) { printf("连接Redis失败\n"); exit(1); } // 设置锁 redisReply *reply = redisCommand(redis_conn, "SETNX %s 1", LOCK_KEY); if (reply == NULL || reply->type == REDIS_REPLY_ERROR || reply->integer != 1) { printf("获取锁失败\n"); exit(1); } // 设置锁的过期时间 reply = redisCommand(redis_conn, "EXPIRE %s %d", LOCK_KEY, LOCK_EXPIRE_TIME); if (reply == NULL || reply->type == REDIS_REPLY_ERROR || reply->integer != 1) { printf("设置锁的过期时间失败\n"); exit(1); } printf("获取锁成功\n"); // 模拟业务操作 sleep(5); // 释放锁 reply = redisCommand(redis_conn, "DEL %s", LOCK_KEY); if (reply == NULL || reply->type == REDIS_REPLY_ERROR || reply->integer != 1) { printf("释放锁失败\n"); exit(1); } printf("释放锁成功\n"); // 关闭Redis连接 redisFree(redis_conn); exit(0); } else if (child_pid > 0) { // 等待子进程结束 waitpid(child_pid, &status, 0); if (WIFEXITED(status)) { printf("子进程正常结束\n"); } else if (WIFSIGNALED(status)) { printf("子进程异常结束\n"); } } else { printf("创建子进程失败\n"); exit(1); } return 0; } ``` 在这个示例中,使用了 hiredis 库来连接 Redis,并通过 SETNX 和 EXPIRE 命令实现分布式锁的获取和释放。主进程创建一个子进程,子进程尝试获取锁并进行业务操作,然后释放锁。主进程等待子进程的结束并打印相应信息。 注意:这只是一个简单的示例,实际应用中可能需要考虑更多的场景,比如锁的重入、超时处理等。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *2* *3* [分布式锁_Redis分布式锁+Redisson分布式锁+Zookeeper分布式锁+Mysql分布式锁(原版)](https://blog.csdn.net/guan1843036360/article/details/127827270)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 100%"] [ .reference_list ]
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值