Bug #1518
是由 陳國瑋 於 4 天 前更新
## 問題摘要
寶台 A17 的 `CP003-005` 實際已停止充電,Charge Point(CP)本機也已將交易 `865` 完成,但停止當下 OCPP WebSocket 斷線,`StopTransaction` 未能送達 Branch。
CP 後續雖然重新連線,但重連時同時補送大量累積的 MeterValues,重要的 `StopTransaction` 沒有在連線再次中斷前送達 Branch。CP 端重試三次後停止補送,導致 Branch 永久保留:
- `e_transactions.transaction_id=865`、`status=ACTIVE`
- `e_connectors.id=CP003-005`、`current_transaction_id=865`
因此車主畫面雖然取得原始 Connector 狀態 `AVAILABLE`、即時電流 `0A`,仍因 Branch 的舊交易證據而投影成「充電中」,只提供「停止充電」。停止操作又因 Connector 並非 `CHARGING` 而被後端拒絕,造成使用者既不能停止舊交易,也無法再次開始充電。
這是交易訊息收斂問題,應與 #1517 的 OCPP 存活判斷/Connector 狀態恢復分開處理。
## 使用者影響
使用者開啟 CP003-005 時會看到:
- 原始狀態:`AVAILABLE/待命`
- driverState:`CHARGING/充電中`
- 即時電流:`0A`
- active operation:無
- 可用操作:只有「停止充電」
2026-09-27 22:35 與 22:50,使用者實際送出兩次停止操作,Branch 均記錄:
```text
transactionId=865
connectorStatus=AVAILABLE
reason=not charging
```
因此正常車主操作流程已被阻斷。
## 事故時間軸
以下時間均為 Asia/Taipei。
| 時間 | 事件 |
| --- | --- |
| 2026-09-25 00:23:44 | CP003-005 開始交易,Branch 配發交易 ID 865。 |
| 2026-09-25 05:11:40 | CP 偵測電流降至 0.05A,判定 EVDisconnected。 |
| 2026-09-25 05:11:42 | CP 本機完成交易 865,停止時間、meter stop、耗電量及 duration 均已保存。 |
| 2026-09-25 05:11:42 | CP 發送 StopTransaction 時失敗,錯誤為 `WebSocket not connected`。 |
| 2026-09-25 05:12:01 | CP 重新建立 OCPP WebSocket。 |
| 2026-09-25 05:12~05:16 | CP 集中補送累積的 MeterValues;Branch 每筆處理最慢約 4.3 秒。此連線至少處理 56 筆 MeterValues。 |
| 2026-09-25 05:13:11 | CP 重試 StopTransaction 865,但 Branch OCPP audit 沒有收到這筆訊息。 |
| 2026-09-25 05:14:01 | WebSocket 因對端未及時回 pong 再次關閉。 |
| 2026-09-25 05:14:12 | StopTransaction 在離線狀態重試失敗。 |
| 2026-09-25 05:16:44 | 第三次失敗,CP 保存 `retry_count=3`、`stop_message_sent=false`,之後即使重連也不再補送。 |
| 2026-09-27 | Branch 交易 865 仍為 ACTIVE,造成車主畫面與停止流程互相矛盾。 |
## 兩端資料差異
事故查核時的資料如下。
### CP 本機
| 欄位 | 值 |
| --- | --- |
| transaction_id | 865 |
| connector_id | CP003-005 |
| status | COMPLETED |
| stop_timestamp | 2026-09-25 05:11:42 |
| meter_start | 2425300 Wh |
| meter_stop | 2458770 Wh |
| energy_consumed | 33470 Wh |
| duration_seconds | 17278 |
| reason | EVDisconnected |
| stop_message_sent | false |
| retry_count | 3 |
| last_retry_timestamp | 2026-09-25 05:16:44 |
CP 的 Connector 為 `AVAILABLE`、`current_transaction_id=NULL`,近期量測為 `0A/0W`,電表總量維持 `2458770 Wh`。
### Branch
| 欄位 | 異常值 |
| --- | --- |
| transaction_id | 865 |
| status | ACTIVE |
| stop_timestamp | NULL |
| meter_stop | NULL |
| reason | NULL |
| connector.current_transaction_id | 865 |
## 根因
本事故包含三個需要一起修正的缺口:
1. **交易訊息沒有優先權**
重連後,StopTransaction 與大量歷史 MeterValues 共用發送流程。MeterValues 塞車時,交易結束訊息無法優先送達。
2. **StopTransaction 重試有上限,但達上限後沒有後續收斂機制**
`retry_count=3` 後即永久停止補送;後續 WebSocket 恢復、BootNotification 成功或 Connector 已為 AVAILABLE,都不會重新處理這筆未送達的完成交易。
3. **Branch 沒有針對「AVAILABLE + 舊 ACTIVE transaction」做安全對帳**
Branch 收到 Connector 的 AVAILABLE 狀態後,只更新 raw status,沒有判斷 CP 本機交易是否已完成,因此保留 current transaction。畫面將舊交易視為仍在充電,而 RemoteStop 又依 raw status 拒絕,形成不可自行恢復的死結。
4. **Branch 在 OCPP 同步回覆路徑重算完整 Connector 指標,MeterValues 被慢查詢阻塞**
Branch 保存每一筆帶 `transactionId` 的 MeterValues 後,會在 transaction commit callback 內同步發布 Connector 更新事件。事件組裝又會重新讀取交易資料、計算最早與最新能源讀值、載入整筆交易的能源歷史並重算預估費用;上述工作全部完成後,Branch 才回覆 OCPP `CALLRESULT`。
A17 的 `e_meter_values` 已有約 169 萬筆資料,但只有分開的 `transaction_id`、`value_type`、`timestamp` 索引。能源差額查詢條件同時使用這三欄,MySQL 卻選擇 `timestamp` 索引。2026-09-27 對進行中的交易 877 執行 `EXPLAIN ANALYZE`,查找最早能源讀值實際掃描 1,691,077 筆索引資料並耗時 4,204 ms;強制使用現有 `transaction_id` 索引後,相同結果約 6.8 ms。這證明主要瓶頸是索引不符合查詢條件,不是 MeterValue insert,也不是 log 中出現在回覆前的 HistoricalTariff 查詢。
現場 CP 又把同一採樣時間的 Current、Voltage、Power、Energy 分成四個 MeterValues request;每一個 request 都重跑相同的 Connector 完整計算。CP003-001 近期四筆 response 分別約 3.753、4.100、3.900、4.012 秒,一個採樣週期合計約 15~16 秒。多 Connector 同時充電或重連補送 backlog 時,會形成 WebSocket head-of-line blocking,連 Heartbeat、StatusNotification、StopTransaction 與 pong 都可能延後。
## 分階段永久修正方案
修正必須依下列順序執行。先消除資料庫主瓶頸,再縮短 OCPP 同步路徑;若先做非同步,只會把四秒慢查詢移到背景 queue,資料庫負擔仍會持續累積。
### 第一階段:新增 MeterValue 複合索引(Branch/CP 均不需重啟)
- 共用 migration 檔案:`java/ems_branch/patch/patch_20260928_redmine_1518_meter_value_lookup_index.sql`。
- 在 `e_meter_values` 新增共用複合索引 `(transaction_id, value_type, timestamp)`,讓最早/最新能源讀值可直接由同一索引頭尾取得。
- 以 `ALGORITHM=INPLACE, LOCK=NONE` 明確要求 MySQL 使用線上 DDL;若該案場版本或資料表無法支援,patch 必須失敗,不可自動退回會長時間鎖表的演算法。
- Patch 必須可重跑:缺少索引才建立、完全相符時 no-op、同名但欄位或順序不符時 fail-fast。
- 套用前確認沒有等待中的 metadata lock、沒有長時間 transaction、磁碟空間足夠;session 設定短 `lock_wait_timeout`,取得不到必要 metadata lock 時應中止,不能等待並阻塞正式流量。
- 套用後以 `information_schema.statistics` 與 `EXPLAIN ANALYZE` 驗證索引及實際掃描筆數,並持續觀察 MeterValues processing time、Heartbeat、WebSocket 與現行交易。
- 這是唯一可直接改善 A17 現況、又不需要重新啟動 Branch 或 CP 的 production 項目;線上 DDL 不等於零負載,仍須按上述 guard 執行。
### 第二階段:將 Connector event 移出 OCPP 同步回覆路徑(需部署並重啟 Branch)
- MeterValues 完成驗證與資料庫 commit 後即可建立 `CALLRESULT`;RabbitMQ、Admin WebSocket snapshot、即時費用等非 OCPP 必要工作改由 commit 後背景流程處理。
- 背景事件只傳遞 `connectorId`、`transactionId`、meter timestamp 等不可變識別資訊,不可把已脫離 transaction 的 JPA entity 直接交給背景執行緒。
- 同一 Connector 的事件必須保序;背景發布失敗需有 retry/metric/告警,不能因改成非同步而靜默遺失。
### 第三階段:即時指標改成索引查詢或增量計算(需部署並重啟 Branch)
- Voltage、Current、Power、累積 Energy 只查各類型最新一筆,不再每次載入整筆交易全部 MeterValue。
- 即時預估費用改為依相鄰能源讀值增量更新,或維護交易摘要;完整歷史重算保留給 StopTransaction 最終結算、月結、人工重算與稽核。
- 最終結算結果必須與現有歷史費率及跨時段規則一致,不能用效能優化改變帳務結果。
### 第四階段:合併 Connector 更新事件(需部署並重啟 Branch)
- 目前程式對每個 `sampledValue` 註冊一次 after-commit event;應改為整個 MeterValues PDU 完成後最多發布一次。
- 對同一 Connector 短時間連續到達的多個 PDU 再做 keyed coalescing/debounce,只發布包含最新資料的 snapshot,並保留最大等待時間,避免管理畫面長時間不更新。
- MeterValue 原始資料仍逐筆保存;合併的是衍生事件,不可丟棄計費與稽核讀值。
### 第五階段:CP 將同一時間點的 measurand 合併為一個 OCPP MeterValues.req(需部署並重啟 CP)
- OCPP 1.6J 的 `MeterValue.sampledValue` 本來就是陣列;同一 timestamp 的 Current、Voltage、Power、Energy 可以放在同一個 `MeterValues.req`。
- 合併後可減少 OCPP request/response、audit insert 與 database transaction 數量;Branch 仍須相容尚未升級、繼續分筆上報的 CP。
- 目前 Branch 即使收到一個含四個 sampledValue 的 PDU,仍會註冊四個 Connector event,因此必須配合第四階段,不能只改 CP 就宣稱事件已減少。
## 現場有進行中交易時的部署界線
| 項目 | 是否需重啟 | 有車充電時的處理 |
| --- | --- | --- |
| 建立、審查、測試 SQL patch | 否 | 可先完成,不影響案場。 |
| A17 線上新增複合索引 | 否 | 可在 guard 通過後執行;需設定短 lock timeout、保留中止條件並監看 OCPP。 |
| Branch 第二~四階段程式開發與離線測試 | 否 | 可先完成程式與測試,但暫不部署到 A17。 |
| 部署第二~四階段 Branch JAR | 是 | 等現行交易結束並安排維護時段。 |
| 部署第五階段 CP JAR | 是 | 等所有受影響 Connector 無交易,再逐 CP 更新與驗收。 |
## 共用 SQL patch 日期與跨案場規則
- `java/ems_branch/patch/` 是所有案場共用的 migration sequence;A17 只是先行驗證案場,不得把 patch 放在 A17 專用的 `documents/sql/`。
- Patch 檔名日期代表 migration 建立/release 排序日期,不是各案場實際執行日期。檔名在合併後保持不變;其他案場日後仍套用同一檔案,不得為每個案場複製並改日期。
- 每次案場部署必須依 release manifest 明確列出 #1517、#1518 所需的完整 patch 檔名,並逐案場記錄已套用檔案;不能只用「最近一次部署日期」推測舊日期 patch 已經執行。
- 本次 release 使用 `patch_20260928_redmine_1517_last_ocpp_message.sql` 與 `patch_20260928_redmine_1518_meter_value_lookup_index.sql`,同日依票號維持執行順序;兩者均須具備首次套用、重跑、partial/incompatible schema guard 及 postcondition query。
- Schema patch 一律先於相依 Branch JAR。A17 先行套用成功不代表其他案場已具備 schema;其他案場部署時仍須逐一執行並保存完整 SQL output。
## 修正目標
### Charge Point
- StartTransaction/StopTransaction 必須優先於歷史 MeterValues 補送。
- WebSocket 未連線時,不應消耗會造成永久放棄的最終重試次數。
- 重新連線並完成 BootNotification 後,重新掃描尚未成功送達的 Start/StopTransaction。
- StopTransaction 的重試必須具備退避、可追蹤狀態及告警,但不能只因固定次數失敗就永久遺失。
- 重送相同 transaction ID 時需維持 idempotent,避免建立重複交易或重複結算。
### Branch
- StopTransaction 重送必須能安全處理;已完成交易收到相同結果時應回覆成功且不得重複計費。
- 當 Connector 為 AVAILABLE,但 `current_transaction_id` 或最新交易仍為 ACTIVE 時,必須辨識為狀態矛盾,不應對車主顯示可執行但必定失敗的「停止充電」。
- 若能取得可信任的 CP runtime transaction,應提供有 guard 的自動收斂流程;至少要產生可監控告警,避免殭屍交易永久存在。
- 收斂動作必須保留 stop time、meter stop、reason、energy、duration 與 audit,不得直接刪除交易。
- 交易收斂不得誤清除真正仍在供電或仍有電流的交易。
## 驗收條件
1. CP 離線期間完成交易,重連後可將 StopTransaction 成功補送至 Branch。
2. 重連時即使有大量 MeterValues backlog,StopTransaction 仍優先處理。
3. WebSocket 反覆斷線超過三次後,未送達的 StopTransaction 不會永久停止處理。
4. Branch 收到重送的 StopTransaction 只完成同一筆交易,不建立重複交易、不重複結算。
5. Branch 最終交易狀態為 COMPLETED,Connector 的 `current_transaction_id` 被清除。
6. Connector 為 AVAILABLE、0A、無現行交易時,driverState 回 READY,提供「開始充電」。
7. Connector 確實仍在充電或仍有電流時,不得因自動對帳誤結束交易。
8. 重連、補送、重試達門檻及自動收斂均有足夠 log/metric/告警可追查。
9. 加入事故回歸案例,涵蓋斷線完成交易、訊息 backlog、重連補送、重複 StopTransaction 與 Branch/CP 狀態收斂。
## 2026-09-28 A17 第一階段線上改善
在 CP003-005 的交易 878 仍為 `ACTIVE/CHARGING` 時,已先完成不需要重啟 Branch 或 CP 的第一階段:
- 正式套用 `patch_20260928_redmine_1518_meter_value_lookup_index.sql`。
- 新增 `idx_mv_transaction_type_timestamp (transaction_id, value_type, timestamp)`。
- DDL 明確使用 `ALGORITHM=INPLACE, LOCK=NONE`,session `lock_wait_timeout=2`。
- 套用前確認 `e_meter_values` 沒有既有或等待中的 metadata lock;無關的 CP2 sleeping transaction 沒有使用或鎖定此表,因此未列為阻擋條件。
- Branch 與 CP3 container 全程未重啟,原 StartedAt 保持不變。
- 交易 878 套用後仍為 ACTIVE、Connector 仍為 CHARGING,CP003 保持 OCPP ONLINE 且 Heartbeat 持續更新。
套用後 `EXPLAIN ANALYZE` 已改用新的複合索引:
| 查詢 | 修正前最差實測 | 修正後實測 |
| --- | ---: | ---: |
| 最新能源讀值 | 依資料分布而異 | 0.371 ms |
| 最早能源讀值 | 4,204 ms;掃描 1,691,077 筆 | 0.169 ms;索引直接定位 1 筆 |
套用後 CP003-005 同一採樣時間的 Current、Voltage、Power、Energy 四筆 MeterValues response 為 17、9、8、9 ms。Patch 重跑結果為 `APPLIED_OR_ALREADY_APPLIED`,索引欄位與順序維持一致。
部署證據保存在 A17:
```text
/opt/ems/deployments/20260928-000111-redmine-1518-online-index/
```
此結果只代表 A17 已完成第一階段。其他案場仍須在各自部署時套用同一個共用 patch;第二~第五階段尚未部署,因此 Branch/CP 重啟仍延後到無現行交易的維護時段。
## 2026-09-27 現場暫時復原
為先恢復 CP003-005 的可用狀態,已依 CP 本機的 COMPLETED 交易資料,對 Branch 進行一次有條件的資料收斂:
- Branch 交易 865:`ACTIVE → COMPLETED`
- 補入 `stop_timestamp=2026-09-25 05:11:42`
- 補入 `meter_stop=2458770`
- 補入 `reason=EVDisconnected`
- 補入 `energy_consumed=33470`
- 補入 `duration_seconds=17278`
- 設定 `stop_message_sent=true`
- 清除 CP003-005 的 `current_transaction_id`
執行前 guard 同時確認:
- CP003 OCPP ONLINE 且近期有 Heartbeat。
- CP003-005 兩端狀態均為 AVAILABLE。
- CP 本機 current transaction 為 NULL。
- 最新電流為 0A、功率為 0W。
- 交易 865 在 CP 本機已完整結束。
- 沒有 SENT/ACCEPTED 的遠端操作。
- 沒有可變動的輪充 queue record。
原始資料備份位於 A17 主機:
```text
/opt/ems/backups/20260927-2307-cp003-005-tx865/
```
這次資料修復只解除現場阻擋,無法防止其他交易再次發生相同問題,仍須完成本票的永久修正。
## 與 #1517 的關係
- #1517:Branch 的 OCPP 存活判斷,以及離線後 Connector 狀態恢復。
- 本票:Start/StopTransaction 在斷線、backlog 與重試耗盡後的可靠補送與跨服務交易收斂。
兩者會在同一段 OCPP 重連流程交會,但資料模型、失敗後果與驗收案例不同,因此獨立追蹤。
返回