前言
我接触物联网项目已经有5年时间,对物联网平台Thingsboard进行过一些改造经验总结。这里聊聊springboot高性能的 HTTP接口实现的思路。
首先给大家展示下高并发http接口的代码
整个代码示例,就是springboot http接口请求,然后查询数据库,返回查询的结果给客户端。
controller 代码示例:
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@RequestMapping(value = "/{entityType}/{entityId}/values/timeseries", method = RequestMethod.GET, params = {"keys", "startTs", "endTs"})
@ResponseBody
public DeferredResult<ResponseEntity> getTimeseries(
@ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, defaultValue = "DEVICE") @PathVariable("entityType") String entityType,
@ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr,
@ApiParam(value = TELEMETRY_KEYS_BASE_DESCRIPTION, required = true) @RequestParam(name = "keys") String keys,
@ApiParam(value = "A long value representing the start timestamp of the time range in milliseconds, UTC.")
@RequestParam(name = "startTs") Long startTs,
@ApiParam(value = "A long value representing the end timestamp of the time range in milliseconds, UTC.")
@RequestParam(name = "endTs") Long endTs,
@ApiParam(value = "A long value representing the aggregation interval range in milliseconds.")
@RequestParam(name = "interval", defaultValue = "0") Long interval,
@ApiParam(value = "An integer value that represents a max number of timeseries data points to fetch." +
" This parameter is used only in the case if 'agg' parameter is set to 'NONE'.", defaultValue = "100")
@RequestParam(name = "limit", defaultValue = "100") Integer limit,
@ApiParam(value = "A string value representing the aggregation function. " +
"If the interval is not specified, 'agg' parameter will use 'NONE' value.",
allowableValues = "MIN, MAX, AVG, SUM, COUNT, NONE")
@RequestParam(name = "agg", defaultValue = "NONE") String aggStr,
@ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES)
@RequestParam(name = "orderBy", defaultValue = "DESC") String orderBy,
@ApiParam(value = STRICT_DATA_TYPES_DESCRIPTION)
@RequestParam(name = "useStrictDataTypes", required = false, defaultValue = "false") Boolean useStrictDataTypes) throws ThingsboardException {
return accessValidator.validateEntityAndCallback(getCurrentUser(), Operation.READ_TELEMETRY, entityType, entityIdStr,
(result, tenantId, entityId) -> {
// If interval is 0, convert this to a NONE aggregation, which is probably what the user really wanted
Aggregation agg = interval == 0L ? Aggregation.valueOf(Aggregation.NONE.name()) : Aggregation.valueOf(aggStr);
List<ReadTsKvQuery> queries = toKeysList(keys).stream().map(key -> new BaseReadTsKvQuery(key, startTs, endTs, interval, limit, agg, orderBy))
.collect(Collectors.toList());
Futures.addCallback(tsService.findAll(tenantId, entityId, queries), getTsKvListCallback(result, useStrictDataTypes), MoreExecutors.directExecutor());
});
}
代码说明accessValidator.validateEntityAndCallback
这个验证 + 回调,然后在回调里面调用tsService.findAll
,并且又采用 了 Futures.addCallback(tsService.findAll())
再次异步调用service的代码逻辑。
service 的findAll()方法
@Override
public ListenableFuture<List<TsKvEntry>> findAll(TenantId tenantId, EntityId entityId, List<ReadTsKvQuery> queries) {
return Futures.transform(findAllByQueries(tenantId, entityId, queries),
result -> {
if (result != null && !result.isEmpty()) {
return result.stream().map(ReadTsKvQueryResult::getData).flatMap(Collection::stream).collect(Collectors.toList());
}
return Collections.emptyList();
}, MoreExecutors.directExecutor());
}
代码说明Futures.transform(findAllByQueries())
这里再是异步调用 findAllByQueries()
方法
findAllByQueries() 方法
@Override
public ListenableFuture<List<ReadTsKvQueryResult>> findAllByQueries(TenantId tenantId, EntityId entityId, List<ReadTsKvQuery> queries) {
validate(entityId);
queries.forEach(this::validate);
if (entityId.getEntityType().equals(EntityType.ENTITY_VIEW)) {
EntityView entityView = entityViewService.findEntityViewById(tenantId, (EntityViewId) entityId);
List<String> keys = entityView.getKeys() != null && entityView.getKeys().getTimeseries() != null ?
entityView.getKeys().getTimeseries() : Collections.emptyList();
List<ReadTsKvQuery> filteredQueries =
queries.stream()
.filter(query -> keys.isEmpty() || keys.contains(query.getKey()))
.collect(Collectors.toList());
return timeseriesDao.findAllAsync(tenantId, entityView.getEntityId(), updateQueriesForEntityView(entityView, filteredQueries));
}
return timeseriesDao.findAllAsync(tenantId, entityId, queries);
}
代码说明:这里调用了 timeseriesDao.findAllAsync()
方法,从方法名称可以看出,这也是异步的调用。
findAllAsync() 方法
@Override
public ListenableFuture<List<ReadTsKvQueryResult>> findAllAsync(TenantId tenantId, EntityId entityId, List<ReadTsKvQuery> queries) {
List<ListenableFuture<ReadTsKvQueryResult>> futures = queries.stream()
.map(query -> findAllAsync(tenantId, entityId, query)).collect(Collectors.toList());
return Futures.allAsList(futures);
}
@Override
public ListenableFuture<ReadTsKvQueryResult> findAllAsync(TenantId tenantId, EntityId entityId, ReadTsKvQuery query) {
if (query.getAggregation() == Aggregation.NONE) {
return findAllAsyncWithLimit(tenantId, entityId, query);
} else {
long startPeriod = query.getStartTs();
long endPeriod = Math.max(query.getStartTs() + 1, query.getEndTs());
long step = Math.max(query.getInterval(), MIN_AGGREGATION_STEP_MS);
List<ListenableFuture<Optional<TsKvEntryAggWrapper>>> futures = new ArrayList<>();
while (startPeriod < endPeriod) {
long startTs = startPeriod;
long endTs = Math.min(startPeriod + step, endPeriod);
long ts = endTs - startTs;
ReadTsKvQuery subQuery = new BaseReadTsKvQuery(query.getKey(), startTs, endTs, ts, 1, query.getAggregation(), query.getOrder());
futures.add(findAndAggregateAsync(tenantId, entityId, subQuery, toPartitionTs(startTs), toPartitionTs(endTs)));
startPeriod = endTs;
}
ListenableFuture<List<Optional<TsKvEntryAggWrapper>>> future = Futures.allAsList(futures);
return Futures.transform(future, new Function<>() {
@Nullable
@Override
public ReadTsKvQueryResult apply(@Nullable List<Optional<TsKvEntryAggWrapper>> input) {
if (input == null) {
return new ReadTsKvQueryResult(query.getId(), Collections.emptyList(), query.getStartTs());
} else {
long maxTs = query.getStartTs();
List<TsKvEntry> data = new ArrayList<>();
for (var opt : input) {
if (opt.isPresent()) {
TsKvEntryAggWrapper tsKvEntryAggWrapper = opt.get();
maxTs = Math.max(maxTs, tsKvEntryAggWrapper.getLastEntryTs());
data.add(tsKvEntryAggWrapper.getEntry());
}
}
return new ReadTsKvQueryResult(query.getId(), data, maxTs);
}
}
}, readResultsProcessingExecutor);
}
}
代码说明:这里是直接查询数据库的逻辑,所有方法都采用的异步调用,交给线程次去执行,返回ListenableFuture
高并发接口设计的核心总结
思考
小伙伴肯定第一反应是,不就是查询下数据库吗,搞这么复杂有必要吗?为什么就一个简单的查询数据库,需要增加这么多的异步执行 controller 调用service,service调用dao 都是异步执行,然后依赖回调,所以代码看着很复杂,为什么要这样实现呢?为什么不直接一步到位,直接调用到dao执行查询,直接返回执行结果。
总结
单线程方式
通常我们的http请求,发起一次调用,如果按照直接调用到数据库返回,不采取任何的异步的情况下,假设这个过程需要执行 10秒。所以一个请求就需要占用服务器线程10秒,假设我们的服务器同时支持 5000个线程,那么 5000个请求,要等待 10秒过后才能接收新的请求。所以这个10s内我们的服务器的并发就是 5000,每秒的并发就是5000/10 = 500个请求/s
全部异步方式
全部异步方式,假设我们整个过程还是10s,拆分成了三步,controller 执行需要3s,service 需要3s,dao需要 4s,假设我们线程池也是5000,这个时候,并发数最直接的就是controller能接收的最大并发数量。
- 优势一
也就是3s内 5000个并发,由于时间缩短了controller的线程释放更快,所以他并发接收请求的能力就提升了。
- 优势二
因为一次请求被拆分成了三个任务,任务变得小了,线程等待的时间会大大降低,所以前面我们的理论上10s完成一次完整的业务请求响应,通常时间会缩短到 5-6秒完成。
- 优势三
因为我们优势一的吞吐量 起来了,优势二的效率也提高了,我们的并发量也就提高了,粗略估算下 5000/3 = 1600个请求/s。
项目源码参考
这个例子,核心在于分享下高并发编程的一些思路,我这里附上我曾经写的GRPC server 源码,里面就采用了大量的这种异步回调思路,增加了我grpc服务器的吞吐量和效率,以及服务的可靠性。