全局唯一ID
自增id问题
- 安全性问题:
- ID规律性太明显,用户可通过ID推测业务量
- 例如:今天ID=10,明天同一时刻ID=100,可推算出日订单量90单
- 扩展性问题:
- 单表数据量限制:电商订单量可能达到数亿级别
- 分表后自增ID会重复,违背业务唯一性要求
全局id生成器
-
五大特性:
- 唯一性:分布式系统下全局唯一
- 高可用:随时可用,不能挂掉
- 高性能:生成速度要快
- 递增性:整体单调递增,利于数据库索引
- 安全性:ID规律不能太明显
-
实现原理:
- 使用Redis的INCR命令实现自增
- 优势:
- 唯一性:Redis独立于数据库,自增唯一
- 高可用:可通过集群/主从/哨兵方案保证
- 高性能:Redis本身性能优异
- 递增性:天然支持自增特性
增加安全性

- ID组成结构(64位long型):
- 符号位:1bit,固定为0表示正数
- 时间戳:31bit,记录与基准时间(如2000年)的秒数差,支持69年
- 序列号:32bit,Redis自增值,支持每秒2^{32}个ID
- 优势:
- 唯一性:时间戳不同或序列号不同都能保证唯一
- 安全性:时间戳+序列号组合使规律不明显
- 性能:仍保持高性能和递增特性
注意事项
- 键设计原则:
- 每天使用独立key
- 优势1:便于按日/月/年统计订单量
- 优势2:防止单个key值过大溢出
- ID结构组成:
- 时间戳 + 自增计数器
- 整体不超过long类型范围
- 存储优势:
- 数字类型占用空间小
- 数据库存储友好
- 业务特性:
- 满足单调递增需求
- 适合分布式系统环境
代码实现
- Key设计策略:
- 格式:“icr:业务前缀:yyyy:MM:dd”
- 按天区分key避免单key自增超过32位上限(每日订单量不可能达到)
- 方便统计每日/每月订单量(通过key前缀匹配)
- 日期格式化:使用
DateTimeFormatter.ofPattern("yyyy:MM:dd")精确到天
- 位移操作:时间戳左移32位(COUNT_BITS常量),低位补零
- 或运算合并:将序列号通过|运算填充到空出的低位
- 最终结构:
- 符号位(1bit) + 时间戳(31bit) + 序列号(32bit)
- 保证同一秒内生成的ID序列号部分连续递增
@Component
public class RedisIdWorker {
/**
* 时间戳起始点
*/
private static final long BEGIN_TIMESTAMP = 1640995200L;
/**
* 序列号位数
*/
private static final long COUNT_BITS = 32;
@Resource
private StringRedisTemplate stringRedisTemplate;
public Long nextId(String keyPrefix) {
// 1. 生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSeconds = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSeconds - BEGIN_TIMESTAMP;
// 2. 生成序列号
// 获取当前日期,精确到天
String date = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
Long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
// 3. 拼接
return timestamp << COUNT_BITS | count;
}
}时间戳的获取
当前代码使用的是:
LocalDateTime now = LocalDateTime.now(); long nowSeconds = now.toEpochSecond(ZoneOffset.UTC); long timestamp = nowSeconds - BEGIN_TIMESTAMP;这里获取的是以秒为单位的时间戳。而
System.currentTimeMillis()返回的是以毫秒为单位的时间戳,所以如果要替代,需要将毫秒转换为秒。
测试方法,用于测试RedisIdWorker的并发性能。
@Test
void testIdWorker() throws InterruptedException {
// 创建一个CountDownLatch,计数为300,用于等待所有线程执行完成
CountDownLatch latch = new CountDownLatch(300);
// 定义一个任务Runnable
Runnable task = () -> {
// 每个线程生成100个ID
for (int i = 0; i < 100; i++) {
// 使用RedisIdWorker生成一个ID,前缀为"order"
long id = redisIdWorker.nextId("order");
System.out.println("id = " + id);
}
// 每个线程执行完后,将latch计数减1
latch.countDown();
};
// 记录开始时间
long begin = System.currentTimeMillis();
// 提交300个任务到线程池中执行(并发执行)
for (int i = 0; i < 300; i++) {
executors.submit(task);
}
// 等待所有任务执行完成(latch计数归零)
latch.await();
// 记录结束时间
long end = System.currentTimeMillis();
// 输出总耗时
System.out.println("time = " + (end - begin));
}这个测试的主要目的:
- 并发测试:通过300个线程并发执行,每个线程生成100个ID,总共生成30,000个ID
- 正确性验证:确保在高并发情况下,RedisIdWorker能正确生成唯一ID
- 性能测试:测量生成30,000个ID所需的总时间,评估性能表现
使用CountDownLatch是为了确保主线程能等待所有子线程执行完成后再输出执行时间。这种测试方式可以验证ID生成器在多线程环境下的表现,确保其生成的ID既唯一又具有良好的性能。
其他id生成策略
UUID生成策略
- 实现方式: 直接使用JDK自带的UUID工具类生成
- 格式特点:
- 16进制长字符串结构
- 非单调递增
- 优缺点:
- 优点:实现简单,无需额外依赖
- 缺点:存储空间大,不满足业务需要的递增特性
- 实际应用:企业中使用较少
Snowflake算法
- 核心结构: 64位long类型数字
- 实现原理:
- 基于机器内部自增机制
- 需要维护机器ID
- 性能特点:
- 理论上性能优于Redis方案
- 不依赖Redis等外部系统
- 主要缺点:
- 对系统时钟依赖性强
- 时钟不同步可能导致异常
数据库自增ID策略
- 实现方式:
- 创建专门的自增表
- 业务表ID从该表获取
- 本质特点:
- 相当于Redis自增的数据库版本
- 性能优化:
- 批量获取ID并缓存
- 减少数据库访问次数
- 对比Redis:
- 原理相似但性能较低
- 需要额外优化措施
添加优惠券
使用接口添加券
POST /voucher/seckill
示例请求数据:
{
"shopId": 1,
"title": "100元代金券",
"subTitle": "周一至周五均可使用",
"rules": "全场通用\n无需预约\n可无限叠加\\不兑现、不找零\n仅限堂食",
"payValue": 8000,
"actualValue": 10000,
"type": 1,
"stock": 100,
"beginTime": "2025-09-16T16:44:01",
"endTime": "2025-09-20T16:44:01"
}注意请求时在Header中带上Authorization: {token}
实现秒杀下单
需求分析
- 核心业务逻辑:
- 创建订单
- 扣减库存
- 关键判断条件:
- 秒杀是否开始或结束
- 库存是否充足
- 数据库表关系:
- 秒杀优惠券表(tb_seckill_voucher)与优惠券表是一对一关系
- 订单表(tb_voucher_order)记录用户购买信息
代码实现
@Transactional
public Result seckillVoucher(Long voucherId) {
// 查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
if (voucher == null) {
return Result.fail("优惠券不存在!");
}
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("优惠券尚未开始!");
}
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("优惠券已过期!");
}
// 判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足!");
}
// 扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock=stock-1")
.eq("voucher_id", voucherId)
.update();
if (!success) {
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
// 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
Long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
// 保存订单
save(voucherOrder);
return Result.ok(orderId);
}超卖问题
使用apifox的自动化测试,试用200个线程抢优惠券,测试结束会发现存在超卖

