iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
Modern Web

《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記系列 第 23 篇

Day 23|消失的回滾:ROLLBACK 了資料卻還在?別讓預設的 Repository 偷溜出去

  • 分享至 

  • xImage
  •  

當一段商業邏輯需要同時修改多筆資料時,我們通常會使用資料庫交易(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 區塊中,確保無論程式是順利結束還是中途拋錯,連線都能被釋放。

總結

  1. 交易邊界由連線決定:交易綁定的是資料庫連線,而不是 callback 的程式碼區塊。在 TypeORM 中,必須使用交易提供的 EntityManager,才能確保 SQL 執行在同一個交易上下文中。
  2. SQLite 可能掩蓋交易邊界問題:SQLite 的單連線模型可能讓錯誤的交易上下文傳遞方式看起來仍能正常運作,直到換成 PostgreSQL 或 MySQL 後才暴露問題。
  3. QueryRunner 用完一定要釋放:手動建立 QueryRunner 時,應在 finally 中執行 release(),避免連線未歸還而逐漸耗盡連線池。

參考資料


上一篇
Day 22|消失的筆數:為什麼 TypeORM 做了 JOIN 後,分頁筆數總是對不上?
下一篇
Day 24|脫離掌控的回應:為什麼用了 @Res(),Interceptor 的轉換結果不見了?
系列文
《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言