秒杀系统学习-seckill

一,秒杀系统DAO层
1,创建项目和依赖
装8.5.70版本TomCat,
改jdbc配置
maven 打war包部署tomcat(有问题)

商家-库存-用户 三方的关系
数据落地的问题:MySQL和NoSQL两种方案,MySQL由于事务性有极大的优势
MySQL的bigint(20),tinyint(4)
复合主键可以用来对同一用户重复秒杀做过滤
MyBatis需要参数和sql,然后进行操作或者封装,优点就是sql自己写

持久层框架
ssh框架:其中h代表Hibernate,他的发展依赖于jpa规范,特点是不写sql,适用于单表的简单查询
ssm框架:其中m代表Mybatis框架,在多表查询时更优秀,说白了可操作性更强。

本项目采用的是springboot整合Hibernate框架,几个要点做下说明
1,表实体的声明,@Entity注解,@Table注解,对于主键可以加@Id注解,以及@GeneratedValue注解,注意主键Id不为int时,不能采用自增方式,这里采用的是GenerationType.AUTO,默认模式
2,具体的sql执行依赖 EntityManager完成,被EntityManager持久化到数据库中的对象,或者从数据库拉入内存中的对象,也会同时被一个持久化上下文(PersistenceContext)管理。这些被管理的对象统称为受管对象(Managed Object)。受到容器托管的EntityManager可以直接通过注解@PersistenceContext注入的方式来获得。
3,EntityManager类似于MyBatis中的Mapper,常用的方法是find,getReference,persist,save ,remove

  • find (Class entityClass,Object primaryKey):根据主键Id查找实体,注意是Object类
  • getReference (Class entityClass,Object primaryKey):类似find,但是如果不存在对应实体,会先创建一个实体的代理,第一次使用时还不存在则抛异常
  • persist (Object entity):持久化方法,将新创建的 Entity 纳入到 EntityManager 的管理。persist和save的区别主要是persist会将sql缓存起来,不是立即执行,而是等到下次flush,save则是直接执行sql
  • remove (Object entity):删除实例,如果实例与数据库记录相关联,则同时删除数据库

springboot的配置文件类型有两种:application.properties 和 application.yml。properties采用.来表递进,yml则是用缩进来体现。application.properties的优先级更高
xml是spring中的配置方式

二,Web层
前端交互–Restful接口-SpringMVC

Restful的接口,就是用HTTP的命令GET,POST,DELETE,PUT表动作,接口表资源

SpringMVC
M:model:模型层,业务处理和数据访问,
C:controller:控制部分,用servlet对view的请求进行处理
V:view:视图层,展示给用户,交互

开发前端页面:Bootstrap

websocket能够提供低延迟,高性能的客户端与服务端的双向数据通信。是socket的增强版。它颠覆了之前web开发的请求处理响应模式,并且提供了一种真正意义上的客户端请求,服务器推送数据的模式,特别适合实时数据交互应用开发。WebSocket也用于可以创建编程式或者注解式的断点。

三,Service层
1,接口设计
要站在使用者角度设计接口,考虑方法定义粒度,参数,返回类型

2,散列算法
md5,sha-1
md5用于信息加密,先获取MessageDigest对象,用getInstance(“MD5”)获取md5对象,再用digest完成哈希计算(需要传入byte数组),然后对哈希结果逐字节再处理。转为十六进制表示,不足两位的加0.

3,Spring托管
@Component用于不清楚托管类类型的情况。其他就是@Service,@Dto,@Controller 三种类型
对于类成员变量,可以用AutoWired,Resource等进行自动装配

4,声明式事务
tx : advice+aop命名空间,一次配置永久生效
@Transactional 通过注解控制事务方法,注意保证事务方法执行尽可能短,如只有一条修改操作,读操作不需要事务控制

四,高并发设计
涉及到高并发问题的点:
1,用户在等待时不停刷新页面:把一些静态资源放在CDN(内容分发网络)上,CDN把资源部署在离用户最近的网络节点上,命中CDN之后不需要访问后端了。互联网公司会自建或者租用CDN。在前端做按钮防重复点击功能
2,秒杀地址接口:由于不能放在CDN上,适合用缓存存储
3,秒杀操作优化的事务竞争:热点商品会产生访问量巨大,同时发出减库存的请求。优化行级锁的持有时间。解决方案是把数据库操作集中到服务器端完成