- 并发时序问题:多个线程同时查询到相同库存值(如1),在各自扣减前都认为库存充足
- 典型场景:
- 线程A查询库存=1
- 线程B在A扣减前也查询库存=1
- 两者都执行扣减导致库存变为-1
- 根本原因:非原子性的”查询-判断-扣减”操作在多线程环境下出现竞态条件

悲观锁与乐观锁
- 悲观锁:
- 特点:假定冲突必然发生,提前加锁保证串行执行
- 实现:synchronized、ReentrantLock、数据库互斥锁
- 缺点:性能较差,不适合高并发场景
- 乐观锁:
- 特点:假定冲突较少发生,更新时进行版本校验
- 优势:无锁设计,性能更优
- 适用场景:读多写少,冲突概率低的场景
乐观锁的实现
版本号
- 实现机制:
- 数据表增加version字段
- 查询时获取当前version值
- 更新时校验version是否变化
- SQL示例:
- 执行流程:
- 线程A查询到version=1,更新成功(version→2)
- 线程B用version=1条件更新时因版本不匹配而失败

CAS
- 简化思想:用数据本身作为版本标识
- 实现方式:
- 优势:无需额外version字段,利用业务字段实现乐观控制
- 注意事项:要求被比较字段在业务中有明确语义(如库存数量)

乐观锁解决超卖
乐观锁的改进
若使用
.eq("stock",voucher.getStock())的方式加锁,则较为严格,失败率较高,故采用.gt("stock", 0)的方式,设置更为宽松的条件
@Transactional
public Result seckillVoucher(Long voucherId) {
// 查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
if (voucher == null) {
return Result.fail("优惠券不存在!");
}
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("优惠券尚未开始!");
}
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("优惠券已过期!");
}
// 判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足!");
}
// 扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock=stock-1")
.eq("voucher_id", voucherId)
.gt("stock", 0) // 乐观锁
.update();
if (!success) {
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
// 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
Long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
// 保存订单
save(voucherOrder);
return Result.ok(orderId);
}一人一单

