BulkSynchronize trong EF Core: Đồng Bộ Dữ Liệu Chỉ Với Một Thao Tác

Hàm Mà Ai Cũng Viết, Nhưng Chẳng Ai Thích

Nếu codebase của bạn có một hàm tên dạng SyncProducts(), RefreshMetrics(), hoặc UpdateFromFeed(), có lẽ nó trông gần giống thế này:

// Load existing rows
var existing = await context.Products
.Where(p => p.SupplierId == supplierId)
.ToListAsync();

// Build lookup for diffing
var existingByKey = existing.ToDictionary(p => p.Sku);
var incomingByKey = incoming.ToDictionary(p => p.Sku);

// Classify each incoming row
foreach (var item in incoming)
{
if (existingByKey.TryGetValue(item.Sku, out var current))
{
current.Price = item.Price;
current.Stock = item.Stock;
current.UpdatedAt = DateTime.UtcNow;
}
else
{
context.Products.Add(item);
}
}

// Find rows in DB not in the incoming list
var toDelete = existing.Where(p => !incomingByKey.ContainsKey(p.Sku));
context.Products.RemoveRange(toDelete);

await context.SaveChangesAsync();

Nó trông ổn trong code review. Nó vượt qua unit test. Nhưng nó”m scalability” như que diêm ướt.

Ba vấn đề xuất hiện khi dữ liệu tăng lên. Việc tải các hàng hiện có kéo tất cả vào change tracker, khiến bộ nhớ tăng theo số hàng. Lệnh SaveChanges() tạo ra một loạt các câu lệnh INSERT, UPDATE, và DELETE, nhưng chi phí phát hiện thay đổi của EF Core tăng nhanh hơn cả số lượng hàng. Tệ nhất, logic diff là của bạn để duy trì. Quên trường hợp”có trong DB nhưng không có trong source”, bạn sẽ có một upsert. Quên trường hợp”mới trong source”, bạn sẽ có update-or-skip. Những bug này rất thầm lặng. Bạn phát hiện ra chúng nhiều tuần sau khi báo cáo không khớp với nguồn dữ liệu gốc.

Bài viết này đề cập đến thao tác mà EF Core vẫn chưa cung cấpnative, thao tác mà Entity Framework Extensions (EFE) gọi là BulkSynchronize. Nó gộp insert, update, và delete vào một lệnh gọi duy nhất phía server, và được thiết kế chính xác cho vấn đề đồng bộ mà code trên cố gắng giải quyết bằng tay. EFE là thư viện bulk phổ biến nhất trong .NET, với hơn 50 triệu lượt tải và hơn 5.000 khách hàng trả phí, vì vậy đây là vùng đất đã được khám phá kỹ. Một điều đáng chú ý ngay từ đầu là cách nhánh delete được scope, và bài viết này dành không gian xứng đáng cho nó.

Thao Tác Mà EF Core Không Cung Cho Bạn

EF Core 7 giới thiệu ExecuteUpdate và ExecuteDelete. EF Core 10 tinh chỉnh chúng further. Không ai trong số này giải quyết được vấn đề đồng bộ hóa với danh sách, vì cả hai đều hoạt động từ predicate, không phải từ sự so sánh giữa danh sách trong bộ nhớ và cơ sở dữ liệu.

Điều bạn muốn là điều gì đó像 thế này:

// What you wish existed
await context.Products.ExecuteSynchronizeAsync(incomingProducts);

Nó không tồn tại trong EF Core gốc. Không có ExecuteSynchronize. Không có AddRangeOrUpdateOrDelete. Không có biến thể SaveChanges() nào nhận danh sách nguồn và xác định hàng nào cần thêm, thay đổi, hay xóa. Quan điểm chính thức của EF Core rất rõ ràng: không có phương thức tích hợp sẵn để mirror danh sách vào bảng, và việc tự làm logic sẽ khiến code phức tạp hơn rất nhiều. Mỗi kiểu entity cần đồng bộ đều có phiên bản riêng. Mỗi edge case là của bạn để nhớ.

Phiên bản tự viết ở trên hoạt động. Nó đúng. Nó cũng thuộc loại code mà gần như mọi .NET shop đều đã triển khai, debug, và ngày càng ghét bỏ. Câu hỏi là bạn có tiếp tục trả chi phí bảo trì của nó hay thay thế bằng một lệnh gọi duy nhất.

