feat: 新增 G4 热表独立双写能力

- 新增配置项以支持旧/新明细表的独立写入开关及目标表名。
- 重构 DatabaseManager,抽象通用批量 COPY 写入内核,支持不同目标表的复用。
- 新增双明细写入编排器,支持旧/新表独立执行、重试及 fallback。
- 调整 HeartbeatProcessor.processBatch(),确保 room_status 独立执行。
- 错误表仅记录新表写入失败,旧表失败不再写入错误表。
- 重新定义消费暂停策略,基于当前启用的关键 sink 判断。
- 补充按 sink 维度的统计项与启动日志。

新增 G4 热表相关的数据库规范与处理逻辑,确保系统在双写模式下的稳定性与可扩展性。
This commit is contained in:
2026-03-09 15:49:12 +08:00
parent f59000f5ef
commit 43fa7505e5
21 changed files with 2546 additions and 154 deletions

View File

@@ -0,0 +1,530 @@
/*
Navicat Premium Dump SQL
Source Server : FnOS 109
Source Server Type : PostgreSQL
Source Server Version : 150014 (150014)
Source Host : 10.8.8.109:5433
Source Catalog : log_platform
Source Schema : heartbeat
Target Server Type : PostgreSQL
Target Server Version : 150014 (150014)
File Encoding : 65001
Date: 09/03/2026 10:11:03
*/
-- ----------------------------
-- Table structure for heartbeat_events_g4_hot_d20260301
-- ----------------------------
DROP TABLE IF EXISTS "heartbeat"."heartbeat_events_g4_hot_d20260301";
CREATE TABLE "heartbeat"."heartbeat_events_g4_hot_d20260301" (
"ts_ms" int8 NOT NULL,
"hotel_id" int2 NOT NULL,
"room_id" varchar(50) COLLATE "pg_catalog"."default" NOT NULL,
"device_id" varchar(64) COLLATE "pg_catalog"."default" NOT NULL,
"ip" varchar(21) COLLATE "pg_catalog"."default" NOT NULL,
"power_state" int2 NOT NULL,
"guest_type" int2 NOT NULL,
"cardless_state" int2 NOT NULL,
"service_mask" int8 NOT NULL,
"pms_state" int2 NOT NULL,
"carbon_state" int2 NOT NULL,
"device_count" int2 NOT NULL,
"comm_seq" int4 NOT NULL,
"elec_address" _text COLLATE "pg_catalog"."default",
"air_address" _text COLLATE "pg_catalog"."default",
"voltage" _float8,
"ampere" _float8,
"power" _float8,
"phase" _text COLLATE "pg_catalog"."default",
"energy" _float8,
"sum_energy" _float8,
"state" _int2,
"model" _int2,
"speed" _int2,
"set_temp" _int2,
"now_temp" _int2,
"solenoid_valve" _int2,
"extra" jsonb,
"write_ts_ms" int8 NOT NULL DEFAULT ((EXTRACT(epoch FROM clock_timestamp()) * (1000)::numeric))::bigint,
"insert_card" int2,
"bright_g" int2,
"version" int2,
"svc_01" bool,
"svc_02" bool,
"svc_03" bool,
"svc_04" bool,
"svc_05" bool,
"svc_06" bool,
"svc_07" bool,
"svc_08" bool,
"svc_09" bool,
"svc_10" bool,
"svc_11" bool,
"svc_12" bool,
"svc_13" bool,
"svc_14" bool,
"svc_15" bool,
"svc_16" bool,
"svc_17" bool,
"svc_18" bool,
"svc_19" bool,
"svc_20" bool,
"svc_21" bool,
"svc_22" bool,
"svc_23" bool,
"svc_24" bool,
"svc_25" bool,
"svc_26" bool,
"svc_27" bool,
"svc_28" bool,
"svc_29" bool,
"svc_30" bool,
"svc_31" bool,
"svc_32" bool,
"svc_33" bool,
"svc_34" bool,
"svc_35" bool,
"svc_36" bool,
"svc_37" bool,
"svc_38" bool,
"svc_39" bool,
"svc_40" bool,
"svc_41" bool,
"svc_42" bool,
"svc_43" bool,
"svc_44" bool,
"svc_45" bool,
"svc_46" bool,
"svc_47" bool,
"svc_48" bool,
"svc_49" bool,
"svc_50" bool,
"svc_51" bool,
"svc_52" bool,
"svc_53" bool,
"svc_54" bool,
"svc_55" bool,
"svc_56" bool,
"svc_57" bool,
"svc_58" bool,
"svc_59" bool,
"svc_60" bool,
"svc_61" bool,
"svc_62" bool,
"svc_63" bool,
"svc_64" bool,
"air_address_1" text COLLATE "pg_catalog"."default",
"air_address_2" text COLLATE "pg_catalog"."default",
"air_address_residual" _text COLLATE "pg_catalog"."default",
"state_1" int2,
"state_2" int2,
"state_residual" _int2,
"model_1" int2,
"model_2" int2,
"model_residual" _int2,
"speed_1" int2,
"speed_2" int2,
"speed_residual" _int2,
"set_temp_1" int2,
"set_temp_2" int2,
"set_temp_residual" _int2,
"now_temp_1" int2,
"now_temp_2" int2,
"now_temp_residual" _int2,
"solenoid_valve_1" int2,
"solenoid_valve_2" int2,
"solenoid_valve_residual" _int2,
"elec_address_1" text COLLATE "pg_catalog"."default",
"elec_address_2" text COLLATE "pg_catalog"."default",
"elec_address_residual" _text COLLATE "pg_catalog"."default",
"voltage_1" float8,
"voltage_2" float8,
"voltage_residual" _float8,
"ampere_1" float8,
"ampere_2" float8,
"ampere_residual" _float8,
"power_1" float8,
"power_2" float8,
"power_residual" _float8,
"phase_1" text COLLATE "pg_catalog"."default",
"phase_2" text COLLATE "pg_catalog"."default",
"phase_residual" _text COLLATE "pg_catalog"."default",
"energy_1" float8,
"energy_2" float8,
"energy_residual" _float8,
"sum_energy_1" float8,
"sum_energy_2" float8,
"sum_energy_residual" _float8,
"power_carbon_on" float8,
"power_carbon_off" float8,
"power_person_exist" float8,
"power_person_left" float8,
"guid" varchar(32) COLLATE "pg_catalog"."default"
)
TABLESPACE "ts_hot"
;
-- ----------------------------
-- Table structure for heartbeat_events_g4_hot_d20260302
-- ----------------------------
DROP TABLE IF EXISTS "heartbeat"."heartbeat_events_g4_hot_d20260302";
CREATE TABLE "heartbeat"."heartbeat_events_g4_hot_d20260302" (
"ts_ms" int8 NOT NULL,
"hotel_id" int2 NOT NULL,
"room_id" varchar(50) COLLATE "pg_catalog"."default" NOT NULL,
"device_id" varchar(64) COLLATE "pg_catalog"."default" NOT NULL,
"ip" varchar(21) COLLATE "pg_catalog"."default" NOT NULL,
"power_state" int2 NOT NULL,
"guest_type" int2 NOT NULL,
"cardless_state" int2 NOT NULL,
"service_mask" int8 NOT NULL,
"pms_state" int2 NOT NULL,
"carbon_state" int2 NOT NULL,
"device_count" int2 NOT NULL,
"comm_seq" int4 NOT NULL,
"elec_address" _text COLLATE "pg_catalog"."default",
"air_address" _text COLLATE "pg_catalog"."default",
"voltage" _float8,
"ampere" _float8,
"power" _float8,
"phase" _text COLLATE "pg_catalog"."default",
"energy" _float8,
"sum_energy" _float8,
"state" _int2,
"model" _int2,
"speed" _int2,
"set_temp" _int2,
"now_temp" _int2,
"solenoid_valve" _int2,
"extra" jsonb,
"write_ts_ms" int8 NOT NULL DEFAULT ((EXTRACT(epoch FROM clock_timestamp()) * (1000)::numeric))::bigint,
"insert_card" int2,
"bright_g" int2,
"version" int2,
"svc_01" bool,
"svc_02" bool,
"svc_03" bool,
"svc_04" bool,
"svc_05" bool,
"svc_06" bool,
"svc_07" bool,
"svc_08" bool,
"svc_09" bool,
"svc_10" bool,
"svc_11" bool,
"svc_12" bool,
"svc_13" bool,
"svc_14" bool,
"svc_15" bool,
"svc_16" bool,
"svc_17" bool,
"svc_18" bool,
"svc_19" bool,
"svc_20" bool,
"svc_21" bool,
"svc_22" bool,
"svc_23" bool,
"svc_24" bool,
"svc_25" bool,
"svc_26" bool,
"svc_27" bool,
"svc_28" bool,
"svc_29" bool,
"svc_30" bool,
"svc_31" bool,
"svc_32" bool,
"svc_33" bool,
"svc_34" bool,
"svc_35" bool,
"svc_36" bool,
"svc_37" bool,
"svc_38" bool,
"svc_39" bool,
"svc_40" bool,
"svc_41" bool,
"svc_42" bool,
"svc_43" bool,
"svc_44" bool,
"svc_45" bool,
"svc_46" bool,
"svc_47" bool,
"svc_48" bool,
"svc_49" bool,
"svc_50" bool,
"svc_51" bool,
"svc_52" bool,
"svc_53" bool,
"svc_54" bool,
"svc_55" bool,
"svc_56" bool,
"svc_57" bool,
"svc_58" bool,
"svc_59" bool,
"svc_60" bool,
"svc_61" bool,
"svc_62" bool,
"svc_63" bool,
"svc_64" bool,
"air_address_1" text COLLATE "pg_catalog"."default",
"air_address_2" text COLLATE "pg_catalog"."default",
"air_address_residual" _text COLLATE "pg_catalog"."default",
"state_1" int2,
"state_2" int2,
"state_residual" _int2,
"model_1" int2,
"model_2" int2,
"model_residual" _int2,
"speed_1" int2,
"speed_2" int2,
"speed_residual" _int2,
"set_temp_1" int2,
"set_temp_2" int2,
"set_temp_residual" _int2,
"now_temp_1" int2,
"now_temp_2" int2,
"now_temp_residual" _int2,
"solenoid_valve_1" int2,
"solenoid_valve_2" int2,
"solenoid_valve_residual" _int2,
"elec_address_1" text COLLATE "pg_catalog"."default",
"elec_address_2" text COLLATE "pg_catalog"."default",
"elec_address_residual" _text COLLATE "pg_catalog"."default",
"voltage_1" float8,
"voltage_2" float8,
"voltage_residual" _float8,
"ampere_1" float8,
"ampere_2" float8,
"ampere_residual" _float8,
"power_1" float8,
"power_2" float8,
"power_residual" _float8,
"phase_1" text COLLATE "pg_catalog"."default",
"phase_2" text COLLATE "pg_catalog"."default",
"phase_residual" _text COLLATE "pg_catalog"."default",
"energy_1" float8,
"energy_2" float8,
"energy_residual" _float8,
"sum_energy_1" float8,
"sum_energy_2" float8,
"sum_energy_residual" _float8,
"power_carbon_on" float8,
"power_carbon_off" float8,
"power_person_exist" float8,
"power_person_left" float8,
"guid" varchar(32) COLLATE "pg_catalog"."default"
)
TABLESPACE "ts_hot"
;
-- ----------------------------
-- Table structure for heartbeat_events_g4_hot
-- ----------------------------
DROP TABLE IF EXISTS "heartbeat"."heartbeat_events_g4_hot";
CREATE TABLE "heartbeat"."heartbeat_events_g4_hot" (
"ts_ms" int8 NOT NULL,
"hotel_id" int2 NOT NULL,
"room_id" varchar(50) COLLATE "pg_catalog"."default" NOT NULL,
"device_id" varchar(64) COLLATE "pg_catalog"."default" NOT NULL,
"ip" varchar(21) COLLATE "pg_catalog"."default" NOT NULL,
"power_state" int2 NOT NULL,
"guest_type" int2 NOT NULL,
"cardless_state" int2 NOT NULL,
"service_mask" int8 NOT NULL,
"pms_state" int2 NOT NULL,
"carbon_state" int2 NOT NULL,
"device_count" int2 NOT NULL,
"comm_seq" int4 NOT NULL,
"elec_address" text[] COLLATE "pg_catalog"."default",
"air_address" text[] COLLATE "pg_catalog"."default",
"voltage" float8[],
"ampere" float8[],
"power" float8[],
"phase" text[] COLLATE "pg_catalog"."default",
"energy" float8[],
"sum_energy" float8[],
"state" int2[],
"model" int2[],
"speed" int2[],
"set_temp" int2[],
"now_temp" int2[],
"solenoid_valve" int2[],
"extra" jsonb,
"write_ts_ms" int8 NOT NULL DEFAULT ((EXTRACT(epoch FROM clock_timestamp()) * (1000)::numeric))::bigint,
"insert_card" int2,
"bright_g" int2,
"version" int2,
"svc_01" bool,
"svc_02" bool,
"svc_03" bool,
"svc_04" bool,
"svc_05" bool,
"svc_06" bool,
"svc_07" bool,
"svc_08" bool,
"svc_09" bool,
"svc_10" bool,
"svc_11" bool,
"svc_12" bool,
"svc_13" bool,
"svc_14" bool,
"svc_15" bool,
"svc_16" bool,
"svc_17" bool,
"svc_18" bool,
"svc_19" bool,
"svc_20" bool,
"svc_21" bool,
"svc_22" bool,
"svc_23" bool,
"svc_24" bool,
"svc_25" bool,
"svc_26" bool,
"svc_27" bool,
"svc_28" bool,
"svc_29" bool,
"svc_30" bool,
"svc_31" bool,
"svc_32" bool,
"svc_33" bool,
"svc_34" bool,
"svc_35" bool,
"svc_36" bool,
"svc_37" bool,
"svc_38" bool,
"svc_39" bool,
"svc_40" bool,
"svc_41" bool,
"svc_42" bool,
"svc_43" bool,
"svc_44" bool,
"svc_45" bool,
"svc_46" bool,
"svc_47" bool,
"svc_48" bool,
"svc_49" bool,
"svc_50" bool,
"svc_51" bool,
"svc_52" bool,
"svc_53" bool,
"svc_54" bool,
"svc_55" bool,
"svc_56" bool,
"svc_57" bool,
"svc_58" bool,
"svc_59" bool,
"svc_60" bool,
"svc_61" bool,
"svc_62" bool,
"svc_63" bool,
"svc_64" bool,
"air_address_1" text COLLATE "pg_catalog"."default",
"air_address_2" text COLLATE "pg_catalog"."default",
"air_address_residual" text[] COLLATE "pg_catalog"."default",
"state_1" int2,
"state_2" int2,
"state_residual" int2[],
"model_1" int2,
"model_2" int2,
"model_residual" int2[],
"speed_1" int2,
"speed_2" int2,
"speed_residual" int2[],
"set_temp_1" int2,
"set_temp_2" int2,
"set_temp_residual" int2[],
"now_temp_1" int2,
"now_temp_2" int2,
"now_temp_residual" int2[],
"solenoid_valve_1" int2,
"solenoid_valve_2" int2,
"solenoid_valve_residual" int2[],
"elec_address_1" text COLLATE "pg_catalog"."default",
"elec_address_2" text COLLATE "pg_catalog"."default",
"elec_address_residual" text[] COLLATE "pg_catalog"."default",
"voltage_1" float8,
"voltage_2" float8,
"voltage_residual" float8[],
"ampere_1" float8,
"ampere_2" float8,
"ampere_residual" float8[],
"power_1" float8,
"power_2" float8,
"power_residual" float8[],
"phase_1" text COLLATE "pg_catalog"."default",
"phase_2" text COLLATE "pg_catalog"."default",
"phase_residual" text[] COLLATE "pg_catalog"."default",
"energy_1" float8,
"energy_2" float8,
"energy_residual" float8[],
"sum_energy_1" float8,
"sum_energy_2" float8,
"sum_energy_residual" float8[],
"power_carbon_on" float8,
"power_carbon_off" float8,
"power_person_exist" float8,
"power_person_left" float8,
"guid" varchar(32) COLLATE "pg_catalog"."default"
)
PARTITION BY RANGE (
"ts_ms" "pg_catalog"."int8_ops"
)
TABLESPACE "ts_hot"
;
ALTER TABLE "heartbeat"."heartbeat_events_g4_hot" ATTACH PARTITION "heartbeat"."heartbeat_events_g4_hot_d20260301" FOR VALUES FROM (
'1772294400000'
) TO (
'1772380800000'
)
;
ALTER TABLE "heartbeat"."heartbeat_events_g4_hot" ATTACH PARTITION "heartbeat"."heartbeat_events_g4_hot_d20260302" FOR VALUES FROM (
'1772380800000'
) TO (
'1772467200000'
)
;
-- ----------------------------
-- Indexes structure for table heartbeat_events_g4_hot_d20260301
-- ----------------------------
CREATE UNIQUE INDEX "heartbeat_events_g4_hot_d20260301_guid_ts_ms_idx" ON "heartbeat"."heartbeat_events_g4_hot_d20260301" USING btree (
"guid" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST,
"ts_ms" "pg_catalog"."int8_ops" ASC NULLS LAST
) TABLESPACE "ts_hot";
CREATE INDEX "heartbeat_events_g4_hot_d2026_hotel_id_room_id_device_id_ts_idx" ON "heartbeat"."heartbeat_events_g4_hot_d20260301" USING btree (
"hotel_id" "pg_catalog"."int2_ops" ASC NULLS LAST,
"room_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST,
"device_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST,
"ts_ms" "pg_catalog"."int8_ops" DESC NULLS FIRST
) WITH (FILLFACTOR = 100) TABLESPACE "ts_hot";
-- ----------------------------
-- Indexes structure for table heartbeat_events_g4_hot_d20260302
-- ----------------------------
CREATE UNIQUE INDEX "heartbeat_events_g4_hot_d20260302_guid_ts_ms_idx" ON "heartbeat"."heartbeat_events_g4_hot_d20260302" USING btree (
"guid" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST,
"ts_ms" "pg_catalog"."int8_ops" ASC NULLS LAST
) TABLESPACE "ts_hot";
CREATE INDEX "heartbeat_events_g4_hot_d2026_hotel_id_room_id_device_id_t_idx1" ON "heartbeat"."heartbeat_events_g4_hot_d20260302" USING btree (
"hotel_id" "pg_catalog"."int2_ops" ASC NULLS LAST,
"room_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST,
"device_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST,
"ts_ms" "pg_catalog"."int8_ops" DESC NULLS FIRST
) WITH (FILLFACTOR = 100) TABLESPACE "ts_hot";
-- ----------------------------
-- Indexes structure for table heartbeat_events_g4_hot
-- ----------------------------
CREATE UNIQUE INDEX "idx_g4_hot_guid_unique" ON "heartbeat"."heartbeat_events_g4_hot" USING btree (
"guid" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST,
"ts_ms" "pg_catalog"."int8_ops" ASC NULLS LAST
) TABLESPACE "ts_hot";
CREATE INDEX "idx_g4_hot_lookup" ON "heartbeat"."heartbeat_events_g4_hot" USING btree (
"hotel_id" "pg_catalog"."int2_ops" ASC NULLS LAST,
"room_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST,
"device_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST,
"ts_ms" "pg_catalog"."int8_ops" DESC NULLS FIRST
) WITH (FILLFACTOR = 100) TABLESPACE "ts_hot";

