Sqlserver
 sql >> Teknologi Basis Data >  >> RDS >> Sqlserver

Agregasi String Selama Bertahun-tahun di SQL Server

Sejak SQL Server 2005, trik menggunakan FOR XML PATH untuk mendenormalisasi string dan menggabungkannya menjadi satu (biasanya dipisahkan koma) daftar telah sangat populer. Namun, di SQL Server 2017, STRING_AGG() akhirnya menjawab permintaan lama dan luas dari komunitas untuk mensimulasikan GROUP_CONCAT() dan fungsionalitas serupa yang ditemukan di platform lain. Baru-baru ini saya mulai memodifikasi banyak jawaban Stack Overflow saya menggunakan metode lama, baik untuk meningkatkan kode yang ada maupun untuk menambahkan contoh tambahan yang lebih cocok untuk versi modern.

Saya sedikit terkejut dengan apa yang saya temukan.

Lebih dari satu kali, saya harus memeriksa ulang apakah kode itu milik saya.

Contoh Singkat

Mari kita lihat demonstrasi sederhana dari masalah ini. Seseorang memiliki tabel seperti ini:

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');

Pada halaman yang menampilkan band favorit setiap pengguna, mereka ingin outputnya terlihat seperti ini:

UserID   Bands
------   ---------------------------------------
1        Pink Floyd, New Order, The Hip
2        Zamfir, ABBA

Pada hari-hari SQL Server 2005, saya akan menawarkan solusi ini:

SELECT DISTINCT UserID, Bands = 
      (SELECT BandName + ', '
         FROM dbo.FavoriteBands
         WHERE UserID = fb.UserID
         FOR XML PATH('')) 
FROM dbo.FavoriteBands AS fb;

Tetapi ketika saya melihat kembali kode ini sekarang, saya melihat banyak masalah yang tidak dapat saya hindari untuk diperbaiki.

BARANG

Cacat paling fatal dalam kode di atas adalah meninggalkan koma tambahan:

UserID   Bands
------   ---------------------------------------
1        Pink Floyd, New Order, The Hip, 
2        Zamfir, ABBA, 

Untuk mengatasi ini, saya sering melihat orang membungkus kueri di dalam kueri lain dan kemudian mengelilingi Bands output dengan LEFT(Bands, LEN(Bands)-1) . Tapi ini adalah perhitungan tambahan yang tidak perlu; sebagai gantinya, kita dapat memindahkan koma ke awal string dan menghapus satu atau dua karakter pertama menggunakan STUFF . Kemudian, kita tidak perlu menghitung panjang string karena tidak relevan.

SELECT DISTINCT UserID, Bands = STUFF(
--------------------------------^^^^^^
      (SELECT ', ' + BandName
--------------^^^^^^
         FROM dbo.FavoriteBands
         WHERE UserID = fb.UserID
         FOR XML PATH('')), 1, 2, '')
--------------------------^^^^^^^^^^^
FROM dbo.FavoriteBands AS fb;

Anda dapat menyesuaikan ini lebih lanjut jika Anda menggunakan pembatas yang lebih panjang atau bersyarat.

BERBEDA

Masalah selanjutnya adalah penggunaan DISTINCT . Cara kerja kode adalah tabel turunan menghasilkan daftar yang dipisahkan koma untuk setiap UserID nilai, maka duplikat akan dihapus. Kita dapat melihat ini dengan melihat rencana dan melihat operator terkait XML dijalankan tujuh kali, meskipun hanya tiga baris yang akhirnya dikembalikan:

Gambar 1:Rencana menampilkan filter setelah agregasi

Jika kita mengubah kode untuk menggunakan GROUP BY bukannya 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;
--^^^^^^^^^^^^^^^

Ini adalah perbedaan yang halus, dan itu tidak mengubah hasil, tetapi kita dapat melihat rencananya meningkat. Pada dasarnya, operasi XML ditangguhkan hingga duplikat dihapus:

Gambar 2:Rencana menampilkan filter sebelum agregasi

Pada skala ini, perbedaannya tidak penting. Tetapi bagaimana jika kita menambahkan beberapa data lagi? Di sistem saya, ini menambahkan sedikit lebih dari 11.000 baris:

INSERT dbo.FavoriteBands(UserID, BandName)
  SELECT [object_id], name FROM sys.all_columns;

Jika kita menjalankan kedua query lagi, perbedaan dalam durasi dan CPU akan langsung terlihat:

Gambar 3:Hasil runtime membandingkan DISTINCT dan GROUP BY

Tetapi efek samping lain juga terlihat jelas dalam rencana. Dalam kasus DISTINCT , UDX sekali lagi mengeksekusi untuk setiap baris dalam tabel, ada spool indeks yang terlalu bersemangat, ada jenis yang berbeda (selalu bendera merah untuk saya), dan kueri memiliki hibah memori tinggi, yang dapat membuat penyok serius pada konkurensi :