BulkSynchronize: Một Lệnh Gọi, Ba Thao Tác

BulkSynchronize nhận danh sách nguồn và đối chiếu một bảng database để khớp với nó. Mô hình tư duy rất đơn giản, và đây là điều duy nhất bạn cần lock trong trước khi đọc tiếp:

  • Một hàng có trong danh sách nguồn nhưng không có trong bảng mục tiêu: INSERT
  • Một hàng có trong danh sách nguồn và có trong bảng mục tiêu: UPDATE (nếu giá trị khác nhau)
  • Một hàng có trong bảng mục tiêu nhưng không có trong danh sách nguồn: DELETE

Điểm thứ ba chính là điều làm cho phương thức này mạnh mẽ, và đó là phần cần cấu hình có chủ đích. Danh sách nguồn là trạng thái mong muốn. Bất cứ thứ gì trong scope nhưng thiếu trong danh sách nguồn sẽ bị xóa, đây chính xác là ý nghĩa của”mirror dữ liệu của tôi”. Phần tiếp theo cho bạn thấy cách định nghĩa”trong scope” để delete hoạt động chính xác như bạn muốn.

Đây là cách sử dụng đơn giản nhất:

await context.BulkSynchronizeAsync(incomingProducts);

Một lệnh gọi duy nhất thay thế phương thức diff-and-apply hiển thị ở trên. Nó chạy ngay lập tức. Không cần SaveChanges(). Các hàng được insert, update, và delete trước khi control trả về, và cả ba thao tác xảy ra trong một transaction duy nhất, nên bảng không bao giờ bị đồng bộ nửa chừng. EFE cũng thêm một tấm lưới an toàn im lặng ở đây: danh sách nguồn trống sẽ không kích hoạt sync, điều này ngăn chặn việc xóa nhầm một bảng khi feed upstream trả về rỗng.

Bên dưới, EFE ghi danh sách nguồn vào một bảng staging tạm thời sử dụng cơ chế bulk copy của provider (BCP trên SQL Server, COPY trên PostgreSQL, v.v.). Một câu lệnh MERGE phía server sau đó đối chiếu staging với target. Ba thao tác, một burst phía server, zero hàng nguồn được materialize trong bộ nhớ .NET. Sự diff xảy ra bên trong database, không phải bên trong ứng dụng của bạn.

Câu cuối cùng là nơi nằm lý thuyết về hiệu năng. Phương thức tự viết tải các hàng hiện có vào change tracker để có thể diff với chúng. BulkSynchronize không bao giờ tải chúng. Ở 10K hàng, sự khác biệt là khó chịu. Ở 500K, đó là sự khác biệt giữa một job chạy trong vài giây và một job hết bộ nhớ.

Scope Delete: Tùy Chọn Đáng Biết

Nếu bạn chỉ nhớ duy nhất một điều từ bài viết này, hãy nhớ điều này: hầu hết các lệnh gọi BulkSynchronize trong production đều cần ColumnSynchronizeDeleteKeySubsetExpression.

Khi không có scope nào được cấu hình, BulkSynchronize coi toàn bộ bảng target như sync scope, điều này hoàn toàn đúng khi bảng thực sự nên mirror nguồn (ví dụ: một bảng tham chiếu nhỏ). Khi bảng chứa hàng từ nhiều nguồn, và bạn chỉ muốn sync một lát cắt, subset expression cho EFE biết sync bắt đầu và kết thúc ở đâu.

Đây là trường hợp điển hình. Bạn sync danh mục sản phẩm của một nhà cung cấp vào bảng Products chung:

await context.BulkSynchronizeAsync(supplierProducts, options =>
{
options.ColumnPrimaryKeyExpression = p => p.Sku;
options.ColumnSynchronizeDeleteKeySubsetExpression =
p => new { p.SupplierId };
});

ColumnPrimaryKeyExpression cho EFE biết cách khớp hàng trong nguồn với hàng trong target. ColumnSynchronizeDeleteKeySubsetExpression cho EFE biết hàng nào trong target nằm trong scope của thao tác. Với expression đó, chỉ các hàng thuộc về nhà cung cấp(s) được đại diện trong danh sách nguồn mới được đối chiếu. Sản phẩm của các nhà cung cấp khác trong cùng bảng sẽ không bị ảnh hưởng.