Redis相关设计
首先和Mybatis一样,逻辑放在Dao层中
通过JedisPool建立连接,获取数据,注意Redis没有实现内部序列化操作,get到的是byte[]数组,需要通过反序列化成需要的数据结构。同样存数据也要先经过序列化
序列化的工具速度最快的是protostuff,Java内置的Serializable性能一般。
比如说我获取到的是空的byte[]数组,想要反序列化为一个seckill对象,可以New 一个RunTimeSchema,通过schema.newMessage() 完成空对象的创建,再用ProtostuffIOUtil.mergeFrom反序列化

五,流程理解
1,通过seckill网页进入秒杀页面

  • 通过 http://localhost:8080/seckill/ 获取static里面的静态资源,包括秒杀网页。点购买跳转到对应网页,点击立即购买,弹出验证码,这边调用了qq.captcha验证插件,防止恶意攻击。这个插件会去官网获取服务id和secretKey,所以需要在腾讯云验证码服务官网https://console.cloud.tencent.com/captcha/graphical?fromCode=true 注册账号先。然后在后端application.properties修改对应属性,还有对应网页的data-appid 也需要改成相同的id和secretkey。
  • 随后调用startSeckill接口,验证通过之后。判断startSeckill内部的response没问题,就往队列里扔消息。由消费者进行消化,减库存,记录秒杀成功的人等等操作
Destination destination = new ActiveMQQueue("seckill.queue");
activeMQSender.sendChannelMess(destination,1000+";"+1);

Destination 指消息发布接收的地点,包括队列或主题
ActiveMQSender,ActiveMQConsumer完成消息发送和接收
其中Consumer中是用JmsListener注解把topic设置为 “seckill.queue”,然后调用seckillService中的减库存方法

2,测试使用的Swagger提供的接口
逻辑代码存在SeckillController内部,这边分开调试。下面逐一介绍

六,秒杀部分(Service锁,数据库锁)
1,秒杀一:/seckill/start (不行,会超卖)
采用CountDownLatch,开1000个线程模拟用户同时秒杀,调用CountDownLatch的await方法等待所有秒杀结束。(后面几种也一样)

final CountDownLatch latch = new CountDownLatch(skillNum);

这边注意每次秒杀开始,都调用了deleteSeckill,把该商品重新设置100个,主要是方便测试
本方法会出现超卖,主要是由于不同的线程同时判断当前库存是否大于0,如果有几个线程同时判断是1,则会超卖

2,秒杀二: /seckill/startLock (不推荐)
实现比较简单,在Service内部代码头和尾加ReentrantLock,公平锁。因为Service都是单例,一个线程获取锁之后不会再被其他Service获取了。但是这里也会出现超卖。原因猜测是虽然代码块执行是互不影响的,但代码执行结束事务(Transctional注解内代码)才提交,而锁是在事务内部,这就导致代码块已经没锁了,但事务还没提交,此时最后一个线程判断num还是1,执行秒杀,其实已经没库存了,导致超卖。

3,秒杀三:AOP锁(其实也有问题,不推荐)
实现方式是
自建注解@ServiceLock,实现ServiceLock接口
编写切面类,采用@Aspect注解,程序中@PointCut注解切面, @Around注解切面处的增强方式
得到的效果是将锁上移,在Service外加锁。这里解决了秒杀二的问题,主要是lock在事务外,保证了事务执行完下一个线程才能动(获取锁)

4,秒杀四:Mysql加锁 (性能差,不推荐)
这种加锁方式个人认为比较直接,也好理解。select语句最后加上for update。表示当前用户对该行加锁,可以读或写,其他用户只能读。行级锁+独占锁。配合Transactional事务特性,达到同一时刻只有一个用户能修改库存的目的

SELECT number FROM seckill WHERE seckill_id=? FOR UPDATE

这种方法能够保证不超卖,能保证不同商品秒杀不受影响。不过性能差,比秒杀五耗时多大概80-120ms

5,秒杀五:利用update语句加where判断(推荐,数据库锁的最优实现)
事实上,update语句执行时mysql会加锁,InnoDB引擎实现了三种锁:next-key锁(间隙锁,行锁),间隙锁,记录锁(行锁)。默认采用next-key锁,当where跟的列有唯一索引,next-key会降级成记录锁。
秒杀五就是利用了update的锁表特性,把查询库存和库存-1的工作放在一条语句内执行。

UPDATE seckill  SET number=number-1 WHERE seckill_id=? AND number>0

这里由于where后面跟了判断,不会走索引,所以锁全表。如果同一时间不同商品开始秒杀,则会影响其他商品的秒杀。

