(一)全局唯一ID



(二)Redis实现全局唯一id
@Component
public class RedisIdWorker {
/**
* 开始的时间戳
*/
private static final long BEGIN_TIMESTAMP = 1640995200L;
/**
* 序列号的位数
*/
private static final int COUNT_BITS = 32;
@Resource
private StringRedisTemplate stringRedisTemplate;
public long nextId(String keyPrefix){
// 1.生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
// 2.生成序列号
// 2.1 获取当前日期
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
// 3.拼接并返回
return timestamp << COUNT_BITS | count;
}
// public static void main(String[] args) {
// LocalDateTime localDateTime = LocalDateTime.of(2022, 1, 1, 0, 0, 0);
// long second = localDateTime.toEpochSecond(ZoneOffset.UTC);
// System.out.println("second = " + second);
// }
}
@Resource
private RedisIdWorker redisIdWorker;
private ExecutorService es = Executors.newFixedThreadPool(500);
@Test
void testIdWorker() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(300);
Runnable task = () -> {
for (int i = 0; i < 100; i++) {
long id = redisIdWorker.nextId("order");
System.out.println("id = " + id);
}
latch.countDown();
};
long begin = System.currentTimeMillis();
for (int i = 0; i < 300; i++) {
es.submit(task);
}
latch.await();
long end = System.currentTimeMillis();
System.out.println("time = " + (end - begin));
}


