JPA 複合主鍵重複情境

情境說明

目前 JPA 幾乎是 Hibernate 框架的主流應用,內建 ORM 功能,並支援基礎的 findByIdfindAll 方法。

這週在支援其他案子時遇到一個奇怪的問題:使用 JPA 取資料時,結果出現了數筆重複資料。直覺上會以為是轉換成 DTO 時重複 set 資料導致,但實際觀察發現,有些資料會重複、有些不會。進一步追查才發現問題出在:使用複合主鍵 @Id + @IdClass 的情況下,主鍵值發生了重複。

原因來自 SA 設計資料表時,誤將 txn_time 當作唯一鍵值的一部分,實際資料卻沒有保證唯一性。

在理想情況下,Table 設計中複合主鍵應該構成一組真正的 PRIMARY KEYUNIQUE KEY。然而理想很豐滿,現實很骨感,特別是新舊系統交接、資料遷移(Data Migration)時,常會出現這類不一致問題。


模擬資料

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-- 建立資料表,且不設定 primary key
CREATE TABLE TXN_LOG (
product_name NVARCHAR(255),
txn_time DATETIME NOT NULL,
product_type NVARCHAR(50) NOT NULL,
user_ixd NVARCHAR(50) NOT NULL
);

-- 插入資料(包含重複主鍵值)
INSERT INTO TXN_LOG (product_name, txn_time, product_type, user_ixd) VALUES
(N'信用卡繳費', '2025-04-07 10:00:00', 'PAYMENT', 'user001'),
(N'信用卡繳費', '2025-04-07 10:05:00', 'PAYMENT', 'user002'),
(N'Netflix 訂閱(A)', '2025-04-07 10:10:00', 'SUBSCRIPTION', 'user001'),
(N'Spotify 訂閱', '2025-04-07 10:10:00', 'SUBSCRIPTION', 'user001'),
(N'帳戶轉帳', '2025-04-07 10:20:00', 'TRANSFER', 'user002'),
(N'Netflix 訂閱', '2025-04-07 10:25:00', 'SUBSCRIPTION', 'user003'),
(N'信用卡繳費', '2025-04-07 10:30:00', 'PAYMENT', 'user004'),
(N'Spotify 訂閱', '2025-04-07 10:10:00', 'SUBSCRIPTION', 'user001'),
(N'帳戶轉帳', '2025-04-07 10:40:00', 'TRANSFER', 'user001'),
(N'信用卡繳費', '2025-04-07 10:45:00', 'PAYMENT', 'user003');

Entity 與複合主鍵

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Entity
@Data
@IdClass(TxnLogEntityPk.class)
@Table(name = "TXN_LOG")
public class TxnLogEntity implements Serializable {

@Column(name = "product_name")
private String productName;

@Id
@Column(name = "txn_time")
private LocalDateTime txnTime;

@Id
@Column(name = "product_type")
private String productType;

@Id
@Column(name = "user_ixd")
private String userIxD;
}
1
2
3
4
5
6
7
8
9
10
11
@Data
@AllArgsConstructor
@NoArgsConstructor
public class TxnLogEntityPk implements Serializable {

private LocalDateTime txnTime;

private String productType;

private String userIxD;
}