6,数据库乐观锁(不推荐)
利用version保证更新库存时,没有别的线程在更改。乐观锁的方式。这里由于秒杀是高并发的情况,锁争用频繁,version频繁变动,用乐观锁肯定是不好的。

"UPDATE seckill  SET number=number-?,version=version+1 WHERE seckill_id=? AND version = ?"

当竞争人数很少时,会出现少卖的情况。Java的CAS实现是当乐观锁失败时,for(;;)一直自旋到获取锁。这个项目没有这么设计,获取失败就没下文了。所以这个方法在这里问题还是很大的:
其一,不能保证抢购人数少的情况下,商品能卖到相应的数值
其二,并不能保证先点击的人先抢到商品。不公平
其三,高并发情况不应该用乐观锁

七,分布式秒杀
1,秒杀七:/seckill/startDisruptorQueue (时间巨长)
这是作者自己实现了一个消息队列。

下面研究几种利用消息队列的分布式秒杀。
首先分布式秒杀我理解是在后台和前端之间加了一层,用于缓冲和处理消息。能够接受大量消息,异步减库存,返回结果。返回结果的时间远长于上面的数据库层面的锁。那么为什么要分布式秒杀?因为上亿的流量同时进来,一台服务器是不可能处理的,需要限流,分流等步骤来减轻服务器压力。

2,Redis分布式锁 /seckillDistributed/startRedisLock (依然超卖一件)
Redisson是Redis推荐的java版Redis客户端,在这里利用它实现Redis分布式锁。新建一个Redisson对象,用getlock获得锁

RedissonClient redissonClient;
String lockKey;
RLock lock = redissonClient.getLock(lockKey);
			//加锁
    	lock.lock();
   		//解锁
   		lock.unlock(); 	

加锁方式底层是继承的JDK的Lock类,类似ReentrantLock实现了一个Rlock类,完成可重入锁
到加锁步骤,其实和前面秒杀二类似,把ReentrantLock换成了RedisLock。

//加锁
res = RedissLockUtil.tryLock(seckillId+"", TimeUnit.SECONDS, 3, 20);
//解锁
RedissLockUtil.unlock(seckillId+"");

三个重要参数:锁名,等待时间,释放时间。锁名相当于Redis存进去时的key;等待时间3s表示最多等3s,等不到拉倒;施放时间20s主要防止服务器宕机,产生死锁。这里注意,trylock如果获取到锁则立即返回,如果没有获取到则会等待规定的时间尝试获取锁。这里往线程池内加的任务是Runnable,没有回调值,所以SeckillDistributedController没有再用CountDownLatch去回收结果,而是一起等待15s。这里非常不优雅
这里依然会超卖一件,还是锁在事务内部的原因,需要AOP将锁上移,保证事务执行完成,这边模仿秒杀三实现了一下,可以不超卖

3,Zookeeper分布式锁 /seckillDistributed/startZkLock
Zookeeper是一个由多个server组成的集群,一个leader,多个follower,负责分布式系统的协调工作。
在这里插入图片描述Curator类似Redisson,是用Java实现的Zookeeper客户端框架。解决了重连,注册等细节工作。本处还是采用它作为分布式锁的实现方案
类似Redis分布式锁,每个连接都去创建节点,由Zookeeper来维持节点的顺序。如果是第一个加锁的,则加锁成功,否则开始监听上一个节点,直到释放锁

	private static String address = "192.168.1.180:2181";
	RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3); 
  client = CuratorFrameworkFactory.newClient(address, retryPolicy); 
  private  static InterProcessMutex mutex = new InterProcessMutex(client, "/curator/lock"); 

这边第4行代表节点前缀,第一次抢锁对应的子节点为“/test/lock/000000000”

4,Redis分布式队列:订阅-监听 /seckillDistributed/startRedisQueue
先看RedisSender
首先是AutoWired自动配置了StringRedisTemplate对象,他是继承了spring-data-redis的RedisTemplate类,实现在spring中访问redis服务,异常处理,序列化,发布订阅等。
RedisTemplate使用

@Autowired
private RedisTemplate redisTemplate;
//基本操作
redisTemplate.delete(key);
redisTemplate.expire(key,time,TimeUnit.MINUTES);
public boolean hasKey(String key){
    return redisTemplate.hasKey(key);
}
//String类型操作,这边就可以改用StringRedisTemplate了
redisTemplate.boundValueOps("StringKey").set("StringValue",1, TimeUnit.MINUTES);
//可以写为
stringRedisTemplate.opsForValue().set("StringKey", "StringValue",1,TimeUnit.SECONDS);

