本仓库包含博客系统的多项分布式能力改造文档,主要包括:
本文档用于说明本项目 Elasticsearch 搜索改造的设计目标、当前实现、代码落点、数据链路、压测结果,以及与《分布式改造工作清单.md》中“三、搜索:Elasticsearch 改造”条目的对应关系。
本次 README 主要基于以下材料整理并扩写:
- 分布式改造工作清单.md
- ES分布式改造.md
- jmeter/reports/dashboard/statistics.json
- jmeter/article-search-test.jmx
- jmeter/search-data.csv
当前这轮改造已经不再是“只做了一个 Demo 搜索框”,而是已经完成了 ES 主链路打通:
- 已接入 Elasticsearch 官方 Java Client
- 已完成文章索引 mapping 和 IK 分词器接入
- 已支持标题、正文、标签、作者名等字段参与检索
- 已支持文章创建、更新、审核、删除时的 ES 增量同步
- 已提供全量重建索引接口
- 已提供独立搜索接口和搜索健康检查接口
- 前端
/search?query=...已接入新搜索链路 - 已支持标题高亮与正文片段高亮
但同时,这套能力距离“生产级最终一致性 + 完整运维化”仍有明显差距,尤其体现在 MQ 化、对账、索引别名切换、补偿机制等方面。
根据《分布式改造工作清单.md》中“三、搜索:Elasticsearch 改造”的要求,本轮改造的核心目标包括:
- 用 Elasticsearch 替代 MySQL 标题
LIKE '%关键词%'搜索。 - 支持正文全文检索,而不是只查标题。
- 让搜索能力具备更好的扩展空间,包括相关性优化、字段加权、时间筛选、高亮等。
- 与 MySQL、Mongo 中的文章主数据保持最终一致。
- 为后续的分布式演进打基础,包括异步同步、索引重建、压测验证和运维观测。
本次改造主要涉及以下模块:
bbs-restbbs-article-servicebbs-vue-uijmeter
关键代码入口包括:
- ArticleController.java
- ArticleSearchDTO.java
- ArticleDTO.java
- ArticleServiceImpl.java
- ArticleSearchServiceImpl.java
- ArticleSearchPageResult.java
- Index.vue
- FrontPageArticle.vue
搜索改造采用“ES 负责召回与排序,MySQL/Mongo 负责主数据回源”的方式:
- 前端发起搜索请求。
bbs-rest暴露搜索 API。bbs-article-service调用 ES 完成全文查询。- ES 返回命中文档 ID、总数、排序结果和高亮片段。
- 服务层再按 ID 回源 MySQL 与 Mongo,补全文章展示数据。
- 最终将高亮标题、高亮正文片段和文章基础信息返回给前端。
这样做的好处是:
- 搜索能力不再受限于 MySQL
LIKE - 文章详情展示仍以主库为准
- 后续可以逐步增加更多搜索字段和排序策略
- 即使 ES 查询逻辑迭代,也不会直接破坏文章主数据模型
- MySQL:保存文章主记录、状态、标签关联、作者 ID 等结构化数据。
- Mongo:保存正文内容,作为全文索引的重要来源。
- Elasticsearch:保存面向检索的搜索文档,用于召回、排序和高亮。
当前文章发布链路是:
- 先写 MySQL 文章主表
- 再写文章标签关系
- 再写 Mongo 正文
- 最后同步 ES 文档
也就是说,从 “Mongo 和 ES 谁先写” 这个角度看,答案是:
MySQL -> Mongo -> ES
项目已接入 Elasticsearch 官方 Java Client,而不是继续停留在数据库模糊查询层。
当前基础设施能力包括:
- Elasticsearch 客户端配置
- 索引初始化
- 连接开关配置
- 文章索引名配置
- 搜索健康检查接口
关键配置位于:
当前常见本地端口为:
7010:bbs-rest7011:bbs-article-service9200:Elasticsearch8082:前端开发服务
当前实现已接入 IK 分词器,用于中文文本分词。它是这轮改造中“支持中文全文检索”的关键前置条件。
如果 ES 没有安装 IK,常见影响包括:
- 索引初始化失败
- 搜索行为异常
- 查询降级回 MySQL 标题模糊搜索
bbs_article
根据代码与阶段文档,当前搜索文档已覆盖以下核心字段:
articleIdtitlecontentstateisDeletedlabelNamescreateUsercreateUserNamecreateTime
这说明当前实现已经明显超出最初“只索引 articleId/title/content/state/isDeleted”的雏形版本,开始向清单中的完整文档模型靠拢。
当前查询已引入基础字段加权:
title^4contentlabelNames^2createUserName^1.5
这意味着:
- 标题命中优先级最高
- 正文命中负责保证全文可搜
- 标签可以增强分类召回
- 作者名具备一定弱召回能力
当前返回结果已支持:
highlightTitlehighlightContent
其中:
highlightTitle用于搜索结果标题高亮highlightContent用于搜索结果摘要或正文片段高亮
当前文章相关操作已经会触发 ES 增量同步,主要包括:
- 文章创建
- 文章更新
- 审核状态变更
- 文章删除
本轮实现已经从“主流程同步直写 ES”提升为“应用内异步执行 + 简单重试”的阶段版本,但仍然不是最终的 MQ 方案。
这意味着当前状态是:
- 比最早的同步直写更稳
- 比生产级异步消息架构仍然简单
项目已提供全量重建接口:
POST /api/bbs/article/rebuildSearchIndex
重建流程主要用于:
- 首次接入 ES 后补建历史文章索引
- 索引结构升级后的全量修复
- 增量同步失败后的人工恢复
重建成功后通常会返回:
indexedCountcostMillis
当前同步链路已经具备“能用、可恢复、可观测到部分问题”的基础能力,但仍缺少完整闭环:
- 不是 MQ 驱动
- 没有死信队列
- 没有失败补偿任务
- 没有 DB/ES 定期对账任务
- 没有全量抽样校验机制
当前已新增独立搜索接口:
GET /api/bbs/article/search
这比“在 getList 里偷偷分流”更符合清单要求,也更便于后续维护和压测。
当前还提供搜索健康检查接口:
GET /api/bbs/article/searchHealth
它主要用于:
- 管理员排查 ES 是否可用
- 本地联调排查连接问题
- 区分“接口问题”和“ES 基础设施问题”
搜索接口目前支持:
title:搜索关键词currentPage:当前页码pageSize:分页大小timeRange:时间范围筛选articleStateEnum:文章状态,可选
其中 timeRange 当前支持:
dayweekmonthyearolder
当前搜索链路不是“ES 中直接保存完整展示页数据”,而是:
- ES 返回命中文档 ID 与排序结果
- 服务层再回源 MySQL / Mongo
- 再将高亮片段挂回 DTO 返回前端
这种实现方式满足了清单中“ES 查询 -> 返回 id 列表与排序 -> 按需回源 MySQL/Mongo”的设计方向。
前端当前仍保留:
/search?query=...
但在内部调用链上,已经切换到新的 ES 搜索接口,而不是继续依赖旧的文章列表模糊查询。
当前前端搜索结果页已支持:
- 标题高亮
- 正文片段高亮
- 若某条结果未带高亮片段,则前端按查询词做本地兜底高亮
因此,这轮改造不仅解决了“正文明明有关键词但搜不到”的问题,也提升了结果可读性。
结合 ES分布式改造.md 的阶段结论,当前完成度可以整理为:
- 基础设施:
85% - 索引与文档模型:
75% - 数据同步:
70% - API:
85% - 前端接入:
80% - 对账与运维化:
10%
整体判断为:
ES 搜索已经具备“可搜索、可高亮、可重建、可增量同步、可前端接入、可压测”的阶段性交付能力,但距离生产级最终一致性和运维闭环仍有明显差距。
对照《分布式改造工作清单.md》中“三、搜索:Elasticsearch 改造”,当前已经满足或基本满足的内容包括:
- 已部署并接入 ES
- 已在 Spring 侧接入官方 Java API
- 已定义文章搜索索引 mapping
- 已接入 IK 中文分词
- 已支持正文从 Mongo 同步到 ES
- 已支持文章创建、更新、审核、删除的增量同步
- 已支持全量重建索引
- 已新增独立搜索接口
- 前端
/search + query已切到新搜索链路 - 已验证相关性、分页、降级和高亮
- 已开展 JMeter 压测并沉淀报告
目前仍未完全达到清单要求的部分包括:
- 增量同步还不是 MQ 方案
- 没有死信、补偿和持久化失败处理
- 没有 DB/ES 定期对账机制
- 没有全量校验与抽样校验任务
- 没有索引别名切换方案
- 没有零停机重建方案
- 没有统一监控接入
- 搜索相关性仍较基础,缺少更复杂的时间衰减、联想词、拼写纠错、热门推荐等能力
本轮改造过程中,除了实现功能,也修复了一批影响联调与验证的问题。
出现过公开搜索接口被权限拦截器误伤的问题。原因是:
- 接口虽然标了免登录注解
- 但登录态下请求仍会经过 URL 权限检查
修复后,公开搜索能力可正常使用。
在 Spring Boot 3 / Spring MVC 新版本下,若 @RequestParam 未显式写参数名,而编译结果又没带 -parameters,会出现参数绑定失败。
这类问题会导致:
- 点赞失败
- 用户主页查询失败
- 部分搜索相关接口抛出“服务器异常”
本轮已通过显式补齐 @RequestParam("xxx") 的方式修复。
出现过“正文明明有 node,但搜索为空”的情况,根因主要有:
- ES 不可用时回退到 MySQL 标题
LIKE - 旧索引结构与新查询字段不一致
- 历史文章未执行重建,导致正文未真正写入 ES
修复和处理方式包括:
- 新增独立搜索接口
- 兼容旧索引行为
- 补充索引重建能力
- 接入高亮与更完整的字段集合
在 Swagger/Knife4j 联调阶段,重建接口还暴露出:
- 登录态依赖 Cookie
- 本地域名与 Cookie 域名不一致时无法带上登录态
- 新接口未进入路径权限表时会返回“无权访问接口”
因此,本轮也补充了这部分兼容处理与联调说明。
JMeter 相关文件位于:
- jmeter/article-search-test.jmx
- jmeter/search-data.csv
- jmeter/README.md
- jmeter/reports/article-search-results.jtl
- jmeter/reports/dashboard/index.html
- jmeter/reports/dashboard/statistics.json
本轮压测接口为:
GET /api/bbs/article/search
压测计划文件中的描述也明确写明,该方案用于覆盖:
- 关键词搜索
- 关键词 + 时间筛选
- 仅时间筛选
CSV 数据源来自:
当前数据样例包括:
node,,1,10java,week,1,10spring,month,1,10redis,year,1,10,day,1,10,month,1,10mysql,older,1,10
这意味着本轮压测覆盖了三类典型搜索模式:
- 有关键词、无时间筛选
- 有关键词、有时间筛选
- 无关键词、仅时间筛选
根据 jmeter/article-search-test.jmx,最新压测配置为:
- 线程数:
1000 - Ramp-Up:
10秒 - 循环次数:
20
理论请求总数为:
1000 * 20 = 20000
这与最新统计结果中的 sampleCount = 20000 完全一致。
根据最新 statistics.json,本轮 GET /api/bbs/article/search 的结果如下:
- 总请求数:
20000 - 错误数:
0 - 错误率:
0.0% - 平均响应时间:
922.37 ms - 中位数响应时间:
1001 ms - 最小响应时间:
7 ms - 最大响应时间:
1999 ms - 吞吐量:
686.84 req/s - 接收吞吐:
440.38 KB/s - 发送吞吐:
136.26 KB/s
在当前 dashboard 默认分位点统计下:
pct1ResTime = 1410 mspct2ResTime = 1493 mspct3ResTime = 1590 ms
如果沿用常见的 dashboard 解释方式,可以把这组数据理解为当前高并发压测下的主要尾延迟分位点区间。
这组结果相比早期基础压测结果,更接近“高并发下的真实上限压力”:
- 优点是:
20000次请求全部成功,错误率为0% - 优点是:接口在
1000线程级别压力下仍然保持可用 - 优点是:吞吐量达到了约
686.84 req/s - 需要注意的是:平均耗时和中位数已经接近
1s - 需要注意的是:尾延迟明显上升,说明高并发下仍存在性能优化空间
因此,这轮压测更适合得出这样的阶段性结论:
当前搜索接口已经具备较好的稳定性和可用性,但在高并发场景下,响应时间仍偏高,系统已经从“功能正确性验证”进入“性能优化阶段”。
综合来看:
- 功能正确性:已验证
- 搜索链路稳定性:已验证
- 高并发可用性:已初步验证
- 高并发低延迟能力:仍需继续优化
关键词搜索:
http://127.0.0.1:7010/api/bbs/article/search?title=node¤tPage=1&pageSize=10
关键词 + 时间筛选:
http://127.0.0.1:7010/api/bbs/article/search?title=node&timeRange=week¤tPage=1&pageSize=10
仅时间筛选:
http://127.0.0.1:7010/api/bbs/article/search?timeRange=month¤tPage=1&pageSize=10
GET /api/bbs/article/searchHealth
POST /api/bbs/article/rebuildSearchIndex
如果本机已配置 jmeter 到 PATH:
.\generate-dashboard.ps1如果未配置,可显式指定:
.\generate-dashboard.ps1 -JMeterBin "D:\apache-jmeter-5.6.3\bin\jmeter.bat"报告入口位于:
E:/Blog/jmeter/reports/dashboard/index.html
结合清单、阶段文档和代码现状,当前主要风险包括:
- 增量同步仍是应用内异步线程方案,不是 MQ 驱动
- ES 与主库的一致性仍是“最终一致性雏形”,不是严格工程闭环
- 重建索引没有 alias 切换,重建过程的线上平滑切流能力不足
- 失败重试、死信、补偿和对账能力不足
- 搜索排序还较基础,缺少更复杂的相关性优化策略
- 高并发压测下延迟偏高,说明性能还有明显优化空间
建议按以下优先级继续推进:
- 增加 DB 与 ES 对账任务
- 增加重建后的数量校验与抽样校验
- 增强增量同步失败日志与告警
- 将增量同步升级为 MQ 驱动
- 引入失败重试、死信和补偿机制
- 为索引重建增加 alias 切换
- 支持无损重建与平滑切流
- 优化搜索相关性排序
- 增强高亮策略
- 支持联想搜索、拼写纠错、热门搜索推荐等扩展能力
- 针对高并发场景继续做性能优化,降低平均响应时间与尾延迟
截至当前,这套 Elasticsearch 搜索改造已经完成了“第一阶段可交付版本”的核心目标:
- 能搜标题
- 能搜正文
- 能高亮
- 能分页
- 能按时间筛选
- 能增量同步
- 能重建索引
- 能前端接入
- 能压测验证
因此,这轮改造可以定义为:
Elasticsearch 搜索主链路已经打通,并具备阶段性交付价值;下一阶段的重点不再是“能不能搜”,而是“是否更稳、更快、更一致、更易运维”。
一个基于 .NET 9 的分布式雪花 ID 生成服务,提供唯一 ID 生成和熔断功能。
- 雪花算法 ID 生成: 生成 64 位唯一 ID,包含时间戳、工作机器 ID、序列号
- 熔断器模式: 当错误达到阈值时自动熔断,防止雪崩效应
- 健康检查: 提供健康状态检查端点
- 批量生成: 支持批量生成 ID
- ID 解析: 解析 ID 的详细信息(时间戳、工作机器 ID、序列号)
- 限流: 基于 IP 的请求速率限制
- 异常处理: 统一的异常处理和错误响应
- API Key 认证: 基于 API Key 的调用方身份验证,支持多 Key 配置
DistributedIdService/
├── src/
│ └── DistributedIdService.Api/ # Web API 项目
│ ├── Controllers/ # API 控制器
│ ├── Middleware/ # 中间件(API认证、异常处理、限流、性能统计)
│ ├── Services/ # 核心服务(雪花生成器、熔断器、worker id 提供者)
│ ├── Models/ # 数据模型
│ └── Program.cs # 应用入口
├── tests/
│ ├── DistributedIdService.Tests/ # 单元测试
│ └── DistributedIdService.LoadTest/ # 压测工具
└── docs/ # 文档和示例
- .NET 9 SDK 或更高版本
- 端口 5000(默认)
# 还原依赖
dotnet restore
# 构建
dotnet build
# 运行
dotnet run --project src/DistributedIdService.Api服务默认启动在 http://localhost:5000
通过 appsettings.json 进行配置:
{
"ApiKeys": [
{ "Key": "blog-article-service-key", "Name": "文章服务", "Enabled": true },
{ "Key": "blog-comment-service-key", "Name": "评论服务", "Enabled": true },
{ "Key": "blog-like-service-key", "Name": "点赞服务", "Enabled": true }
],
"WorkerId": "", // 手动指定 workerId,留空则自动生成
"UseMachineId": true, // 使用机器标识生成 workerId
"UseCircuitBreaker": true, // 是否启用熔断器
"CircuitBreaker": {
"FailureThreshold": 5, // 失败阈值
"OpenStateDurationSeconds": 30, // 熔断持续时间
"HalfOpenMaxCalls": 3 // 半开状态最大调用次数
}
}| 端点 | 方法 | 描述 |
|---|---|---|
/ |
GET | 服务信息 |
/ping |
GET | 简单健康检查 |
/health |
GET | 详细健康检查 |
/api/id |
GET | 生成单个雪花 ID |
/api/id/batch |
POST | 批量生成 ID(Body: {"count": N}) |
/api/id/info/{id} |
GET | 解析 ID 详细信息 |
/api/id/status |
GET | 获取生成器状态 |
/metrics |
GET | 性能指标(P50/P90/P95/P99/QPS) |
注意: 除
/,/ping,/health,/metrics外,其他接口需要X-API-KeyHeader 认证
# 生成单个 ID(需要 API Key)
curl -H "X-API-Key: blog-article-service-key" http://localhost:5000/api/id
# 批量生成 5 个 ID
curl -X POST -H "X-API-Key: blog-comment-service-key" http://localhost:5000/api/id/batch \
-H "Content-Type: application/json" \
-d '{"count":5}'
# 解析 ID
curl -H "X-API-Key: blog-like-service-key" http://localhost:5000/api/id/info/311700624614801408
# 健康检查(无需认证)
curl http://localhost:5000/health运行单元测试:
dotnet test tests/DistributedIdService.Tests/DistributedIdService.Tests.csproj测试覆盖:
- 雪花ID生成器
- 熔断器状态转换
- Worker ID 提供者
项目包含压测工具,可测试 P50/P90/P95/P99 等延迟指标:
# 参数: [并发数] [持续秒数],默认 50并发 30秒
dotnet run --project tests/DistributedIdService.LoadTest
# 示例:100并发,持续60秒
dotnet run --project tests/DistributedIdService.LoadTest 100 60输出示例:
=== 压测结果 ===
总请求数: 150000 (成功: 150000, 失败: 0)
--- 延迟分布 ---
最小值: 0.15ms
平均值: 2.35ms
最大值: 45.23ms
P50: 1.89ms
P90: 3.21ms
P95: 4.15ms
P99: 8.67ms
P99.9: 15.32ms
服务端也会每10秒输出一次指标到控制台,或直接访问 /metrics 端点查看实时数据。
实现 Twitter 雪花算法:
- 时间戳(41 位)- 毫秒级,从自定义 epoch 开始
- 工作机器 ID(10 位)- 支持最多 1024 个实例
- 序列号(12 位)- 同一毫秒内的序列,最多 4096
熔断器实现三种状态:
- Closed: 正常请求通过
- Open: 快速失败,不调用实际服务
- HalfOpen: 允许部分请求试探服务是否恢复
支持回退机制,熔断时返回默认值。
自动生成和持久化 worker ID:
- 支持从配置文件指定固定 ID
- 支持基于机器名和进程 ID 自动生成
- 自动保存到本地文件,重启后保持相同 ID
- WorkerId 必须在 0-1023 范围内
- 确保所有实例使用不同的 WorkerId
- 系统时钟回拨会抛出异常,建议使用 NTP 同步时间
- 熔断器默认配置:5 次失败后打开,持续 30 秒
- .NET 9
- ASP.NET Core Web API
- xUnit(测试)
- 无外部依赖
对于使用 Java 的队友,可以使用以下示例调用服务:
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class JavaClientExample {
private static final String BASE_URL = "http://localhost:5000";
private static final String API_KEY = "blog-article-service-key"; // 配置你的 API Key
private static final HttpClient httpClient;
static {
httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
}
public static void main(String[] args) throws IOException, InterruptedException {
// 1. 生成单个ID
String id = generateSingleId();
System.out.println("生成的ID: " + id);
// 2. 批量生成5个ID
String batch = generateBatchIds(5);
System.out.println("批量结果: " + batch);
// 3. 解析ID详情
String info = getIdInfo(id);
System.out.println("ID详情: " + info);
// 4. 健康检查
String health = getHealth();
System.out.println("健康状态: " + health);
}
public static String generateSingleId() throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/api/id"))
.header("X-API-Key", API_KEY)
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
return response.body();
}
public static String generateBatchIds(int count) throws IOException, InterruptedException {
String jsonBody = "{\"count\":" + count + "}";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/api/id/batch"))
.header("Content-Type", "application/json")
.header("X-API-Key", API_KEY)
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
return response.body();
}
public static String getIdInfo(String id) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/api/id/info/" + id))
.header("X-API-Key", API_KEY)
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
return response.body();
}
public static String getHealth() throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/health"))
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
return response.body();
}
}# 1. 确保 C# 服务已启动
dotnet run --project src/DistributedIdService.Api
# 2. 编译 Java 代码
javac docs/JavaClientExample.java
# 3. 运行(需要 Java 11+)
java -cp docs JavaClientExample生成ID:
{
"success": true,
"message": "ID generated successfully",
"data": 311704606653464576,
"timestamp": "2026-05-10T03:22:49.4180262Z"
}批量生成:
{
"success": true,
"message": "Generated 5 IDs",
"data": [311700651613536256, 311700651617730560, ...],
"timestamp": "2026-05-10T03:07:06.4661471Z"
}ID详情:
{
"id": 311700624614801408,
"timestamp": 1778382420025,
"workerId": 748,
"sequence": 0,
"generatedAt": "2026-05-10T03:07:00.025"
}在博客项目中,可以直接使用 HttpClient 封装成工具类,根据业务需求调用相应接口。