Gambar 4:Rencana BERBEDA dalam skala

Sementara itu, di GROUP BY kueri, UDX hanya dijalankan sekali untuk setiap UserID yang unik , spool yang bersemangat membaca jumlah baris yang jauh lebih sedikit, tidak ada operator pengurutan yang berbeda (sudah diganti dengan kecocokan hash), dan pemberian memori kecil dibandingkan:

Gambar 5:GROUP BY rencana dalam skala

Perlu beberapa saat untuk kembali dan memperbaiki kode lama seperti ini, tetapi untuk beberapa waktu sekarang, saya sangat ketat untuk selalu menggunakan GROUP BY bukannya DISTINCT .

Awalan N

Terlalu banyak sampel kode lama yang saya temui mengasumsikan tidak ada karakter Unicode yang akan digunakan, atau setidaknya data sampel tidak menunjukkan kemungkinan tersebut. Saya akan menawarkan solusi saya seperti di atas, dan kemudian pengguna akan kembali dan berkata, “tetapi pada satu baris saya memiliki 'просто красный' , dan itu kembali sebagai '?????? ???????' !” Saya sering mengingatkan orang bahwa mereka selalu perlu mengawali literal string Unicode potensial dengan awalan N kecuali mereka benar-benar tahu bahwa mereka hanya akan berurusan dengan varchar string atau bilangan bulat. Saya mulai menjadi sangat eksplisit dan bahkan mungkin terlalu berhati-hati tentang hal itu:

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;

Entitas XML

Lain "bagaimana jika?" skenario yang tidak selalu ada dalam data sampel pengguna adalah karakter XML. Misalnya, bagaimana jika band favorit saya bernama “Bob & Sheila <> Strawberries ”? Keluaran dengan kueri di atas dibuat aman untuk XML, yang tidak selalu kita inginkan (mis., Bob &amp; Sheila &lt;&gt; Strawberries ). Pencarian Google pada saat itu akan menyarankan “Anda perlu menambahkan TYPE ,” dan saya ingat pernah mencoba sesuatu seperti ini:

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;

Sayangnya, tipe data keluaran dari subquery dalam hal ini adalah xml . Ini mengarah ke pesan kesalahan berikut:

Msg 8116, Level 16, State 1
Tipe data argumen xml tidak valid untuk argumen 1 dari fungsi stuff.

Anda perlu memberi tahu SQL Server bahwa Anda ingin mengekstrak nilai yang dihasilkan sebagai string dengan menunjukkan tipe data dan bahwa Anda menginginkan elemen pertama. Saat itu, saya akan menambahkan ini sebagai berikut:

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;

Ini akan mengembalikan string tanpa entitas XML. Tapi apakah itu yang paling efisien? Tahun lalu, Charlieface mengingatkan saya bahwa Tuan Magoo melakukan beberapa pengujian ekstensif dan menemukan ./text()[1] lebih cepat daripada pendekatan lain (lebih pendek) seperti . dan .[1] . (Saya awalnya mendengar ini dari komentar yang ditinggalkan Mikael Eriksson untuk saya di sini.) Saya sekali lagi menyesuaikan kode saya agar terlihat seperti ini:

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;

Anda mungkin mengamati mengekstraksi nilai dengan cara ini mengarah ke rencana yang sedikit lebih kompleks (Anda tidak akan mengetahuinya hanya dari melihat durasi, yang tetap cukup konstan selama perubahan di atas):

Gambar 6:Rencanakan dengan ./text()[1]

Peringatan pada root SELECT operator berasal dari konversi eksplisit ke nvarchar(max) .

Pesan

Kadang-kadang, pengguna akan menyatakan pemesanan itu penting. Seringkali, ini hanya memesan berdasarkan kolom yang Anda tambahkan — tetapi terkadang, itu dapat ditambahkan di tempat lain. Orang cenderung percaya jika mereka melihat urutan tertentu keluar dari SQL Server sekali, itu adalah urutan yang akan selalu mereka lihat, tetapi tidak ada keandalan di sini. Pesanan tidak pernah dijamin kecuali Anda mengatakannya. Dalam hal ini, misalkan kita ingin memesan dengan BandName menurut abjad. Kita dapat menambahkan instruksi ini di dalam subquery:

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;

Perhatikan bahwa ini dapat menambah sedikit waktu eksekusi karena operator pengurutan tambahan, tergantung pada apakah ada indeks pendukung.

STRING_AGG()

Saat saya memperbarui jawaban lama saya, yang seharusnya masih berfungsi pada versi yang relevan pada saat pertanyaan, cuplikan terakhir di atas (dengan atau tanpa ORDER BY ) adalah formulir yang kemungkinan besar akan Anda lihat. Tetapi Anda mungkin juga melihat pembaruan tambahan untuk bentuk yang lebih modern.