呼叫 findAll() 取得資料

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
public List<TxnLogRs> getTxnLogList(TxnLogRq rq) {
List<TxnLogRs> rsList = new ArrayList<>();
List<TxnLogEntity> entityList = txnLogRepository.findAll();

for (TxnLogEntity entity : entityList) {
TxnLogRs rs = new TxnLogRs();
rs.setTxnTime(entity.getTxnTime());
rs.setProductType(entity.getProductType());
rs.setUserIxD(entity.getUserIxD());
rs.setProductName(entity.getProductName());
rsList.add(rs);
}

return rsList;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
[
{
"productName": "信用卡繳費",
"txnTime": "2025-04-07T10:00:00",
"productType": "PAYMENT",
"userIxD": "user001"
},
{
"productName": "信用卡繳費",
"txnTime": "2025-04-07T10:05:00",
"productType": "PAYMENT",
"userIxD": "user002"
},
{
"productName": "Netflix 訂閱(A)",
"txnTime": "2025-04-07T10:10:00",
"productType": "SUBSCRIPTION",
"userIxD": "user001"
},
{
"productName": "Netflix 訂閱(A)",
"txnTime": "2025-04-07T10:10:00",
"productType": "SUBSCRIPTION",
"userIxD": "user001"
},
{
"productName": "帳戶轉帳",
"txnTime": "2025-04-07T10:20:00",
"productType": "TRANSFER",
"userIxD": "user002"
},
{
"productName": "Netflix 訂閱",
"txnTime": "2025-04-07T10:25:00",
"productType": "SUBSCRIPTION",
"userIxD": "user003"
},
{
"productName": "信用卡繳費",
"txnTime": "2025-04-07T10:30:00",
"productType": "PAYMENT",
"userIxD": "user004"
},
{
"productName": "Netflix 訂閱(A)",
"txnTime": "2025-04-07T10:10:00",
"productType": "SUBSCRIPTION",
"userIxD": "user001"
},
{
"productName": "帳戶轉帳",
"txnTime": "2025-04-07T10:40:00",
"productType": "TRANSFER",
"userIxD": "user001"
},
{
"productName": "信用卡繳費",
"txnTime": "2025-04-07T10:45:00",
"productType": "PAYMENT",
"userIxD": "user003"
}
]

你會發現重複的主鍵資料,JPA 只會保留第一筆,導致資料丟失或重複。

即使改用 JPQL 或 Native Query?

1
2
3
4
5
@Query("SELECT r FROM TxnLogEntity r")
List<TxnLogEntity> findAllTxnLogEntity();

@Query(value = "SELECT * FROM TXN_LOG", nativeQuery = true)
List<TxnLogEntity> findAllTxnLogEntityWithNative();

依然會有重複問題!

Hibernate/JPA 的底層解析

Hibernate 背後維護一個 Persistence Context(即 Entity 一級快取),裡面用如下結構儲存 Entity:

1
Map<Serializable, Object> entitiesById;

這裡的 key 就是你設定的主鍵(@Id 或 @IdClass),value 是對應的 Entity。

每當 Hibernate 從資料庫取出一筆 row,它會:

  1. 讀取主鍵組合
  2. 檢查是否已經有相同主鍵的 Entity 在快取中
  3. 有的話 -> 跳過 or 覆蓋
  4. 沒有的話 -> 加入 Map

所以如果主鍵欄位值重複,只會保留第一筆或最後一筆。

解法:使用 DTO 接資料

若無法變更資料庫設計(或不能加上真正的主鍵/唯一鍵),最佳解法就是不用 Entity 當回傳型別,而是用自訂的 DTO 或 VO 來承接結果

使用 JPQL constructor expression

1
2
@Query("SELECT new com.william.all_test.repository.dto.TxnLogDto(t.productName, t.txnTime, t.productType, t.userIxD) FROM TxnLogEntity t")
List<TxnLogDto> findAllTxnLogToDto();

接著用這些 DTO 組出回傳資料:

1
2
3
4
5
6
7
8
9
10
List<TxnLogDto> dtoList = txnLogRepository.findAllTxnLogToDto();

for (TxnLogDto dto : dtoList) {
TxnLogRs rs = new TxnLogRs();
rs.setTxnTime(dto.getTxnTime());
rs.setProductType(dto.getProductType());
rs.setUserIxD(dto.getUserIxD());
rs.setProductName(dto.getProductName());
rsList.add(rs);
}

結果就會正確地包含每一筆資料,即使主鍵值重複,也不會被覆蓋掉,因為 JPA 不會把 DTO 當成持久化實體處理,也就沒有主鍵比對與快取問題。


這類問題其實在系統設計初期就可以避免,但實務上在資料遷移、系統整併或接手舊案時,仍很容易踩到坑。

若沒遇過這種狀況,Debug 起來真的會很崩潰,尤其是一直以為是 lambda、轉 DTO、list 重複 add 導致,然後卡在那裏核對快一個小時。

剛好這次遇到,把案例記錄下來。