(三)添加优惠卷
普通优惠卷(平价卷)
CREATE TABLE `tb_voucher` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`shop_id` bigint unsigned DEFAULT NULL COMMENT '商铺id',
`title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '代金券标题',
`sub_title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '副标题',
`rules` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '使用规则',
`pay_value` bigint unsigned NOT NULL COMMENT '支付金额,单位是分。例如200代表2元',
`actual_value` bigint NOT NULL COMMENT '抵扣金额,单位是分。例如200代表2元',
`type` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '0,普通券;1,秒杀券',
`status` tinyint unsigned NOT NULL DEFAULT '1' COMMENT '1,上架; 2,下架; 3,过期',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=COMPACT;
秒杀卷
CREATE TABLE `tb_seckill_voucher` (
`voucher_id` bigint unsigned NOT NULL COMMENT '关联的优惠券的id',
`stock` int NOT NULL COMMENT '库存',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`begin_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '生效时间',
`end_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '失效时间',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`voucher_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=COMPACT COMMENT='秒杀优惠券表,与优惠券是一对一关系';

@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_voucher")
public class Voucher implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 商铺id
*/
private Long shopId;
/**
* 代金券标题
*/
private String title;
/**
* 副标题
*/
private String subTitle;
/**
* 使用规则
*/
private String rules;
/**
* 支付金额
*/
private Long payValue;
/**
* 抵扣金额
*/
private Long actualValue;
/**
* 优惠券类型
*/
private Integer type;
/**
* 优惠券类型
*/
private Integer status;
/**
* 库存
*/
@TableField(exist = false)
private Integer stock;
/**
* 生效时间
*/
@TableField(exist = false)
private LocalDateTime beginTime;
/**
* 失效时间
*/
@TableField(exist = false)
private LocalDateTime endTime;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}
调用接口,这里记得添加请求凭证到请求头当中,当然你也可以在拦截器里面不拦截添加优惠卷的请求路由

(四)实现秒杀下单


controller
@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {
@Resource
private IVoucherOrderService voucherOrderService;
@PostMapping("seckill/{id}")
public Result seckillVoucher(@PathVariable("id") Long voucherId) {
return voucherOrderService.seckillVoucher(voucherId);
}
}
service实现类
public interface IVoucherOrderService extends IService<VoucherOrder> {
Result seckillVoucher(Long voucherId);
}
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService{
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
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().
setSql("stock = stock - 1").
eq("voucher_id", voucherId).update();
if(!success){
// 库存不足
return Result.fail("库存不足");
}
// 6. 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 代金卷id
voucherOrder.setVoucherId(voucherId);
// 7. 返回订单id
save(voucherOrder);
return Result.ok(voucherOrder);
}
}

(五)库存超卖问题分析

正常情况下:

高并发问题下:

自己去apipost实现压测,也是同样的问题


同时会发现订单表不止100条订单数据。。。就是超卖了。




(六)乐观锁解决超卖问题
cas操作:在执行sql语句时将stock与一开始查询到的stock进行比较
// 5. 扣减库存
boolean success = seckillVoucherService.update().
setSql("stock = stock - 1"). // set stock = stock - 1
eq("voucher_id", voucherId).eq("stock", voucher.getStock()) // where id = ? and stock =
.update();
再次进行测试:发现100的库存剩下84 。。。 超卖解决完毕了,但是stock按道理应该为0吧


订单表也只有16个数据

重新修改代码,因为之前的代码是比较库存是否和当前查询的相同,比如当100张票的时候有十几个线程同时消费库存,此时只有一个线程会成功,因此只需要将条件改为库存大于0即可。
// 5. 扣减库存
boolean success = seckillVoucherService.update().
setSql("stock = stock - 1"). // set stock = stock - 1
eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
.update();
此时压测发现,库存成功从100变为0,订单表中数据也恰好为100条。


(七)实现一人一单的功能
之前使用的乐观锁,所有的购票都是同一个用户买的100单。。。。成黄牛了哈哈哈,因此需要判断用户是否买过这个优惠卷。

先上代码,这里的代码可能有点小复杂,在乐观锁的基础上,对用户id进行了加上了悲观锁,因为不加悲观锁也会导致同一个id买到多张卷的问题,原因类比乐观锁的超卖。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.12.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.hmdp</groupId>
<artifactId>hm-dianping</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>hm-dianping</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
<version>5.1.47</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.3</version>
</dependency>
<!--hutool-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.17</version>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
新增了 aspectj 依赖,实现代理。。。 同时启动类增加@EnableAspectJAutoProxy(exposeProxy = true) 暴露代理
@EnableAspectJAutoProxy(exposeProxy = true)
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication {
public static void main(String[] args) {
SpringApplication.run(HmDianPingApplication.class, args);
}
}
public interface IVoucherOrderService extends IService<VoucherOrder> {
Result seckillVoucher(Long voucherId);
Result createVoucherOrder(Long voucherId);
}
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService{
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@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("库存不足");
}
// ctrl + alt + m 封装方法
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
Long userId = UserHolder.getUser().getId();
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if(count > 0){
// 用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
// 5. 扣减库存
boolean success = seckillVoucherService.update().
setSql("stock = stock - 1"). // set stock = stock - 1
eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
.update();
if(!success){
// 库存不足
return Result.fail("库存不足");
}
// 6. 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
// 代金卷id
voucherOrder.setVoucherId(voucherId);
// 7. 返回订单id
save(voucherOrder);
return Result.ok(voucherOrder);
}
}
本地apipost压测200个线程跑1s,因为都是同一个用户,最终stock的数量只会到99,订单表里面也只有一个订单数据。
总体来说:这是一个实现秒杀优惠券功能的Java服务代码,主要逻辑是:先校验秒杀活动时间(未开始/已结束则报错)和库存(不足则报错),然后通过用户ID级别的同步锁确保同一用户只能有一个请求进入下单流程,在事务方法中再次校验"一人一单"规则(防止重复购买),使用数据库乐观锁(stock > 0条件)安全扣减库存,最后用Redis生成分布式ID创建订单并返回结果。整个过程通过双重校验(活动状态+库存+用户订单)和锁机制保障数据一致性。
很多小伙伴不懂这个地方的代理为啥要这样写:
// ctrl + alt + m 封装方法
Long userId = UserHolder.getUser().getId();
synchronized(userId.toString().intern()){
// 获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
使用代理对象是为了确保事务注解 @Transactional 生效。由于 Spring 的事务功能通过动态代理实现,当同一个类中直接调用 this.createVoucherOrder() 时,会绕过代理对象导致事务失效。通过 AopContext.currentProxy() 获取当前类的代理对象再调用方法,能够触发 Spring 的事务拦截器,保证库存扣减和订单创建在同一个事务中执行,实现操作的原子性(要么同时成功,要么同时回滚)。
小伙伴的疑问又来了 当前类的代理对象是什么 为什么Service是代理对象 而不是当前impl的是代理对象
关于代理对象和实现类的关系(清晰版解释):
实现类:你编写的 VoucherOrderServiceImpl 类(带 @Service 注解)
代理对象:Spring 运行时动态生成的增强版实现类
为什么实现类本身不是代理对象?
你编写的 VoucherOrderServiceImpl.java 是原始代码
→ 无法包含动态生成的事务逻辑(事务开启/提交/回滚)
类比理解:
想象你买了部手机(实现类),Spring 就像手机贴膜+装保护壳的服务:
原始手机(实现类):功能正常但易损坏(无事务保护)
带壳手机(代理对象):增加了防摔保护(事务能力)
当你从口袋(@Autowired)拿出时,拿到的是带壳手机
但在手机内部电路(this)看来,它仍是原始手机
这就是为什么需要显式获取代理对象——为了唤醒"保护壳"的事务功能。
(八)集群下的线程并发安全问题




用这个接口测试两个服务是否成功:http://localhost:8080/api/shop/1 因为不需要token 但是可能会被缓存,第二个8082不需要走数据库了。
理想情况下:

但是集群下,syn锁同一个用户在两个服务下可能都能进去,因为不是同一把锁,因此需要->分布式锁


被折叠的 条评论
为什么被折叠?