STRING_AGG() bisa dibilang salah satu fitur terbaik yang ditambahkan di SQL Server 2017. Ini lebih sederhana dan jauh lebih efisien daripada salah satu pendekatan di atas, yang mengarah ke kueri yang rapi dan berkinerja baik seperti ini:

SELECT UserID, Bands = STRING_AGG(BandName, N', ')
  FROM dbo.FavoriteBands
  GROUP BY UserID;

Ini bukan lelucon; itu dia. Inilah rencananya—yang terpenting, hanya ada satu pemindaian terhadap tabel:

Gambar 7:Paket STRING_AGG()

Jika Anda ingin memesan, STRING_AGG() mendukung ini juga (selama Anda berada di tingkat kompatibilitas 110 atau lebih tinggi, seperti yang ditunjukkan Martin Smith di sini):

SELECT UserID, Bands = STRING_AGG(BandName, N', ')
    WITHIN GROUP (ORDER BY BandName)
----^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  FROM dbo.FavoriteBands
  GROUP BY UserID;

Rencananya tampak sama dengan yang tanpa penyortiran, tetapi kuerinya sedikit lebih lambat dalam pengujian saya. Ini masih jauh lebih cepat daripada FOR XML PATH variasi.

Indeks

Tumpukan hampir tidak adil. Jika Anda bahkan memiliki indeks nonclustered yang dapat digunakan kueri, paketnya terlihat lebih baik. Misalnya:

CREATE INDEX ix_FavoriteBands ON dbo.FavoriteBands(UserID, BandName);

Berikut adalah paket untuk kueri terurut yang sama menggunakan STRING_AGG() —perhatikan kurangnya operator pengurutan, karena pemindaian dapat dipesan:

Gambar 8:STRING_AGG() paket dengan indeks pendukung

Ini juga mengurangi waktu istirahat—tetapi agar adil, indeks ini membantu FOR XML PATH variasi juga. Berikut adalah paket baru untuk versi yang dipesan dari kueri tersebut:

Gambar 9:UNTUK paket XML PATH dengan indeks pendukung

Rencananya sedikit lebih ramah dari sebelumnya, termasuk pencarian alih-alih pemindaian di satu tempat, tetapi pendekatan ini masih jauh lebih lambat daripada STRING_AGG() .

Peringatan

Ada sedikit trik untuk menggunakan STRING_AGG() di mana, jika string yang dihasilkan lebih dari 8.000 byte, Anda akan menerima pesan kesalahan ini:

Pesan 9829, Level 16, Status 1
STRING_AGG hasil agregasi melebihi batas 8000 byte. Gunakan jenis LOB untuk menghindari pemotongan hasil.

Untuk menghindari masalah ini, Anda dapat menyuntikkan konversi eksplisit:

SELECT UserID, 
       Bands = STRING_AGG(CONVERT(nvarchar(max), BandName), N', ')
--------------------------^^^^^^^^^^^^^^^^^^^^^^
  FROM dbo.FavoriteBands
  GROUP BY UserID;

Ini menambahkan operasi skalar komputasi ke rencana—dan CONVERT . yang tidak mengejutkan peringatan pada root SELECT operator—tetapi sebaliknya, ini hanya berdampak kecil pada kinerja.

Kesimpulan

Jika Anda menggunakan SQL Server 2017+ dan Anda memiliki FOR XML PATH agregasi string dalam basis kode Anda, saya sangat menyarankan untuk beralih ke pendekatan baru. Saya melakukan beberapa pengujian kinerja yang lebih menyeluruh selama pratinjau publik SQL Server 2017 di sini dan di sini Anda mungkin ingin mengunjungi kembali.

Keberatan umum yang saya dengar adalah orang-orang menggunakan SQL Server 2017 atau lebih tinggi tetapi masih pada tingkat kompatibilitas yang lebih lama. Tampaknya kekhawatiran itu karena STRING_SPLIT() tidak valid pada tingkat kompatibilitas yang lebih rendah dari 130, jadi menurut mereka STRING_AGG() bekerja dengan cara ini juga, tetapi sedikit lebih lunak. Ini hanya masalah jika Anda menggunakan WITHIN GROUP dan level compat lebih rendah dari 110. Jadi tingkatkan!


  1. Database
  2.   
  3. Mysql
  4.   
  5. Oracle
  6.   
  7. Sqlserver
  8.   
  9. PostgreSQL
  10.   
  11. Access
  12.   
  13. SQLite
  14.   
  15. MariaDB
  1. Cara Mengubah Nomor Urutan Akun Email Database dalam Profil di SQL Server (T-SQL)

  2. Memformat Angka dengan mengisi dengan nol di depan di SQL Server

  3. SQL Server secara diam-diam memotong varchar dalam prosedur tersimpan

  4. Batasi koneksi SQL Server ke alamat IP tertentu

  5. Validasi Email TSQL (tanpa ekspresi reguler)