1.优惠券秒杀
1.1 全局ID生成器
1.1.1 什么是全局ID生成器
全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具。
需要满足以下特性:
- 唯一性
- 高可用
- 高性能
- 递增性
- 安全性
1.1.2 为什么需要全局ID生成器?
自增ID存在的问题:
- ID的规律性太明显
- 受单表数据量的限制
1.1.2 如何构建一个全局ID生成器
全局唯一ID生成策略
- UUID
- Redis自增(可以携带一些信息)
- snowflake算法
- 数据库自增
Redis自增ID策略 - 每天一个key,方便统计订单量
- ID构造是时间搓+计数器
ID的组成部分: - 符号位:1bit,永远为0
- 时间戳:31bit,以秒为单位,可以使用69年
- 序列号:32bit,秒内的计数器,支持每秒最多可以产生2^32个不同ID
1.2 优惠券秒杀的下单功能流程图
1.3 库存超卖问题
- 悲观锁:添加同步锁,让线程串行执行
- 优点:简单粗暴
- 缺点:性能一般
- 乐观锁:不加锁,在更新时判断是否有其它线程在修改
- 优点:性能好
- 缺点:存在成功率低的问题
1.4 乐观锁解决超卖
乐观锁的关键是判断之前的数据是否有被修改过,常见的方式有两种:
- 版本号法(在这里库存可以当做版本号)
- CAS法
@Transactional
@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("库存不足");
}
// 5.扣减库存
boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).gt("stock",0).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 = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3 代金券id
voucherOrder.setVoucherId(voucherId);
// 7.返回订单id
return Result.ok(orderId);
}
1.5 实现一人一单
在这段代码中我们需要先判断该用户是否已经购买过优惠券,我们需要对用户Id进行加锁,通过userId.toString().intern()来获取同一个对象。同时通过代理来防止事务失效。
@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();
synchronized (userId.toString().intern()){
// 获取代理对象(事务),通过代理对象防止Transaction失效
IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}
@Transactional
public Result createVoucherOrder(Long voucherId){
// 5.一人一单
Long userId = UserHolder.getUser().getId();
// 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)
.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);
save(voucherOrder);
// 8. 返回订单id
return Result.ok(orderId);
}
2. 知识储备
2.1 事务失效的常见原因
2.1.1 访问权限问题
众所周知,java的访问权限主要有四种:private、default、protected、public,它们的权限从左到右,依次变大。
但如果我们在开发过程中,把有某些事务方法,定义了错误的访问权限,就会导致事务功能出问题。
spring要求被代理方法(开启事务的方法)必须是public的。
也就是说,如果我们自定义的事务方法(即目标方法),它的访问权限不是public,而是private、default或protected的话,spring则不会提供事务功能。
2.1.2 方法用final修饰
有时候,某个方法不想被子类重新,这时可以将该方法定义成final的。普通方法这样定义是没问题的,但如果将事务方法定义成final,这样会导致事务失效。
如果你看过spring事务的源码,可能会知道spring事务底层使用了aop,也就是通过jdk动态代理或者cglib,帮我们生成了代理类,在代理类中实现的事务功能。
但如果某个方法用final修饰了,那么在它的代理类中,就无法重写该方法,而添加事务功能。
注意:如果某个方法是static的,同样无法通过动态代理,变成事务方法。
2.1.3 方法内部调用
在某个Service类的某个方法中,调用另外一个事务方法。
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
@Transactional
public void add(UserModel userModel) {
userMapper.insertUser(userModel);
updateStatus(userModel);
}
@Transactional
public void updateStatus(UserModel userModel) {
doSameThing();
}
}
我们看到在事务方法add中,直接调用事务方法updateStatus。从前面介绍的内容可以知道,updateStatus方法拥有事务的能力是因为spring aop生成代理了对象,但是这种方法直接调用了this对象的方法,所以updateStatus方法不会生成事务。
由此可见,在同一个类中的方法直接内部调用,会导致事务失效。
如何解决这个问题?
2.1.3.1 新加一个Service方法
只需要新加一个Service方法,把@Transactional注解加到新Service方法上,把需要事务执行的代码移到新方法中。具体代码如下:
@Servcie
public class ServiceA {
@Autowired
prvate ServiceB serviceB;
public void save(User user) {
queryData1();
queryData2();
serviceB.doSave(user);
}
}
@Servcie
public class ServiceB {
@Transactional(rollbackFor=Exception.class)
public void doSave(User user) {
addData1();
updateData2();
}
}
2.1.3.2 在该Service类中注入自己
如果不想再新加一个Service类,在该Service类中注入自己也是一种选择。具体代码如下:
@Servcie
public class ServiceA {
@Autowired
prvate ServiceA serviceA;
public void save(User user) {
queryData1();
queryData2();
serviceA.doSave(user);
}
@Transactional(rollbackFor=Exception.class)
public void doSave(User user) {
addData1();
updateData2();
}
}
2.1.3.3 通过AopContent类
可以通过在该Service类中使用AOPProxy获取代理对象,实现相同的功能。
@Servcie
public class ServiceA {
public void save(User user) {
queryData1();
queryData2();
((ServiceA)AopContext.currentProxy()).doSave(user);
}
@Transactional(rollbackFor=Exception.class)
public void doSave(User user) {
addData1();
updateData2();
}
}
2.1.4 未被spring管理
在我们平时开发过程中,有个细节很容易被忽略。即使用spring事务的前提是:对象要被spring管理,需要创建bean实例。
通常情况下,我们通过@Controller、@Service、@Component、@Repository等注解,可以自动实现bean实例化和依赖注入的功能。
2.1.5 多线程调用
spring的事务是通过数据库连接来实现的。当前线程中保存了一个map,key是数据源,value是数据库连接。
我们说的同一个事务,其实是指同一个数据库连接,只有拥有同一个数据库连接才能同时提交和回滚。如果在不同的线程,拿到的数据库连接肯定是不一样的,所以是不同的事务。
2.1.6 表不支持事务
在mysql5之前,默认的数据库引擎是myisam。它的好处就不用多说了:索引文件和数据文件是分开存储的,对于查多写少的单表操作,性能比innodb更好。myisam好用,但有个很致命的问题是:不支持事务。
2.1.7 未开启事务
2.2 toString().intern()的作用
intern() 方法用于在运行时将字符串添加到内部的字符串池中,并返回字符串池中的引用。
它遵循以下规则:对于任意两个字符串 s 和 t,当且仅当 s.equals(t) 为 true 时,s.intern() == t.intern() 才为 true。
返回值
当调用 intern() 方法时,如果字符串池中已经存在相同内容的字符串,则返回字符串池中的引用;否则,将该字符串添加到字符串池中,并返回对字符串池中的新引用。
public class RunoobTest {
public static void main(String args[]) {
String str1 = "Runoob";
String str2 = new String("Runoob");
String str3 = str2.intern();
System.out.println(str1 == str2); // false
System.out.println(str1 == str3); // true
}
}
优点
使用 intern() 方法可以在需要比较字符串内容时节省内存,因为它可以确保相同内容的字符串共享同一个对象。然而,过度使用 intern() 方法可能导致字符串池的增长,消耗大量内存。因此,应谨慎使用 intern() 方法,只在必要时使用。
3. 问题及反思
3.1 一人一单的并发安全问题
如果是集群模式下,会有多个tomcat,tomcat中的锁不共享。需要采用分布式锁才可以生效。