全局ID生成器
每个店铺都可以发布优惠券:当用户抢购时,就会生成订单并保存到tb_voucher_order(优惠券订单)这张表中,而订单表如果使用数据库自增ID就存在一些问题:
- id的规律性太明显
- 受单表数据量的限制
全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:
为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息:
ID的组成部分:
- 符号位:1bit,永远为0
- 时间戳:31bit,以秒为单位,可以使用69年
- 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID
@Component
public class RedisIdWorkerTest {
@Autowired
private StringRedisTemplate redisTemplate;
public static final Long BEGIN_TIMESTAMP = 1640995200L;
private static final int COUNT_BITS = 32;
public long nextId(String keyPrefix) {
// 1. 31位时间戳
Long timestamp = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) - BEGIN_TIMESTAMP;
// 2. 32位自增序列
String format = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
Long value = redisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + format);
// 3. 拼接
return timestamp << COUNT_BITS | value;
}
}
测试
@SpringBootTest
class HmDianPingApplicationTests {
@Autowired
RedisIdWorkerTest redisIdWorker;
@Test
public void idWork() throws InterruptedException {
ExecutorService pool = Executors.newFixedThreadPool(300);
CountDownLatch latch = new CountDownLatch(300);
long begin = System.currentTimeMillis();
Runnable task = () -> {
for (int i = 0; i < 100; i++) {
System.out.println(redisIdWorker.nextId("id"));
}
latch.countDown();
};
for (int i = 0; i < 300; i++) {
pool.submit(task);
}
latch.await();
System.out.println(System.currentTimeMillis() - begin);
}
}
全局唯一ID生成策略:
- UUID
- Redis自增
- snowflake算法
- 数据库自增
Redis自增ID策略:
- 每天一个key,方便统计订单量
- ID构造是 时间戳 + 计数器
实现优惠券秒杀下单
下单时需要判断两点:
- 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
- 库存是否充足,不足则无法下单
@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();
//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);
save(voucherOrder);
//7.返回订单id
return Result.ok(orderId);
}
}
超卖问题
情景:通过JMeter做高并发测试,100张优惠券卖出去变成负数,生成了两百来个订单
解决方案:
乐观锁:
乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种:
- 版本号法:通过增加版本号字段,对比版本号是否是查询到的版本号,是才做修改
- cas法:通过比较数据是否是原来查到的数据,是才做修改
// 5.扣减库存
boolean success = update().setSql("stock = stock -1").eq("voucher_id", voucherId).eq("stock", seckillVoucher.getStock()).update();
该cas没有自旋的操作,所以失败率比较高,这个在业务满足的基础上做个优化
// 5.扣减库存
boolean success = update().setSql("stock = stock -1").eq("voucher_id", voucherId).gt("stock", 0).update();
一人一单
需求:修改秒杀业务,要求同一个优惠券,一个用户只能下一单
// 用户id
Long userId = UserHolder.getUser().getId();
// 查询订单
Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
return Result.fail("用过已经购买过一次了");
}
查询订单和下订单操作非原子性,存在线程安全问题,需要加锁,由于是新增的数据,无法使用乐观锁
@Override
@Transactional
public synchronized Result seckillVoucher(Long voucherId) {
//...
}
一开始想到的肯定是给整个方法加锁,但是这样加锁粒度太大,几乎串行执行,效率低,通过业务分析,给用户纬度加锁是最合适的
@Transactional
public Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
//一人一单
synchronized (userId.toString().intern()) {
Integer 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")
.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
voucherOrder.setUserId(userId);
//6.3代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
//7.返回订单id
return Result.ok(orderId);
}
}
由于userId.toString()
每次都是返回的新对象,而我们只需要比较值,所以将该对象放到常量池中
这里还有一个问题,由于事务是方法执行前后由spring代理对象处理的,所以这里会存在方法执行完了,锁释放了,但是事务没提交的情况,所以,这里需要把锁的范围放大
@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("库存不足!");
}
//7.返回订单id
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
// 获取代理对象
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}
@Transactional
@Override
public Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
//一人一单
Integer 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")
.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
voucherOrder.setUserId(userId);
//6.3代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
//7.返回订单id
return Result.ok(orderId);
}
}
这里由于调用本类的方法是使用的this当前对象,所以需要获取spring的代理对象才能保证事务的正常执行。
一人一单的并发安全问题
通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了。
- 我们将服务启动两份,端口分别为8081和8082:
- 然后修改nginx的conf目录下的nginx.conf文件,配置反向代理和负载均衡:
现在,同一个用户请求会在这两个节点上负载均衡,再次测试下是否存在线程安全问题。
集群模式下,有多个JVM存在,每个JVM内部都有自己的锁,导致每个锁都可以有一个线程获取,变成并行运行。