事务处理
- 在createVoucherOrder方法添加@Transactional
- 确保减库存和创建订单的原子性
- 事务范围仅限数据库操作部分
确定锁定资源范围
-
初始方案:方法级synchronized(性能差)
-
优化方案:基于用户ID的细粒度锁
-
使用
userId.toString().intern()确保相同用户ID获得同一把锁 -
不同用户使用不同锁,提高并发性能
-
锁范围调整:
-
将synchronized移到方法外部
-
确保事务提交后才释放锁
-
防止其他线程在事务未提交时查询到不完整数据
事务代理
- 使用
AopContext.currentProxy()获取代理对象 - 通过代理对象调用方法确保事务生效
- 需要在接口中声明
createVoucherOrder方法 - 添加
aspectjweaver依赖
具体实现
- 库存校验逻辑:通过
voucher.getStock() < 1判断库存是否充足,不足时返回”库存不足”提示 - 用户级锁实现:
- 使用
synchronized(userId.toString().intern())对用户ID加锁 - 通过
AopContext.currentProxy()获取事务代理对象保证事务生效 - 最终调用
createVoucherOrder方法完成下单
- 使用
添加依赖
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>启动类加上注解
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true)
public class HmDianPingApplication {
public static void main(String[] args) {
SpringApplication.run(HmDianPingApplication.class, args);
}
}核心代码
/**
* 秒杀优惠券
*
* @param voucherId
* @return
*/public Result seckillVoucher(Long voucherId) {
// 查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
if (voucher == null) {
return Result.fail("优惠券不存在!");
}
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("优惠券尚未开始!");
}
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("优惠券已过期!");
}
// 判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
// 获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}@Transactional
public 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)
.gt("stock", 0) // 乐观锁
.update();
if (!success) {
return Result.fail("库存不足!");
}
// 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
Long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
// 保存订单
save(voucherOrder);
return Result.ok(orderId);
}代码解释
1. 为什么synchronized要放在seckillVoucher方法中?
在seckillVoucher方法中使用synchronized是为了确保秒杀操作的线程安全性。具体来说:
synchronized (userId.toString().intern()) {
// 获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}这里使用synchronized的原因是:
- 防止重复下单:确保同一用户不能同时发起多次秒杀请求,避免一个用户购买多个秒杀优惠券。
- 保证用户级别的线程安全:使用
userId.toString().intern()作为锁对象,确保同一用户只能有一个线程执行秒杀操作。 - 减少锁的粒度:只对相同用户进行互斥,不同用户可以并行执行秒杀操作,提高系统并发能力。
2. 为什么要使用AopContext.currentProxy()?
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();使用AopContext.currentProxy()的原因是解决Spring AOP的自调用问题:
- 事务管理需要:
createVoucherOrder方法上有@Transactional注解,需要通过代理对象调用才能触发事务管理。 - AOP自调用问题:在同一个类中,一个方法直接调用另一个带有切面注解(如
@Transactional)的方法时,切面逻辑不会生效,因为直接调用的是目标对象的方法,而不是代理对象的方法。 - 确保事务生效:通过
AopContext.currentProxy()获取当前的代理对象,再通过代理对象调用createVoucherOrder方法,确保事务管理能够正常工作。
3. 为什么要对userId.toString().intern()加锁?
不能直接对userId加锁的原因:
- 对象实例问题 :userId是Long类型的包装类,每次获取可能是不同的对象实例,即使值相同, == 比较也可能为false。
- 锁对象不唯一 :如果直接用userId作为锁对象,相同用户ID的不同Long对象实例会被认为是不同的锁,无法实现真正的互斥。
使用userId.toString().intern()作为锁对象有以下原因:
- 保证锁的唯一性:
intern()方法确保相同内容的字符串在常量池中只有一个实例,这样相同用户ID的线程会获取到同一个锁对象。 - 避免重复创建对象:如果不使用
intern(),每次userId.toString()都会创建一个新的String对象,导致锁失效。 - 用户级别的互斥:确保同一用户只能有一个线程执行秒杀操作,防止用户重复购买,同时不同用户之间不会相互影响。
// 这样写确保相同userId的线程使用的是同一个锁对象
synchronized (userId.toString().intern()) {
// ...
}为什么不直接将createVoucherOrder加上synchronized呢
1. 锁的粒度和范围问题
如果直接给createVoucherOrder方法加上synchronized:
@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {
// ...
}这会将整个方法标记为同步,导致以下问题:
- 全局锁:所有用户都会竞争同一把锁,无论他们购买的是同一张优惠券还是不同优惠券
- 性能瓶颈:即使不同用户购买不同优惠券,也会串行执行,大大降低系统并发能力
- 用户体验差:不必要的等待时间增加
而当前的设计:
synchronized (userId.toString().intern()) {
// 获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}只对相同用户ID进行同步,不同用户可以并行执行,提高了并发性能。
2. 事务与同步的结合问题
createVoucherOrder方法上有@Transactional注解,表示这是一个事务方法。如果直接给方法加synchronized:
- 锁会在整个事务期间持有,包括数据库操作时间
- 事务可能执行较长时间,锁持有的时间也会相应增加
- 这会增加锁竞争,影响并发性能
而当前实现将同步控制放在seckillVoucher方法中,在调用事务方法前就获取锁,事务方法执行完后立即释放锁,减少了锁持有时间。
3. AOP代理问题
当前实现需要通过AopContext.currentProxy()获取代理对象来调用事务方法。如果直接在createVoucherOrder上加synchronized:
- 当通过代理对象调用时,仍然会执行同步,但锁的范围可能不符合预期
- 可能导致不必要的同步开销
4. 业务逻辑分离
将同步控制放在seckillVoucher方法中,而不是createVoucherOrder方法中,有以下好处:
- createVoucherOrder专注于业务逻辑实现
- seckillVoucher负责控制访问权限和并发控制
- 符合单一职责原则,代码结构更清晰
为什么不直接在createVoucherOrder内使用synchronized
主要问题:事务和锁的执行顺序问题
如果在方法内部使用synchronized,代码结构会是这样:
@Transactional
public Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
// 一人一单逻辑
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)
.gt("stock", 0)
.update();
if (!success) {
return Result.fail("库存不足!");
}
// 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// ... 订单创建逻辑
save(voucherOrder);
return Result.ok(orderId);
}
}存在的问题:
1. 锁释放时机过早
- synchronized块执行完毕后立即释放锁
- 但此时事务还没有提交到数据库
- 其他线程可能在事务提交前就获取到锁并执行
2. 并发安全问题
假设两个用户A的请求同时到达:
- 线程1执行完synchronized块,释放锁,但事务未提交
- 线程2获取锁,查询数据库时发现用户A还没有订单(因为线程1的事务未提交)
- 线程2也通过了”一人一单”检查
- 最终两个线程都创建了订单,违反了业务规则
3. 数据一致性问题
时间线:
T1: 线程1进入synchronized块
T2: 线程1执行业务逻辑
T3: 线程1退出synchronized块(锁释放)
T4: 线程2获取锁并执行
T5: 线程1事务提交
T6: 线程2事务提交
在T4时刻,线程2看到的数据库状态还是线程1事务提交前的状态。
正确的做法对比:
当前正确的实现:
synchronized (userId.toString().intern()) {
// 获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
// 锁在事务完全提交后才释放这样确保了:
- 锁的持有时间覆盖整个事务的生命周期
- 事务提交完成后才释放锁
- 后续线程看到的是已提交的数据状态
总结
在方法内部使用synchronized会导致锁的生命周期短于事务的生命周期,这是典型的并发控制反模式。正确的做法是让锁的范围包含整个事务过程,确保数据的一致性和业务规则的正确执行。
集群下的线程安全并发问题
- 单机锁的局限性:通过synchronized锁可以解决单机情况下的一人一单安全问题,但在集群模式下失效
- 集群模拟方法:
- 启动两个服务实例(8081和8082端口)
- 配置nginx反向代理和负载均衡:
- 问题复现步骤:
- 使用Postman同时发送两个相同用户的秒杀请求
- 观察两个服务实例的断点都被触发
- 数据库出现两条相同用户的订单记录
- 库存被错误地扣减两次
配置nginx负载均衡
upstream backend {
server 127.0.0.1:8081 max_fails=5 fail_timeout=10s weight=1;
server 127.0.0.1:8082 max_fails=5 fail_timeout=10s weight=1;
}问题产生原因

- 单机锁原理:
- 每个JVM内部维护独立的锁监视器对象
- 使用userId作为锁对象时,相同userId会对应同一个监视器
- 通过监视器实现线程互斥访问
- 集群环境问题:
- 每个服务实例运行在独立的JVM中
- 各JVM有自己独立的锁监视器系统
- 导致不同JVM中的线程可以同时获取”相同”的锁
- 根本原因:
- synchronized是JVM级别的锁,无法跨JVM工作
- 集群部署时存在多个独立的锁监视器
- 每个JVM都能有一个线程成功获取锁,导致并发问题
- 解决方案方向:
- 需要使用分布式锁替代JVM锁
- 确保多个JVM共用同一把锁
- 通过外部系统实现跨JVM的锁协调