全局唯一ID

自增id问题

  • 安全性问题:
    • ID规律性太明显,用户可通过ID推测业务量
    • 例如:今天ID=10,明天同一时刻ID=100,可推算出日订单量90单
  • 扩展性问题:
    • 单表数据量限制:电商订单量可能达到数亿级别
    • 分表后自增ID会重复,违背业务唯一性要求

全局id生成器

  • 五大特性:

    • 唯一性:分布式系统下全局唯一
    • 高可用:随时可用,不能挂掉
    • 高性能:生成速度要快
    • 递增性:整体单调递增,利于数据库索引
    • 安全性:ID规律不能太明显
  • 实现原理:

    • 使用Redis的INCR命令实现自增
    • 优势:
      • 唯一性:Redis独立于数据库,自增唯一
      • 高可用:可通过集群/主从/哨兵方案保证
      • 高性能:Redis本身性能优异
      • 递增性:天然支持自增特性

增加安全性

image-1

  • 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));
}

这个测试的主要目的:

  1. 并发测试:通过300个线程并发执行,每个线程生成100个ID,总共生成30,000个ID
  2. 正确性验证:确保在高并发情况下,RedisIdWorker能正确生成唯一ID
  3. 性能测试:测量生成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个线程抢优惠券,测试结束会发现存在超卖

image-2

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

image-3

悲观锁与乐观锁

  • 悲观锁:
    • 特点:假定冲突必然发生,提前加锁保证串行执行
    • 实现:synchronized、ReentrantLock、数据库互斥锁
    • 缺点:性能较差,不适合高并发场景
  • 乐观锁:
    • 特点:假定冲突较少发生,更新时进行版本校验
    • 优势:无锁设计,性能更优
    • 适用场景:读多写少,冲突概率低的场景

乐观锁的实现

版本号

  • 实现机制:
    • 数据表增加version字段
    • 查询时获取当前version值
    • 更新时校验version是否变化
  • SQL示例:
  • 执行流程:
    • 线程A查询到version=1,更新成功(version→2)
    • 线程B用version=1条件更新时因版本不匹配而失败

image-4

CAS

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

image-6

乐观锁解决超卖

乐观锁的改进

若使用.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);  
}

一人一单

image-7

事务处理

  • 在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加锁的原因:

  1. 对象实例问题 :userId是Long类型的包装类,每次获取可能是不同的对象实例,即使值相同, == 比较也可能为false。
  2. 锁对象不唯一 :如果直接用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内使用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. 线程1执行完synchronized块,释放锁,但事务未提交
  2. 线程2获取锁,查询数据库时发现用户A还没有订单(因为线程1的事务未提交)
  3. 线程2也通过了”一人一单”检查
  4. 最终两个线程都创建了订单,违反了业务规则
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;
}

问题产生原因

image-8

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