iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
Modern Web

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

Day 20|隱藏的前置查詢:save() 如何在更新時悄悄吞噬你的資料庫效能?

  • 分享至 

  • xImage
  •  

在使用 TypeORM 時,為了圖方便,我們常習慣不論新增或修改,都一律呼叫 save() 來搞定。這種「一招打天下」的寫法雖然順手,卻可能在無意間為資料庫帶來不必要的效能開銷。

明明只是在 Service 寫了一行 save() 想修改文章標題,開了 SQL Log 一看,卻發現 TypeORM 居然先送出了一條沒寫在程式碼裡的 SELECT,接著才執行 UPDATE。

在開發階段,這條悄悄出現的 SELECT 幾乎毫無存在感;但在高流量或 API 頻繁被呼叫的實務場景中,這額外的一趟資料庫往返和連線佔用,累積起來就是效能的隱形成本。

今天這篇文章要拆解的就是:為什麼 save() 在更新既有資料時,會預先執行一次查詢?當我們確定操作只是單純更新時,又該如何避開這個額外的成本?

問題怎麼發生?

假設我們有一個簡單的 Post 實體:

@Entity()
export class Post {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  title: string;
}

接著在 PostsService 注入 Post 的 Repository,並使用 save() 更新指定 id 的文章標題:

@Injectable()
export class PostsService {
  constructor(
    @InjectRepository(Post)
    private readonly postsRepository: Repository<Post>,
  ) {}

  updateTitle(id: number, title: string): Promise<Post> {
    return this.postsRepository.save({ id, title });
  }
}

最後由 PostsController 提供一支更新標題 API:

@Controller('posts')
export class PostsController {
  constructor(private readonly postsService: PostsService) {}

  @Patch(':id')
  updateTitle(
    @Param('id', ParseIntPipe) id: number,
    @Body() body: UpdatePostTitleDto,
  ) {
    return this.postsService.updateTitle(id, body.title);
  }
}

這時候發送請求:

curl -X PATCH http://localhost:3000/posts/1 \
  -H 'Content-Type: application/json' \
  -d '{"title":"新標題"}'

API 雖然順利回傳了結果,但打開 SQL Log 一看,實際跑的 SQL 流程卻長這樣:

SELECT "Post"."id", "Post"."title"
FROM "post" "Post"
WHERE "Post"."id" IN (1);

BEGIN TRANSACTION;

UPDATE "post"
SET "title" = ?
WHERE "id" IN (1);

COMMIT;

明明 Service 裡完全沒寫 findOne(),第一條 SELECT 卻還是莫名其妙出現了。

雖然單次查詢延遲可能只有幾毫秒,但若這條 API 位於高頻寫入的路徑上,這些額外的傳輸與查詢負擔就會隨呼叫量成倍放大。

根因:save() 需要先確認資料目前的狀態

TypeORM 的 save() 設計定位是「狀態持久化(Persistence)」,而不是單純的 SQL 語法封裝。它需要同時包辦兩種情境:

  • 資料不存在 ➔ 執行 INSERT
  • 資料已存在 ➔ 執行 UPDATE

當你傳入 { id: 1, title: '新標題' } 時,需要進行狀態比對:

https://ithelp.ithome.com.tw/upload/images/20261004/20184306y9KbLKu97j.png

這意味著:如果資料庫裡 id 為 1 的標題原本就是 '新標題',你再次執行相同的 save(),TypeORM 查完資料庫發現內容沒有任何變化後,甚至根本不會送出 UPDATE 指令。

這並非失敗,而是 TypeORM 認定「資料庫狀態已是最新,無需重複寫入」。save() 的核心邏輯是先了解實體當前的真實狀態,再決定後續的持久化動作。

排雷指南:更新意圖明確時使用 update()

當業務情境非常明確——「需要直接修改 id 為 1 的文章標題」,完全不需要 TypeORM 幫我們判斷該新增還是更新,也不需要進行欄位差異比對時,直接改用 Repository.update() 會是更精準的做法:

async updateTitle(id: number, title: string) {
  const result = await this.postsRepository.update(id, { title });

  if (result.affected === 0) {
    throw new NotFoundException(`Post ${id} not found`);
  }

  return { id, title };
}

此時 TypeORM 產生的 SQL 就只剩單純的一條:

UPDATE "post"
SET "title" = ?
WHERE "id" IN (1);

update() 的行為非常單純,就是將指令直接傳遞給資料庫執行,不需要先查出這筆資料目前的內容,也不會觸發任何關聯比對。

找不到資料時要自己處理

繞過了前置的 SELECT,代價就是 TypeORM 不會事先幫你確認資料到底在不在。

如果傳入了一個不存在的 ID:

const result = await this.postsRepository.update(999, {
  title: '新標題',
});

資料庫依然會執行這條合法的 SQL,只是影響筆數為 0:

result.affected === 0

它不會自動拋出錯誤。因此,若業務邏輯要求在資料不存在時回傳 HTTP 404,就必須手動檢查 affected:

if (result.affected === 0) {
  throw new NotFoundException(`Post ${id} not found`);
}

我們省下了更新前的查詢開銷,將「確認資料是否存在」的邏輯,改由更新後檢查資料庫影響筆數來完成。

save() 與 update() 怎麼選?

update() 不是效能比較好的 save(),兩者的設計定位不同:

save()(狀態持久化) update()(直接更新)
底層動作 更新既有資料時,可能先 SELECT 並比較差異,再決定 INSERT、UPDATE 或不寫入 直接發送 UPDATE 指令,不預查、不比對
進階功能 支援 Cascade 關聯、Entity Listener 不支援 Cascade 關聯、Entity Listener
回傳與控制 回傳儲存後的實體;查無資料可能轉為新增 回傳 UpdateResult;需透過 affected === 0 自行處理 404
適合情境 需要處理 Cascade 關聯或 Entity Listener 的情境 部分欄位更新 (Partial Update)、高頻 API

總結

  1. save() 做的是狀態持久化,不只是組 SQL:更新既有資料時,它會先 SELECT 查出資料庫現狀、進行欄位差異比對,最後才決定執行 INSERT、UPDATE 還是直接跳過。
  2. 更新意圖明確時,優先使用 update():如果只是想修改已知 ID 的特定欄位,update() 能直接發送 UPDATE 指令,避開前置查詢的開銷。
  3. 注意 update() 的 404 處理:update() 不會在查無資料時自動報錯,需要手動透過 UpdateResult.affected === 0 來判斷並拋出例外。
  4. 依情境挑選工具,不要盲目替換:需要 Cascade 關聯操作或 Entity Listener 時,save() 依然是首選;但面對高頻 API 與單純欄位變更時,update() 能為資料庫省下不必要的吞吐負擔。

參考資料


上一篇
Day 19|遺失的實體:為什麼 TypeORM 找不到 Entity?
下一篇
Day 21|悄悄崩潰的效能:Lazy Loading 如何讓 N+1 藏進屬性存取裡
系列文
《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言