最近看了一个不错的架构设计视频,总结下学习笔记
云端部署、性能压测
1、将项目部署到云服务器上
编写deploy脚本
编写deploy脚本,实现自动打包上传并java执行的操作。
服务端springboot配置文件application.properties里面的配置可能随着设备环境不同而变化,每次直接修改源码又很复杂,可以采用外挂配置文件的方法。
在项目目录下新建 vim application.properties外挂配置文件,修改文件,指定服务器端口server.port = 80 执行 java -jar miaosha.jar --spring.config.addition-location=/var/www/miaosha/application.properties 新建deploy.sh文件,编辑nohup指令,设置最大/最小堆栈为400m,JVM新生代200m,最大新生代大小200m,指定额外配置文件
nohup java -Xms400m -Xmx400m -XX:NewSize=200m -XX:MaxNewSize=200m -jar miaosha.jar
--spring.config.addition-location=/var/www/miaosha/application.properties
后台方式启动应用程序 ./deploy.sh & 避免关闭控制台,应用程序关闭的风险。输出nohup.out文件
2、压测工具Jmeter
- 线程组
Http请求- 查看结果树
- 聚合报告
3、并发容量问题
Linux命令
pstree - p 进程号 查看进程下的所有线程
pstree -p 进程号 | wc - l 统计进程下的线程数
ps -ef | grep java 查看正在进行的线程
top H 查看cpu进程情况
查看SpringBoot配置
- spring-configuration-metadata.json
- 查看各个节点的配置
默认内嵌Tomcat配置
- server.tomcat.accept-count:等待队列长度,默认100
- server.tomcat.max-connections:最大可被连接数,默认10000
- server.tomcat.max-thread:最大工作线程数,默认200
- server.tomcat.min-spare-threads:最小工作线程数,默认10
- 默认配置下,连接超过10000后出现拒绝连接情况
- 默认配置下,触发的请求超过200+100后拒绝处理
定制化内嵌Tomcat开发
tomcat保持KeepAlive的好处是:可以不用每次进行耗时的http请求断开和连接的操作,只用传输数据;而坏处就是:若连接空闲造成资源的浪费并且容易成为黑客攻击的对象。
所以定制化内嵌Tomcat成为需求,我们需要关注两个参数:
keepAliveTimeOut:多少毫秒后不响应就断开keepalivemaxKeepAliveRequests:多少次请求后keeperAlive断开失效- 使用
WebServerFactoryCustomizer定制化内嵌tomcat配置
// 当Spring容器内没有TomcatEmbedServletWebServerFactory这个bean时,会把该bean加载进spring
@Component
public class WebServerConfiguration implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
@Override
public void customize(ConfigurableWebServerFactory factory) {
// 使用对应工程类提供给我们的接口定制化我们的tomcat connector
((TomcatServletWebServerFactory)factory).addConnectorCustomizers(new TomcatConnectorCustomizer() {
@Override
public void customize(Connector connector) {
Http11NioProtocol protocol = (Http11NioProtocol) connector.getProtocolHandler();
// 定制化keepalivetimeout,设置30s内没有请求服务器自动断开连接
protocol.setKeepAliveTimeout(30000);
// 当客户端发送超过10000个请求则自动断开keepalive连接
protocol.setMaxKeepAliveRequests(10000);
}
});
}
// 随着并发数上涨 发现容量问题 响应时间边长 TPS上不去
// 但web容器上限
}
4、另一个容量问题
单Web容器上限
- 线程数量:4核cpu 8G内存进程调度线程数800-1000以上后即花费巨大的时间在cpu
- 等待队列长度:队列做缓冲作用,但也不能不限长,消耗内存,出队入队也耗cpu
MySql数据库QPS容量问题
- 主键查询:千万级别数据 = 1-10毫秒
- 唯一索引查询:千万级别数据 = 10 - 100毫秒
- 非唯一索引查询:千万级别数据 = 100 - 1000毫秒
- 无索引:百万条数据 = 1000毫秒 +
MySql数据库TPS容量问题
- 非插入更新删除操作:同查询
- 插入操作:1w ~ 10w tps(依赖配置优化)
分布式扩展
1、Nginx反向代理
上一章我们看到当单机系统时候,容量有限,响应时间变长TPS上不去的问题。nginx反向代理的功能就是代理后端Tomcat服务器集群,以统一域名方式来访问
- 单机容量问题,水平扩展
- nginx反向代理
- 负载均衡配置
2、单机容量问题,水平扩展
nginx三种用途
- 使用nginx作为web服务器
- 使用nginx作为动静分离服务器
- 使用nginx作为反向代理服务器
nginx静态资源部署
- 进入nginx根目录下的html下,然后新建resources目录用于存放前端静态资源
- 设置指向resources目录下的location可以访问对应的html下的静态资源文件
nginx动态请求反向代理
反向代理配置,配置一个backend server,可以用于指向后端不同的server集群,配置内容为server集群的局域网ip,以及轮训的权重值,并且配置 个location,当访问规则命中location任何一个规则的时候则可以进入反向代理规则
upstream backend_server{
server miaoshaserverbk1 weight=1;
server miaoshaserverbk2 weight=1;
}
location / {
proxy_pass http://backend_server;
proxy_set_header Host $http_host;
}
tomcat动态服务的acc日志
验证开关 acc日志开关
考虑整个系统优化的地方:
1)miaosha.jar包从mysql本地中分离出来,涉及到局域网的通信,而连接数据库的datasouce采用阿里巴巴druid数据库连接池,所以延迟低。
2) nginx和H5客户端采用了tomcat长连接,降低了开销
3)nginx服务器和应用服务器miaosha.jar采用的是短连接方式,可以优化
修改/usr/local/openresty/nginx/conf/nginx.conf文件,因为nginx服务器和应用服务器的通信采用HTTP1.0协议,要配置HTTP1.1才支持长连接
3、部署Nginx (OpenResty)
使用Nginx的框架OpenResty来开发配置Nginx,OpenResty是基于NGINX和LuaJIT的动态Web平台。优点是可以支持lua的一些开发。
nginx高性能原因
- epoll多路复用——采用epoll多路复用机制完成非阻塞IO操作
- master worker进程模型——允许进程平滑重启以及平滑的加载配置保证不断开与客户端连接,可以依赖进程模型完成对应的操作
- 协程机制—— 基于协程的非阻塞式编程的一套机制来完成单线程、单进程的机制,却又支持并发的编程调用接口
epoll多路复用
- java bio模型,阻塞进程式
- linux select模型,变更触发轮询查找,有1024数量上限
- epoll模型,变更触发回调直接读取,理论上无上限
epoll类似于select模型,先将自己阻塞,监听客户端连接,但为了避免select遍历队列,设置回调函数,如果连接发生变化就唤醒自己并直接执行回调函数
协程机制
- 依附于线程的内存模型,切换开销小
- 遇阻塞及归还执行权,代码同步
- 无需加速
master worker进程模型
master-worker模型,有一个master进程,管理多个worker模型。master进程和worker进程是父子进程的方式,通过fork的方式创建,master进程可以管理worker内部的内存空间。
nginx的master进程负责启动、停止服务、重载配置文件、平缓升级等,worker进程负责于Client端socket的send和receive。
nginx创建master进程后,会创建一个socket文件句柄,用于listen()在对应端口上,等待客户端发起Connect连接请求(这里用到了epoll多路复用技术),当Client发起请求时,epoll会执行对应的回调函数。
socket建立连接三次握手:首先服务端socket先bind()在80端口,然后监听listen()端口,客户端发起Connect请求连接到80端口,在端口上的进程执行accecpt方法,进行三次握手,连接建立起socket句柄,socket可以实现socket.read()和write()的方法,这个过程就对应了request的read和response的write。
会话管理
- 基于cookie传输sessionid:java tomcat容器session实现
- 基于token传输类似sessionid:java代码session
分布式会话
- 基于cookie传输sessionid:java tomcat容器实现session实现迁移到redis
@Component
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 3600)
public class RedisConfig {
@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory){
RedisTemplate redisTemplate = new RedisTemplate();
redisTemplate.setConnectionFactory(redisConnectionFactory);
// 首先解决key的序列化方式
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
redisTemplate.setKeySerializer(stringRedisSerializer);
// 解决value的序列化方式
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
return redisTemplate;
}
}
- 基于token传输类似sessionid:java代码session实现迁移到redis
// 修改成若用户登录验证成功后将对应的登录信息和登录凭证一起存入redis
// 生成登录凭证 token,UUID
String uuidToken = UUID.randomUUID().toString();
// 建立token和用户登录态之间的联系
redisTemplate.opsForValue().set(uuidToken,userModel);
redisTemplate.expire(uuidToken,1, TimeUnit.HOURS);
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);
查询性能优化——高性能缓存
1、缓存设计
- 用快速存取设备,用内存
- 将缓存推到离用户最近的地方
- 脏缓存清理
2、多级缓存
- redis缓存——Redis缓存有集中管理缓存的特点,是常见NoSql数据库组件
- 热点内存本地缓存——热点数据存到JVM本地缓存中
- nginx proxy cache缓存——所有数据最后都会在nginx服务器上做反向代理,nginx服务器也可以开启proxy cache缓存
- nginx lua缓存——nginx定制lua脚本做nginx内存缓存
3、Redis集中式缓存
Redis是一个NoSql 基于Key-valule数据库的中间件,是易失的
- 单机版:单个redis,目前项目就采用这种设计
优点:架构简单,方便,高性能;
缺点:缓存中使用,重启后会丢失,数据丢失,即使有备用的节点解决高可用性,但是仍然不能解决缓存预热问题,因此不适用于数据可靠性要求高的业务+受CPU处理能力限制,CPU性能有瓶颈
- sentinal哨兵模式
- 集群cluster
Redis sentinal哨兵模式
Sentinel(哨兵)是用于监控redis集群中Master状态的工具,是Redis 的高可用性解决方案,sentinel哨兵模式已经被集成在redis2.4之后的版本中。
sentinel可以让redis实现主从复制,当一个集群中的master失效之后,sentinel可以选举出一个新的master用于自动接替master的工作,集群中的其他redis服务器自动指向新的master同步数据。一般建议sentinel采取奇数台,防止某一台sentinel无法连接到master导致误切换。
Redis支持主从同步机制,redis2作为redis1的slave从机,同步复制master的内容,当其中一个数据库宕机,应用服务器是很难直接通过找地址来切换成redis2,这时就用到了redis sentinal 哨兵机制。sentinal与redis1和redis2建立长连接,与主机连接是心跳机制,miaosha.jar无需知道redis1,redis2主从关系,只需ask redis sentinal,之后sentinal就response回应redis1为master,redis2为slave
一旦发生redis1坏掉或者发生网络异常,心跳机制就会破坏掉,sentinal更改redis2为master,redis1为slave,变换主从关系,然后发送change给应用服务器,然后miaosha.jar就向redis2进行get、set操作(或者redis读写分离,在master上set,slave上get)——redis 哨兵机制
Sentinal作用:
- Master状态检测
- 如果Master异常,则会进行Master-Slave切换,将其中一个Slave作为Master,将之前的Master作为Slave
Redis集群cluster模式
一般情况下,使用主从模式加Sentinal监控就可以满足基本需求了,但是当数据量过大一个主机放不下的时候,就需要对数据进行分区,将key按照一定的规则进行计算,并将key对应的value分配到指定的Redis实例上,这样的模式简称Redis集群。
cluster集群配置有多个slave用来读,master用来写,各种redis服务器彼此知道相互关系。
cluster好处:
- 将数据自动切分到多个节点
- 当集群某台设备故障时,仍然可以处理请求
- 节点的fail是集群中超过半数的节点检测失效时才生效
cluster故障转移
- 节点故障判断 首先,在Redis Cluster中每个节点都存有集群中所有节点的信息。它们之间通过互相ping-pong判断节点是否可以连接。如果有一半以上的节点去ping一个节点的时候没有回应,集群就认为这个节点宕机。
slave选举 当主节点被集群公认为fail状态,那么它的从节点就会发起竞选,如果存在多个从节点,数据越新的节点越有可能发起竞选。集群中其他主节点返回响应信息。- 结构变更 当竞选从节点收到过半主节点同意,便会成为新的主节点。此时会以最新的Epoch通过PONG消息广播,让Redis Cluster的其他节点尽快的更新集群信息。当原主节点恢复加入后会降级为从节点。
Redis cluster 高可用性
- 主节点保护 当集群中某节点中的所有从实例宕机时,Redis Cluster会将其他节点的非唯一从实例进行副本迁移,成为此节点的从实例。 这样集群中每个主节点至少有一个slave,使得Cluster 具有高可用。集群中只需要保持 2*master+1 个节点,就可以保持任一节点宕机时,故障转移后继续高可用。
- 集群fail条件 Redis Cluster保证基本可用的特性,在达到一定条件时才会认定为fail: 1、某个主节点和所有从节点全部挂掉,则集群进入fail状态。 2、如果集群超过半数以上主节点挂掉,无论是否有从节点,集群进入fail状态。 3、如果集群任意主节点挂掉,且当前主节点没有从节点,集群进入fail状态。
4.本地数据热点缓存
- 热点数据
- 脏读不敏感
- 内存可控
本地数据热点缓存的解决方案类似于hashmap,key是item_id,value装的是itemModel。而且还要解决高并发问题,我们想到有Concurrenthashmap,为什么不用呢?
- Concurrenthashmap是分段锁,在JDK1.8之前,采用的是Segment+HashEntry+ReentrantLock实现的,在1.8后采用Node+CAS+Synchronized实现,get操作没有加锁,put锁加上后,会对读锁性能有影响
- 热点数据缓存要设置过期时间
Guava cache
- 可控制的大小和超时时间
- 可配置的lru策略
- 线程安全
本地缓存
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.manster.seckill.service.CacheService;
import javax.annotation.PostConstruct;
import java.util.concurrent.TimeUnit;
public class CacheServiceImpl implements CacheService {
private Cache<String ,Object> commonCache = null;
// 本地缓存,JVM
@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);
}
}
ItemModel itemModel = null;
// 先取本地缓存
itemModel = (ItemModel) cacheService.getFromCommonCache("item_"+id);
if (itemModel == null){
// 根据商品的id到redis内获取
itemModel = (ItemModel) redisTemplate.opsForValue().get("item_" + id);
// 若redis不存在
if (itemModel == null){
itemModel = itemService.getItemById(id);
// 设置item到redis内
redisTemplate.opsForValue().set("item_"+id,itemModel);
redisTemplate.expire("item_"+id,10,TimeUnit.MINUTES);
}
// 填充本地缓存
cacheService.setCommonCache("item_"+id,itemModel);
}
nginx proxy cache缓存
- nginx反向代理前置
- 依靠文件系统存索引级的文件——将请求存成本地文件,在本地磁盘中
- 依赖内存缓存文件地址——(内存缓存文件的内容value是以文件形式存放在磁盘中,但是缓存的key以缓存的方式在内存中,缓存key在内存的内容就是 —内存缓存文件的地址)也就是说nginx proxy cahce 寻址的key在内存当中,value在磁盘中,key内存中存储的是value的地址
高存档的ssd也无法与内存相比。
缓存实现
修改conf文件nginx.conf
#声明一个cache缓存节点的内容
proxy_cache_path /usr/local/openresty/nginx/tmp_cache levels=1:2 keys_zone=tmp_cache:100m inactive=7d max_size=10g;
//做一个二级目录,先将对应的url做一次hash,取最后一位做一个文件目录的索引;
//在取一位做第二级目录的索引来完成对应的操作,文件内容分散到多个目录,减少寻址的消耗
//在nginx内存当中,开了100m大小的空间用来存储keys_zone中的所有的key
//文件存取7天,文件系统组多存取10个G
location / {
proxy_cache tmp_cache;
proxy_cache_key &uri;
proxy_cache_valid 200 206 304 302 7d;//只有后端返回的状态码是这些,对应的cache操作才会生效,缓存周期7天
}
sbin/nginx -s reload重启服务器
nginx lua
- lua协程机制
- nginx机制
- nginx lua插载点
- OpenResty,将lua脚本和nginx打包在一起
协程机制
协程又叫微线程,最近几年在Lua脚本中得以广泛应用。协程,区别于子程序的层级调用,执行过程中,在子程序内部可中断,然后转而执行其他子程序,在适当的时候再返回来接着执行。
协程不是内部函数调用,类似于中断机制 协程区别于多线程就是不需要锁机制,只在某个线程内部,省去了线程切换的开销 多进程+协程 发挥协程的高效性
nginx协程
- nginx的每一个Worker进程都是在epoll或kqueue这种时间模型之上,封装成协程
- 每一个请求都有一个协程进行处理
- 即使ngx_lua须要运行Lua,相对C有一定的开销,但依旧能保证高并发能力
nginx运行协程机制
- nginx每个工作进程创建一个lua虚拟机
- 工作进程内所有协程共享同一个vm
- 每个外部请求由一个lua协程处理,之间数据隔离
- lua代码调用io等异步接口时,协程被挂起,上下文数据
- 自动保存,不阻塞工作进程
- io异步操作完成后还原协程上下文,代码继续执行
Openresty实践
shared dic:共享内存字典,所有worker进程可见,lru淘汰
更新机制好,淘汰机制并不是那么好。
openresty redis支持
静态请求加静态请求
- 静态请求CDN
- 回溯缓存设置
- 强推失效
cache control响应头
Cache Control状态标志着缓存的策略
private:客户端可以缓存public:客户端和代理服务器都可以缓存(代理服务器指的是从客户端到服务器经过所有的中间服务器结点,比如nginx,CDN,正向代理服务器等)max-age=xxx:缓存的内容将在xxx秒后失效no-cache:强制向服务端再验证一次(客户端缓存在本地,下次使用缓存时要向服务器请求验证是否可以使用缓存)no-store:不缓存请求的任何返回内容
客户端向服务器验证是如何做的呢?
有效性判断
浏览器三种刷新方式
- 回车刷新或a链接:看cache-control对应的max-age是否仍然有效,有效则直接from cache,若cache-control中为no-cache,则进入缓存协商逻辑。
- F5刷新或command+R刷新:去掉cache-control中的max-age或直接设置max-age为0,然后进入缓存协商逻辑。
- ctrl+F5或commond+shift+R刷新:去掉cache-control和协商头,强制刷新
- 协商机制,比较Last-modified和ETag到服务端,若服务端判断没变化则304不返回数据,否则200返回数据。
整个客户端向浏览器请求流程如下:
首先用户请求资源,
1)先判断URL本地是否有缓存,如果没有直接向服务器请求,然后返回;
2)如果有,判断缓存是否过期(max-age),没有过期,直接使用缓存资源;
3)如果有max-age但过期,则优先判断ETag:有的话向服务器请求If-None-Match,请求带上ETage;
4)没有ETag,判断是否有Last-Modified,然后向服务器请求If-modified-since(客户端发送的匹配资源最后修改时间如果早于资源修改时间Last-Modified,则无效已经被修改,如果晚于则有效)
5)服务器返回的是304表示资源没有修改,则本地缓存可直接使用;如果返回200,表示资源被修改,向服务器发起请求;
CDN自定义缓存策略
- 可自定义目录过期时间
- 可自定义后缀名过期时间
- 可自定义对应权重
- 可通过界面或api强制cdn对应目录刷新(非保成功)
静态资源部署策略
- css,js,img等元素使用带版本号部署,例如a.js?v=1.0不便利,且维护困难
- css,js,img等元素使用带摘要部署,例如a.js?v=45edw存在先部署html还是先部署资源的覆盖问题。
- css,js,img等元素使用摘要做文件名部署,例如45edw.js不便利,新老版本并存且可回滚,资源部署完后部署html
全页面静态化
phantomjs
无头浏览器,可以借助其模拟webkit js的执行
缓存库存
交易性能瓶颈
- jmeter压测(对活动下单过程进行压测,采用post请求,设置传入参数,性能发现下单avarage大约2s,tps500,交易验证主要完全依赖数据库的操作)
- 交易验证完全依赖数据库
- 库存行锁
- 后置处理逻辑
交易验证优化
- 用户风控策略优化:策略缓存模型化
- 活动校验策略优化:引入活动发布流程,模型缓存化,紧急下线能力
活动缓存库存方案一——库存行锁优化
<update id="decreaseStock">
update item_stock
set stock = stock - #{amount}
where item_id = #{itemId} and stock >= #{amount}
</update>
- 扣减库存缓存化
方案:(1)、活动发布同步库存进缓存
(2)、下单交易减缓存库存
(3)、异步消息扣减数据库内库存
问题:数据库记录不一致
- 异步同步数据库
- 库存数据库最终一致性
缓存库存接入异步化
@Component
public class MqConsumer {
private DefaultMQPushConsumer consumer;
@Autowired
private ItemStockDOMapper itemStockDOMapper;
@Value("mq.nameserver.addr")
private String nameAddr;
@Value("mq.topicname")
private String topicName;
@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> list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
// 实现库存扣减
Message msg = list.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;
}
});
}
}
@Component
public class MqProducer {
private DefaultMQProducer producer;
@Value("mq.nameserver.addr")
private String nameAddr;
@Value("mq.topicname")
private String topicName;
@PostConstruct
public void init() throws MQClientException {
// 做mq producer的初始化
producer = new DefaultMQProducer("producer_group");
producer.setNamesrvAddr(nameAddr);
producer.start();
}
// 同步库存扣减消息
public boolean asyncReduceStock(Integer itemId,Integer amount) {
HashMap<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) {
return false;
} catch (RemotingException e) {
return false;
} catch (MQBrokerException e) {
return false;
} catch (InterruptedException e) {
return false;
}
return true;
}
}
问题(1)、异步消息发送失败
(2)、扣减操作执行失败
(3)、下单失败无法正确回补库存
事务型消息
操作流水
数据类型
- 主业务数据:master data
- 操作型数据:log data
库存数据库最终一致性保证
方案:(1)、引入库存操作流水
(2)、引入事务性消息机制
问题:(1)、redis不可用时如何处理
(2)、扣减流水错误如何处理
业务场景决定高可用技术实现
设计原则 宁可少卖,不能超卖。
方案:(1)、redis可以比实际数据库中少
(2)、超时释放
库存售罄
- 都会initStockLog(),添加售罄标识。
- 售罄后不去操作后续流程。
- 售罄后通知各系统售罄
- 回补上新
后置流程
销量 +1也会有itemId级别的行锁,将销量 + 1的操作异步化控制。
- 销量逻辑异步化
- 交易单逻辑异步化
流量削峰(削峰填谷之神级操作)
缺陷
- 秒杀下单接口会被脚本不停的刷
- 秒杀验证逻辑和秒杀下单接口强关联,代码冗余度高
- 秒杀验证逻辑复杂,对交易系统产生无关联负载
秒杀令牌原理
- 秒杀接口需要依靠令牌才能进入
- 秒杀的令牌由秒杀活动模块负责生成
- 秒杀活动模块对秒杀令牌生成全权处理,逻辑收口
- 秒杀下单前需要先获得秒杀令牌
秒杀令牌只要活动一开始就无限制生成,影响系统性能。
秒杀大闸原理
- 依靠秒杀令牌的授权原理定制化发牌逻辑,做到大闸功能
- 根据秒杀商品初始库存颁发对应数据令牌,控制大闸流量
- 用户风控策略前置到秒杀令牌发放中
- 库存售罄判断前置到秒杀令牌发放中
队列泄洪原理
- 排队有些时候比并发更高效(例如
redis单线程模型,innodb mutex key等)innodb在数据库操作时要加上行锁,mutex key是竞争锁,阿里sql优化了mutex key结构,当判断存在多个线程竞争锁时,会设置队列存放SQL语句 - 依靠排队去限制并发流量
- 依靠排队和下游拥塞窗口程度调整队列释放流量大小
本地或分布式
- 本地:将队列维护在本地内存中
- 分布式:将队列设置到redis内
防刷限流技术
验证码技术
- 包装秒杀令牌前置,需要验证码来错峰
- 数学公式验证码生成器
限并发
对同一时间固定访问接口的线程数做限制,利用全局计数器,在下单接口OrderController处加一个全局计数器,并支持并发操作,当controller在入口的时候,计数器减1,判断计数器是否大于0,在出口时计数器加一,就可以控制同一时间访问的固定。
令牌桶算法
漏桶算法
限流范围
- 集群限流:依赖redis或其他中间件技术做统一计数器,往往会产生性能瓶颈
- 单机限流:负载均衡的前提下单机平均限流效果更好
限流代码实现(Guava RateLimit)
RateLimiter没有实现令牌桶内定时器的功能, reserve方法是当前秒的令牌数,如果当前秒内还有令牌就直接返回; 若没有令牌,需要计算下一秒是否有对应的令牌,有一个下一秒计算的提前量 使得下一秒请求过来的时候,仍然不需要重复计算 RateLimiter的设计思想比较超前,没有依赖于人为定时器的方式,而是将整个时间轴 归一化到一个数组内,看对应的这一秒如果不够了,预支下一秒的令牌数,并且让当前的线程睡眠; 如果当前线程睡眠成功,下一秒唤醒的时候令牌也会扣掉,程序也实现了限流
防刷技术
排队,限流,令牌均只能控制总流量,无法控制黄牛流量
传统防刷
- 限制一个会话(
session_id,token)同一秒/分钟接口调用多少次:多会话接入绕开无效(黄牛开多个会话) - 限制一个ip同一秒钟/分钟 接口调用多少次:数量不好控制,容易误伤,黑客仿制ip
黄牛为什么难防
- 模拟器作弊:模拟硬件设备,可修改设备信息
- 设备牧场作弊:工作室里一批移动设备
- 人工作弊:靠佣金吸引兼职人员刷单
设备指纹
- 菜鸡终端设备各项参数,启动应用时生成唯一设备指纹
- 根据对应设备指纹的参数猜测出模拟器等可疑设备概率
凭证系统
- 根据设备指纹下发凭证
- 关键业务链路上带上凭证并由业务系统到凭证服务器上验证
- 凭证服务器根据对应凭证所等价的设备指纹参数并根据实时行为风控系统判定对应凭证的可疑度分数
- 若分数低于某个数值则由业务系统返回固定错误码,拉起前端验证码验身,验身成功后加入凭证服务器对应分数