SQL được tạo trên SQL Server trông gần giống thế này:

MERGE INTO [Products] AS target
USING #StagingProducts AS source
ON target.[Sku] = source.[Sku]
WHEN MATCHED THEN UPDATE SET ...
WHEN NOT MATCHED BY TARGET THEN INSERT (...) VALUES (...)
WHEN NOT MATCHED BY SOURCE
AND target.[SupplierId] IN
(SELECT DISTINCT [SupplierId] FROM #StagingProducts)
THEN DELETE;

Câu lệnh AND trên nhánh DELETE chính là thứ scope thao tác. Không có nó, nhánh delete xem xét mọi hàng trong [Products], nên bất kỳ sản phẩm nào không có trong danh sách nhà cung cấp của bạn đều trở thành ứng viên bị xóa. Đây là hành vi đúng cho mirror đầy đủ và hành vi sai cho sync theo từng nhà cung cấp. Subset expression là cách bạn chọn giữa hai cách.

Thói quen giữ an toàn rất đơn giản. Trong quá trình phát triển, gắn tùy chọn Log, chạy sync trên bản sao dữ liệu production, và đọc câu WHEN NOT MATCHED BY SOURCE một lần. Khi nó khớp với ý định của bạn, bạn đã xong, và bạn không bao giờ phải nghĩ đến nó nữa.

Bốn Tình Huống BulkSynchronize Đáng Được Sử Dụng

1. Đồng Bộ Cache Local Từ API Từ Xa

Một job hàng đêm lấy danh mục sản phẩm từ REST API của nhà cung cấp và cập nhật bảng local điều khiển storefront. Danh sách nhà cung cấp là nguồn chính. Sản phẩm mới xuất hiện, giá thay đổi, và sản phẩm ngừng kinh doanh biến mất.

var supplierProducts = await _supplierApi.GetCatalogAsync(supplierId);

var entities = supplierProducts.Select(dto => new Product
{
Sku = dto.Sku,
SupplierId = supplierId,
Name = dto.Name,
Price = dto.Price,
Stock = dto.Stock,
UpdatedAt = DateTime.UtcNow
}).ToList();

await context.BulkSynchronizeAsync(entities, options =>
{
options.ColumnPrimaryKeyExpression = p => p.Sku;
options.ColumnSynchronizeDeleteKeySubsetExpression =
p => new { p.SupplierId };
});

Các listing mới được insert. Thay đổi giá và stock được áp dụng. Sản phẩm mà nhà cung cấp đã bỏ khỏi danh mục bị xóa. Một lệnh gọi. Dữ liệu của các nhà cung cấp khác vẫn nguyên.

2. Làm Mới Bảng Reporting

Bảng DailyMetrics chứa dữ liệu tổng hợp được tính toán từ các nguồn giao dịch. Quá trình tổng hợp chạy hàng đêm và nên thay thế hoàn toàn dữ liệu cho các ngày nó bao phủ mà không làm phiền các báo cáo trước đó.

var freshMetrics = await ComputeMetricsAsync(reportDate);

await context.BulkSynchronizeAsync(freshMetrics, options =>
{
options.ColumnPrimaryKeyExpression =
m => new { m.ReportDate, m.MetricKey };
options.ColumnSynchronizeDeleteKeySubsetExpression =
m => new { m.ReportDate };
});

Chỉ các hàng cho các ngày có trong danh sách nguồn mới được đối chiếu. Các metrics lịch sử cho các ngày trước đó nằm ngoài scope và giữ nguyên vị trí.

3. Mirror Dữ Liệu Tham Chiếu

Bảng Currencies chứa dữ liệu tham chiếu được kéo từ registry trung tâm. Danh sách nhỏ. Toàn bộ bảng nên khớp với nội dung registry.

var registryCurrencies = await _registry.GetAllCurrenciesAsync();

await context.BulkSynchronizeAsync(registryCurrencies, options =>
{
options.ColumnPrimaryKeyExpression = c => c.IsoCode;
});

Không có ColumnSynchronizeDeleteKeySubsetExpression ở đây, vì toàn bộ bảng CHÍNH LÀ scope. Đây là trường hợp điển hình cho mirror đầy đủ. Thêm một comment ngắn để người đọc tiếp theo biết hành vi không scope là có chủ đích.

4. Sync Theo Tenant Trong Ứng Dụng Multi-Tenant

Một ứng dụng SaaS nhận dữ liệu export của một tenant và cần đối chiếu nó với lát cắt của tenant đó trong bảng chung.

await context.BulkSynchronizeAsync(tenantRecords, options =>
{
options.ColumnPrimaryKeyExpression = r => r.ExternalId;
options.ColumnSynchronizeDeleteKeySubsetExpression =
r => new { r.TenantId };
});

Đây là tình huống production phổ biến nhất cho BulkSynchronize. Expression scope trên TenantId chính là thứ giữ mỗi sync của tenant bị giới hạn trong các hàng của tenant đó, đây chính xác là sự cô lập mà hệ thống multi-tenant cần.

Những Tùy Chọn Bạn Sẽ Thực Sự Sử Dụng

EFE cung cấp hàng trăm tùy chọn đã được kiểm tra kỹ trên các phương thức bulk của nó. Với BulkSynchronize, một handful bên dưới bao phủ gần như tất cả những gì dự án thực tế cần:

ColumnPrimaryKeyExpression đặt key được sử dụng để khớp hàng nguồn với hàng target. Mặc định là EF primary key đã cấu hình. Ghi đè khi khớp trên business key (SKU, ExternalId, IsoCode) thay vì database identity.

ColumnSynchronizeDeleteKeySubsetExpression scope thao tác vào một tập con của bảng. Đã được đề cập chi tiết ở trên. Đây là tùy chọn cần thiết lập có chủ đích bất cứ khi nào bảng chứa nhiều hơn lát cắt bạn muốn sync.

OnSynchronizeInsertInputExpression và OnSynchronizeUpdateInputExpression chọn cột mà giai đoạn insert và giai đoạn update lần lượt ghi. Sử dụng chúng khi danh sách nguồn là partial projection (chỉ giá và stock chẳng hạn) và bạn không muốn EFE chạm vào các cột bạn đã không tải.

IgnoreOnSynchronizeInsertExpression và IgnoreOnSynchronizeUpdateNames là inverse: liệt kê các cột cần loại khỏi giai đoạn insert và update, và EFE sẽ ghi tất cả các cột còn lại. Hữu ích khi các cột audit (CreatedAt, ModifiedBy) được quản lý bởi trigger hoặc code ứng dụng, và thao tác bulk không nên ghi đè chúng.

SynchronizeSoftDeleteFormula chuyển nhánh delete thành soft delete. Thay vì xóa vật lý các hàng không có trong danh sách nguồn, EFE chạy SQL bạn cung cấp, ví dụ: đặt IsDeleted = 1. Đây là công cụ đúng khi quy tắc kinh doanh nói rằng archive thay vì erase.

UseAudit ghi lại lịch sử before-and-after đầy đủ cho mọi hàng mà sync insert, update, hoặc xóa vào danh sách bạn cung cấp. Mặc định tắt vì nó tốn SQL thêm, nhưng đây là câu trả lời sạch sẽ khi sync cần trail compliance. Kết hợp với UseRowsAffected để đọc lại chính xác bao nhiêu hàng được insert, update, và xóa từ ResultInfo sau khi lệnh gọi trả về.

BatchSize và BatchTimeout kiểm soát chunking và timeout mỗi batch. Đáng để tuning trên sync rất lớn (một triệu hàng trở lên) và trên database production bận rộn nơi giữ transaction dài là không mong muốn.

Log gắn một delegate bắt SQL được tạo. Sử dụng nó trong quá trình phát triển để xác nhận expression scope đã biên dịch thành những gì bạn mong đợi, sau đó tắt trong production trừ khi bạn muốn audit trail.

Các Con Số

Dự án benchmark đi kèm bài viết này sử dụng BenchmarkDotNet 0.14 trên .NET 10 với SQL Server. Phương thức diff-and-apply tự viết và BulkSynchronize được so sánh trên bảng Products với mười hai thuộc tính, bao gồm các cột string, pricing decimal, datetime audit field, và cột TenantId để scope. Đo lường là thời gian chạy trung bình trên năm lần lặp sau hai lần warm-up. Các số liệu bộ nhớ đến từ MemoryDiagnoser của BenchmarkDotNet. Để tham khảo, EFE công bố con số nhanh hơn gấp 14 lần insert và giảm khoảng 93% thời gian save so với SaveChanges(), và bộ suite dưới đây nhằm xác nhận mẫu đó trong môi trường của riêng bạn.

Sync hỗn hợp (50% insert, 30% update, 20% delete ẩn)

Danh sách nguồn Diff-and-apply tự viết BulkSynchronize (EFE)
1K hàng 131.7 ms 121.6 ms
10K hàng 618.7 ms 286.4 ms
50K hàng 2,990.9 ms 1,382.9 ms
100K hàng 5,979.4 ms 2,733.8 ms
500K hàng 35,699.2 ms 19,156.6 ms

Sync trạng thái ổn định (90% no-op, 10% update)

Đây là profile sync định kỳ thực tế hơn, trong đó hầu hết hàng trong danh sách nguồn không thay đổi. Đây thường là hình dạng của job sync hàng đêm sau khi seed ban đầu.

Danh sách nguồn Diff-and-apply tự viết BulkSynchronize (EFE)
10K hàng 227.8 ms 148.3 ms
50K hàng 1,037.2 ms 294.1 ms
100K hàng 2,304.1 ms 510.6 ms

Khi Nào Dùng BulkSynchronize Và Khi Nào Không

Không phải mọi vấn đề sync đều là vấn đề BulkSynchronize. Hướng dẫn trung thực:

Dùng BulkSynchronize khi danh sách nguồn đại diện cho trạng thái mong muốn của một lát cắt được xác định rõ ràng của một bảng, lát cắt có thể được biểu diễn như key subset (TenantId, SupplierId, ReportDate), và số hàng đủ lớn để lý thuyết về bộ nhớ và hiệu năng có ý nghĩa. Bốn tình huống trên đều phù hợp mẫu này.

Dùng BulkSynchronize không scope khi toàn bộ bảng là scope và bảng nhỏ. Currencies, Countries, ProductCategories, mã trạng thái. Trường hợp mirror đầy đủ rất phổ biến và chính xác là những gì mặc định làm.

Dùng phương thức diff-and-apply tự viết khi số hàng nhỏ (vài trăm hoặc ít hơn), các lần chạy không thường xuyên, và logic kinh doanh mỗi hàng đủ phức tạp để bạn thực sự cần kiểm soát imperative cho mỗi transition. Những trường hợp này tồn tại, và BulkSynchronize không thay thế chúng.

Dùng TRUNCATE cộng BulkInsertOptimized khi bạn có thể thay thế hoàn toàn một bảng, không có ràng buộc FK cần lo, và bạn không quan tâm giữ giá trị identity. Mẫu nuke-and-pave này có thể nhanh hơn BulkSynchronize khi ràng buộc cho phép, vì không có diff cần tính.

Bỏ qua EFE hoàn toàn khi bạn không thể thêm dependency trả phí. Phương thức diff-and-apply tự viết vẫn hoạt động. Kết hợp với AddRange và ExecuteDelete để lấy lại một phần hiệu năng mất mát.

Ghi Chú Production Đáng Biết

Danh sách ngắn các hành vi cần lập kế hoạch, xếp theo tần suất chúng gây ngạc nhiên cho team lần đầu.

Scope Delete Theo Cách Ý Định

BulkSynchronize xóa các hàng nằm trong scope nhưng ngoài danh sách nguồn. ColumnSynchronizeDeleteKeySubsetExpression định nghĩa scope đó; không có nó, scope là toàn bộ bảng. Thiết lập có chủ đích, log SQL một lần, đọc câu WHEN NOT MATCHED BY SOURCE, và hành vi là của bạn để tin tưởng. Nếu quy tắc kinh doanh ưa archive hơn xóa, SynchronizeSoftDeleteFormula chuyển nhánh delete thành soft delete.

Thao Tác Là Atomic; Quy Trình Rộng Hơn Là Của Bạn

BulkSynchronize chạy insert, update, và delete trong một transaction duy nhất, nên bảng không bao giờ bị đồng bộ nửa chừng nếu một giai đoạn thất bại. Lệnh gọi cũng commit ngay lập tức thay vì hoãn đến SaveChanges(). Khi sync là một bước trong unit of work lớn hơn, mở transaction rõ ràng với context.Database.BeginTransactionAsync(), truyền nó qua, và commit khi mọi bước thành công, để failure sau đó không liên quan cũng roll back sync.

Ràng Buộc Foreign Key Vẫn Áp Dụng

Các hàng mà BulkSynchronize xóa chịu các quy tắc FK cấp database. Nếu các bảng con tham chiếu hàng mà sync muốn xóa, thao tác sẽ thất bại hoặc cascade-delete, dựa trên cách FK của bạn được cấu hình. Với các bảng cha được tham chiếu ở nơi khác, thiết lập cascade behavior rõ ràng hoặc sync các dependents trước.

Change Tracker Sẽ Lỗi Thời

Bất kỳ entity nào được tải trong DbContext hiện tại trước khi BulkSynchronize chạy đều đã lỗi thời. Database đã di chuyển. Tracker thì chưa. Gọi context.ChangeTracker.Clear() hoặc refresh các entity bị ảnh hưởng trước khi tiếp tục làm việc với context.

Interceptor Không Kích Hoạt; Dùng Audit Xây Dựng Sẵn

Vì BulkSynchronize bỏ qua change tracker, các triển khai ISaveChangesInterceptor và domain events dựa trên SaveChanges() không chạy. EFE bao phủ lý do phổ biến team dựa vào các hook đó với tùy chọn UseAudit, ghi lại mọi hàng inserted, updated, và deleted. Với domain events, raise chúng rõ ràng sau sync, hoặc chuyển logic đó sang lớp database.

Hành Vi Provider Khác Nhau

BulkSynchronize hoạt động trên SQL Server, Azure SQL, PostgreSQL, SQLite, MySQL, MariaDB, và Oracle. Cơ chế bên dưới khác nhau tùy provider. SQLite không có protocol bulk copy native, nên EFE quay lại batched INSERT cho staging, và lợi thế so với phương thức tự viết nhỏ hơn ở đó so với SQL Server. Benchmark trên provider mục tiêu của bạn.

Giá Trì Identity Trả Về Mặc Định

EFE ghi các giá trị identity do database tạo trở lại các entity trong bộ nhớ của bạn sau sync, sử dụng trung gian temp-table. Khi bạn không cần identity trả về, đặt AutoMapOutputDirection thành false để có đường dẫn nhanh hơn bỏ qua round trip đó.

Nó Có Đáng Với License Không?

BulkSynchronize là một phần của Entity Framework Extensions, thư viện trả phí từ ZZZ Projects, được bảo trì liên tục từ 2014 trên mọi phiên bản EF Core chính. Bản dùng thử miễn phí hàng tháng có sẵn tại entityframework-extensions.net để đánh giá. Quyết định license rất trung thực, và context quyết định.

Nếu team của bạn chạy các job sync định kỳ với nguồn từ xa, làm mới các bảng reporting, hoặc duy trì dữ liệu theo tenant trong ứng dụng multi-tenant, BulkSynchronize là một trong các tính năng EFE lấp đầy khoảng trống thực sự trong EF Core native, bên cạnh InsertFromQuery và BulkMerge. Sự kết hợp của”không có tương đương native” và”phiên bản tự viết nhàm chán và chậm” khiến phép tính license rõ ràng ở khối lượng production.

Nếu team của bạn sync vài bảng tham chiếu nhỏ mỗi tháng một lần, phương thức tự viết có thể chấp nhận được, và chi phí license khó biện minh chỉ với tính năng này.

Nếu codebase của bạn đã phụ thuộc EFE cho BulkInsert, BulkMerge, hoặc InsertFromQuery, BulkSynchronize là incremental win miễn phí. Hãy dùng nó.

Kết Luận

BulkSynchronize gộp một loại code mà gần như mọi .NET team đều đã viết, debug, và ngày càng ghét. Insert, update, và delete trong một thao tác phía server duy nhất, bọc trong một transaction, với bảo vệ danh sách trống và hỗ trợ soft delete tích hợp sẵn. Nó xóa bỏ một chi phí bảo trì thực sự và một nguồn bug tinh vi thực sự.

Nó cũng là phương thức bulk duy nhất thưởng cho một khoảnh khắc cấu hình có chủ đích. Đặt ColumnSynchronizeDeleteKeySubsetExpression để khớp lát cắt bạn muốn sync, log SQL một lần để xác nhận scope, và đặt test xung quanh nó. Làm vậy, BulkSynchronize xứng đáng vị trí như câu trả lời sạch sẽ cho vấn đề EF Core chưa bao giờ giải quyết native.