Kể từ SQL Server 2005, mẹo sử dụng FOR XML PATH
để không chuẩn hóa các chuỗi và kết hợp chúng thành một danh sách duy nhất (thường được phân tách bằng dấu phẩy) đã rất phổ biến. Tuy nhiên, trong SQL Server 2017, STRING_AGG()
cuối cùng đã trả lời những lời cầu xin lâu dài và phổ biến từ cộng đồng để mô phỏng GROUP_CONCAT()
và chức năng tương tự được tìm thấy trong các nền tảng khác. Gần đây, tôi đã bắt đầu sửa đổi nhiều câu trả lời Stack Overflow của mình bằng phương pháp cũ, vừa để cải thiện mã hiện có vừa để thêm một ví dụ bổ sung phù hợp hơn cho các phiên bản hiện đại.
Tôi hơi kinh hãi với những gì tôi tìm thấy.
Nhiều lần, tôi đã phải kiểm tra lại mã có phải là của mình không.
Một ví dụ nhanh
Hãy xem một minh chứng đơn giản về vấn đề. Ai đó có một bảng như thế này:
CREATE TABLE dbo.FavoriteBands ( UserID int, BandName nvarchar(255) ); INSERT dbo.FavoriteBands ( UserID, BandName ) VALUES (1, N'Pink Floyd'), (1, N'New Order'), (1, N'The Hip'), (2, N'Zamfir'), (2, N'ABBA');
Trên trang hiển thị các ban nhạc yêu thích của mỗi người dùng, họ muốn kết quả đầu ra giống như sau:
UserID Bands ------ --------------------------------------- 1 Pink Floyd, New Order, The Hip 2 Zamfir, ABBA
Trong những ngày SQL Server 2005, tôi đã đưa ra giải pháp này:
SELECT DISTINCT UserID, Bands = (SELECT BandName + ', ' FROM dbo.FavoriteBands WHERE UserID = fb.UserID FOR XML PATH('')) FROM dbo.FavoriteBands AS fb;
Nhưng bây giờ khi nhìn lại đoạn mã này, tôi thấy có nhiều vấn đề mà tôi không thể cố gắng khắc phục.
NHÂN VIÊN
Lỗ hổng nghiêm trọng nhất trong đoạn mã trên là nó để lại dấu phẩy ở cuối:
UserID Bands ------ --------------------------------------- 1 Pink Floyd, New Order, The Hip, 2 Zamfir, ABBA,
Để giải quyết vấn đề này, tôi thường thấy mọi người quấn truy vấn bên trong một truy vấn khác và sau đó bao quanh Bands
đầu ra với LEFT(Bands, LEN(Bands)-1)
. Nhưng đây là tính toán bổ sung không cần thiết; thay vào đó, chúng ta có thể di chuyển dấu phẩy lên đầu chuỗi và xóa một hoặc hai ký tự đầu tiên bằng cách sử dụng STUFF
. Sau đó, chúng tôi không phải tính độ dài của chuỗi vì nó không liên quan.
SELECT DISTINCT UserID, Bands = STUFF( --------------------------------^^^^^^ (SELECT ', ' + BandName --------------^^^^^^ FROM dbo.FavoriteBands WHERE UserID = fb.UserID FOR XML PATH('')), 1, 2, '') --------------------------^^^^^^^^^^^ FROM dbo.FavoriteBands AS fb;
Bạn có thể điều chỉnh thêm điều này nếu bạn đang sử dụng dấu phân cách dài hơn hoặc có điều kiện.
DISTINCT
Vấn đề tiếp theo là việc sử dụng DISTINCT
. Cách mã hoạt động là bảng dẫn xuất tạo danh sách được phân tách bằng dấu phẩy cho mỗi UserID
giá trị, sau đó các bản sao được loại bỏ. Chúng ta có thể thấy điều này bằng cách nhìn vào kế hoạch và thấy toán tử liên quan đến XML thực thi bảy lần, mặc dù cuối cùng chỉ có ba hàng được trả về:
Hình 1:Sơ đồ hiển thị bộ lọc sau khi tổng hợp
Nếu chúng tôi thay đổi mã để sử dụng GROUP BY
thay vì DISTINCT
:
SELECT /* DISTINCT */ UserID, Bands = STUFF( (SELECT ', ' + BandName FROM dbo.FavoriteBands WHERE UserID = fb.UserID FOR XML PATH('')), 1, 2, '') FROM dbo.FavoriteBands AS fb GROUP BY UserID; --^^^^^^^^^^^^^^^
Đó là một sự khác biệt nhỏ và nó không thay đổi kết quả, nhưng chúng ta có thể thấy kế hoạch được cải thiện. Về cơ bản, các hoạt động XML được trì hoãn cho đến khi các bản sao được loại bỏ:
Hình 2:Kế hoạch hiển thị bộ lọc trước khi tổng hợp
Ở quy mô này, sự khác biệt là phi vật chất. Nhưng nếu chúng ta thêm một số dữ liệu nữa thì sao? Trên hệ thống của tôi, điều này thêm một chút hơn 11.000 hàng:
INSERT dbo.FavoriteBands(UserID, BandName) SELECT [object_id], name FROM sys.all_columns;
Nếu chúng tôi chạy lại hai truy vấn, sự khác biệt về thời lượng và CPU sẽ rõ ràng ngay lập tức:
Hình 3:Kết quả thời gian chạy so sánh DISTINCT và GROUP BY
Nhưng các tác dụng phụ khác cũng rõ ràng trong các kế hoạch. Trong trường hợp của DISTINCT
, UDX một lần nữa thực thi cho mọi hàng trong bảng, có một cuộn chỉ mục quá háo hức, có một loại riêng biệt (luôn là cờ đỏ đối với tôi) và truy vấn có mức bộ nhớ cao, điều này có thể gây ảnh hưởng nghiêm trọng đến tính đồng thời :
Hình 4:Kế hoạch DISTINCT theo quy mô
Trong khi đó, trong GROUP BY
truy vấn, UDX chỉ thực thi một lần cho mỗi UserID
duy nhất , ống cuộn háo hức đọc một số lượng hàng thấp hơn nhiều, không có toán tử sắp xếp riêng biệt (nó được thay thế bằng kết hợp băm) và việc cấp bộ nhớ là rất nhỏ so với:
Hình 5:NHÓM THEO kế hoạch theo quy mô
Phải mất một lúc để quay lại và sửa mã cũ như thế này, nhưng hiện tại, tôi rất hài lòng về việc luôn sử dụng GROUP BY
thay vì DISTINCT
.
Tiền tố N
Quá nhiều mẫu mã cũ mà tôi đã xem qua cho rằng không có ký tự Unicode nào được sử dụng hoặc ít nhất là dữ liệu mẫu không gợi ý khả năng. Tôi đưa ra giải pháp của mình như trên và sau đó người dùng sẽ quay lại và nói, “nhưng trên một hàng, tôi có 'просто красный'
và nó quay trở lại dưới dạng '?????? ???????'
! ” Tôi thường nhắc mọi người rằng họ luôn cần đặt tiền tố chuỗi Unicode tiềm năng bằng tiền tố N trừ khi họ hoàn toàn biết rằng họ sẽ chỉ giao dịch với varchar
chuỗi hoặc số nguyên. Tôi bắt đầu tỏ ra rất rõ ràng và thậm chí có thể cẩn thận về điều đó:
SELECT UserID, Bands = STUFF( (SELECT N', ' + BandName --------------^ FROM dbo.FavoriteBands WHERE UserID = fb.UserID FOR XML PATH(N'')), 1, 2, N'') ----------------------^ -----------^ FROM dbo.FavoriteBands AS fb GROUP BY UserID;
Nhập liệu XML
Một "nếu điều gì xảy ra?" tình huống không phải lúc nào cũng xuất hiện trong dữ liệu mẫu của người dùng là các ký tự XML. Ví dụ:điều gì sẽ xảy ra nếu ban nhạc yêu thích của tôi có tên là “Bob & Sheila <> Strawberries
”? Kết quả đầu ra với truy vấn trên được tạo ở dạng XML an toàn, đây không phải là điều chúng tôi luôn muốn (ví dụ:Bob & Sheila <> Strawberries
). Các tìm kiếm của Google vào thời điểm đó sẽ gợi ý rằng “bạn cần thêm TYPE
, ”Và tôi nhớ đã thử một cái gì đó như thế này:
SELECT UserID, Bands = STUFF( (SELECT N', ' + BandName FROM dbo.FavoriteBands WHERE UserID = fb.UserID FOR XML PATH(N''), TYPE), 1, 2, N'') --------------------------^^^^^^ FROM dbo.FavoriteBands AS fb GROUP BY UserID;
Rất tiếc, kiểu dữ liệu đầu ra từ truy vấn con trong trường hợp này là xml
. Điều này dẫn đến thông báo lỗi sau:
Kiểu dữ liệu đối số xml không hợp lệ cho đối số 1 của hàm thứ.
Bạn cần cho SQL Server biết rằng bạn muốn trích xuất giá trị kết quả dưới dạng một chuỗi bằng cách chỉ ra kiểu dữ liệu và rằng bạn muốn phần tử đầu tiên. Hồi đó, tôi sẽ thêm cái này như sau:
SELECT UserID, Bands = STUFF( (SELECT N', ' + BandName FROM dbo.FavoriteBands WHERE UserID = fb.UserID FOR XML PATH(N''), TYPE).value(N'.', N'nvarchar(max)'), --------------------------^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 1, 2, N'') FROM dbo.FavoriteBands AS fb GROUP BY UserID;
Điều này sẽ trả về chuỗi mà không có sự xâm nhập của XML. Nhưng nó có phải là hiệu quả nhất? Năm ngoái, Charlieface đã nhắc tôi Mister Magoo đã thực hiện một số thử nghiệm mở rộng và tìm thấy ./text()[1]
nhanh hơn các phương pháp tiếp cận khác (ngắn hơn) như .
và .[1]
. (Tôi ban đầu nghe thấy điều này từ một bình luận mà Mikael Eriksson đã để lại cho tôi ở đây.) Tôi một lần nữa điều chỉnh mã của mình để trông như thế này:
SELECT UserID, Bands = STUFF( (SELECT N', ' + BandName FROM dbo.FavoriteBands WHERE UserID = fb.UserID FOR XML PATH(N''), TYPE).value(N'./text()[1]', N'nvarchar(max)'), ------------------------------------------^^^^^^^^^^^ 1, 2, N'') FROM dbo.FavoriteBands AS fb GROUP BY UserID;
Bạn có thể nhận thấy việc trích xuất giá trị theo cách này dẫn đến một kế hoạch phức tạp hơn một chút (bạn sẽ không biết nó chỉ khi nhìn vào thời lượng, nó luôn không đổi trong suốt những thay đổi ở trên):
Hình 6:Lập kế hoạch với ./text()[1]
Cảnh báo trên SELECT
gốc toán tử đến từ chuyển đổi rõ ràng thành nvarchar(max)
.
Đặt hàng
Đôi khi, người dùng bày tỏ việc đặt hàng là quan trọng. Thông thường, điều này chỉ đơn giản là sắp xếp theo cột bạn đang thêm — nhưng đôi khi, nó có thể được thêm vào một nơi khác. Mọi người có xu hướng tin rằng nếu họ nhìn thấy một đơn đặt hàng cụ thể xuất phát từ SQL Server một lần, đó là thứ tự mà họ sẽ luôn thấy, nhưng không có độ tin cậy ở đây. Thứ tự không bao giờ được đảm bảo trừ khi bạn nói như vậy. Trong trường hợp này, giả sử chúng tôi muốn đặt hàng qua BandName
theo thứ tự bảng chữ cái. Chúng ta có thể thêm hướng dẫn này vào bên trong truy vấn con:
SELECT UserID, Bands = STUFF( (SELECT N', ' + BandName FROM dbo.FavoriteBands WHERE UserID = fb.UserID ORDER BY BandName ---------^^^^^^^^^^^^^^^^^ FOR XML PATH(N''), TYPE).value(N'./text()[1]', N'nvarchar(max)'), 1, 2, N'') FROM dbo.FavoriteBands AS fb GROUP BY UserID;
Lưu ý rằng điều này có thể kéo dài thêm một chút thời gian thực thi do toán tử sắp xếp bổ sung, tùy thuộc vào việc có chỉ mục hỗ trợ hay không.
STRING_AGG ()
Khi tôi cập nhật các câu trả lời cũ của mình, câu trả lời vẫn sẽ hoạt động trên phiên bản có liên quan tại thời điểm câu hỏi, đoạn mã cuối cùng ở trên (có hoặc không có ORDER BY
) là biểu mẫu bạn có thể sẽ thấy. Nhưng bạn cũng có thể thấy một bản cập nhật bổ sung cho biểu mẫu hiện đại hơn.
STRING_AGG()
được cho là một trong những tính năng tốt nhất được thêm vào SQL Server 2017. Nó vừa đơn giản hơn vừa hiệu quả hơn nhiều so với bất kỳ cách tiếp cận nào ở trên, dẫn đến các truy vấn gọn gàng, hoạt động tốt như sau:
SELECT UserID, Bands = STRING_AGG(BandName, N', ') FROM dbo.FavoriteBands GROUP BY UserID;
Đây không phải là một trò đùa; đó là nó. Đây là kế hoạch — quan trọng nhất, chỉ có một lần quét duy nhất so với bảng:
Hình 7:Kế hoạch STRING_AGG ()
Nếu bạn muốn đặt hàng, hãy STRING_AGG()
cũng hỗ trợ điều này (miễn là bạn ở mức độ tương thích 110 trở lên, như Martin Smith đã chỉ ra ở đây):
SELECT UserID, Bands = STRING_AGG(BandName, N', ') WITHIN GROUP (ORDER BY BandName) ----^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FROM dbo.FavoriteBands GROUP BY UserID;
Kế hoạch có vẻ giống như truy vấn không có sắp xếp, nhưng truy vấn chậm hơn một chút trong các thử nghiệm của tôi. Nó vẫn nhanh hơn bất kỳ FOR XML PATH
nào các biến thể.
Chỉ mục
Một đống là khó công bằng. Nếu bạn có thậm chí cả một chỉ mục không phân biệt mà truy vấn có thể sử dụng, thì kế hoạch này thậm chí còn tốt hơn. Ví dụ:
CREATE INDEX ix_FavoriteBands ON dbo.FavoriteBands(UserID, BandName);
Đây là kế hoạch cho cùng một truy vấn được sắp xếp bằng cách sử dụng STRING_AGG()
—Lưu ý thiếu toán tử sắp xếp, vì quá trình quét có thể được sắp xếp theo thứ tự:
Hình 8:Gói STRING_AGG () có chỉ mục hỗ trợ
Điều này cũng có tác dụng một chút — nhưng công bằng mà nói, chỉ mục này giúp cho FOR XML PATH
các biến thể nữa. Đây là kế hoạch mới cho phiên bản có thứ tự của truy vấn đó:
Hình 9:CHO kế hoạch XML PATH có chỉ mục hỗ trợ
Kế hoạch này thân thiện hơn một chút so với trước đây, bao gồm tìm kiếm thay vì quét tại một điểm, nhưng phương pháp này vẫn chậm hơn đáng kể so với STRING_AGG()
.
Một cảnh báo
Có một mẹo nhỏ để sử dụng STRING_AGG()
trong đó, nếu chuỗi kết quả lớn hơn 8.000 byte, bạn sẽ nhận được thông báo lỗi sau:
Kết quả tổng hợp STRING_AGG vượt quá giới hạn 8000 byte. Sử dụng các loại LOB để tránh bị cắt bớt kết quả.
Để tránh vấn đề này, bạn có thể thực hiện một chuyển đổi rõ ràng:
SELECT UserID, Bands = STRING_AGG(CONVERT(nvarchar(max), BandName), N', ') --------------------------^^^^^^^^^^^^^^^^^^^^^^ FROM dbo.FavoriteBands GROUP BY UserID;
Điều này thêm một hoạt động tính toán vô hướng vào kế hoạch — và một CONVERT
không ngạc nhiên cảnh báo trên gốc SELECT
toán tử — nhưng mặt khác, nó có ít tác động đến hiệu suất.
Kết luận
Nếu bạn đang sử dụng SQL Server 2017+ và bạn có bất kỳ FOR XML PATH
nào tổng hợp chuỗi trong cơ sở mã của bạn, tôi thực sự khuyên bạn nên chuyển sang cách tiếp cận mới. Tôi đã thực hiện một số kiểm tra hiệu suất kỹ lưỡng hơn trong bản xem trước công khai SQL Server 2017 tại đây và tại đây bạn có thể muốn truy cập lại.
Một ý kiến phản đối phổ biến mà tôi đã nghe là mọi người đang sử dụng SQL Server 2017 trở lên nhưng vẫn ở mức tương thích cũ hơn. Có vẻ như sự e ngại là vì STRING_SPLIT()
không hợp lệ ở mức độ tương thích thấp hơn 130, vì vậy họ nghĩ rằng STRING_AGG()
cũng hoạt động theo cách này, nhưng nó khoan dung hơn một chút. Chỉ có vấn đề nếu bạn đang sử dụng WITHIN GROUP
và mức compat thấp hơn 110. Vì vậy, hãy cải thiện!