當一段商業邏輯需要同時修改多筆資料時,我們通常會使用資料庫交易(Transaction),確保操作要不全部生效,要不全部回滾。於是你把一系列操作包進 dataSource.transaction(),直到某次錯誤發生後打開資料庫一看:
「終端機上明明印出了 ROLLBACK,為什麼有些資料狀態卻還是改變了?」
明明兩段程式碼都寫在同一個 transaction callback 裡,為何卻只有部分操作被回滾?這篇我們要拆解的,就是這個「以為在交易裡,其實根本沒在同條船上」的陷阱。
假設我們要做一個發表文章的功能:在新增 Post 的同時,必須一併將作者的發文數量(postCount)加一。為了確保資料庫的一致性,只要文章建立失敗,計數就不應該跟著增加。
首先,建立實體:
// users/user.entity.ts
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column({ default: 0 })
postCount: number;
}
// posts/post.entity.ts
@Entity()
export class Post {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@ManyToOne(() => User, { nullable: false })
author: User;
}
接著,定義接收前端資料的 DTO,並在 Controller 新增端點:
// posts/dto/create-post.dto.ts
export class CreatePostDto {
@IsString()
@IsNotEmpty()
title: string;
@IsInt()
@Min(1)
authorId: number;
}
// posts/posts.controller.ts
@Controller('posts')
@UsePipes(new ValidationPipe())
export class PostsController {
constructor(private readonly postsService: PostsService) {}
@Post()
createPost(@Body() createPostDto: CreatePostDto) {
return this.postsService.createPost(createPostDto);
}
}
將「更新使用者計數」的邏輯交由 UsersService 來處理:
// users/users.service.ts
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly usersRepository: Repository<User>,
) {}
async incrementPostCount(userId: number) {
await this.usersRepository.increment({ id: userId }, 'postCount', 1);
}
}
而在 PostsService 實作真正的發文流程時,我們把所有操作包進 dataSource.transaction 裡。為了觀察交易失敗的情況,我們還特地在流程最後手動拋出一個錯誤,模擬後續邏輯出錯:
// posts/posts.service.ts
@Injectable()
export class PostsService {
constructor(
private readonly dataSource: DataSource,
private readonly usersService: UsersService,
) {}
async createPost(createPostDto: CreatePostDto) {
const { authorId, title } = createPostDto;
await this.dataSource.transaction(async (manager) => {
// 地雷:呼叫外部 Service 時,沒有把交易專屬的 manager 傳下去
await this.usersService.incrementPostCount(authorId);
await manager.save(Post, {
title,
author: { id: authorId },
});
throw new Error('模擬發文後續流程失敗');
});
}
}
發起請求來測試看看:
curl -X POST http://localhost:3000/posts \
-H "Content-Type: application/json" \
-d '{
"title": "第一篇文章",
"authorId": 1
}'
API 跟預期一樣回傳了 500 Internal Server Error,且終端機確實印出了 ROLLBACK 指令,一切看似都在掌控之中。
然而,當我們去資料庫一看,最終的資料竟然出現「不一致」的狀態:
User.postCount 已經加一了,而且更新結果被保留。Post 則因為交易回滾,沒有被建立。明明兩段程式碼都包在同一個 transaction callback 裡,為什麼只有 postCount 成了漏網之魚?
執行 dataSource.transaction() 時,TypeORM 會先向連線池(Connection Pool)索取一條獨立連線來開啟交易,接著把這條連線綁定在一個新建的 EntityManager 上——這正是我們在 callback 參數中收到的那個 manager。
這意味著,後續的資料庫操作都必須透過這個 manager 執行,兩者才能參與同一筆交易。只要整個 callback 順利跑完,TypeORM 就會自動執行 COMMIT;若中間拋出任何例外錯誤,則會觸發 ROLLBACK。
而上面的範例問題就出在 usersService.incrementPostCount() 使用的是預設注入的 this.usersRepository。 這個 Repository 並沒有綁定外層交易所提供的 EntityManager,所以它執行的 SQL 不會自動加入目前這筆交易。
在使用連線池的環境中,這次 UPDATE 可能會落到另一條連線 B 上,並依資料庫的自動提交機制獨立提交。因此,即使後續連線 A 發生 ROLLBACK,也無法撤銷連線 B 已經完成的更新。
在資料庫眼中,這是兩條平行的連線:
| 執行順序 | 連線 A:外層交易連線 | 連線 B:預設 Repository 連線 |
|---|---|---|
| 1 | START TRANSACTION |
|
| 2 | UPDATE user SET postCount = ... |
|
| 3 | 自動提交 | |
| 4 | INSERT INTO post ... |
|
| 5 | 程式拋出錯誤 | |
| 6 | ROLLBACK |
|
| 最終結果 | Post 寫入被回滾 |
postCount 已提交,無法被一併回滾 |
所以這並不是「回滾失效」,而是 User 的更新操作根本不在外層交易涵蓋的連線上。
💡 補充說明:為什麼這類問題在 SQLite 測試中不容易被發現?
TypeORM 的 SQLite Driver 不使用像 PostgreSQL、MySQL 那樣的連線池,而是以單一底層資料庫連線運作,並共用單一QueryRunner。因此,如果本機測試使用 SQLite,而正式環境使用具連線池的資料庫,一些涉及連線分配或交易邊界的問題,可能在 SQLite 測試中無法重現。
EntityManager 明確傳遞下去既然這筆交易是透過 manager 往下傳遞的,解法就是修改 UsersService,讓它可以接收外層傳進來的 manager:
// users/users.service.ts
async incrementPostCount(userId: number, manager?: EntityManager) {
// 有傳 manager 就用綁定該連線的 Repository,沒傳就退回預設注入的 Repository
const repository = manager
? manager.getRepository(User)
: this.usersRepository;
await repository.increment({ id: userId }, 'postCount', 1);
}
接著在 PostsService 呼叫時,把 manager 明確作為參數傳下去:
// posts/posts.service.ts
async createPost(createPostDto: CreatePostDto) {
const { authorId, title } = createPostDto;
await this.dataSource.transaction(async (manager) => {
// 將 manager 傳入
await this.usersService.incrementPostCount(authorId, manager);
await manager.save(Post, {
title,
author: { id: authorId },
});
throw new Error('模擬發文後續流程失敗');
});
}
如此一來,兩個操作就會走在同一條連線上。最終觸發拋錯時,User 的計數更新跟 Post 的新增就會一起被正確回滾。
QueryRunner 的生命週期如果交易流程較長,或需要更細緻的錯誤控制,也可以改用 QueryRunner 手動管理交易。
這種寫法的概念相同,只是控制權全在自己手上,還是必須記得把 queryRunner.manager 傳下去:
async createPost(createPostDto: CreatePostDto) {
const { authorId, title } = createPostDto;
// 取得獨立連線
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
await this.usersService.incrementPostCount(
authorId,
queryRunner.manager, // 傳遞 QueryRunner 專屬的 manager
);
await queryRunner.manager.save(Post, {
title,
author: { id: authorId },
});
throw new Error('模擬發文後續流程失敗');
// 正常流程完成後需要手動提交
// await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
release(),系統會安靜地陷入癱瘓使用 dataSource.transaction() 有個隱藏的好處:TypeORM 會在交易結束時,自動幫你歸還連線。但如果改用手動控制的 QueryRunner,把連線還給連線池就成了開發者的責任。
最怕的是,萬一漏寫了 finally { await queryRunner.release(); },連線洩漏並不會立刻引發系統報錯。這個範例的單次請求仍會因模擬錯誤而回傳 500,但背後還有一條資料庫連線被佔用後沒有歸還。
隨著使用者不斷觸發這個功能,未釋放的連線會越積越多,直到連線池徹底枯竭。這時應用程式未必會馬上崩潰,而是所有需要資料庫的操作都會開始排隊,甚至卡死逾時。這種問題往往極難察覺,因為表面上伺服器還活著,但實際上已經無力處理任何新的資料操作。
因此,只要動用到 QueryRunner,請務必養成好習慣:記得將 release() 寫在 finally 區塊中,確保無論程式是順利結束還是中途拋錯,連線都能被釋放。
EntityManager,才能確保 SQL 執行在同一個交易上下文中。QueryRunner 用完一定要釋放:手動建立 QueryRunner 時,應在 finally 中執行 release(),避免連線未歸還而逐漸耗盡連線池。