1、高并发原则
无状态、拆分、服务化、消息队列、数据异构、缓存银弹、并发化。
数据异构:订单分库分表一般按照订单ID进行划分,如果要查询某个用户的订单列表,需要聚合多个表的数据后才能返回,导致订单表读性能很低。此时可以按照用户ID进行分库分表,异构一套用户订单表。
数据闭环:商品详情页,因为数据来源太多,影响服务稳定性的因素就非常多,最好的办法就是把使用到的数据进行异构存储,形成数据闭环。步骤:数据异构(通过MQ机制接收数据变更存储到合适的存储引擎,如:redis)->数据聚合(可选)->前端展示(通过一次或少量调用拿到数据)。
缓存银弹:
流程节点 | 缓存技术 |
客户端 | 使用浏览器缓存 |
客户端应用缓存 | |
客户端网络 | 代理服务器开启缓存 |
广域网 | 使用代理服务器(含CDN) |
使用镜像服务器 | |
使用P2P技术 | |
源站及源站网络 | 使用接入层提供的缓存机制(Nginx) |
使用应用层提供的缓存机制(堆内缓存、堆外缓存local redis cache) | |
使用分布式缓存 | |
静态化、伪静态化 | |
使用服务器操作系统提供的缓存机制 |
2、高可用原则
降级、限流、切流量、可回滚
降级开关设计思路:开关集中化管理、可降级的多级读服务、开关前置化(前置到Nginx层)、业务降级(异步调用,优先处理高优先级数据)
3、业务设计原则
防重设计、幂等设计、流程可定义、状态与状态机、后台操作系统可反馈、后台系统审批化、文档和注释、备份。
高可用
4、隔离术
线程隔离、进程隔离、集群隔离、机房隔离、读写隔离、动静隔离、爬虫隔离、热点隔离、资源隔离。
线程隔离:可根据服务等级划分不同等级的线程池;
集群隔离:例如秒杀服务可能会影响到其他系统稳定性时,考虑为秒杀提供单独的服务集群;
基于Servlet3实现请求隔离:
Servlet3之前的线程模型
引入Servlet3
业务线程池隔离
使用Servlet3异步化
异步化可以增加吞吐量和我们需要的灵活性,但是不会提升响应时间。
5、限流
- 限流算法:令牌桶算法、漏桶、计数器。
令牌桶:限制平均流入速度,允许一定程度的突发;
漏桶:用于流量整形,主要目的是平滑流出速率;
计数器:限制总并发数。
- 应用级限流:限流总并发/连接/请求数、限制总资源数、限制某个接口的总并发/请求数、限制某个接口的时间窗请求数(使用Guava的Cache来存储计数器,设置过期时间)、平滑限流某个接口的请求数(Guava RateLimiter提供令牌桶算法)
- 分布式限流:分布式限流最关键的是要将限流服务做成原子化,解决方案是Redis+Lua或者Nginx+Lua。如果应用并发流量非常大,可考虑一致性哈希将分布式限流进行分片、降级为应用及限流。
Redis+Lua:
Nginx+Lua:使用lua-resty-lock互斥做来解决原子性问题,使用ngx.shared.DICT共享字典来实现计数器。
- 接入层限流:
对于Nginx接入层限流可以使用:连接数限流模块ngx_http_limit_conn_module和漏桶算法实现的请求限流模块mgx_http_limit_req_module,还可以使用OpenResty提供的Lua限流模块lua-resty-limit-traffic应对更复杂的限流场景。
- 前端:节流和防抖
6、降级特技
- 自动开关降级:自动降级是根据系统负载、资源使用:情况、SLA等指标进行降级。包括:超时降级、统计失败次数降级、故障降级、限流降级。
- 人工开关降级
- 读服务降级:策略有暂时切换读(降级到读缓存、降级到静态化)、暂时屏蔽读(屏蔽读入口、屏蔽某个读服务)
- 写服务降级:写降级在大多数场景下是不可降级的,不过,可以通过一些迂回战术来解决问题,同步降为异步的方式。
- 多级降级:
开关多级降级:页面JS降级开关、接入层降级开关、应用层降级开关
工作流降级:优先处理高优先级数据、只处理某些特征数据、合理分配流量到最需要的场合。
举例:
1)如果恶意订单校验出现不可用情况,可以直接绕过或改成异步;
2)如果订单计划性能出现下降,但还可以处理,则在这里优先处理高优级订单、处理逻辑较简单的数据(例单品单件);
3)分发订单时,如果仓库负载饱和,可以降低向京东库房的输送量,增大其他目标地的输送量。
- 配置中心
- 使用Hystrix实现降级
7、超时与重试机制
代理层超时与重试、Web容器超时、中间件与客户端超时重试、数据库客户端重试、业务超时、前端Ajax超时。
- MySQL数据库配置超时(参考:https://www.jianshu.com/p/99bafb3a466f):
注意:高级别timeout依赖于低级别的timeout。
1)JDBC超时设置:connectTimeout(等待和MySQL数据库建立socket链接的时间,默认0,可不设置)、socketTimeout(客户端和MySQL数据库建立socket后,读写socket时等待的超时时间,linux系统默认30分钟,可不设置);
2)连接池超时设置:默认值0,表示无限等待,单位毫秒,建议60000;
3)MyBatis查询超时:defaultStatementTimeout(表示在MyBatis配置文件中默认查询超时时间,单位秒,不设置无限等待),如果一些sql需要执行超时,可以通过Mapper文件单独设置
4)事务超时:事务内多有代码执行总和,单位秒
- 超时处理常见策略:重试、摘掉不存活节点、托底、等待页或错误页。
- 对于非幂等写服务应避免重试,或者可以考虑提前生成唯一流水号实现幂等。
- 数据库/缓存服务器要经常检查慢操作,也要考虑超时严重时服务降级。
8、回滚机制
事务回滚、代码库回滚、部署版本回滚、数据版本回滚、静态资源版本回滚。
事务回滚:分布式事务大多数场景中需要考虑的是最终一致性,而不是强一致性。常见的两阶段提交、三阶段提交协议,回滚难度低,但是对性能影响比较大。可以考虑如事务表、消息队列、补偿机制、TCC模式(预占/确认/取消)等实现最终一致性。
9、压测和预案
- 系统压测:
压测之前要有压测方案(压测接口、并发量、压测策略(突发、逐步加压、并发量)、压测指标(机器负载、QPS/TPS、响应时间)),之后要产出压测报告并进行优化和容灾。
在压测时,应该选择离散压测,如果压测的是热点数据不能反映出系统的真实处理能力;另外在实际压测时应该进行全链路压测,防止非核心系统服务调用或者系统之间存在资源竞争导致的问题。
线下压测:针对单个接口,仿真度不高;
线上压测:按读写(读压测、写压测、混合压测)、按数据仿真度(仿真压测和引流压测)、按是否给用户提供服务(隔离集群压测和线上压测)。
仿真压测:通过模拟请求进行系统压测,模拟数据可以程序构造、人工构造或者使用Nginx访问日志;引流压测:使用TCPCopy复制线上真实流量,然后引流到压测集群进行压测,还可以将流量放大N倍。
隔离集群压测:将对外提供服务的部分服务器从线上集群摘除,然后将线上流量引流到该集群进行压测,安全;线上压测,通过缩减线上服务器数量实现,风险很大,通过逐步减少服务器在低峰期进行。
- 系统优化:系统优化、系统扩容。
- 应急预案:系统分级、全链路分析、配置监控报警、最后制定应急预案。
系统分级可以按照核心系统和支撑系统进行划分。
全链路应急预案举例:
网络接入层:主要关注机房不可用、DNS故障、VIP故障;
应用接入层:主要关注点是上游应用路由切换、限流、降级、隔离等预案处理;
Web应用层:主要关注点是依赖服务的路由切换、连接池异常、限流、超时降级、服务异常降级、应用负载异常、数据库故障切换、缓存故障切换等;
数据层:主要关注点是数据库/缓存负载高、数据库/缓存故障等。
10、应用级缓存
- Java缓存类型
堆缓存:好处是没有序列化/反序列化、快;缺点是受限于堆空间大小,数据量大时会导致GC时间变长;
堆外缓存:可以减少DC暂停时间,只受机器内存大小限制;缺点是读取数据需要序列化/反序列化,比堆缓存慢很多;
磁盘缓存:JVM重启时数据还是存在的;
分布式缓存:可以解决单机缓存的以下问题:单机容量问题、多台数据一致性问题。
Guava:只提供堆缓存,小巧灵活,性能最好;
Ehcache:3.x提供了堆缓存、堆外缓存、磁盘缓存、分布式缓存;
MapDB:是一款嵌入式Java数据库引擎和集合框架,提供了Maps、Sets、Lists、Queues、Bitmaps的支持,还支持ACID事务、增量备份,支持堆缓存、堆外缓存、磁盘缓存。
- 实现
1)堆缓存
Guava Cache实现:
Cache<String, String> myCache =
CacheBuilder.newBuilder()
.concurrencyLevel(4)
.expireAfterWrite(10, TimeUnit.SECONDS)
.maximumSize(10000)
.build();
缓存回收策略:
基于容量:maximumSize,设置缓存容量,超出按照LRU回收;
基于时间:expireAfterWrite,设置TTL(TimeToLiveSeconds),定期回收缓存数据;expireAfterAccess,设置TTI(TimeToIdleSeconds),可能会导致脏数据存在很长会时间;
基于Java对象引用:weakKeys/weakValues(弱引用缓存),softValue(软引用缓存);
主动失效:invalidate(Object key)/invalidateAll(Iterable<?>keys)/invalidateAll(),主动失效某些缓存数据;
并发级别:
concurrencyLevel。
统计命中率:
recordStats。
Ehcache 3.x实现:
CacheManager cacheManager = CacheManagerBuilder.newCacheManagerBuilder().build(true);
CacheConfigurationBuilder<String, String> cacheConfig = CacheConfigurationBuilder.newCacheConfigurationBuilder(String.class, String.class, ResourcePollsBuilder.newResourcePoolsBuilder()
.heap(100, EntryUnit.ENTRIES))
.withDispatcherConcurrency(4)
.withExpiry(Expirations.timeToLiveExpiration(Duration.of(10, TimeUnit.SECONDS)));
Cache<String, String> myCache = cacheManager.createCache("myCache", cacheConfig);
缓存回收策略:
基于容量:heap(100, EntryUnit.ENTRIES),设置缓存的条目数量,超过时按照LRU回收;
基于空间:heap(100, EntryUnit.MemoryUnit.MB),设置缓存的内存空间,超过时按照LRU回收;另外,应该设置withSizeOfMaxObjectGraph(2)统计对象大小时对象图遍历深度和withSizeOfMaxObjectSize(1, MemoryUnit.KB)可缓存的最大对象大小;
基于时间:withExpiry(Expirations.timeToLiveExpiration(Duration.of(10, TimeUnit.SECONDS))),设置TTL,没有TTI;withExpiry(Expirations.timeToIdleExpiration(Duration.of(10, TimeUnit.SECONDS))),同时设置TTL和TTI,TTL和TTI值一样;
主动失效:remove(K key)/removeAll(Set<? extends K>keys)/clear();
并发级别:withDispatcherConcurrency;
统计命中率:暂无。
MapDB 3.x实现:使用较少,实现略
缓存回收策略:基于容量、基于时间、设置TTI、主动失效;
并发级别设置:支持
统计命中率:暂无
2)堆外缓存
Ehcache 3.x实现:.heap换成.offheap即可,不支持基于容量的缓存过期策略;
MapDB 3.x实现:略;
- 应用级缓存示例
1)多级缓存API封装
本地缓存初始化:
public class LocalCacheInitService extends BaseService {
@Override
public void afterPropertiesSet() throws Exception {
// 商品类目缓存
Cache<String, Object> categoryCache =
CacheBuilder.newBuilder()
.softValues()
.maximumSize()
.expireAfterWrite(Switches.CATEGORY.getExpiresInSeconds() / 2, TimeUnit.SECONDS)
.build();
addCache(CacheKeys.CATEGORY_KEY, categoryCache);
}
private void addCache(String key, Cache<?, ?> cache) {
localCacheService.addCache(key, cache);
}
tip:
本地缓存过期时间是分布式缓存的一半;
将缓存key前缀和本地缓存关联;
2)写缓存API封装
public void set(final String key, final Object value, final int remoteCacheExpiresInSeconds) throw RuntimeException {
if (value == null) {
return;
}
// 复制值对象
// 本地缓存是引用,分布式缓存需要序列化
// 如果不复制的话,则假设数据更改后将造成本地缓存与分布式缓存不一致
final Object finalObject = copy(value);
// 如果配置了写本地缓存,则根据KEY获得相关的本地缓存,然后写入
if (writeLocalCache) {
Cache localCache = getLocalCache(key);
if (localCache != null) {
localCache.put(key, finalValue);
}
}
// 如果配置了不写分布式缓存,则直接返回
if (!writeRemoteCache) {
return;
}
// 异步更新分布式缓存
asyncTaskExecutor.execute(() -> {
try {
redisCache.set(
key,
JSONUtils.toJSON(finalValue),
remoteCacheExpiresInSeconds);
} cache (Exception e) {
LOG.error("update redis cache error, key : {}", key, e);
}
});
}
3) 读缓存API封装
private Map innerMget(List<String> keys, List<Class> types) throw Exception {
Map<String, Object> result = Maps.newHashMap();
List<String> missKeys = Lists.newArrayList();
List<String> missTypes = Lists.newArrayList();
// 如果配置了本地缓存,则先读本地缓存
if (readLocalCache) {
for (int i = 0; i < keys.size(); i++) {
String key = keys.get(0);
Class type = types.get(0);
Cache localCache = getLocalCache(key);
if (localCache != null) {
Object value = localCache.getIfPresent(key);
result.put(key, value);
if (value == null) {
missKeys.add(key);
missTypes.add(type);
}
} else {
missKeys.add(key);
missTypes.add(type);
}
}
}
// 如果设置了不读分布式缓存,则返回
if (!readRemoteCache) {
return result;
}
final Map<String, String> missResult = Maps.newHashMap();
// 对key分区,不要一次性调用太大
final List<List<String>> keysPage = Lists.partition(missKeys, 10);
List<Future<Map<String, String>>> pageFutures = Lists.newArrayList();
try {
// 批量获取分布式缓存数据
for (final List<String> partitionKeys : keysPage) {
pageFutures.add(asyncTaskExecutor.submit(
() -> redisCache.mget(partitionKeys)));
}
for (Future<Map<String, String>> future : pageFutures) {
missResult.putAll(future.get(3000, TimeUnit.MILLSECONDS);
}
} catch (Exception e) {
pageFutures.forEach(future -> future.cancel(true));
throw e;
}
return result;
}
4)NULL Cache
private static final String NULL_STRING = new String();
// 查询DB
String value = loadDB();
// 如果DB没有数据,则将其封装为NULL_STRING并放入缓存
if(value == null) {
value = NULL_STRING;
}
myCache.put(id, value);
// 读取时
value = suitCache.getIfPresent(id);
// 缓存了NULL,返回
if(value == NULL_STRING) {
return null;
}
5)强制获取最新数据
通过设置ThreadLocal开关来决定是否强制刷新缓存
if(ForceUpdater.isForceUpdateMyInfo()) {
myCache.refresh(skuId);
}
String result = myCache.get(skuId);
if (result == NULL_STRING) {
return null;
}
6)失败统计、延迟报警(略)
- 缓存使用模式实践
1)Cache-Aside:即业务代码围绕着Cache写,由业务代码直接维护缓存。
// 1. 先从缓存中获取数据
value = myCache.getIfPresent(key);
if (value == null) {
// 2.1. 如果缓存没有命中,则回源到SoR获取源数据
value = loadFromSoR(key);
// 2.2. 将数据放入缓存,下次即可以从缓存中获取数据
myCache.put(key, value);
}
// 2. 写场景,写入后立即同步缓存
writeToSoR(key, value);
myCache.put(key, value);
// 3. 写场景,写入后失效缓存
writeToSoR(key, value);
myCache.invalidate(key);
针对并发更新,缓存不一致问题:
对于用户维度的数据,并发情况非常少,可以不用考虑;
对于商品数据,可以监听binlog进行缓存更新,但是缓存更新会存在延迟;
对于读服务场景,可以使用一致性哈希,将相同操作负载均衡到同一个实例,减少并发几率,或者设置比较短的时间。
2)Cache-As-SoR:即把Cache看作SoR,所有操作都是对Cache进行,然后Cache再委托给SoR进行真实的读/写。原文是基于Guava和Ehcache的,源码略。
11、HTTP缓存
- HTTP缓存(源码略)
1)服务端响应Last-Modified,将If-Modified-Since请求头带到服务端进行文档是否修改验证,没有修改则返回304;
2)Cache-Control:max-age(HTTP/1.1)和Expire(HTTP/1.0)用于决定浏览器内容缓存多久,前者优先级高;
3)一般情况下Expires=当前系统时间+缓存时间(Cache-Control:max-age);
4)ETag(HTTP/1.1)可以用来判断页面内容是否已经被修改过。
- HttpClient客户端缓存
HttpClient 4.3版本开始提供HTTP/1.1兼容的客户端缓存(HTTP/1.0缓存没有实现)。
在使用HttpClient客户端缓存时,需要引入如下依赖
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient-cache</artifactId>
<version>4.5.2</version>
</dependency>
配置:
CacheConfig cacheConfig = CacheConfig.custom()
.setMaxCacheEntries(1000) // 最多缓存1000个条目
.setMaxObjectSize(1 * 1024 * 1024) // 缓存对象最大为1MB
.setAsynchronousWorkersCore(5) // 异步更新缓存线程池最小空闲线程数
.setAsynchronousWorkersMax(10) // 异步更新缓存线程池最大线程数
.setRevalidationQueueSize(10000) // 异步更新线程池队列大小
.build();
// 缓存存储
HttpCacheStorage cacheStorage = new BasicHttpCacheStorage(cacheConfig);
// 创建HttpClient
httpClient = CachingHttpClients.custom()
.setCacheConfig(cacheConfig) // 缓存配置
.setHttpCacheStorage(cacheStorage) // 缓存存储
.setSchedulingStrategy(new ImmediateSchedulingStrategy(cacheConfig)) //验证缓存时,缓存调度策略
.setConnectionManager(manager)
.build();
BasicHttpCacheStorage表示放在内存中存储(使用LInkedHashMap实现了最简单的LRU算法),默认还提供了Ehcache和Mamcached存储实现,没有基于时间的过期策略,实际使用时建议使用Ehcache。
ImmediateSchedulingStrategy将使用我们配置的线程池参数创建线程池,然后异步进行重新号验证请求。
使用Ehcache实现的缓存可参考:https://www.cnblogs.com/dehai/p/5063106.html
- Nginx Http缓存
- Nginx代理层缓存
12、多级缓存
- 整体流程
1)接入Nginx将请求负载均衡到应用Nginx,使用轮询或一致性哈希算法;
2)应用Nginx读取本地缓存(Lua Shared Dict、Nginx Proxy Cache、Local Redis实现),降低后端压力,尤其对热点问题非常有效;
3)如果本地Nginx缓存没命中,则会读取分布式缓存,并回写Nginx本地缓存;
4)如果分布式缓存也没有命中,则会回源到Tomcat集群,也可以使用轮询或一致性哈希算法;
5)在Tomcat应用中,首先读取本地堆缓存,如果有,则返回并写入主Redis集群;
6)作为可选部分,可以再尝试赌一次主Redis集群操作,目的是防止当从集群有问题时的流量冲击;
7)如果所有缓存都没有命中,则只能查询DB或相关服务获取相关数据并返回;
8)步骤7)返回的数据异步写到Redis主集群,此处可能有多个Tomcat实例同时写,会造成的数据错乱?
- 如何缓存数据
1)过期与不过期
对于缓存的数据我们可以考虑不过期缓存和带过期时间缓存。
不过期缓存场景:对于长尾访问的数据、大多数数据访问频率都很高的场景,或者是缓存空间足够,都可以考虑不过期缓存,比如用户、分类、商品、价格、订单等。当缓存满了,可以考虑用LRU机制驱逐老的缓存数据。使用Cache-Aside模式;
过期缓存机制:一般用于缓存其他系统的数据(无法订阅变更消息,或者成本很高)、缓存空间有限、低频热点缓存等场景。
2)维度化缓存与增量缓存
3)大Value缓存
4)热点缓存
- 分布式缓存与应用负载均衡
- 热点数据与更新缓存
1)单机全量缓存+主从
所有缓存都存储在应用本机,回源之后会把数据更新到主Redis集群,然后通过主从模式复制到其他从Redis集群。缓存的更新可以采用懒加载或者订阅消息进行同步。
2)分布式缓存+应用本地热点
对于分布式缓存,我们需要在Nginx+Lua应用中进行应用缓存来减少Redis集群的访问冲击,即首先查询应用本地缓存,如果命中,则直接缓存,如果没有命中,则接着查询Redis集群、回源到Tomcat,然后将数据缓存到应用本地。
- 更新缓存与原子性
1)更新数据时使用更新时间戳或者版本对比,如果使用Redis,则可以利用其单线程机制进行原子化更新;
2)使用如canal订阅数据库binlog;
3)将更新请求按照相应的规则分散到多个队列,然后每个队列进行单线程更新,更新时拉去最新的数据保存;
4)用分布式锁。
- 缓存崩溃与快速修复
1)取模:一个实例坏了会导致大量缓存不命中,要用主从避免;
2)一致性哈希:一个实例坏了只影响一部分缓存;
3)快速修复:主从机制、请求降级、缓存重建。
13、连接池线程池详解
- 数据库连接池
- HttpClient连接池:在开启长连接时才是真正的连接池,短连接只是作为一个信号量来限制总请求数,连接并没有实现复用;JVM在停止或重启时,记得关闭连接池释放连接;HttpClient是线程安全的,不要每次使用创建一个;连接池配置得比较大可以考虑创建多个HttpClient实例;使用连接池要尽快消费响应体并释放连接。
- 线程池:
Java线程池,在使用线程池时务必设置池大小、队列大小并设置相应的拒绝策略;
Tomcat线程池
14、异步并发实战
异步并发并不能是响应变的更快,更多是为了提升吞吐量、对请求更细粒度控制,或是通过多以来服务并发调用降低服务响应时间。异步是针对CPU和IO的,当IO没有就绪时就要让出CPU来处理其他任务,这才是异步。Java应用大多数场景并不是真正的异步化,而是通过线程池模拟实现。
- 同步阻塞调用
- 异步Future:通过Future可以并发发出N个请求,然后等待最慢的一个返回;
- 异步Callback:如HttpAsyncClient使用基于NIO的异步I/O模型,它实现了Reactor模式,摒弃了阻塞I/O模型one thread per connection,采用线程池分发事件通知,从而有效支撑大量并发连接。不能提升性能,是为了提升吞吐量。
- 异步编排CompletableFuture:内部使用ForkJoinPool实现异步处理。
使用场景一:三个服务异步并发调用,然后对结果合并处理,可以不阻塞主线程;
// 不阻塞主线程
public static void test1() throw Exception {
MyService service = new MyService();
CompletableFuture<String> future1 = service.getHttpData("http://www.jd.com");
CompletableFuture<String> future2 = service.getHttpData("http://www.jd.com");
CompletableFuture<String> future3 = service.getHttpData("http://www.jd.com");
CompletableFuture.allOf(future1, future2, future3)
.thenApplyAsync((Void) -> {
// 异步处理future1, future2, future3结果
}).exceptionally(e -> {
// 处理异常
});
}
// 阻塞主线程
CompletableFuture.allOf(future1, future2, future3)
.thenApplyAsync((Void) -> {
// 异步处理future1, future2, future3结果
return Lists.newArrayList(
future1.get(), future2.get(), future3.get());
}).exceptionally(e -> {
// 处理异常
});
使用场景二:两个服务并发调用,然后消费结果
public static void test2() throw Exception {
MyService service = new MyService();
CompletableFuture<String> future1 = service.getHttpData("http://www.jd.com");
CompletableFuture<String> future2 = service.getHttpData("http://www.jd.com");
future1.thenAcceptBothAsync(future2, (future1Result, future2Result) -> {
// 异步处理结果
}).exceptionally(e -> {
// 异常处理
});
}
使用场景三:服务1执行完成后,接着并发执行服务2和服务3,然后消费相关结果
public static void test3() throw Exception {
MyService service = new MyService();
CompletableFuture<String> future1 = service.getHttpData("http://www.jd.com");
CompletableFuture<String> future2 = future1.thenApplyAsync((v) -> {
return "result from service2";
});
CompletableFuture<String> future3 = service.getHttpData("http://www.jd.com");
future2.thenCombineAsync(future3, (f2Result, f3Result) -> {
// 处理业务
}).exceptionally(e -> {
// 处理异常
});
}
- 异步Web服务实现(参考:Spring中的异步Servlet https://blog.csdn.net/gameover8080/article/details/103512740):
前端采用长连接,后端应用中有大IO事务的情况,采用异步Servlet是明智的选择。
- 请求缓存:基于Hystrix的一次请求多次查询缓存。
使用CompletableFuture实现批量查询
List<CompletableFuture<List<Double>>> futures = Lists.newArrayList();
List<List<Long>> pages = Lists.partition(ids, 10);
for(List<Long> page : pages) {
futures.add(CompletableFuture.supplyAsync(() -> {
return priceService.getPrice(page);
}));
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).get();
- 请求合并:基于Hystrix将多个单个请求转换为单个批量请求。
15、如何扩容
- 应用拆分
- 数据库拆分
分库分表策略:
分库不是越多越好,分库太多会导致消耗更多的数据库连接,并且应用会创建更多的线程。
1)取模:按照数值型主键取模,或者字符串主键哈希进行分库分表。
优点是热点数据分散,缺点是按照非主键维度进行查询时需要跨库/跨表查询,扩容需要建立新集群并进行数据迁移(前期设计时可以将数据库和表的数量适当冗余,数量为2的指数倍,并把几个库或表部署在一起)。
2)分区:按照时间分区、范围分区。缺点是存在热点,但是已于水平扩展。
另外也可以取模+分区组合。
使用sharding-jdbc分库分表
1)数据库DDL
// N为0,1 M为0,1
CREATE DATABASE IF NOT EXISTS product_N;
CREATE TABLE product_M(
id bigint primary key,
title varchar(255),
last_modified datetime
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
2)数据库配置
<bean id="dataSource_0" parent="abstractDataSource">
<property name="url" value="jdbc:mysql://192.168.1.2:3306/product_0" />
<property name="username" value="root" />
<property name="password" value="root" />
</bean>
<bean id="dataSource_1" parent="abstractDataSource">
<property name="url" value="jdbc:mysql://192.168.1.3:3306/product_1" />
<property name="username" value="root" />
<property name="password" value="root" />
</bean>
3)sharding-jdbc分库分表配置
依赖
<dependency>
<groupId>com.dangdang</groupId>
<artifactId>sharding-jdbc-core</artifactId>
<version>1.3.2</version>
</dependency>
<dependency>
<groupId>com.dangdang</groupId>
<artifactId>sharding-jdbc-transaction</artifactId>
<version>1.3.2</version>
</dependency>
<dependency>
<groupId>com.dangdang</groupId>
<artifactId>sharding-jdbc-config-spring</artifactId>
<version>1.3.2</version>
</dependency>
配置
<!-- 分库规则 -->
<rdb:strategy id="dataSourceStrategy" sharding-columns="id"
algorithm-expression="dataSource_${Math.floorMod(id.longValue(), 2L}" />
<!-- 分表规则 -->
<rdb:strategy id="productTableStrategy" sharding-columns="id"
algorithm-expression="product_${Math.floorMod(Math.floorDiv(id.longValue(), 2L), 2L}" />
<!-- 分库分表数据源 -->
<rdb:data-source id="shardingDataSource">
<!-- 使用的真实数据源 -->
<rdb:sharding-rule data-sources="dataSource_0, dataSource_1">
<rdb:table-rules>
<!-- 分表规则:分库策略、分表策略、逻辑表名、实际表名-->
<rdb:table-rule
database-startegy="dataSourceStrategy"
table-strategy="productTableStrategy"
logic-table="product"
actual-tables="produtct_${0..1}" />
</rdb:table-rules>
</rdb:sharding-rule>
</rdb:data-source>
分库/分表策略:库ID=id%库数量,表ID=id/库数量%单库表数量。
4)事务管理器配置
<!-- 事务管理器,此处使用弱事务 -->
<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource" ref="shardingDataSource" />
</bean>
此处使用了弱事务机制,事务不是原子的,可能提交分库1事务后,提交分库2事务失败,造成跨库事务不一致。可以考虑sharding-jdbc提供的柔性事务实现。
柔性事务目前支持最大努力送达,是当事务失败后通过最大努力反复尝试送达操作实现,保证数据最终一致性,适用幂等操作。分为同步和异步送达,异步送达需要通过elastic-job实现。
5)代码逻辑
// 获取分库分表数据源
DataSource shardingDataSource = (DataSource) ctx.getBean("shardingDataSource");
// 创建JdbcTemplate
JdbcTemplate jdbcTemplate = new JdbcTemplate(shardingDataSource);
// 获取事务管理器
AbstractPlatformTransactionManager transactionManager = (AbstractPlatformTransactionManager) ctx.getBean("transactionManager");
// 创建事务模板
TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
// 执行SQL(product是逻辑表名、id是分库分表键)
transactionTemplate.execute(new TransactionCallbackWithoutResult()) {
@Override
protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) {
jdbcTemplate.update("insert into product(id, title, last_modified) values(?, ?, ?)", 1L, "title", new Date())
}
});
实际执行时,逻辑表明product会被替换为product_1这种实际表名。
使用sharding-jdbc读写分离
<rdb:master-slave-data-source id="dataSource_0" master-data-source-ref="dataSource_master_0" slave-data-sources-ref="dataSource_slave_0, dataSource_slave_1" />
sharding-jdbc的读写分离为了最大限度避免由于同步延迟而产生强制读取主库的场景,在更新方面做了优化,字啊一个请求线程中,只要存在对数据的更新操作,则在此操作之后的任何对数据库的访问都会自动通过路由到达主库。因此,在写后读的场景中不需要使用HintManager,只有在读场景下,需要强制读主库时才使用。
- 数据异构
数据异构主要按照不同查询维度建立表结构,有查询维度异构、聚合数据异构。
1)查询维度异构:比如对于订单库,当其分库分表后,如果想按照商家维度或者按照用户维度进行查询,那么是非常困难的,因此可以采用下图的架构:
异构数据主要存储数据之间的关系,然后通过查询源库查询实际数据。不过,优势可以通过数据冗余存储来减少源库查询量提升或者提升查询性能。
2)聚合数据异构:商品详情页中一般包括商品基本信息、商品属性、商品图片,前端按照商品ID维度进行查询,需要查询3个库,此时如果其中一个库不稳定,就会导致商品详情页出现问题。因此,我们把数据聚合后异构存储到KV存储集群(如存储JSON),这样只需要一次查询就能得到所有展示数据。
- 任务系统扩容
Elastic-Job
1)Elactic-Job-Lite vs Elastic-Job-Cloud
Elastic-Job-Lite定位为轻量务中心化解决方案,使用jar的形式提供分布式任务的协调服务。
Elastic-Job-Cloud:采用自研Mesos Framework的解决方案,提供额外资源治理、应用分发以及进程隔离等功能。
ElasticJob-Lite | ElasticJob-Cloud | |
---|---|---|
无中心化 | 是 | 否 |
资源分配 | 不支持 | 支持 |
作业模式 | 常驻 | 常驻 + 瞬时 |
部署依赖 | ZooKeeper | ZooKeeper + Mesos |
2)Elastic-Job-Lite
整体架构:Elastic-Job-Lite采用去中心化的调度方案,由Elastic-Job-Lite的客户端定时自动触发任务调度,通过任务分片的概念实现服务器负载的动态扩容/缩容,并且使用ZooKeeper作为分布式任务调度的注册和协调中心,当某任务实例崩溃后,自动失效转移,实现高可用,并提供了运维控制台,实现任务参数的动态修改。
任务分片:任务需要并行或者分布式处理时,需要使用任务分片,即把任务拆成N个子任务。
任务开发:
Simple类型任务(配置略):
public class MySimpleJob implements SimpleJob {
public void execute(ShardingContext shardingContext) {
switch (shardingContext.getShardingItem()) {
// 任务按照主键ID分3片(ID%3)
case 0:
process(fetch(0, 3, 6, 9));
break;
case 1:
process(fetch(1, 4, 7));
break;
case 2:
process(fetch(2, 5, 8));
break;
}
}
}
Dataflow类型任务(配置略):
Dataflow类型任务将任务分为抓取数据(fetchData)和处理数据(processData)两部分。其中,流式任务只有当fetchData方法返回值为null时,任务才停止抓取,非流式任务在每次执行过程中,只执行一次fetchData和processData方法。
public class MyDataflowJob implements DataflowJob<String> {
public List<String> fetchData(ShardingContext shardingContext) {
switch (shardingContext.getShardingItem()) {
// 任务按照主键ID分3片(ID%3)
case 0:
return fetch(0, 3, 6, 9);
case 1:
return fetch(1, 4, 7);
case 2:
return fetch(2, 5, 8);
}
return null;
}
public void processData(ShardingContext shardingContext, List<String> data) {
// 任务处理
}
}
16、队列术
- 缓冲队列:实现批量处理、异步的处理和平滑流量;
- 任务队列:实现异步处理、任务分解/聚合处理;
- 消息队列:实现异步处理、系统解耦和数据异构;
- 请求队列:实现流量控制、请求分级、请求隔离;
- 数据总线队列:实现变更部分数据的推送和保证数据的有序性,例如数据库变更后需要同步数据到缓存。(Canal)
- Disruptor+Redis队列:
disruptor介绍(https://tech.meituan.com/2016/11/18/disruptor.html)