//本处使用的订阅
public void sendChannelMess(String channel, String message) {
    stringRedisTemplate.convertAndSend(channel, message);
}
//看下convertAndSend源码
    public void convertAndSend(String channel, Object message) {
        Assert.hasText(channel, "a non-empty channel is required");
        final byte[] rawChannel = this.rawString(channel);
        final byte[] rawMessage = this.rawValue(message);
        this.execute(new RedisCallback<Object>() {
            public Object doInRedis(RedisConnection connection) {
                connection.publish(rawChannel, rawMessage);
                return null;
            }
        }, true);
    }

channel,message信息都经过序列化,通过publish发布到RedisConnection上面即可
再看RedisConsumer
Redis订阅-发布中的消费者
这里只实现了receiveMessage()方法,具体怎么订阅还需要研究。获取到消息后,取第一个字节在Redis中存的值

八,消息队列

消息队列的概念模型如下图
概念模型Spring Message是 Spring framework 4中添加的模块,基于JmsTemplate实现。对消息发送和接收进行了规定。Message是一个消息体Payload和消息头Header
在这里插入图片描述
各个消息中间件的开发商可以通过实现XXXTemplate和XXXMessageListener接口来实现监听和消费信息

以阿里的RocketMQ 为例
引入依赖

<dependency>
	<groupId>org.apache.rocketmq</groupId>
	<artifactId>rocketmq-spring-boot-starter</artifactId>
	<version>2.0.4</version>
</dependency>

配置properties

rocketmq.name-server=127.0.0.1:9876
rocketmq.producer.group=greetings-producer-group

下面是生产者程序

@RequiredArgsConstructor
@SpringBootApplication
public class ProducerApplication {

	@Bean
	ApplicationListener<ApplicationReadyEvent> ready(RocketMQTemplate template) {
		return event -> {

			var now = Instant.now();
			var destination = "greetings-topic";

			for (var name : "Tammie,Kimly,Josh,Rob,Mario,Mia".split(",")) {

				var payload = new Greeting("Hello @ " + name + " @ " + now.toString());
				var messagePostProcessor = new MessagePostProcessor() {

					@Override
					public Message<?> postProcessMessage(Message<?> message) {
						var headerValue = Character.toString(name.toLowerCase().charAt(0));
						return MessageBuilder
							.fromMessage(message)
							.setHeader("letter", headerValue)
							.build();
					}
				};
				template.convertAndSend(destination, payload, messagePostProcessor);
			}
		};
	}

	public static void main(String[] args) {
		SpringApplication.run(ProducerApplication.class, args);
	}
}

@Data
@AllArgsConstructor
@NoArgsConstructor
class Greeting {
	private String message;
}

Greeting理解为一个消息模板,通过RocketMQTemplate将payload发送至RocketMQ的 topic。MessagePostProcessor 用于将Spring自带的的Message设置header等属性。
下面是消费者代码

@SpringBootApplication
public class ConsumerApplication {

	public static void main(String[] args) {
		SpringApplication.run(ConsumerApplication.class, args);
	}
}

@Data
@AllArgsConstructor
@NoArgsConstructor
class Greeting {
	private String message;
}

@Log4j2
@Service
@RocketMQMessageListener(
	topic = "greetings-topic",
	consumerGroup = "simple-group"
)
class SimpleConsumer implements RocketMQListener<Greeting> {

	@Override
	public void onMessage(Greeting greeting) {
		log.info(greeting.toString());
	}
}

消费者就是监听监听greetings-topic,这里只是log输出了收到的greetings
然后可以通过注解的配置实现对letter 的筛选

@Log4j2
@Service
@RocketMQMessageListener(
	topic = "greetings-topic",
	selectorExpression = " letter = 'm' or letter = 'k' or letter = 't' ",
	selectorType = SQL92,
	consumerGroup = "sql-consumer-group-mkt"
)
class MktSqlSelectorConsumer implements RocketMQListener<Greeting> {

	@Override
	public void onMessage(Greeting greeting) {
		log.info("'m', 'k', 't': " + greeting.toString());
	}
}


@Log4j2
@Service
@RocketMQMessageListener(
	topic = "greetings-topic",
	selectorExpression = " letter = 'j' ",
	selectorType = SQL92,
	consumerGroup = "sql-consumer-group-j"
)
class JSqlSelectorConsumer implements RocketMQListener<Greeting> {

	@Override
	public void onMessage(Greeting greeting) {
		log.info("'j': " + greeting.toString());
	}
}
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值