源码仓库地址:git@gitee.com:chuangchuang-liu/hm-dingping.git
1、全局唯一ID
数据库默认自增的存在的问题:
- id增长规律明显
- 受单表数据量的限制
场景一分析:id如果增长规律归于明显,容易被用户或者商业对手猜测出一些敏感信息,比如早上出的第一个单子的id是1,晚上再查看出的单子的id是1001,那别人就很容易猜测出你这一天的销售情况。
场景二分析:Mysql数据库的由于查询性能的考虑,单表数据量不建议超过500W。数据量更大时,需要进行分库分表,但从逻辑上来讲这两张表是同一个表,需要保证id的唯一性,增添了一定的维护成本。
全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:
特性 | 含义 |
---|---|
唯一性 | 需要保证id全局唯一 |
高可用 | 生成器需要保证所有线程来调用时都能提供服务来生成id |
高性能 | 很多业务要求执行时间不能过长如缓存重建,所以对生成器生成id的性能有一定要求 |
递增性 | 为了不重复、便于管理和查询以及提高系统的性能和效率 |
安全性 | 生成的id不易被猜测或篡改 |
为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息:
符号位:永远为0
时间戳:31bit,以秒为单位,可以使用69年
序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID
- 编码实现
全局唯一id生成器
@Component
public class RedisIdWorker {
/**
* 开始时间戳
*/
private static final long BEGIN_TIMESTAMP = 1640995200L;
/**
* 序列号的位数
*/
private static final int COUNT_BITS = 32;
private StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.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"));
// 2.2.自增长
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
// 3.拼接并返回
return timestamp << COUNT_BITS | count;
}
}
单元测试
@SpringBootTest
class HmDianPingApplicationTests {
@Autowired
private RedisIdWorker redisIdWorker;
private ExecutorService es;
/**
* 初始化线程池
*/
@BeforeEach
void setUp() {
es = Executors.newFixedThreadPool(10);
}
@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));
}
}
2、实现优惠券秒杀下单
秒杀需要思考的点:
- 秒杀活动是否开始或结束
- 库存是否足够
思路分析:
- 编码实现
/**
* 下单购买秒杀券
* @param voucherId 秒杀券id
* @return 订单id
*/
@Override
@Transactional
public Result order(Long voucherId) {
// 查询优惠券信息
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
if (ObjectUtil.isEmpty(voucher)) {
return Result.fail("id为" + voucherId + "的秒杀券不存在");
}
// 判断秒杀是否开始
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("秒杀券已售罄");
}
// 生成订单
VoucherOrder order = new VoucherOrder();
long orderID = redisIdWorker.nextId("order");
order.setId(orderID);
order.setVoucherId(voucherId);
order.setUserId(UserHolder.getUser().getId());
save(order);
return Result.ok(orderID);
}
3、超卖问题
超卖问题原因分析:假设线程1过来查询库存,判断出来库存大于1,正准备去扣减库存,但是还没有来得及去扣减,此时线程2过来,线程2也去查询库存,发现这个数量一定也大于1,那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题。
超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁,而对于加锁通常有两种方案:
方案名称 | 含义 | 例子 |
---|---|---|
悲观锁 | 认为线程安全问题一定会发生,因此在操作数据前,添加互斥锁,保证操作是串行的 | Synchronized、Lock |
乐观锁 | 认为线程安全问题不一定会发生,因此就不添加锁,只有在做更新操作时,判断其他线程是否做过更新。如果没有改过,才去做更新 | CAS |
什么是CAS?
CAS(Compare And Swap)是一种用于管理并发数据访问的无锁算法。CAS操作包含三个参数:内存位置(V)、预期原值(A)和新值(B)。执行CAS操作的基本步骤是:如果V的值和预期原值A相同,那么就用新值B替换V的值;如果V的值和预期原值A不相同,就不做任何操作。
乐观锁的两种常见方式:
- 版本号法(version)
- 利用业务数据本身来充当版本号
- 编码实现解决超卖问题
一般是CAS+自旋组合来解决超卖问题:如果CAS失败,但只要库存仍大于0,就允许其继续尝试购买秒杀券
但这里可以简化为只要当前库存量大于0,就允许其继续尝试购买秒杀券
// 扣减库存,添加乐观锁
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
// 这种方式反而会增加下单的失败率
// .eq("stock", voucher.getStock())
// 只要库存还大于0,就认为下单成功
.gt("stock", 0)
.update();
4、一人一单
需求: 要求一个用户只能购买一张秒杀券
目前存在的问题: 一个用户可以无限制次数的抢优惠券。应当加一层判断逻辑,当用户成功下完单后,不允许其再次抢优惠券。
判断逻辑: 查询该用户和优惠券在订单表里是否已经存在,如果存在,说明其之前已经抢过优惠券了
- 编码实现
// 保证一人一单
Integer count = query().eq("voucher_id", voucherId)
.eq("user_id", userId).count();
if (count > 0) {
return Result.fail("id为:" + userId + "的用户已经购买过该秒杀券");
}
存在问题: 在并发情况下仍然会出现一人多单的问题
分析: 该问题和超卖的问题是一致的,因为在查询时,多个线程都进来查询,发现该用户没有下过单,因此都做创建订单操作。
解决方案: 和之前一样,通过添加锁来实现。乐观锁比较适合更新数据,而这里是插入数据操作,适合悲观锁。
注:添加悲观锁这里,存在诸多问题,一个个来分析:
- 锁的存放位置:在方法上添加同步锁。这种方式下,锁的粒度太粗了,导致每一个线程进来都会被锁住,性能太差
@Transactional
/*
将锁放在方法体上,那么这个方法就是一个同步方法,只有一个线程能够进入,会导致性能问题
*/
public synchronized Result oneUserAndOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// 保证一人一单
Integer count = query().eq("voucher_id", voucherId)
.eq("user_id", userId).count();
if (count > 0) {
return Result.fail("id为:" + userId + "的用户已经购买过该秒杀券");
}
// 扣减库存,添加乐观锁
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
// 这种方式反而会增加下单的失败率
// .eq("stock", voucher.getStock())
// 只要我库存还大于0,就允许用户继续下单
.gt("stock", 0)
.update();
if (!success) {
return Result.fail("秒杀券已售罄");
}
// 生成订单
VoucherOrder order = new VoucherOrder();
long orderID = redisIdWorker.nextId("order");
order.setId(orderID);
order.setVoucherId(voucherId);
order.setUserId(UserHolder.getUser().getId());
save(order);
return Result.ok(orderID);
}
- 锁的存放位置:在方法体内添加同步锁,且以用户id进行加锁,这样锁的粒度更细,同一个用户线程进来后会去争锁资源,而不会导致所有线程都被锁住。
存在的问题: 在方法体内添加同步代码块,代码块执行完毕后立即释放锁,但事务又是由Spring管理的,此时事务还未提交。其他线程进来后,查询用户未下单,执行创建订单操作。因此这种方式仍会出现多线程并发问题。
解决方案: 事务提交后再释放锁
- 编码实现
<!--引入aspectJ-->
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
@Override
public Result order(Long voucherId) {
// 查询优惠券信息
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
if (ObjectUtil.isEmpty(voucher)) {
return Result.fail("id为" + voucherId + "的秒杀券不存在");
}
// 判断秒杀是否开始
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();
// 3、事务提交后,锁会被释放
synchronized (userId.toString().intern()) {
// 获取代理对象,代理对象才具备事务功能
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.oneUserAndOrder(voucherId);
}
}
@Transactional
public Result oneUserAndOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// 保证一人一单
Integer count = query().eq("voucher_id", voucherId)
.eq("user_id", userId).count();
if (count > 0) {
return Result.fail("id为:" + userId + "的用户已经购买过该秒杀券");
}
// 扣减库存,添加乐观锁
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
// 这种方式反而会增加下单的失败率
// .eq("stock", voucher.getStock())
// 只要我库存还大于0,就允许用户继续下单
.gt("stock", 0)
.update();
if (!success) {
return Result.fail("秒杀券已售罄");
}
// 生成订单
VoucherOrder order = new VoucherOrder();
long orderID = redisIdWorker.nextId("order");
order.setId(orderID);
order.setVoucherId(voucherId);
order.setUserId(UserHolder.getUser().getId());
save(order);
Result.ok(orderID);
}
@MapperScan("com.hmdp.mapper")
// 启动aop,否则service获取不到aop代理类
@EnableAspectJAutoProxy(exposeProxy = true)
@SpringBootApplication
public class HmDianPingApplication {
public static void main(String[] args) {
SpringApplication.run(HmDianPingApplication.class, args);
}
}
- JMeter进行并发安全测试
可以发现,至此已成功添加了一人一单的限制!
5、集群环境下的并发问题
通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了。
1、我们将服务启动两份,端口分别为8081和8082:
2、修改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;
}
具体操作:
1、通过IDEA克隆一份应用配置
2、添加 vm options
3、重启nginx
nginx -s reload
集群环境下锁失效的原因分析:
现在部署了多套tomcat服务器,每个tomcat内部都有一个jvm,jvm内部多个线程间可以实现锁互斥,但jvm间的线程的锁并不互斥,从而导致互斥锁失效,出现一人多单的问题,这就是集群环境下syn锁失效的原因,在这种情况下就需要使用分布式锁来解决该问题!