秒杀项目笔记
第二章,项目回归
ItemController: 商品相关Controller
OrderController:交易相关Controller
UserController:用户相关Controller
OrderService:下单交易
UserService:获取用户信息、注册、登录
ItemService:创建商品、商品列表浏览、商品详情浏览、商品减库存、商品加销量
PromoService:获取秒杀活动商品信息
PromoDoMapper:秒杀活动数据表操作
ItemDoMapper:商品数据操作
ItemStockDoMapper:库存数据表操作
OrderDoMapper:订单数据表操作
UserDoMapper:用户数据表操作
UserPasswordMapper:用户密码操作
第三章 云端部署,修改Server配置
本地在项目根目录下使用mvn clean package 打包生成miaosha.jar文件
将jar包上传到服务端并编写额外的application.properties配置文件
编写deploy.sh文件启动对应的项目 java命令启动,设置JVM初始和最大内存为2048m,2个g大小,设置JVM初始新生代和最大新生代大小为1024m,设置成一样的目的是为了减少扩展jvm内存过程中向操作系统索要内存分配的消耗,
-spring config addition-location=指定额外的配置文件地址
nohub java -Xms2048m -Xmx2048m -XX:NewSize=1024m -XX:MaxNewSize=1024m -jar miaosha.jar --spring.config.addition-location=/var/www/miaosha/application.properties
Spring-configuration-metadata.json
server.tomcat.accept-count:等待队列长度,默认100 server.tomcat.max-connections:最大可被连接数,默认10000 server.tomcat.max-threads:最大工作线程数,默认200 server.min-spare-threads:最小工作线程数,默认是10 # 默认配置下,请求超过1000后出现拒绝连接情况 # 默认配置下,触发的请求超过200+100后拒绝连接 最大工作线程数+等待队列的长度
# 修改默认参数值 4核8G机器 server.tomcat.max-threads:800 server.min-spare-threads:100
keepAliveTimeOut:多少毫秒后不响应的断开Keepalive
maxKeepAliveRequests:多少次请求后Keepalive断开失效
定制化:使用WebServerFactoryCunstomizer 定制化内嵌Tomcat
//当Spring容器内没有TomcatEmbeddedServletContainerFactory这个bean时,会吧此bean加载进spring容器中 @Component public class WebServerConfiguration implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> { @Override public void customize(ConfigurableWebServerFactory configurableWebServerFactory) { //使用对应工厂类提供给我们的接口定制化我们的tomcat connector ((TomcatServletWebServerFactory)configurableWebServerFactory).addConnectorCustomizers(new TomcatConnectorCustomizer() { @Override public void customize(Connector connector) { Http11NioProtocol protocol = (Http11NioProtocol) connector.getProtocolHandler(); //定制化keepalivetimeout,设置30秒内没有请求则服务端自动断开keepalive链接 protocol.setKeepAliveTimeout(30000); //当客户端发送超过10000个请求则自动断开keepalive链接 protocol.setMaxKeepAliveRequests(10000); } }); } }
第四章,单机容量问题,水平扩展方案引入
表象:单机cpu使用率增高,memory占用增加,网路带宽使用增加
cpu us:用户空间的cpu使用情况(用户层代码)
Cpu sy:内核空间的cpu使用情况(系统调用)
Load average:1.5,15分钟load平均值,跟这核数增加,0代表通常,1代表打满,1+代表等待阻塞
memory:free空闲内存,used使用内存
nginx 动静分离设置
会话管理
- 基于token传输类似sessionid:java代码session实现迁移到redis
String uuidToken = UUID.randomUUID().toString(); uuidToken = uuidToken.replace("-",""); //建立token和用户登陆态之间的联系 redisTemplate.opsForValue().set(uuidToken,userModel); //设置超时时间 1hour redisTemplate.expire(uuidToken,1, TimeUnit.HOURS); //下发了token return CommonReturnType.create(uuidToken);
前端代码存储uuidToken
Login.html
$.ajax({ type:"POST", contentType:"application/x-www-form-urlencoded", url:"http://"+g_host+"/user/login", data:{ "telphone":$("#telphone").val(), "password":password }, xhrFields:{withCredentials:true}, success:function(data){ if(data.status == "success"){ alert("登陆成功"); var token = data.data; //存储token window.localStorage["token"] = token; //从定向到listitem.html window.location.href="listitem.html"; }else{ alert("登陆失败,原因为"+data.data.errMsg); } }, error:function(data){ alert("登陆失败,原因为"+data.responseText); } }); return false; });
getItem.html
var token = window.localStorage["token"]; $.ajax({ type:"POST", contentType:"application/x-www-form-urlencoded", url:"http://"+g_host+"/order/generatetoken?token="+token, data:{ "itemId":g_itemVO.id, "promoId":g_itemVO.promoId, "verifyCode":$("#verifyContent").val() },
OrderController.java--> createOrder Method
String token = httpServletRequest.getParameterMap().get("token")[0]; if(StringUtils.isEmpty(token)){ throw new BusinessException(EmBusinessError.USER_NOT_LOGIN,"用户还未登陆,不能下单"); } //获取用户的登陆信息 UserModel userModel = (UserModel) redisTemplate.opsForValue().get(token); if(userModel == null){ throw new BusinessException(EmBusinessError.USER_NOT_LOGIN,"用户还未登陆,不能下单"); }
基于cookie传输的session:java tomcat容器session的实现
// 1.引入依赖 <!--引入依赖--> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> <version>2.0.5.RELEASE</version> </dependency> // 2.修改Redis配置文件 保存到的Session 过期时间 @Component @EnableRedisHttpSession(maxInactiveIntervalInSeconds = 3600) //3.保存登录状态 public class RedisConfig { } this.httpServletRequest.getSession().setAttribute("IS_LOGIN",true); this.httpServletRequest.getSession().setAttribute("LOGIN_USER",userModel);
缺点:企业级应用,不光支持html,还要支持Android、IOS 网络情况下cookie的规则会不会改变,还有Cookie被客户端禁用的可能。
第五章 查询优化技术之多级优化
缓存设计
- 用快速缓存设备,用内存
- 将缓存推到离用户最近的地方
- 脏缓存清理(关键型数据必须存储在数据库中,将查询的热点数据放入缓存,注意一致性问题)
多集缓存
- redis缓存<单机版、Sentinal哨兵模式、集群Cluster模式>
- 热点内存本地缓存
- nginx proxy cache缓存
- nginx lua 缓存
商品详情动态内容实现。
ItemController --->getItem()
//商品详情页浏览 @RequestMapping(value = "/get",method = {RequestMethod.GET}) @ResponseBody public CommonReturnType getItem(@RequestParam(name = "id")Integer id){ ItemModel itemModel = null; //根据商品的id到redis内获取 itemModel = (ItemModel) redisTemplate.opsForValue().get("item_"+id); //若redis内不存在对应的itemModel,则访问下游service if(itemModel == null){ itemModel = itemService.getItemById(id); //设置itemModel到redis内 redisTemplate.opsForValue().set("item_"+id,itemModel); redisTemplate.expire("item_"+id,10, TimeUnit.MINUTES); } ItemVO itemVO = convertVOFromModel(itemModel); return CommonReturnType.create(itemVO); }
本地热点缓存<VM虚拟机堆栈2G设置就是为了使用对象管理区间>
用于存放热点数据、脏读非常不敏感、内存可控
Guava Cache 类似于HashMap,可空值的大小和超时时间、可以配置lru清除策略、线程安全的
Map<Integer、ItemModel>
可以支持并发读写的hashMap、想到currentHashMap是基于段的处理方式去加速,在处理Put时写锁加上会对读锁性能有影响,而且可以设置key过期时间。
// 1.maven引入guava <!--引入guava--> <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>18.0</version> </dependency> //2.封装CacheService,完成对象的存储 //3.编写CacheService //封装本地缓存操作类 public interface CacheService { //存方法 void setCommonCache(String key,Object value); //取方法 Object getFromCommonCache(String key); } //4.编写CacheServiceImpl @Service public class CacheServiceImpl implements CacheService { private Cache<String,Object> commonCache = null; @PostConstruct public void init(){ commonCache = CacheBuilder.newBuilder() //设置缓存容器的初始容量为10 .initialCapacity(10) //设置缓存中最大可以存储100个KEY,超过100个之后会按照LRU的策略移除缓存项 .maximumSize(100) //设置写缓存后多少秒过期 .expireAfterWrite(60, TimeUnit.SECONDS).build(); } @Override public void setCommonCache(String key, Object value) { commonCache.put(key,value); } @Override public Object getFromCommonCache(String key) { return commonCache.getIfPresent(key); } } //5.改造ItemService //商品详情页浏览 @RequestMapping(value = "/get",method = {RequestMethod.GET}) @ResponseBody public CommonReturnType getItem(@RequestParam(name = "id")Integer id){ ItemModel itemModel = null; //先取本地缓存 itemModel = (ItemModel) cacheService.getFromCommonCache("item_"+id); if(itemModel == null){ //根据商品的id到redis内获取 itemModel = (ItemModel) redisTemplate.opsForValue().get("item_"+id); //若redis内不存在对应的itemModel,则访问下游service if(itemModel == null){ itemModel = itemService.getItemById(id); //设置itemModel到redis内 redisTemplate.opsForValue().set("item_"+id,itemModel); redisTemplate.expire("item_"+id,10, TimeUnit.MINUTES); } //填充本地缓存 cacheService.setCommonCache("item_"+id,itemModel); } ItemVO itemVO = convertVOFromModel(itemModel); return CommonReturnType.create(itemVO); }
缺点:当数据更新时、本地热点缓存无能为力而且也有容量上的问题
nginx proxy cache缓存<有这个方案,但是提升不明显,弃用>
- nginx反向***前置
- 依靠文件系统存索引级的文件
- 依靠内存缓存文件地址
nginx lua
第六章 查询优化技术之页面静态化[动态请求加静态页面静态化]
第七章 交易优化技术之缓存库存[用缓存解决交易问题]
交易系统性能瓶颈
1.交易校验操作完全依赖数据库<发送了6次sql>
2.落单减库存操作
<update id="decreaseStock"> update item_stock set stock = stock - #{amount} where item_id = #{itemId} and stock >= #{amount} 有数据库行锁等待 </update>
- 用户风控策略优化:策略缓存模型化
//1.通过缓存获取item模型 ItemService -->getItemByIdInCache() @Override public ItemModel getItemByIdInCache(Integer id) { ItemModel itemModel = (ItemModel) redisTemplate.opsForValue().get("item_validate_"+id); if(itemModel == null){ itemModel = this.getItemById(id); redisTemplate.opsForValue().set("item_validate_"+id,itemModel); redisTemplate.expire("item_validate_"+id,10, TimeUnit.MINUTES); } return itemModel; } //2.通过缓存获取user模型 UserService -->getUserByIdInCache() @Override public UserModel getUserByIdInCache(Integer id) { UserModel userModel = (UserModel) redisTemplate.opsForValue().get("user_validate_"+id); if(userModel == null){ userModel = this.getUserById(id); redisTemplate.opsForValue().set("user_validate_"+id,userModel); redisTemplate.expire("user_validate_"+id,10, TimeUnit.MINUTES); } return userModel; } //2.修改下单流程(直接查询数据库--->先查询缓存,在查询数据库) //3.校验下单状态,下单的商品是否存在,用户是否合法,购买数量是否正确 //ItemModel itemModel = itemService.getItemById(itemId); ItemModel itemModel = itemService.getItemByIdInCache(itemId); if(itemModel == null){ throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,"商品信息不存在"); } UserModel userModel = userService.getUserByIdInCache(userId); if(userModel == null){ throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,"用户信息不存在"); }
- 活动校验策略优化:引入活动发布流程,模型缓存化,紧急下线能力
库存行锁优化
alter table item_stock add unique index item_id_index(itemid)
扣减库存缓存化
活动发布同步库存进库存
//1.活动发布 PromoService public void publishPromo(Integer promoId) { //通过活动id获取活动 PromoDO promoDO = promoDOMapper.selectByPrimaryKey(promoId); //验证活动是否存在 if(promoDO.getItemId() == null || promoDO.getItemId().intValue() == 0){ return; } // TODO 上下架商品操作... ItemModel itemModel = itemService.getItemById(promoDO.getItemId()); //将库存同步到redis内 redisTemplate.opsForValue().set("promo_item_stock_"+itemModel.getId(), itemModel.getStock()); }
下单交易减缓存库存<数据库记录不一致>
@Transactional public boolean decreaseStock(Integer itemId, Integer amount) throws BusinessException { //int affectedRow = itemStockDOMapper.decreaseStock(itemId,amount); long result = redisTemplate.opsForValue().increment("promo_item_stock_"+itemId,amount.intValue() * -1); if(result >0){ //更新库存成功 return true; }else if(result == 0){ //打上库存已售罄的标识 redisTemplate.opsForValue().set("promo_item_stock_invalid_"+itemId,"true"); //更新库存成功 return true; }else{ //更新库存失败 increaseStock(itemId,amount); return false; } }
异步消息扣减数据库内库存
异步消息队列rocketmq基于kafka改造的中间件,重试消息队列、延迟消息队列。
引入maven依赖
<dependency> <groupId>org.apache.rocketmq</groupId> <artifactId>rocketmq-client</artifactId> <version>4.3.0</version> </dependency>
Application.properties
mq.nameserver.addr=115.28.59.132:9876 mq.topicname=stock
MQProducer
// 1.MQ初始化 @PostConstruct public void init() throws MQClientException { //做mq producer的初始化 producer = new DefaultMQProducer("producer_group"); producer.setNamesrvAddr(nameAddr); producer.start(); } //2.同步扣减库存消息 //同步库存扣减消息 public boolean asyncReduceStock(Integer itemId,Integer amount) { Map<String,Object> bodyMap = new HashMap<>(); bodyMap.put("itemId",itemId); bodyMap.put("amount",amount); Message message = new Message(topicName,"increase", JSON.toJSON(bodyMap).toString().getBytes(Charset.forName("UTF-8"))); try { producer.send(message); } catch (MQClientException e) { e.printStackTrace(); return false; } catch (RemotingException e) { e.printStackTrace(); return false; } catch (MQBrokerException e) { e.printStackTrace(); return false; } catch (InterruptedException e) { e.printStackTrace(); return false; } return true; }
MQConsumer
@Component public class MqConsumer { private DefaultMQPushConsumer consumer; @Value("${mq.nameserver.addr}") private String nameAddr; @Value("${mq.topicname}") private String topicName; @Autowired private ItemStockDOMapper itemStockDOMapper; @PostConstruct public void init() throws MQClientException { consumer = new DefaultMQPushConsumer("stock_consumer_group"); consumer.setNamesrvAddr(nameAddr); consumer.subscribe(topicName,"*"); consumer.registerMessageListener(new MessageListenerConcurrently() { @Override public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) { //实现库存真正到数据库内扣减的逻辑 Message msg = msgs.get(0); String jsonString = new String(msg.getBody()); Map<String,Object>map = JSON.parseObject(jsonString, Map.class); Integer itemId = (Integer) map.get("itemId"); Integer amount = (Integer) map.get("amount"); itemStockDOMapper.decreaseStock(itemId,amount); return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; } }); consumer.start(); }
异步同步数据库的问题
- 异步消息发送失败
- 扣减操作执行失败
- 下单失败无法正确回补库存
第八章 交易优化技术之事务型消息[保证最终一致性的利器]
- 操作流水(stock_log表)
create table `stock_log`( `stock_log_id` varchar(64) not null primary key, `item_id` int(11) not null, `amount` int(11), `status` tiny int comment '1表示系统初始状态,2代表下单扣库存成功,3代表下单回滚' )
初始化stock_log
1.下单之前加入库存流水Init状态 //初始化对应的库存流水 @Override @Transactional public String initStockLog(Integer itemId, Integer amount) { StockLogDO stockLogDO = new StockLogDO(); stockLogDO.setItemId(itemId); stockLogDO.setAmount(amount); stockLogDO.setStockLogId(UUID.randomUUID().toString().replace("-","")); stockLogDO.setStatus(1); stockLogDOMapper.insertSelective(stockLogDO); return stockLogDO.getStockLogId(); }
LocalTransactionState的三种状态
:COMMIT_MESSAGE
、ROLLBACK_MESSAGE
、UNKNOW
rocket mq提供的TransactionMQProducer API 执行流程:
- 先发送需要发送的消息到消息中间件broker,并获取到该message的transactionId。在第一次发送的时候,该消息的状态为LocalTransactionState.UNKNOW
- 处理本地事物。
- 根据本地事物的执行结果,结合transactionId,找到该消息的位置,在mq中标志该消息的最终处理结果。
上述:如果第三阶段出现异常或者网络原因,就是本地事务执行成功持久化到数据库中,但是在修改mq中消息状态出现异常的时候,这样就可以出现本地和mq的消息状态的不一致问题。或者说,所有的数据不一致问题。rocketmq都会定期通过TransactionMQProducer API初始化的时候,设置的TransactionCheckListener的的实现类的checkLocalTransactionState 方法检查本地消息的状态,根据本地状态修改mq的状态
第九章 流量削峰计数[削峰填谷之神操作]
- 掌握秒杀令牌的原理和使用方式
- 掌握秒杀大闸的原理和使用方式
- 掌握队列泄洪的原理和使用方式
抛缺陷
- 秒杀下单接口会被脚本不停的刷
- 秒杀验证逻辑和秒杀下单接口强关联,代码冗余度高
- 秒杀验证逻辑复杂,对交易系统产生无关联负载
秒杀令牌原理
- 秒杀接口需要依靠令牌才能进入
- 秒杀的令牌由秒杀活动模块负责生成
- 秒杀活动模块对秒杀令牌生成全权处理,逻辑收口
- 秒杀下单前需要先获得秒杀令牌
// 1.管理令牌生成 PromoService --> generateToken //生成token并且存入redis内并给一个5分钟的有效期 //判断当前时间是否秒杀活动即将开始或正在进行 if(promoModel.getStartDate().isAfterNow()){ promoModel.setStatus(1); }else if(promoModel.getEndDate().isBeforeNow()){ promoModel.setStatus(3); }else{ promoModel.setStatus(2); } //判断活动是否正在进行 if(promoModel.getStatus().intValue() != 2){ return null; } //判断item信息是否存在 ItemModel itemModel = itemService.getItemByIdInCache(itemId); if(itemModel == null){ return null; } //判断用户信息是否存在 UserModel userModel = userService.getUserByIdInCache(userId); if(userModel == null){ return null; } String token = UUID.randomUUID().toString().replace("-",""); redisTemplate.opsForValue().set("promo_token_"+promoId+"_userid_"+userId+"_itemid_"+itemId,token); redisTemplate.expire("promo_token_"+promoId+"_userid_"+userId+"_itemid_"+itemId,5, TimeUnit.MINUTES);
//生成秒杀令牌 @RequestMapping(value = "/generatetoken",method = {RequestMethod.POST},consumes={CONTENT_TYPE_FORMED}) @ResponseBody public CommonReturnType generatetoken(@RequestParam(name="itemId")Integer itemId, @RequestParam(name="promoId")Integer promoId, @RequestParam(name="verifyCode")String verifyCode) throws BusinessException { //根据token获取用户信息 String token = httpServletRequest.getParameterMap().get("token")[0]; if(StringUtils.isEmpty(token)){ throw new BusinessException(EmBusinessError.USER_NOT_LOGIN,"用户还未登陆,不能下单"); } //获取用户的登陆信息 UserModel userModel = (UserModel) redisTemplate.opsForValue().get(token); if(userModel == null){ throw new BusinessException(EmBusinessError.USER_NOT_LOGIN,"用户还未登陆,不能下单"); } //通过verifycode验证验证码的有效性 String redisVerifyCode = (String) redisTemplate.opsForValue().get("verify_code_"+userModel.getId()); if(StringUtils.isEmpty(redisVerifyCode)){ throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,"请求非法"); } if(!redisVerifyCode.equalsIgnoreCase(verifyCode)){ throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,"请求非法,验证码错误"); } //获取秒杀访问令牌 String promoToken = promoService.generateSecondKillToken(promoId,itemId,userModel.getId()); if(promoToken == null){ throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,"生成令牌失败"); } //返回对应的结果 return CommonReturnType.create(promoToken); }
秒杀大闸
- 依靠秒杀令牌的授权原理定制化发牌逻辑,做到大闸功能
- 根据秒杀商品初始化库存颁发对应数量令牌,控制大闸数量<库存数量*5发放令牌>
- 用户风控策略前置到秒杀令牌发放
- 库存售罄判断前置到秒杀令牌发放中
抛出缺陷
- 浪涌流量涌入后系统无法应对
- 多库存,多商品等令牌限制能力弱
方案:队列泄洪策略
- 排队有时候比并发更加高效(例如redis单线程模型,innodb mutex key等)
- 依靠排队去限制并发流量
- 依靠排队和下游拥塞窗口程度调整队列释放流量大小
Redis为什么快?
内存级别数据库,
单线程操作,不会有线程内上下文内存上的开销
CreateOrder() private ExecutorService executorService; @PostConstruct public void init(){ executorService = Executors.newFixedThreadPool(20); } //同步调用线程池的submit方法 //拥塞窗口为20的等待队列,用来队列化泄洪 Future<Object> future = executorService.submit(new Callable<Object>() { @Override public Object call() throws Exception { //加入库存流水init状态 String stockLogId = itemService.initStockLog(itemId,amount); //再去完成对应的下单事务型消息机制 if(!mqProducer.transactionAsyncReduceStock(userModel.getId(),itemId,promoId,amount,stockLogId)){ throw new BusinessException(EmBusinessError.UNKNOWN_ERROR,"下单失败"); } return null; } });
本地 or 分布式
- 本地:将队列维护在本地内存中<JVM中,性能和高可用,缺点负载可能不均衡>
- 分布式:将队列设置到外部redis内<性能问题:发送任何请求都要发送网络IO、而且还有单点问题>
第十章 防刷限流计数[保护系统,避免过载]
- 掌握验证码生成与验证技术
- 掌握限流原理与实现
- 掌握防黄牛技术
验证码
- 包装秒杀令牌前置,需要验证码来错峰<使用户流量错峰>
限流目的
- 限流方案
- 限并发
- 令牌桶算法<应对突发流量>
- 漏桶算法<以固定的速率流入网络>
- 限流力度
- 接口维度
- 总维度
- 限流范围
- 集群限流:依赖redis或者其他的技术做统一计数器,往往会产生性能瓶颈
- 单机限流:负载均衡的前提下单机平均限流效果更好
限流代码实现:
Guava RateLimit
// orderController private RateLimiter orderCreateRateLimiter; if(order)