N

Norlcayn的博客

A text-focused Halo theme

  • 首页
  • 文章
  • 默认分类
  • 关于
主页 超卖方案(线程锁)
文章

超卖方案(线程锁)

发表于 2026-01-16 更新于 昨天
作者 Administrator
16~21 分钟 阅读

超卖方案

Java锁

jvm提供了很多锁,它们都可以解决线程安全问题,大致可以分为两类:悲观锁和乐观锁

悲观锁

悲观锁认为当前线程在操作共享资源期间,总会有其它线程修改数据

为了保证线程安全,就要在操作前总是先加锁,操作完成后释放锁

像Java中的synchronized、ReentrantLock都属于悲观锁

乐观锁

乐观锁认为当前线程在操作共享资源期间,不会有其它线程去并发修改数据,所以谁都可以去执行代码

但是为了保证数据的安全性,它会在更新之前先进行一次新老值的比较,即CAS(Compare And Swap)

示例:库存数据对应一个版本,库存每次变化则版本号跟着变化,如下:

优惠券活动名称 库存 版本号
元旦促销活动 1 1

修改库存前拿到库存及对应的版本号:1和1,然后判断库存如果大于0,则将库存减1,然后准备更新库存,更新前先校验版本号

  • 如果版本号依旧是1,说明自己在执行的过程没有其它线程去修改过库存,此时将库存更新为0,版本号更新为2
  • 如果版本号不再是1,说明自己在执行的过程中,其它线程修改了库存版本,此次更新会被放弃或者重试

分布式锁

上边介绍的锁只控制了单个JVM本身的线程争抢同一个锁,无法控制多个JVM之间争抢同一个锁

如下图,每个JVM进程都有一个Lock锁,但是两个JVM中的线程仍然会同时去修改库存,依旧会出现线程安全问题

6252ef38-74c7-4def-979c-c65e0746a3ed

这种情况下,就需要使用分布式锁来保证线程安全,它的本质是将锁提取到JVM的外部

以redis为例,看一下分布式锁的使用思路图

whiteboard_exported_image (4)

Redis原子操作

上面的方案每次操作,都需要远程与Redis交互来获取锁、释放锁,有没有方法避免申请锁与释放锁的交互呢?

这就可以使用Redis的自带的命令来完成扣减库存的操作,比如使用decr命令,它可以完成数字的自减

而且Redis的命令都具有原子性,这就保证了当多个线程去执行decr命令扣减库存时是顺序执行的

whiteboard_exported_image (5)

但是这里又产生了一个问题,抢券的操作可不是只有一个简单的扣减库存,而是一些列的操作

  1. 首先查询库存
  2. 判断库存大小,如果大于0则扣减库存,否则直接返回
  3. 记录抢券成功的记录,用于后面判断用户不能重复抢券的依据
  4. 记录抢券同步的记录,用于后面将抢券结果同步到数据库

这样一来就需要执行多条Redis命令,那么整体还是原子性吗?

whiteboard_exported_image (6)

