在使用 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: '新標題' } 時,需要進行狀態比對:

這意味著:如果資料庫裡 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 |
save() 做的是狀態持久化,不只是組 SQL:更新既有資料時,它會先 SELECT 查出資料庫現狀、進行欄位差異比對,最後才決定執行 INSERT、UPDATE 還是直接跳過。update():如果只是想修改已知 ID 的特定欄位,update() 能直接發送 UPDATE 指令,避開前置查詢的開銷。update() 的 404 處理:update() 不會在查無資料時自動報錯,需要手動透過 UpdateResult.affected === 0 來判斷並拋出例外。save() 依然是首選;但面對高頻 API 與單純欄位變更時,update() 能為資料庫省下不必要的吞吐負擔。