<think>我们重新梳理需求:
1. 每日任务:每天计算一批分数(可能包含多个时间点的分数),需要将其中10点的分数(或最接近10点的分数)单独存储(如果当天还没有10点分数记录的话)。
2. 全局计算:给定一个时间范围,生成该范围内每天10点的分数序列。如果某天没有10点的分数记录,则用当天早于10点且最接近10点的分数代替(注意:是当天的分数,不能跨天)。
因此,我们需要两个核心功能:
- 每日任务:在每天计算出的新分数中,检查当天10点的分数是否存在。如果不存在,则从当天已计算的分数中找出最接近10点(且早于10点)的分数作为10点的分数,并存储。
- 全局计算:对于给定的时间范围,生成每一天的10点分数。对于每一天,如果存在10点的分数记录,则直接使用;否则,从当天的分数记录中找出最接近10点(且早于10点)的分数作为10点的分数。
注意:这里强调“当天”的分数,不能跨天使用(即不能使用前一天的数据)。
我们设计一个统一的索引`scores`来存储所有分数记录,包括每日任务计算出的分数和补充的10点分数。
存储结构示例:
- 原始分数:{ "timestamp": "2024-06-01T09:45:00", "score": 85, "type": "raw" }
- 补充的10点分数:{ "timestamp": "2024-06-01T10:00:00", "score": 85, "type": "supplemented", "original_timestamp": "2024-06-01T09:45:00" }
每日任务流程:
1. 每天计算出一批分数(多个时间点),将这些分数存入索引(type="raw")。
2. 检查当天(以日期为粒度)是否存在10点的分数记录(即timestamp为当天10:00:00的记录)?
3. 如果不存在,则从当天的所有分数记录中(注意:只限当天,且时间早于10:00:00)找出最接近10:00:00的分数记录(按时间倒序,取第一条)。
4. 如果找到,则用该分数值创建一个10:00:00的记录(type="supplemented"),并存储。
5. 如果当天没有任何早于10点的记录,则可能无法补充(或者根据业务要求,使用默认值?这里我们假设必须要有分数,所以如果没有则使用当天最早的一条?或者最晚的一条?但问题要求是早于10点且最接近,所以如果当天没有早于10点的,则无法补充,需要根据业务逻辑处理。这里我们先假设如果找不到,则跳过,不补充,这样在全局计算时再处理(但全局计算也只找当天的,所以也可能找不到)。因此,我们需要一个策略:如果当天没有早于10点的记录,则使用当天最早的一条记录(无论时间)?或者使用前一天的10点分数?但需求不允许跨天,所以不能使用前一天。因此,我们只能使用当天任意时间点的记录?但是需求要求是“早于但离10点最近的分数”,所以如果没有早于的,则可能使用10点之后的?但是需求要求是早于。所以这里存在矛盾。
根据需求,必须是早于10点的分数。因此,如果当天没有早于10点的分数,则无法补充。那么全局计算时,对于这一天,我们也没有10点的分数(即空缺)。但是,业务上可能要求必须有一个分数,所以我们需要明确:
需求:如果当天没有早于10点的分数记录,那么:
- 每日任务:不补充10点记录(因为找不到符合条件的记录)。
- 全局计算:同样找不到,那么这一天就没有10点分数。
但是,如果业务要求必须每一天都有10点分数,那么就需要放宽条件:允许使用当天任意时间点最接近10点的分数(无论早于还是晚于)。但原需求是“早于但离10点最近的分数”,所以这里我们严格按照需求,只使用早于的。
因此,在每日任务中,如果当天没有早于10点的记录,就不补充。在全局计算中,如果某天没有10点记录(包括补充的)且没有早于10点的记录,则该天空缺。但这样在连续性上会有问题。所以我们需要和业务确认。
由于问题描述中并没有说必须每一天都有分数,所以我们按照需求:只使用早于10点的记录。
全局计算流程:
输入:开始日期、结束日期
输出:每一天的10点分数(如果存在记录则用记录,否则用当天早于10点最接近的分数,如果还没有则空缺)
但是,在全局计算中,我们可能遇到某天在索引中已经有了补充的10点记录,也可能有原始记录。所以我们需要:
对于每一天:
1. 检查是否存在timestamp为当天10:00:00的记录(不管类型)。
2. 如果存在,则使用该记录。
3. 如果不存在,则从当天的所有记录中(type="raw")找出时间早于10:00:00且最接近10:00:00的一条(按时间倒序取第一条)。
4. 如果找到,则使用该记录的分数作为当天10点的分数(注意,这里并不需要存储,因为全局计算是查询过程,不是存储过程)。
5. 如果没有,则该天分数为null(或者根据业务需求,抛异常或使用默认值,这里我们返回null)。
注意:全局计算是查询,不需要改变索引中的数据。
但是,为了效率,我们可能希望将每日任务中补充的10点分数存储起来,这样全局计算时直接查询即可,避免重复计算。
因此,我们设计:
- 每日任务:补充10点分数(如果有合适的早于10点的记录)并存储(存储后,全局计算就可以直接查到当天的10点记录)。
- 全局计算:对于没有存储10点分数的天,临时查询当天的原始记录来补充(但这样效率低,因为可能涉及大量天的查询)。所以,我们也可以考虑在全局计算时,先批量查询所有需要的天是否在索引中有10点记录(包括补充的),然后对于没有的,再去查询原始记录。
为了性能,我们可以将每日任务中补充的10点分数存储,这样全局计算时只需要查询10点记录即可(因为每日任务已经将能补充的都补充了)。对于历史数据,我们可能需要一个后台任务去补充。
因此,我们有两种做法:
- 做法1:在全局计算时,实时计算(包括查询原始记录)来填充空缺。
- 做法2:在全局计算前,先运行一个后台任务,将指定时间范围内所有缺失10点分数的天都补充(存储)好,然后全局计算只需要查询10点记录。
这里,我们采用做法1,因为全局计算可能涉及历史数据,而历史数据可能已经不会再变化,所以实时计算一次即可。而且,如果每天的任务已经将当天的10点分数补充,那么历史数据中缺失的10点分数可能很少。
下面,我们分别实现每日任务和全局计算。
每日任务代码(Java):</think>### 回答
感谢您的澄清。我理解了核心需求:无需单独索引,只需在现有分数数据基础上处理两个场景:
1. **每日任务**:在每日计算的新分数中,若10点无数据,则补充早于但最接近10点的分数
2. **全局计算**:在历史数据中,对缺失10点的日期补充早于但最接近10点的分数
以下是优化后的统一解决方案:
#### 核心逻辑实现
```java
import org.elasticsearch.action.search.SearchRequest;
import org.elasticsearch.action.search.SearchResponse;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.RestHighLevelClient;
import org.elasticsearch.index.query.BoolQueryBuilder;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.search.SearchHit;
import org.elasticsearch.search.builder.SearchSourceBuilder;
import org.elasticsearch.search.sort.SortOrder;
import org.springframework.scheduling.annotation.Scheduled;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.*;
public class ScoreService {
private final RestHighLevelClient esClient;
private static final String SCORE_INDEX = "security_scores";
private static final DateTimeFormatter TIMESTAMP_FORMAT = DateTimeFormatter.ISO_DATE_TIME;
// 每日任务:补充当天10点分数
@Scheduled(cron = "0 5 10 * * ?") // 10:05执行确保数据完整
public void supplementDailyScore() throws Exception {
LocalDate today = LocalDate.now();
LocalDateTime today10AM = today.atTime(10, 0);
// 检查当天10点分数是否存在
if (!isScoreExists(today10AM)) {
// 获取早于10点的最近分数
Optional<Map<String, Object>> nearestScore = findNearestScoreBefore(today10AM);
// 存在则补充10点记录
nearestScore.ifPresent(score -> {
Map<String, Object> newRecord = new HashMap<>(score);
newRecord.put("timestamp", today10AM.format(TIMESTAMP_FORMAT));
newRecord.put("is_supplemented", true);
saveScoreRecord(newRecord);
});
}
}
// 全局计算:获取日期范围内连续10点分数
public List<Map<String, Object>> getContinuousScores(long startTimestamp, long endTimestamp) throws Exception {
LocalDate startDate = Instant.ofEpochMilli(startTimestamp).atZone(ZoneId.systemDefault()).toLocalDate();
LocalDate endDate = Instant.ofEpochMilli(endTimestamp).atZone(ZoneId.systemDefault()).toLocalDate();
List<Map<String, Object>> results = new ArrayList<>();
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
LocalDateTime targetTime = date.atTime(10, 0);
Map<String, Object> scoreRecord = getOrSupplementScore(targetTime);
results.add(scoreRecord);
}
return results;
}
// 获取或补充10点分数
private Map<String, Object> getOrSupplementScore(LocalDateTime targetTime) throws Exception {
// 尝试获取精确10点记录
Optional<Map<String, Object>> exactScore = getScoreAtTime(targetTime);
if (exactScore.isPresent()) {
return exactScore.get();
}
// 补充早于10点的最近分数
Optional<Map<String, Object>> nearestScore = findNearestScoreBefore(targetTime);
if (nearestScore.isPresent()) {
Map<String, Object> supplemented = new HashMap<>(nearestScore.get());
supplemented.put("timestamp", targetTime.format(TIMESTAMP_FORMAT));
supplemented.put("is_supplemented", true);
return supplemented;
}
// 无数据时返回空记录
return createEmptyScoreRecord(targetTime);
}
// 查询早于目标时间的最接近分数
private Optional<Map<String, Object>> findNearestScoreBefore(LocalDateTime targetTime) throws Exception {
String maxTime = targetTime.format(TIMESTAMP_FORMAT);
LocalDate targetDate = targetTime.toLocalDate();
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder()
.query(QueryBuilders.boolQuery()
.must(QueryBuilders.rangeQuery("timestamp").lt(maxTime))
.must(QueryBuilders.termQuery("date", targetDate.toString())) // 限定当天
)
.sort("timestamp", SortOrder.DESC)
.size(1);
SearchRequest request = new SearchRequest(SCORE_INDEX).source(sourceBuilder);
SearchResponse response = esClient.search(request, RequestOptions.DEFAULT);
if (response.getHits().getHits().length > 0) {
return Optional.of(response.getHits().getHits()[0].getSourceAsMap());
}
return Optional.empty();
}
// 检查指定时间分数是否存在
private boolean isScoreExists(LocalDateTime timestamp) throws Exception {
return getScoreAtTime(timestamp).isPresent();
}
// 获取精确时间点的分数
private Optional<Map<String, Object>> getScoreAtTime(LocalDateTime timestamp) throws Exception {
String exactTime = timestamp.format(TIMESTAMP_FORMAT);
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder()
.query(QueryBuilders.termQuery("timestamp", exactTime))
.size(1);
SearchRequest request = new SearchRequest(SCORE_INDEX).source(sourceBuilder);
SearchResponse response = esClient.search(request, RequestOptions.DEFAULT);
if (response.getHits().getHits().length > 0) {
return Optional.of(response.getHits().getHits()[0].getSourceAsMap());
}
return Optional.empty();
}
// 创建空记录
private Map<String, Object> createEmptyScoreRecord(LocalDateTime timestamp) {
Map<String, Object> record = new HashMap<>();
record.put("timestamp", timestamp.format(TIMESTAMP_FORMAT));
record.put("score", null);
record.put("is_supplemented", false);
record.put("is_empty", true);
return record;
}
// 保存分数记录(简化实现)
private void saveScoreRecord(Map<String, Object> record) {
// 实际实现使用ES index API
}
}
```
#### 数据结构设计
所有分数存储在同一索引,通过字段区分类型:
```json
// 原始分数记录
{
"timestamp": "2023-10-05T09:45:00",
"score": 85,
"date": "2023-10-05",
"metric1": 12,
"metric2": 8
}
// 补充的10点记录
{
"timestamp": "2023-10-05T10:00:00",
"score": 85, // 继承自09:45记录
"is_supplemented": true,
"original_timestamp": "2023-10-05T09:45:00",
"date": "2023-10-05"
}
// 空记录(无数据时)
{
"timestamp": "2023-10-06T10:00:00",
"score": null,
"is_empty": true,
"date": "2023-10-06"
}
```
#### 关键处理流程
1. **每日补充逻辑**:
```mermaid
graph TD
A[每日10:05执行] --> B{当天10点分数存在?}
B -->|是| C[跳过]
B -->|否| D[查询当天早于10点的最近记录]
D --> E{找到记录?}
E -->|是| F[创建补充记录]
E -->|否| G[不做处理]
```
2. **全局查询逻辑**:
```mermaid
graph TD
A[输入时间范围] --> B[遍历每一天]
B --> C{当天10点记录存在?}
C -->|是| D[直接使用]
C -->|否| E[查询当天早于10点的最近记录]
E --> F{找到记录?}
F -->|是| G[创建补充记录]
F -->|否| H[创建空记录]
```
#### 性能优化
1. **批量查询优化**(全局计算时):
```java
// 预先批量查询所有日期的10点记录
Map<LocalDate, Optional<Map<String, Object>>> preFetchedScores =
batchGetScores(dateRange.stream()
.map(date -> date.atTime(10, 0))
.collect(Collectors.toList()));
```
2. **缓存机制**:
```java
// 缓存最近3天的查询结果
private final Cache<LocalDate, Optional<Integer>> dailyScoreCache =
Caffeine.newBuilder()
.expireAfterWrite(3, TimeUnit.DAYS)
.build();
```
3. **索引优化**:
```json
PUT security_scores/_mapping
{
"properties": {
"date": { "type": "keyword", "doc_values": true },
"timestamp": {
"type": "date",
"format": "strict_date_hour_minute_second"
}
}
}
```
#### 异常处理
```java
private Optional<Map<String, Object>> findNearestScoreBefore(LocalDateTime targetTime) {
try {
// ES查询逻辑
} catch (Exception e) {
log.error("查询失败: {}", targetTime, e);
// 降级方案:返回当天最早记录
return getFirstScoreOfDay(targetTime.toLocalDate());
}
}
private Optional<Map<String, Object>> getFirstScoreOfDay(LocalDate date) {
// 查询当天第一条记录作为降级值
}
```
### 方案优势
1. **统一存储**:所有分数存在同一索引,无需额外维护
2. **按需补充**:仅在缺失时动态创建补充记录
3. **日期隔离**:严格限定在当天范围内查找最近分数
4. **状态标记**:清晰区分原始/补充/空记录
5. **资源节约**:避免不必要的存储和计算