如果上述四步整体不具有原子性仍然没有办法控制超卖问题,如何保证多个Redis命令具有原子性呢?

  • 使用MULTI实现:这是Redis提供的用来模拟事务的一套命令,它能保证多个命令成为一个原子操作

  • ``` MULTIHSET key1 field1 value2 field2 value2INCR key2EXEC

    ``` - Redis+Lua实现:Lua是一种可嵌入在Redis中执行的脚本语言,可以使用它编写一批Redis命令

    • 然后使用eval命令执行Lua脚本,eval是redis的命令本身具有原子性,整个脚本的执行具有原子性

    c71bfc5e-261e-493d-b7f3-c2e73343205d

    Lua eval " local ret = redis.call('hset', KEYS[1], ARGV[1], ARGV[2], ARGV[3], ARGV[4]); redis.call('incr', KEYS[2]); return ret..''; " 2 test_key01 test_key02 field1 aa field2 bb ```

总结

解决超卖问题有哪些方案?

  • 单机环境下,可以使用JVM悲观锁和乐观锁解决
  • 分布式环境下,可以使用分布式锁解决,也可以使用Redis+lua实现原子操作来解决

Java调用Lua

语法

下边是使用RedisTemplate执行Lua脚本的方法:

<T> T execute(RedisScript<T> script, List<K> keys, Object... args)

第一个参数是要执行的Lua脚本,RedisScript的实现类是DefaultRedisScript,它需要脚本的字符串或位置进行构建

@Bean("Lua_test01")
public DefaultRedisScript<Integer> getLuaTest01() {
    DefaultRedisScript<Integer> redisScript = new DefaultRedisScript<>();
    //resource目录下的scripts文件下的Lua_test01.Lua文件
    redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("scripts/Lua_test01.Lua")));
    redisScript.setResultType(Integer.class);
    return redisScript;
}

测试

注入上边定义的DefaultRedisScript,注意注入时指定名称“Lua_test01”

package com.jzo2o.market.service;

@SpringBootTest
@Slf4j
public class RedisLuaTest {

    @Resource(name = "redisTemplate")
    RedisTemplate redisTemplate;

    @Resource(name = "Lua_test01")
    DefaultRedisScript script;

    //测试Lua
    @Test
    public void test_Luafirst() {
        //参数1:key ,key1:test_key01  key2:test_key02
        List<String> keys = Arrays.asList("test_key01","test_key02");
        //参数2:传入Lua脚本的参数,"field1","aa","field2", "bb"
        Object result = redisTemplate.execute(script, keys, "field1","aa","field2", "bb");
        log.info("执行结果:{}",result);
    }
}

集群注意事项

在redis分片集群下执行lua脚本,它要求脚本中所涉及到的所有key必须要在同一个Redis节点上

否则调用Lua脚本会报错:ERR eval/evalsha command keys must be in same slot

如何保证多个key落地到一个redis节点呢?

解决方法:一次执行Lua脚本的所有key中使用大括号‘{}’且保证大括号中的内容相同即可

@Test
public void test_Luafirst2() {
    //参数1:key ,key1:test_key01
    List<String> keys = Arrays.asList("test_key01{111}","test_key02{111}");
    //参数2:传入Lua脚本的参数,"field1","aa","field2", "bb"
    Object result = redisTemplate.execute(script, keys, "field1","aa","field2", "bb");
    log.info("执行结果:{}",result);
}

执行测试成功,观察redis多了两个key:”test_key01{111}”和”test_key02{111}”

方案总结

抢券的交互流程如下:

1、由预热程序将优惠券活动的库存同步到redis(活动开始将不允许更改库存)

2、活动开始后,抢券程序请求Redis扣减库存,扣减库存成功向抢券成功队列和抢券同步队列写入记录

  • 抢券成功队列:记录活动-用户,证明某个活动已经被某个用户抢了,后面用于处理用户限领券的逻辑
  • 抢券同步队列:记录用户-活动,证明哪个用户抢了那个活动的券,后面用于处理向数据库保存抢券记录

3、通过定时任务程序根据Redis中同步队列记录的用户抢券结果信息将数据同步到MySQL,具体操作如下:

  • 向优惠券表插入用户抢券记录
  • 更新优惠券活动表的库存
  • 写入数据库完成后删除Redis中同步队列的相应记录

8512786b-d698-4f20-9077-56b548a8a865

whiteboard_exported_image (7)

为了让Redis中每个队里中的数据不至于非常多,我们在当前项目中对抢券时用到的三个队列做了优化

那就是将每个对列拆成10个,这样的话,每个队列中的数据就只有原来的十分之一了,具体设计如下:

缓存内容 结构类型 Key HashKey Value
优惠券活动库存 Hash COUPON:RESOURCE:STOCK:{活动id%10} 活动id 库存数
抢券成功队列 Hash COUPON:SEIZE:LIST:活动id_{活动id%10} 用户id 1
抢券同步队列 Hash QUEUE:COUPON:SEIZE:SYNC:{活动id%10} 用户id 活动id

bdbb79f2-7c5d-43b7-b842-f7787e315352

云岚到家相关技术栈
Redis 分布式锁 悲观锁 乐观锁 Lua
许可协议:  CC BY 4.0
分享

相关文章

1月 16, 2026

状态机

什么是状态机 需求说明 在订单模块中订单共设计了7种状态,如下图 订单会在多个状态间进行转换,这就需要开发人员都去记忆这些转换关系,这是非常麻烦的 if(订单状态 == 待支付){ //如果用户支付成功,执行此场景下的业务逻辑,更新订单状态为派单中 update(id,派单中) ) if(

1月 16, 2026

数据表设计规范

设计范式 数据库设计三范式: 第一范式:数据表中的每一列都是原子的,不可再拆分 第二范式:每张表中的一条记录只描述一个实体 第三范式:数据表中不要存在冗余字段(可以通过其他字段推导出的字段) 1)第一范式:数据表中的每一列都是原子的,不可再拆分 有一张用户考勤表设计如下 id 用户名 考勤时间 1

1月 16, 2026

缓存优化

针对系统中访问频率很高的页面,常见的优化方案如下: 将页面做页面静态化处理 如果静态化之后依旧无法满足需求,可以将静态资源转移到CDN服务器 页面上变动的内容可以异步请求后端服务器获取数据,后端可以添加缓存来减轻数据库的压力 数据库方面可以通过合理的存储引擎选择、表字段设计、索引设计等进一步提高查询

下一篇

分库分表

上一篇

策略模式

最近更新

  • Hello Halo
  • 状态机
  • 数据表设计规范
  • 缓存优化
  • 事务失效

热门标签

Halo 异常处理 Redisson 并行 Vue 并发 递归 请求限流 SpringAMQP 类加载器

目录

©2026 Norlcayn的博客. 保留部分权利。

使用 Halo 主题 Chirpy