目录
一 生成全局id
策略:
- 每天一个Key值,方便统计订单量
- ID构造为:时间戳+计数器
代码实现:
package com.hmdp.utils;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
@Component
public class RedisIdWorker {
// 序列号位数
private static final int COUNT_BITS = 32;
private final StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 开始时间戳
*/
private static final long BEGIN_TIMESTAMP = 1640995200;
/**
* 序列号的位数
*/
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;
}
}
核心思路:
Redis的角色:分布式计数器->其不存储订单号,仅提供原子递增的序列号。
订单号的本质:由时间戳(高32位)和序列号位(低32位)运算生成的Long型数值。
1 按照一定的规则来设置key键值。
String key = "icr:" + keyPrefix + ":" + date;
- icr:代表自增(区分键值种类)
- keyPrefix:业务标识(区分业务种类)
- date:当前日期(实现按天区分避免单个键值过大)
2 向redis数据库当中添加数据。
Long count = stringRedisTemplate.opsForValue().increment(key);
Redis的INCR是一个原子操作,对指定键的值执行加1。
若键不存在,就先初始化为0,后续再次执行的时候就会加1,可在Redis当中统计数量,
同时,订单号则作为返回的值。
二 添加优惠券
策略:
存在两个表,一个是普通优惠券的表tb_voucher,一个是秒杀优惠券的表tb_voucher_order。
但是秒杀优惠券的表是建立在普通优惠券的基础之上的。有些共有属性存储在普通优惠表(里面同时也存储一个type类型0/1用于区分是否是优惠券),在秒杀优惠券的表当中存储券开启的时间,结束时间,张数这些核心参数。
代码:
controller控制层的业务实现
/**
* 新增普通券
* @param voucher 优惠券信息
* @return 优惠券id
*/
@PostMapping
public Result addVoucher(@RequestBody Voucher voucher) {
voucherService.save(voucher);
return Result.ok(voucher.getId());
}
下面是实体类的形式(一些特有属性不做强制要求)
package com.hmdp.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* <p>
*
* </p>
*
* @author 虎哥
* @since 2021-12-22
*/
@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;
}
三 实现秒杀下单
下单之前首先需要判断两点:
- 时间是否开始,如果尚未开始或者已经结束则无法下单。
- 库存是否充足,不足则无法下单。
方案一(会出现超卖问题)
VoucherOrderController控制层
@PostMapping("seckill/{id}")
public Result seckillVoucher(@PathVariable("id") Long voucherId) {
return voucherOrderService.seckillVoucher(voucherId);
}
Service业务层(业务层接口)
/**
* 秒杀优惠券
* @param voucherId 优惠券id
* @return 结果
*/
Result seckillVoucher(Long voucherId);
Service业务层(业务层实现类)
/**
* 秒杀优惠券
*
* @param voucherId 优惠券id
* @return
*/
@Transactional
@Override
public Result seckillVoucher(Long voucherId) {
// 1查询优惠券
SeckillVoucher byId = seckillVoucherService.getById(voucherId);
// 2判断时间范围
if (byId.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀尚未开始");
}
if (byId.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已经结束");
}
// 3判断库存
if (byId.getStock() < 1) {
return Result.fail("库存不足");
}
// 4扣减库存
boolean update = seckillVoucherService.update()
.set("stock", byId.getStock() - 1).eq("voucher_id", voucherId).update();
if (!update) {
return Result.fail("库存不足");
}
// 5生成订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6订单id
Long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 7用户id
voucherOrder.setUserId(1L);
// 8优惠券id
voucherOrder.setVoucherId(voucherId);
// 9保存订单
save(voucherOrder);
// 10返回订单id
return Result.ok(orderId);
}
超卖问题的解决
乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种:
1 版本号法
2 CAS
代码实现:(CAS)
方案二(解决了超卖但是错误率较高)
乐观锁的 WHERE stock=原库存-1
条件在高并发下导致大量冲突。
核心修改:在修改之前判断库存与刚开始查询到的数据是否相同,但是会出现错误率较高,导致很多人提前抢到但是别人修改了,再次校验stock时,出现错误,就无法抢购成功。(出现有前一百个人抢但是他们并没有得到这些优惠券)
boolean update = seckillVoucherService.update()
.set("stock", byId.getStock() - 1)// set stock=stock-1(更新操作的条件)
.eq("voucher_id", voucherId)// where voucher_id=? and stock=?(判断秒杀券的id)
.eq("stock", byId.getStock())// where stock=?(判断票数是否与刚开始查询到的相同)
.update();
方案三(解决了错误率较高和超卖但是会出现一人抢多张问题)
核心修改:在修改时判断库存是否还是>0即可(出现类似黄牛使用脚本同时发送请求将优惠券抢完,破坏了一人一单的规则)
boolean update = seckillVoucherService.update()
.setSql("stock=stock-1")// set stock=stock-1(更新操作的条件)
.eq("voucher_id", voucherId)// where voucher_id=? and stock=?(判断秒杀券的id)
.gt("stock", 0)// where stock>0(判断修改时券是否>0)
.update();
方案四(解决一人抢多张问题“非分布式情况”)
bug版(会出现一个用户开多个线程并发的查询操作,出现查询的都是0,导致都去抢购订单并抢购成功,导致一个用户购买多次)
//实现一人一单
Long userId = UserHolder.getUser().getId();
if (query().eq("user_id", userId).eq("voucher_id", voucherId).count() > 0) {
return Result.fail("用户已经购买过");
}
改进版
实现代理,将购买订单加上锁。(分布式情况就会出现错误)
- synchronized是基于JVM的内存锁,确保同意用户ID的请求在单机内串行执行。
- userId.toString().intern()保证相同用户ID的字符串对象唯一避免锁失效。
- AopContext.currentProxy()确保@Transactional事务注解生效避免事务失效问题。
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()){
IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
方案五(实现一人一单,跨JVM锁的实现“分布式情况”)
实现原理
满足分布式系统或集群模式下多线程可见并且互斥/
- 多进程可见
- 互斥
- 高可用
- 高性能
- 安全性
分布式锁的实现
分布式锁的核心是实现多进程之间互斥,而满足这一点的方式有很多,常见的有三种
我们需要实现的就是一个用户名id只能抢购一个优惠券的目的。
我们先定义一个锁工具接口
package com.hmdp.utils;
import org.springframework.stereotype.Component;
public interface ILock {
/**
* 尝试获取锁
*
* @param timeoutSec 锁持有的超时时间,过期自动释放
* @return true代表获取锁成功,false代表获取锁失败
*/
boolean tryLock(Long timeoutSec);
/**
* 释放锁
*/
void unLock();
}
再在实现类当中完善相关方法
我们是根据lock前缀以及用户名来写入锁的名称,以到达区分效果,不同的JVM当中的线程读取时达到互斥效果。
package com.hmdp.utils;
import lombok.Data;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
@Data
public class SimpleRedisLock implements ILock {
private StringRedisTemplate stringRedisTemplate;
private String name;
private static final String KEY_PREFIX = "lock:";
public SimpleRedisLock(String s, StringRedisTemplate stringRedisTemplate) {
this.name = s;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(Long timeoutSec) {
//获取线程标识和锁
String key = KEY_PREFIX + name;
long threadId = Thread.currentThread().getId();
String value = threadId + "";
//获取锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(key, value, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unLock() {
//释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
存在的问题:
防止误删(加一个线程标识进行校验,设置特定的value值用于校验setnx是基于key的)
加一个判断是否是自己的锁(是自己的才删)
代码实现:
根据JVM的id-key值与当前线程的UUID线程标识-value进行区分获取当前线程的身份,解决线程误删操作。
package com.hmdp.utils;
import lombok.Data;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Data
public class SimpleRedisLock implements ILock {
private StringRedisTemplate stringRedisTemplate;
private String name;
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID() + "-";
public SimpleRedisLock(String s, StringRedisTemplate stringRedisTemplate) {
this.name = s;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(Long timeoutSec) {
//获取线程标识和锁
String key = KEY_PREFIX + name;//key值
String value = ID_PREFIX + Thread.currentThread().getId();//value值
//获取锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(key, value, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unLock() {
//获取线程标识
String value = ID_PREFIX + Thread.currentThread().getId();//value值
//获取锁中的标识
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
if (value.equals(id)) {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
}
存在的问题:
防止误删,如果在判断结束后出现了阻塞情况,导致时间达到了TTL时间,其他的线程进入锁依然会被误删,那被误删的线程就会没有锁,导致其他的线程进入抢券,引发线程并发问题)
这里的判断与释放分成了两部分,非原子性操作
get
(校验锁归属)和 delete
(释放锁)是独立操作,期间锁可能过期并被其他线程获取。
最终解决方案:Lua脚本(解决原子性)
Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,他的基本语法可以参考:Lua 教程 | 菜鸟教程
基于Redis的分布式锁
释放锁的业务流程是这样的:
- 1 获取锁中的线程标识。
- 2 判断是否与指定的标识一致。
- 3 如果一致则释放锁。
- 4 如果不一致则什么都不做。
代码实现:
Lua脚本的编写
--比较线程标识与所种的标识是否一致
if (redis.call('get', KEYS[1]) == ARGV[1]) then
--释放锁
return redis.call('del', KEYS[1])
end
return 0
调用代码的改进:
package com.hmdp.utils;
import lombok.Data;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Data
public class SimpleRedisLock implements ILock {
private static final String KEY_PREFIX = "lock:";
private StringRedisTemplate stringRedisTemplate;
private String name;
private final String ID_PREFIX;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
this.ID_PREFIX = UUID.randomUUID() + "-";
}
// 释放锁的脚本(static初始化避免多次读取,这样可以优化性能)
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
@Override
public boolean tryLock(Long timeoutSec) {
//获取线程标识和锁
String key = KEY_PREFIX + name;//key值
String value = ID_PREFIX + Thread.currentThread().getId();//value值
//获取锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(key, value, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unLock() {
//调用Lua脚本
stringRedisTemplate
.execute(
UNLOCK_SCRIPT
, Collections.singletonList(KEY_PREFIX + name)
, ID_PREFIX + Thread.currentThread().getId());
}
// @Override
// public void unLock() {
// //获取线程标识
// String value = ID_PREFIX + Thread.currentThread().getId();//value值
// //获取锁中的标识
// String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// if (value.equals(id)) {
// stringRedisTemplate.delete(KEY_PREFIX + name);
// }
// }
}
Service接口的代码实现展示
package com.hmdp.service.impl;
import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.SimpleRedisLock;
import com.hmdp.utils.UserHolder;
import org.springframework.aop.framework.AopContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
private RedisIdWorker redisIdWorker;
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 秒杀优惠券
*
* @param voucherId 优惠券id
* @return
*/
@Override
public Result seckillVoucher(Long voucherId) {
// 1查询优惠券
SeckillVoucher byId = seckillVoucherService.getById(voucherId);
// 2判断时间范围
if (byId.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀尚未开始");
}
if (byId.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已经结束");
}
// 3判断库存
if (byId.getStock() < 1) {
return Result.fail("库存不足");
}
// 4实现一人一单
Long userId = UserHolder.getUser().getId();
//创建锁对象
SimpleRedisLock simpleRedisLock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
//获取锁
boolean isLock = simpleRedisLock.tryLock(1000L);
//判断是否成功
if (!isLock) {
//获取锁失败
return Result.fail("请勿重复下单");
}
try {
//获取锁成功,开始创建订单
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
//释放锁
simpleRedisLock.unLock();
}
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
//实现一人一单
Long userId = UserHolder.getUser().getId();
if (query().eq("user_id", userId).eq("voucher_id", voucherId).count() > 0) {
return Result.fail("用户已经购买过");
}
// 4扣减库存
boolean update = seckillVoucherService.update()
.setSql("stock=stock-1")// set stock=stock-1(更新操作的条件)
.eq("voucher_id", voucherId)// where voucher_id=? and stock=?(判断秒杀券的id)
.gt("stock", 0)// where stock>0(判断修改时券是否>0)
.update();
if (!update) {
return Result.fail("库存不足");
}
// 5生成订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6订单id
Long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 7用户id
voucherOrder.setUserId(userId);
// 8优惠券id
voucherOrder.setVoucherId(voucherId);
// 9保存订单
save(voucherOrder);
// 10返回订单id
return Result.ok(orderId);
}
}