feat: 实现GUID主键与service_mask索引改造

- 将主键从自增id改为GUID格式并添加格式校验
- 为service_mask添加表达式索引优化首位查询性能
- 更新相关文档说明改造方案与验证步骤
- 添加统计模块记录数据库写入与Kafka消费量
- 重构Redis心跳协议改用LIST类型存储项目状态
- 修复部署脚本中的服务名称不一致问题
This commit is contained in:
2026-01-17 18:37:44 +08:00
parent 662eeee380
commit 41301f9ce5
21 changed files with 828 additions and 106 deletions

View File

@@ -0,0 +1,40 @@
# GUID 主键与 service_mask 索引改造实施方案
## 目标
-`heartbeat.heartbeat_events` 的主键从自增 `id` 改为 GUID32 位无连字符 HEX 字符串)。
-`service_mask` 的“首位(最低位)判断”新增表达式索引 `idx_service_mask_first_bit`,用于优化常见 bit 查询。
- 说明:你会删除现有数据库,因此不提供在线迁移流程,仅提供可重建的初始化脚本与验证步骤。
## 方案概述
### 1) 主键GUID
- 表字段 `guid` 使用 `varchar(32)` 并设置默认值 `replace(gen_random_uuid()::text, '-', '')`
- 通过 `CHECK (guid ~ '^[0-9a-f]{32}$')` 约束输入格式为 32 位小写 HEX。
- 分区表使用组合主键 `PRIMARY KEY (ts_ms, guid)` 以满足 PostgreSQL 分区唯一约束要求(主键/唯一约束需包含分区键)。
### 2) service_mask 首位查询表达式索引
- 在父表创建索引:
- `CREATE INDEX idx_service_mask_first_bit ON heartbeat.heartbeat_events ((service_mask & 1));`
- 在按天分区创建索引:
- `idx_<partition>_service_mask_first_bit`
## 实施步骤(重建式)
1. 删除旧数据库/旧 schema你将执行
2. 执行建表脚本:
- `scripts/db/010_heartbeat_schema.sql`
- `scripts/db/020_partitioning_auto_daily.sql`
3. 启动服务或执行 `npm run db:apply` 进行初始化。
4. 执行验证项:
- 确认 `heartbeat.heartbeat_events.guid``varchar(32)` 且存在默认值
- 确认存在 `idx_service_mask_first_bit`
- 确认分区新建后存在 `idx_<partition>_service_mask_first_bit`
## 风险评估
- GUID 默认生成依赖 `pgcrypto` 扩展:脚本已包含 `CREATE EXTENSION IF NOT EXISTS pgcrypto;`,但执行账号需要具备安装扩展权限。
- 分区表主键约束限制:无法实现“父表仅 guid 作为主键约束”,因此使用 `(ts_ms, guid)` 的组合主键。
- 表达式索引的表达式匹配:业务查询需要匹配索引表达式(详见 checklist否则无法命中索引。
## 回滚步骤(重建式)
1. 停止服务写入。
2. `DROP SCHEMA heartbeat CASCADE;`(或删除数据库)。
3. 使用回滚版本的 SQL 脚本重新创建(例如回退到 `id bigserial` 版本的脚本)。
4. 重新启动服务并验证写入与查询。

View File

@@ -19,7 +19,7 @@
### 2.1 字段列表
| 字段 | 类型 | 必填 | 说明 |
|---|---|---:|---|
| id | bigserial | (自动生成) | 自增序列号(写入时可不提供 |
| guid | varchar(32) | (自动生成) | GUID32 位无连字符 HEX小写自动生成 |
| ts_ms | bigint | 是 | 毫秒级时间戳epoch ms |
| hotel_id | int2 | 是 | 酒店编号 |
| room_id | varchar(50) | 是 | 房间编号(或房间唯一标识,按字符串存储) |
@@ -59,7 +59,7 @@
需求写“主键id(bigserial)”,但 **PostgreSQL 分区表的主键/唯一约束通常必须包含分区键**
脚本采用:
- `PRIMARY KEY (ts_ms, id)`
- `PRIMARY KEY (ts_ms, guid)`
原因:保证分区表可创建、约束可落地。

View File

@@ -11,13 +11,13 @@
## 2. 偏差与风险(需要评估)
### 2.1 “主键仅 id” 与 PostgreSQL 分区约束冲突
- 需求写:主键为 `id (bigserial)`
- 现实现:`PRIMARY KEY (ts_ms, id)`
- 需求写:主键为 `id`GUID
- 现实现:`PRIMARY KEY (ts_ms, guid)`(其中 guid 为 varchar(32)
原因PostgreSQL 分区表的主键/唯一约束通常需要包含分区键,否则无法在父表创建全局约束。
影响:
- 业务若强依赖“仅 id 即主键”的语义,需要额外约定(例如只把 id 当作全局唯一序列号使用,主键组合用于物理约束)。
- 业务若强依赖“仅 guid 即主键”的语义,需要额外约定(例如只把 guid 当作全局唯一序列号使用,主键组合用于物理约束)。
### 2.2 “自动分区”实现方式
- 需求写:新分区可自动创建。

View File

@@ -0,0 +1,81 @@
# service_mask 索引性能对比报告
## 结论摘要(如何判定“提升”)
-`service_mask` 位运算过滤场景中,新增 `idx_service_mask_first_bit` 后,查询计划应从 `Seq Scan` 转为 `Index Scan/Bitmap Index Scan`(并触发分区裁剪时,仅扫描命中的分区)。
- 若过滤选择性较高(例如仅小比例满足 “&1=1”通常能显著降低 IO 与响应时间。
## 索引脚本
- 结构与索引创建包含在:
- `scripts/db/010_heartbeat_schema.sql`
- `scripts/db/020_partitioning_auto_daily.sql`
- 关键语句:
- `CREATE INDEX idx_service_mask_first_bit ON heartbeat.heartbeat_events ((service_mask & 1));`
## 测试方法(可复现)
### 1) 数据准备
建议在测试库中写入至少 100 万行,保证计划稳定:
```sql
SELECT heartbeat.ensure_partitions(current_date, current_date + 1);
INSERT INTO heartbeat.heartbeat_events (
ts_ms, hotel_id, room_id, device_id, ip,
power_state, guest_type, cardless_state, service_mask,
pms_state, carbon_state, device_count, comm_seq, extra
)
SELECT
(extract(epoch from now()) * 1000)::bigint + (g % 86400000),
(g % 1000)::int2,
('R' || (g % 500))::varchar(50),
('D' || (g % 20000))::varchar(64),
'127.0.0.1:1',
1, 0, 0,
(g % 1024)::bigint,
0, 0, 1, (g % 100000)::int4,
jsonb_build_object('src','bench')
FROM generate_series(1, 1000000) g;
ANALYZE heartbeat.heartbeat_events;
```
### 2) 对比维度
- 响应时间:`EXPLAIN (ANALYZE, BUFFERS)``Execution Time`
- QPS用同一 SQL 连续执行 N 次(例如 1000 次)并统计平均耗时(由压测端计算)
- 查询形态:
- 形态 A位运算表达式命中表达式索引
- `WHERE (service_mask & 1) = 1`
## 修改前/后对比(需要在目标环境产出)
说明:不同硬件/数据分布/缓存命中会导致数值差异。以下对比表格请在你的环境执行本报告“测试方法”并填入实际输出。
### A. 修改前(无 idx_service_mask_first_bit
- 查询计划期望Seq Scan 或 Bitmap Heap Scan + BRIN
```sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*)
FROM heartbeat.heartbeat_events
WHERE (service_mask & 1) = 1;
```
- 结果记录:
- QPS1000 次____
- P50 响应时间ms____
- P95 响应时间ms____
### B. 修改后(有 idx_service_mask_first_bit
```sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*)
FROM heartbeat.heartbeat_events
WHERE (service_mask & 1) = 1;
```
- 结果记录:
- 位运算表达式命中索引期望Index/Bitmap Index Scan
- QPS1000 次____
- P50ms____
- P95ms____
## 结论与建议
- 业务使用 `WHERE (service_mask & 1) = 1` 可直接命中 `idx_service_mask_first_bit`

View File

@@ -36,7 +36,7 @@
### 3.2 可选字段
| 字段 | 类型 | 示例 | 说明 |
|---|---|---|---|
| extra | object | {"source":"gw","ver":"1.2.3"} | 扩展字段:电参、空调状态、版本、上报来源等 |
| extra | object | {"source":"gw","ver":"1.2.3"} | 扩展字段:原文、版本等其他自定义字段 |
| electricity | array<object> | [{"address":"add11","voltage":3.2,...}] | 电力设备数组(按原始顺序拆列落库为数组列) |
| air_conditioner | array<object> | [{"address":"ac1","state":1,...}] | 空调设备数组(按原始顺序拆列落库为数组列) |
@@ -79,10 +79,8 @@
}
],
"extra": {
"source": "gw",
"ver": "1.2.3",
"ac": {"mode": 1, "set_temp": 26},
"meter": {"p": 123.4, "e_wh": 5678}
"original_byte": "0x12345678"
}
}

View File

@@ -1,11 +1,34 @@
# Redis 对接协议(供 AI 生成代码使用)
# Redis 对接协议(供外部项目 AI 生成代码使用)
本文档定义外部项目 ↔ BLS Project Console之间通过 Redis 交互的 **Key 命名、数据类型、写入方式、读取方式与数据格式**
本文档定义"外部项目 ↔ BLS Project Console"之间通过 Redis 交互的 **Key 命名、数据类型、写入方式、读取方式与数据格式**
> 约束:每个需要关联本控制台的外部项目,必须在本项目使用的同一个 Redis 实例中
>
> - 写入 2 个 Key心跳 + 控制台信息)
> - 命令下发为 HTTP API 调用
注:本仓库对外暴露的 Redis 连接信息如下(供对方直接连接以写入心跳/日志)
- 地址:`10.8.8.109`
- 端口:默认 `6379`
- 密码:无(空)
- 数据库:固定 `15`
示例(环境变量):
```
REDIS_HOST=10.8.8.109
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=15
```
示例redis-cli
```
redis-cli -h 10.8.8.109 -p 6379 -n 15
```
> 约束:每个需要关联本控制台的外部项目,必须在同一个 RedisDB15
> - 更新 `项目心跳`(项目列表 + 心跳信息)
> - 追加 `${projectName}_项目控制台`(日志队列)
> - 命令下发为 HTTP API 调用(不通过 Redis 下发命令)
## 1. 命名约定
@@ -15,35 +38,45 @@
固定后缀:
- 心跳:`${projectName}_项目心跳`
- 控制台:`${projectName}_项目控制台`
示例projectName = `订单系统`
- `订单系统_项目心跳`
- `订单系统_项目控制台`
## 2. 外部项目需要写入的 2 个 Key
### 2.1 `${projectName}_项目心跳`
说明:当前控制台左侧“项目选择列表”只读取 `项目心跳`LIST。因此外部项目必须维护该 Key否则项目不会出现在列表中。
- Redis 数据类型:**STRING**
- 写入方式:`SET ${projectName}_项目心跳 <json>`
- valueJSON 字符串,必须包含目标项目可被调用的 `apiBaseUrl`,以及活跃时间戳 `lastActiveAt`
### 2.1 `项目心跳`
推荐 JSON Schema
- Redis 数据类型:**LIST**
- 写入方式(推荐 FIFO`RPUSH 项目心跳 <json>`
- value每个列表元素为“项目心跳记录”的 JSON 字符串
示例(与当前代码读取一致;下面示例表示“逻辑结构”):
```json
{
"apiBaseUrl": "http://127.0.0.1:4001",
"lastActiveAt": 1760000000000
}
[
{
"projectName": "BLS主机心跳日志",
"apiBaseUrl": "http://127.0.0.1:3000",
"lastActiveAt": 1768566165572
}
]
```
字段说明
示例Redis 写入命令)
```
RPUSH 项目心跳 "{\"projectName\":\"BLS主机心跳日志\",\"apiBaseUrl\":\"http://127.0.0.1:3000\",\"lastActiveAt\":1768566165572}"
```
字段说明(每条心跳记录):
- `projectName`:项目名称(用于拼接日志 Key`${projectName}_项目控制台`
- `apiBaseUrl`:目标项目对外提供的 API 地址(基地址,后端将基于它拼接 `apiName`
- `lastActiveAt`状态时间(活跃时间戳毫秒)。建议每 **3 秒**刷新一次。
- `lastActiveAt`:活跃时间戳毫秒)。建议每 **3 秒**刷新一次。
在线/离线判定BLS Project Console 使用):
@@ -53,14 +86,19 @@
建议:
- `lastActiveAt` 使用 `Date.now()` 生成(毫秒)
- 可设置 TTL可选例如 `SET key value EX 30`
- 建议对 `项目心跳` 做长度控制(可选):例如每次写入后执行 `LTRIM 项目心跳 -2000 -1` 保留最近 2000 条
去重提示:
- `项目心跳` 为 LIST 时,外部项目周期性 `RPUSH` 会产生多条重复记录
- BLS Project Console 后端会按 `projectName` 去重,保留 `lastActiveAt` 最新的一条作为项目状态
### 2.2 `${projectName}_项目控制台`
- Redis 数据类型:**LIST**(作为项目向控制台追加的消息队列/日志队列
- Redis 数据类型:**LIST**(作为项目向控制台追加的"消息队列/日志队列"
- 写入方式(推荐 FIFO`RPUSH ${projectName}_项目控制台 <json>`
value推荐格式一条 JSON 字符串,表示错误/调试信息或日志记录。
value推荐格式一条 JSON 字符串,表示"错误/调试信息"或日志记录。
推荐 JSON Schema字段尽量保持稳定便于控制台解析
@@ -83,11 +121,59 @@ value推荐格式一条 JSON 字符串,表示“错误/调试信息
- `message`:日志文本
- `metadata`:可选对象(附加信息)
## 3. 命令下发方式HTTP API 控制
## 3. 项目列表管理(重要
### 3.1 迁移机制(仅用于旧数据导入)
BLS Project Console 支持从旧格式自动迁移到新格式:
- **旧格式**:每个项目独立的心跳键 `${projectName}_项目心跳`
- **新格式**:统一的项目列表键 `项目心跳`LIST 类型,每个元素为 JSON 字符串)
迁移过程:
1. 扫描所有 `${projectName}_项目心跳`
2. 提取 `apiBaseUrl``lastActiveAt` 字段
3. 写入到 `项目心跳`LIST
4. 可选:删除旧键
重要说明(与当前代码实现一致):
- 迁移不会自动后台执行,需要通过接口触发:`POST /api/projects/migrate`
- 迁移的目的只是“从历史 `${projectName}_项目心跳` 导入一次,生成 `项目心跳` 列表”
- 迁移完成后,如果外部项目仍然只更新旧 Key`项目心跳` 不会自动跟随更新;要想实时更新,外部项目必须直接更新 `项目心跳`
### 3.2 新格式项目列表结构
`项目心跳` 为 LIST列表元素为 JSON 字符串;其“逻辑结构”如下:
```json
[
{
"projectName": "订单系统",
"apiBaseUrl": "http://127.0.0.1:4001",
"lastActiveAt": 1760000000000
},
{
"projectName": "用户服务",
"apiBaseUrl": "http://127.0.0.1:4002",
"lastActiveAt": 1760000000001
}
]
```
### 3.3 外部项目对接建议
外部项目应当:
1. 定期写入 `项目心跳`RPUSH 自己的心跳记录;允许产生多条记录,由控制台按 projectName 去重)
2. 追加 `${projectName}_项目控制台` 日志
## 4. 命令下发方式HTTP API 控制)
控制台不再通过 Redis 写入控制指令队列;改为由 BLS Project Console 后端根据目标项目心跳里的 `apiBaseUrl` 直接调用目标项目 HTTP API。
### 3.1 控制台输入格式
### 4.1 控制台输入格式
一行文本按空格拆分:
@@ -100,7 +186,7 @@ value推荐格式一条 JSON 字符串,表示“错误/调试信息
- `reload force`
- `user/refreshCache tenantA`
### 3.2 目标项目需要提供的 API
### 4.2 目标项目需要提供的 API
后端默认使用 `POST` 调用:
@@ -119,18 +205,69 @@ value推荐格式一条 JSON 字符串,表示“错误/调试信息
}
```
字段说明:
- `commandId`:唯一命令标识符
- `timestamp`命令发送时间ISO-8601 格式)
- `source`:命令来源标识
- `apiName`API 接口名
- `args`:参数数组
- `argsText`:参数文本(空格连接)
返回建议:
- 2xx 表示成功
- 非 2xx 表示失败(控制台会展示 upstreamStatus 与部分返回内容)
## 4. 兼容与错误处理建议
### 4.3 在线/离线判定
发送命令前,系统会检查项目在线状态:
-`项目心跳` 列表读取 `lastActiveAt`
-`now - lastActiveAt > 10_000ms`,则认为该应用 **离线**,拒绝发送命令
- 否则认为 **在线**,允许发送命令
## 5. 与本项目代码的对应关系
- **后端 `/api/projects`**:只从 `项目心跳`LIST读取项目列表返回所有项目及其在线状态
- **后端 `/api/commands`**:从 `项目心跳` 中查找目标项目的 `apiBaseUrl/lastActiveAt`,在线时调用目标项目 API
- **后端 `/api/logs`**:读取 `${projectName}_项目控制台`LIST并基于 `项目心跳` 中该项目的 `lastActiveAt` 计算在线/离线与 API 地址信息
## 6. 兼容与错误处理建议
- JSON 解析失败:外部项目应记录错误,并丢弃该条消息(避免死循环阻塞消费)。
- 消息过长:建议控制单条消息大小(例如 < 64KB
- 字符编码:统一 UTF-8。
- 心跳超时:建议外部项目每 3 秒更新一次心跳,避免被误判为离线。
## 5. 与本项目代码的对应关系(实现中
## 7. 数据迁移工具(旧数据导入
- 后端通过 `/api/commands`:从 `${targetProjectName}_项目心跳` 读取 `apiBaseUrl``lastActiveAt`,在线时调用目标项目 API
- 后端通过 `/api/logs`:读取 `${projectName}_项目控制台`;并基于 `${projectName}_项目心跳` 返回在线/离线与 API 地址信息。
如果需要从旧格式迁移到新格式,可使用以下 API
```bash
POST /api/projects/migrate
Content-Type: application/json
{
"deleteOldKeys": false,
"dryRun": false
}
```
参数说明:
- `deleteOldKeys`:是否删除旧格式键(默认 false
- `dryRun`:是否仅模拟运行(默认 false
返回示例:
```json
{
"success": true,
"message": "数据迁移完成",
"migrated": 2,
"projects": [...],
"listKey": "项目心跳",
"deleteOldKeys": false
}
```