Redis优惠券秒杀企业实战
每个店铺都可以发布优惠券:
当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题:
id的规律性太明显 受单表数据量的限制所以tb_voucher_order表的主键不能用自增ID:
create table tb_voucher_order(
id bigint not null comment 主键
primary key,
user_id bigint unsigned not null comment 下单的用户id ,
voucher_id bigint unsigned not null comment 购买的代金券id ,
pay_type tinyint(1) unsigned default 1 not null comment 支付方式 1:余额支付;2:支付宝;3:微信 ,
status tinyint(1) unsigned default 1 not null comment 订单状态,1:未支付;2:已支付;3:已核销;4:已取消;5:退款中;6:已退款 ,
create_time timestamp default CURRENT_TIMESTAMP not null comment 下单时间 ,
pay_time timestamp null comment 支付时间 ,
use_time timestamp null comment 核销时间 ,
refund_time timestamp null comment 退款时间 ,
update_time timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment 更新时间
全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:
为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息:
D的组成部分:
符号位:1bit,永远为0,表示正数 时间戳:31bit,以秒为单位,可以使用69年 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID编写全局ID生成器代码:
@Componentpublic class RedisIdWorker {
/**
* 开始时间戳,以2022.1.1为基准计算时间差
*/
private static final long BEGIN_TIMESTAMP = 1640995200L;
/**
* 序列号的位数
*/
private static final int COUNT_BITS = 32;
private StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 生成带有业务前缀的redis自增id
* @param keyPrefix
* @return
*/
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.拼接并返回
// 用于是数字类型的拼接,所以不能像拼接字符串那样处理,而是通过位运算将高32位存 符号位+时间戳,低32位存 序列号
return timestamp COUNT_BITS | count;
}
public static void main(String[] args) {
LocalDateTime time = LocalDateTime.of(2022, 1, 1, 0, 0, 0);
long second = time.toEpochSecond(ZoneOffset.UTC);
System.out.println(second);// 1640995200
}
测试全局ID生成器:
@SpringBootTestclass HmDianPingApplicationTests {
@Resource
private RedisIdWorker redisIdWorker;
private ExecutorService executorService = Executors.newFixedThreadPool(500);
@Test
void testIdWorker() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(300);
// 每个线程生成100个id
Runnable task = () - {
for (int i = 0; i 100; i++) {
long id = redisIdWorker.nextId( order );
System.out.println( id = + id);
}
latch.countDown();
};
// 300个线程
long begin = System.currentTimeMillis();
for (int i = 0; i 300; i++) {
executorService.submit(task);
}
latch.await();
long end = System.currentTimeMillis();
System.out.println( time = + (end begin));
}
测试结果:
每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀抢购:
优惠券表信息:
tb_voucher:优惠券的基本信息,优惠金额、使用规则等(tb_voucher表的type字段区分是普通券还是秒杀券) tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间(秒杀券才需要填写这些信息),同时秒杀券拥有普通券的基本信息(秒杀券表tb_seckill_voucher的主键id绑定的是普通券表tb_voucher的id) create table tb_voucher(
id bigint unsigned auto_increment comment 主键
primary key,
shop_id bigint unsigned null comment 商铺id ,
title varchar(255) not null comment 代金券标题 ,
sub_title varchar(255) null comment 副标题 ,
rules varchar(1024) null comment 使用规则 ,
pay_value bigint(10) unsigned not null comment 支付金额,单位是分。例如200代表2元 ,
actual_value bigint(10) not null comment 抵扣金额,单位是分。例如200代表2元 ,
type tinyint(1) unsigned default 0 not null comment 0,普通券;1,秒杀券 ,
status tinyint(1) unsigned default 1 not null comment 1,上架; 2,下架; 3,过期 ,
create_time timestamp default CURRENT_TIMESTAMP not null comment 创建时间 ,
update_time timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment 更新时间
create table tb_seckill_voucher
(
voucher_id bigint unsigned not null comment 关联的优惠券的id
primary key,
stock int(8) not null comment 库存 ,
create_time timestamp default CURRENT_TIMESTAMP not null comment 创建时间 ,
begin_time timestamp default CURRENT_TIMESTAMP not null comment 生效时间 ,
end_time timestamp default CURRENT_TIMESTAMP not null comment 失效时间 ,
update_time timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment 更新时间
)
comment 秒杀优惠券表,与优惠券是一对一关系 2. 编写添加秒杀券的接口
主要代码:
@RestController@RequestMapping( /voucher )
public class VoucherController {
@Resource
private IVoucherService voucherService;
/**
* 新增秒杀券
* @param voucher 优惠券信息,包含秒杀信息
* @return 优惠券id
*/
@PostMapping( seckill )
public Result addSeckillVoucher(@RequestBody Voucher voucher) {
voucherService.addSeckillVoucher(voucher);
return Result.ok(voucher.getId());
}
@Service
public class VoucherServiceImpl extends ServiceImpl VoucherMapper, Voucher implements IVoucherService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
// 保存优惠券
save(voucher);
// 保存秒杀信息
SeckillVoucher seckillVoucher = new SeckillVoucher();
seckillVoucher.setVoucherId(voucher.getId());
seckillVoucher.setStock(voucher.getStock());
seckillVoucher.setBeginTime(voucher.getBeginTime());
seckillVoucher.setEndTime(voucher.getEndTime());
seckillVoucherService.save(seckillVoucher);
}
测试添加:
测试结果:
下单时需要判断两点:
秒杀是否开始或结束,如果尚未开始或已经结束则无法下单 库存是否充足,不足则无法下单主要代码:
@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 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( 库存不足! );
}
//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);
return Result.ok(orderId);
}
简单测试秒杀成功:
扣减库存成功:
当有大量请求同时访问时,就会出现超卖问题
超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:
乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种:
(1)版本号法
(2)CAS法
用库存代替了版本号,可以少加一个字段 扣库存时,与查询时的库存比较,没被修改则可以扣减库存乐观锁方式,通过CAS判断前后库存是否一致,解决超卖问题:
// 之前的代码boolean success = seckillVoucherService.update()
.setSql( stock= stock -1 )
.eq( voucher_id , voucherId).update();
// 乐观锁方式,通过CAS判断前后库存是否一致,解决超卖问题
boolean success = seckillVoucherService.update()
.setSql( stock= stock -1 ) // set stock = stock -1
.eq( voucher_id , voucherId).eq( stock ,voucher.getStock()).update(); // where id = ? and stock = ?
又出现新的问题:
假设100个线程同时请求,但通过CAS判断后,只有一个线程能扣减库存成功,其余99个线程全部失败 此时,库存剩余99,但是实际业务可以满足其余99个线程扣减库存 虽然能解决超卖问题,但是设计不合理所以为了解决失败率高的问题,需要进一步改进:
通过CAS 不再 判断前后库存是否一致,而是判断库存是否大于0
boolean success = seckillVoucherService.update().setSql( stock= stock -1 )
.eq( voucher_id , voucherId).gt( stock ,0).update(); // where id = ? and stock 0 3. 小结
超卖这样的线程安全问题,解决方案有哪些?
(1)悲观锁:添加同步锁,让线程串行执行
(2)乐观锁:不加锁,在更新时判断是否有其它线程在修改
优点:性能相对悲观锁好(但是仍然需要同时查数据库,影响性能) 缺点:存在成功率低的问题(可以采用分段锁方式提高成功率) 五、一人一单问题需求:修改秒杀业务,要求同一个优惠券,一个用户只能下一单
在扣减库存之前,加上一人一单的逻辑:
// 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( 用户已经购买过一次! );
此处仍会出现并发问题,当同一用户模拟大量请求同时查询是否下过单时,如果正好都查询出count为0,就会跳过判断继续执行扣减库存的逻辑,此时就会出现一人下多单的问题
解决方法:
由于是判断查询的数据是否存在,而不是像之前判断查询的数据是否修改过 所以这里只能加悲观锁 1. 加锁分析 首先将一人一单之后的逻辑全部加锁,所以将一人一单之后的逻辑抽取出一个方法进行加锁,public Result createVoucherOrder(Long voucherId) 如果直接在方法上加锁,则锁的是this对象,锁的对象粒度过大,就算是不同的人执行都会阻塞住,影响性能,public synchronized Result createVoucherOrder(Long voucherId) 所以将锁的对象改为userId,但是不能直接使用synchronized (userId),因为每次执行Long userId = UserHolder.getUser().getId();虽然值一样,但是对象不同,因此需要这样加锁 synchronized (userId.toString().intern()),intern()表示每次从字符串常量池中获取,这样值相同时,对象也相同 为了防止事务还没提交就释放锁的问题,则不能将锁加在createVoucherOrder方法内部,例如: @Transactionalpublic Result createVoucherOrder(Long voucherId) {
synchronized (userId.toString().intern()) {
。。。
}
而是需要等事务提交完再释放锁,例如:
synchronized (userId.toString().intern()) {// 获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
2. 事务分析
由于只有一人一单之后的逻辑涉及到修改数据库,所以只需对该方法加事务
@Transactional
public Result createVoucherOrder(Long voucherId)
由于只对createVoucherOrder方法加了事务,而该方法是在seckillVoucher方法中被调用,seckillVoucher方法又没有加事务,为了防止事务失效,则不能直接在seckillVoucher方法调用createVoucherOrder方法,例如:
@Overridepublic Result seckillVoucher(Long voucherId) {
。。。。
synchronized (userId.toString().intern()) {
return this.createVoucherOrder(voucherId);
}
而是需要通过代理对象调用createVoucherOrder方法,因为@Transactional事务注解的原理是通过获取代理对象执行目标对象的方法,进行AOP操作,所以需要这样:
@Overridepublic Result seckillVoucher(Long voucherId) {
。。。。
// 获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
并且还要引入依赖:
dependencygroupId org.aspectj /groupId
artifactId aspectjweaver /artifactId
/dependency
还要开启注解暴露出代理对象:
@EnableAspectJAutoProxy(exposeProxy = true)@MapperScan( com.hmdp.mapper )
@SpringBootApplication
public class HmDianPingApplication {
public static void main(String[] args) {
SpringApplication.run(HmDianPingApplication.class, args);
}
完整VoucherOrderServiceImpl代码:
@Servicepublic 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( 库存不足! );
}
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
// 获取代理对象(事务)
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,扣减库存
// 乐观锁方式,通过CAS判断库存是否大于0,解决超卖问题:
boolean success = seckillVoucherService.update()
.setSql( stock= stock -1 )
.eq( voucher_id , voucherId).gt( stock ,0).update(); // where id = ? and stock 0
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);
}
通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了。
我们将服务启动两份,端口分别为8081和8082:
然后修改nginx的conf目录下的nginx.conf文件,配置反向代理和负载均衡:
修改完后,重新加载nginx配置文件:
现在,用户请求会在这两个节点上负载均衡,再次测试下是否存在线程安全问题:
访问8081端口的线程进入了synchronized中
访问8082端口的线程也进入了synchronized中
最终同一个用户下了2单扣了2个库存,所以在集群模式下,出现了一人多单的问题:
分析:
锁的原理是每个JVM中都有一个Monitor作为锁对象,所以当对象相同时,获取的就是同一把锁 但是不同的JVM中的Monitor不同,所以获取的不是同一把锁 因此集群模式下,加synchronized锁也会出现并发安全问题,需要加分布式锁我想要获取技术服务或软件
服务范围:MySQL、ORACLE、SQLSERVER、MongoDB、PostgreSQL 、程序问题
服务方式:远程服务、电话支持、现场服务,沟通指定方式服务
技术标签:数据恢复、安装配置、数据迁移、集群容灾、异常处理、其它问题
本站部分文章参考或来源于网络,如有侵权请联系站长。
数据库远程运维 Redis优惠券秒杀企业实战
相关文章
- redis适合场景八点总结
- 精通Redis:管理命令实战(redis管理命令)
- 用户粉丝数统计Redis缓存解决微博用户粉丝数统计问题(redis实现微博)
- 方法使用Redis客户端的简明步骤(redis客户端使用)
- 解决多线程环境下的Redis数据同步问题(多线程redis问题)
- 如何在Redis中轻松导出数据(怎么到处redis数据)
- 后的数据如何在Redis中保存修改后的数据(怎么保存redis中修改)
- 腾讯云赋能Redis集群架构,强势推动企业实现卓越发展(腾讯云redis集群架构)
- Redis常见面试题及解答精选(经常问redis的面试题)
- 极速验证Redis短信验证功能(短信验证功能redis)
- 用Redis服务器支撑企业发展(服务器 redis 一台)
- Redis实战开发·优化·指南推荐(推荐redis的书)
- 新技术Web环境下Redis配置实战分享(web配置redis)
- Ubuntu 下搭建 Redis 环境实战(ubutn redis)
- 从安卓到Redis,一步到位实现整合(安卓整合redis实战)
- 理解Redis多线程一种新方法(怎么理解redis多线程)
- 基于关系型数据库的Redis迁移实践(关系型数据库转redis)
- 警惕Redis Map的频繁变更(redis频繁修改map)
- 实战Redis项目实战视频,助你快速搭建稳定专业环境(redis项目视频)
- Redis实战简单而又有效的缓存策略(关于redis书籍)
- Redis面试普通企业常问的问题(redis面试一般问啥)
- 果然无法避免Redis集群节点挂了(redis集群节点挂了后)
- Redis集群安全体系修改密码(redis集群改密码)
- 开源Redis集群给企业构建无限灵活的数据库架构(redis 集群 开源)
- 成本Redis如何帮助企业降低内存使用成本(redis 降低内存使用)
- Redis操作实战如何获取Hash值(redis获取hash值)
- 如何使用Redis连接池(redis连接池怎么使用)
- 破解Redis缓存难题,舒缓企业系统压力(redis 缓存问题)