View File

@@ -1,55 +0,0 @@
-- 通过 docker compose 在容器内执行 psql并使用 here-doc 传入 SQL
docker compose exec -T postgres psql -U log_admin -d log_platform -v ON_ERROR_STOP=1 <<'SQL'
-- 使用匿名代码块批量处理分区创建与索引迁移
DO $$
DECLARE
d date; -- 循环日期(从今天到未来 29 天)
pname text; -- 分区表名,例如 heartbeat_events_20260303
start_ms bigint; -- 分区起始毫秒时间戳UTC
end_ms bigint; -- 分区结束毫秒时间戳UTC不含
idx record; -- 遍历分区索引时的游标记录
BEGIN
-- 生成从 current_date 到 current_date+29 的日期序列(共 30 天)
FOR d IN
SELECT generate_series(current_date, current_date + 29, interval '1 day')::date
LOOP
-- 按约定命名分区名heartbeat_events_YYYYMMDD
pname := format('heartbeat_events_%s', to_char(d, 'YYYYMMDD'));
-- 计算该日期 00:00:00 UTC 的毫秒时间戳作为分区下界
start_ms := (extract(epoch from (d::timestamp at time zone 'UTC')) * 1000)::bigint;
-- 计算下一天 00:00:00 UTC 的毫秒时间戳作为分区上界
end_ms := (extract(epoch from ((d + 1)::timestamp at time zone 'UTC')) * 1000)::bigint;
-- 若分区不存在则创建;存在则跳过(幂等)
EXECUTE format(
'CREATE TABLE IF NOT EXISTS heartbeat.%I PARTITION OF heartbeat.heartbeat_events FOR VALUES FROM (%s) TO (%s) TABLESPACE ts_hot',
pname, start_ms, end_ms
);
-- 无论新建或已存在,强制把分区表迁移到 ts_hot保证热分区落热盘
EXECUTE format('ALTER TABLE heartbeat.%I SET TABLESPACE ts_hot', pname);
-- 遍历该分区的全部索引,筛出不在 ts_hot 的索引
FOR idx IN
SELECT idxn.nspname AS index_schema, i.relname AS index_name
FROM pg_index x
JOIN pg_class t ON t.oid = x.indrelid
JOIN pg_namespace nt ON nt.oid = t.relnamespace
JOIN pg_class i ON i.oid = x.indexrelid
JOIN pg_namespace idxn ON idxn.oid = i.relnamespace
LEFT JOIN pg_tablespace ts ON ts.oid = i.reltablespace
WHERE nt.nspname = 'heartbeat'
AND t.relname = pname
AND COALESCE(ts.spcname, 'pg_default') <> 'ts_hot'
LOOP
-- 将索引迁移到 ts_hot确保“分区与索引同盘”
EXECUTE format('ALTER INDEX %I.%I SET TABLESPACE ts_hot', idx.index_schema, idx.index_name);
END LOOP;
END LOOP;
END $$;
-- here-doc 结束标记
SQL