Database
 sql >> Teknologi Basis Data >  >> RDS >> Database

Hasilkan bilangan bulat acak tanpa tabrakan

Dari waktu ke waktu saya melihat seseorang menyatakan persyaratan untuk membuat nomor acak untuk sebuah kunci. Biasanya ini untuk membuat beberapa jenis ID Pelanggan atau ID Pengguna pengganti yang merupakan nomor unik dalam rentang tertentu, tetapi tidak dikeluarkan secara berurutan, dan karena itu jauh lebih mudah ditebak daripada IDENTITY nilai.

Mari kita kesampingkan, untuk saat ini, apakah seseorang dapat menebak ID Pelanggan atau ID Pengguna sebagian besar tidak relevan – nilai pengganti ini tidak boleh diekspos di luar aplikasi, dan pengguna akhir tidak boleh melakukan sesuatu yang berbeda dengan pengetahuan (atau tebak!) dari ID orang lain. Lagi pula, bahkan jika Anda menghasilkan angka "acak" antara 1 dan 100.000 atau 1 dan 1.000.000, saya masih bisa menebak nilai ID apa pun yang telah dihasilkan, misalnya melalui brute force. Dan jika saya dapat melakukan sesuatu dengan kecocokan yang positif, ada sesuatu yang sangat rusak dengan aplikasi tersebut.

NEWID() memecahkan masalah menebak, tetapi penalti kinerja biasanya merupakan pemecah kesepakatan, terutama ketika dikelompokkan:kunci yang jauh lebih luas daripada bilangan bulat, dan pemisahan halaman karena nilai yang tidak berurutan. NEWSEQUENTIALID() memecahkan masalah pemisahan halaman, tetapi masih merupakan kunci yang sangat lebar, dan memperkenalkan kembali masalah yang dapat Anda tebak nilai berikutnya (atau nilai yang baru-baru ini diterbitkan) dengan beberapa tingkat akurasi.

Akibatnya, mereka menginginkan teknik untuk menghasilkan dan random acak bilangan bulat unik. Menghasilkan angka acak sendiri tidaklah sulit, menggunakan metode seperti RAND() atau CHECKSUM(NEWID()) . Masalah muncul ketika Anda harus mendeteksi tabrakan. Mari kita lihat sekilas pendekatan umum, dengan asumsi kita menginginkan nilai CustomerID antara 1 dan 1.000.000:

DECLARE @rc INT = 0,
  @CustomerID INT = ABS(CHECKSUM(NEWID())) % 1000000 + 1;
              -- or ABS(CONVERT(INT,CRYPT_GEN_RANDOM(3))) % 1000000 + 1;
              -- or CONVERT(INT, RAND() * 1000000) + 1;
WHILE @rc = 0
BEGIN
  IF NOT EXISTS (SELECT 1 FROM dbo.Customers WHERE CustomerID = @CustomerID)
  BEGIN
    INSERT dbo.Customers(CustomerID) SELECT @CustomerID;
    SET @rc = 1; 
  END
  ELSE
  BEGIN
    SELECT @CustomerID = ABS(CHECKSUM(NEWID())) % 1000000 + 1,
      @rc = 0;
  END
END

Saat tabel semakin besar, pemeriksaan duplikat tidak hanya menjadi lebih mahal, tetapi peluang Anda untuk menghasilkan duplikat juga meningkat. Jadi pendekatan ini mungkin tampak bekerja dengan baik ketika tabelnya kecil, tetapi saya menduga bahwa itu pasti akan semakin menyakitkan seiring waktu.

Pendekatan Berbeda

Saya penggemar berat tabel tambahan; Saya telah menulis secara publik tentang tabel kalender dan tabel angka selama satu dekade, dan menggunakannya lebih lama. Dan ini adalah kasus di mana saya pikir tabel yang sudah diisi sebelumnya bisa sangat berguna. Mengapa mengandalkan menghasilkan angka acak saat runtime dan menangani kemungkinan duplikat, ketika Anda dapat mengisi semua nilai tersebut sebelumnya dan mengetahui – dengan kepastian 100%, jika Anda melindungi tabel Anda dari DML yang tidak sah – bahwa nilai berikutnya yang Anda pilih tidak pernah ada digunakan sebelumnya?

CREATE TABLE dbo.RandomNumbers1
(
  RowID INT,
  Value INT, --UNIQUE,
  PRIMARY KEY (RowID, Value)
);
 
;WITH x AS 
(
  SELECT TOP (1000000) s1.[object_id]
  FROM sys.all_objects AS s1
  CROSS JOIN sys.all_objects AS s2
  ORDER BY s1.[object_id]
)
INSERT dbo.RandomNumbers(RowID, Value)
SELECT
    r = ROW_NUMBER() OVER (ORDER BY [object_id]),
    n = ROW_NUMBER() OVER (ORDER BY NEWID())
FROM x
ORDER BY r;

Populasi ini membutuhkan waktu 9 detik untuk dibuat (dalam VM pada laptop), dan menempati sekitar 17 MB pada disk. Data dalam tabel terlihat seperti ini:

(Jika kami khawatir tentang bagaimana angka-angka itu terisi, kami dapat menambahkan batasan unik pada kolom Nilai, yang akan membuat tabel menjadi 30 MB. Jika kami menerapkan kompresi halaman, itu akan menjadi 11 MB atau 25 MB, masing-masing. )

Saya membuat salinan tabel yang lain, dan mengisinya dengan nilai yang sama, sehingga saya dapat menguji dua metode berbeda untuk menurunkan nilai berikutnya:

CREATE TABLE dbo.RandomNumbers2
(
  RowID INT,
  Value INT, -- UNIQUE
  PRIMARY KEY (RowID, Value)
);
 
INSERT dbo.RandomNumbers2(RowID, Value)
  SELECT RowID, Value FROM dbo.RandomNumbers1;

Sekarang, kapan pun kita menginginkan nomor acak baru, kita cukup mengeluarkan satu dari tumpukan nomor yang ada, dan menghapusnya. Ini mencegah kita dari harus khawatir tentang duplikat, dan memungkinkan kita untuk menarik angka – menggunakan indeks berkerumun – yang sebenarnya sudah dalam urutan acak. (Sebenarnya, kita tidak perlu menghapus nomor yang kami gunakan; kita dapat menambahkan kolom untuk menunjukkan apakah suatu nilai telah digunakan – ini akan memudahkan untuk mengembalikan dan menggunakan kembali nilai tersebut jika Pelanggan kemudian dihapus atau terjadi kesalahan di luar transaksi ini tetapi sebelum sepenuhnya dibuat.)

DECLARE @holding TABLE(CustomerID INT);
 
DELETE TOP (1) dbo.RandomNumbers1
OUTPUT deleted.Value INTO @holding;
 
INSERT dbo.Customers(CustomerID, ...other columns...)
  SELECT CustomerID, ...other params...
    FROM @holding;

Saya menggunakan variabel tabel untuk menampung output perantara, karena ada berbagai batasan dengan DML yang dapat dikomposisi sehingga tidak memungkinkan untuk menyisipkan ke tabel Pelanggan langsung dari DELETE (misalnya, keberadaan kunci asing). Namun, mengakui bahwa itu tidak selalu mungkin, saya juga ingin menguji metode ini:

DELETE TOP (1) dbo.RandomNumbers2
  OUTPUT deleted.Value, ...other params...
  INTO dbo.Customers(CustomerID, ...other columns...);

Perhatikan bahwa tidak satu pun dari solusi ini yang benar-benar menjamin urutan acak, terutama jika tabel angka acak memiliki indeks lain (seperti indeks unik pada kolom Nilai). Tidak ada cara untuk menentukan urutan DELETE menggunakan TOP; dari dokumentasi:

Ketika TOP digunakan dengan INSERT, UPDATE, MERGE, atau DELETE, baris yang direferensikan tidak diatur dalam urutan apa pun dan klausa ORDER BY tidak dapat ditentukan secara langsung dalam pernyataan ini. Jika Anda perlu menggunakan TOP untuk menyisipkan, menghapus, atau memodifikasi baris dalam urutan kronologis yang berarti, Anda harus menggunakan TOP bersama dengan klausa ORDER BY yang ditentukan dalam pernyataan subselect.

Jadi, jika Anda ingin menjamin pemesanan acak, Anda bisa melakukan sesuatu seperti ini:

DECLARE @holding TABLE(CustomerID INT);
 
;WITH x AS 
(
  SELECT TOP (1) Value 
  FROM dbo.RandomNumbers2
  ORDER BY RowID
)
DELETE x OUTPUT deleted.Value INTO @holding;
 
INSERT dbo.Customers(CustomerID, ...other columns...)
  SELECT CustomerID, ...other params...
    FROM @holding;

Pertimbangan lain di sini adalah, untuk pengujian ini, tabel Pelanggan memiliki kunci utama yang dikelompokkan pada kolom ID Pelanggan; ini pasti akan menyebabkan pemisahan halaman saat Anda memasukkan nilai acak. Di dunia nyata, jika Anda memiliki persyaratan ini, Anda mungkin akan berakhir mengelompok di kolom yang berbeda.

Perhatikan bahwa saya juga mengabaikan transaksi dan penanganan kesalahan di sini, tetapi ini juga harus menjadi pertimbangan untuk kode produksi.

Pengujian Kinerja

Untuk menggambar beberapa perbandingan kinerja yang realistis, saya membuat lima prosedur tersimpan, yang mewakili skenario berikut (menguji kecepatan, distribusi, dan frekuensi tabrakan dari metode acak yang berbeda, serta kecepatan menggunakan tabel angka acak yang telah ditentukan sebelumnya):

  • Pembuatan waktu proses menggunakan CHECKSUM(NEWID())
  • Pembuatan waktu proses menggunakan CRYPT_GEN_RANDOM()
  • Pembuatan waktu proses menggunakan RAND()
  • Tabel angka yang telah ditentukan sebelumnya dengan variabel tabel
  • Tabel angka standar dengan sisipan langsung

Mereka menggunakan tabel logging untuk melacak durasi dan jumlah tabrakan:

CREATE TABLE dbo.CustomerLog
(
  LogID INT IDENTITY(1,1) PRIMARY KEY, 
  pid INT, 
  collisions INT, 
  duration INT -- microseconds
);

Kode untuk prosedur berikut (klik untuk menampilkan/menyembunyikan):

/* Runtime using CHECKSUM(NEWID()) */
 
CREATE PROCEDURE [dbo].[AddCustomer_Runtime_Checksum]
AS
BEGIN
  SET NOCOUNT ON;
 
  DECLARE 
    @start DATETIME2(7) = SYSDATETIME(), 
    @duration INT,
    @CustomerID INT = ABS(CHECKSUM(NEWID())) % 1000000 + 1,
    @collisions INT = 0,
    @rc INT = 0;
 
  WHILE @rc = 0
  BEGIN
    IF NOT EXISTS
    (
      SELECT 1 FROM dbo.Customers_Runtime_Checksum
        WHERE CustomerID = @CustomerID
    )
    BEGIN
      INSERT dbo.Customers_Runtime_Checksum(CustomerID) SELECT @CustomerID;
      SET @rc = 1; 
    END
    ELSE
    BEGIN
      SELECT 
        @CustomerID = ABS(CHECKSUM(NEWID())) % 1000000 + 1,
        @collisions += 1,
        @rc = 0;
    END
  END
 
  SELECT @duration = DATEDIFF(MICROSECOND, @start, CONVERT(DATETIME2(7),SYSDATETIME()));
 
  INSERT dbo.CustomerLog(pid, collisions, duration) SELECT 1, @collisions, @duration;
END
GO
 
/* runtime using CRYPT_GEN_RANDOM() */
 
CREATE PROCEDURE [dbo].[AddCustomer_Runtime_CryptGen]
AS
BEGIN
  SET NOCOUNT ON;
 
  DECLARE
    @start DATETIME2(7) = SYSDATETIME(), 
    @duration INT,
    @CustomerID INT = ABS(CONVERT(INT,CRYPT_GEN_RANDOM(3))) % 1000000 + 1,
    @collisions INT = 0,
    @rc INT = 0;
 
  WHILE @rc = 0
  BEGIN
    IF NOT EXISTS
    (
      SELECT 1 FROM dbo.Customers_Runtime_CryptGen
        WHERE CustomerID = @CustomerID
    )
    BEGIN
      INSERT dbo.Customers_Runtime_CryptGen(CustomerID) SELECT @CustomerID;
      SET @rc = 1; 
    END
    ELSE
    BEGIN
      SELECT 
        @CustomerID = ABS(CONVERT(INT,CRYPT_GEN_RANDOM(3))) % 1000000 + 1,
        @collisions += 1,
        @rc = 0;
    END
  END
 
  SELECT @duration = DATEDIFF(MICROSECOND, @start, CONVERT(DATETIME2(7),SYSDATETIME()));
 
  INSERT dbo.CustomerLog(pid, collisions, duration) SELECT 2, @collisions, @duration;
END
GO
 
/* runtime using RAND() */
 
CREATE PROCEDURE [dbo].[AddCustomer_Runtime_Rand]
AS
BEGIN
  SET NOCOUNT ON;
 
  DECLARE
    @start DATETIME2(7) = SYSDATETIME(), 
    @duration INT,
    @CustomerID INT = CONVERT(INT, RAND() * 1000000) + 1,
    @collisions INT = 0,
    @rc INT = 0;
 
  WHILE @rc = 0
  BEGIN
    IF NOT EXISTS
    (
      SELECT 1 FROM dbo.Customers_Runtime_Rand
        WHERE CustomerID = @CustomerID
    )
    BEGIN
      INSERT dbo.Customers_Runtime_Rand(CustomerID) SELECT @CustomerID;
      SET @rc = 1; 
    END
    ELSE
    BEGIN
      SELECT 
        @CustomerID = CONVERT(INT, RAND() * 1000000) + 1,
        @collisions += 1,
        @rc = 0;
    END
  END
 
  SELECT @duration = DATEDIFF(MICROSECOND, @start, CONVERT(DATETIME2(7),SYSDATETIME()));
 
  INSERT dbo.CustomerLog(pid, collisions, duration) SELECT 3, @collisions, @duration;
END
GO
 
/* pre-defined using a table variable */
 
CREATE PROCEDURE [dbo].[AddCustomer_Predefined_TableVariable]
AS
BEGIN
  SET NOCOUNT ON;
 
  DECLARE @start DATETIME2(7) = SYSDATETIME(), @duration INT;
 
  DECLARE @holding TABLE(CustomerID INT);
 
  DELETE TOP (1) dbo.RandomNumbers1
    OUTPUT deleted.Value INTO @holding;
 
  INSERT dbo.Customers_Predefined_TableVariable(CustomerID)
    SELECT CustomerID FROM @holding;
 
  SELECT @duration = DATEDIFF(MICROSECOND, @start, CONVERT(DATETIME2(7),SYSDATETIME()));
 
  INSERT dbo.CustomerLog(pid, duration) SELECT 4, @duration;
END
GO
 
/* pre-defined using a direct insert */
 
CREATE PROCEDURE [dbo].[AddCustomer_Predefined_Direct]
AS
BEGIN
  SET NOCOUNT ON;
 
  DECLARE @start DATETIME2(7) = SYSDATETIME(), @duration INT;
 
  DELETE TOP (1) dbo.RandomNumbers2
    OUTPUT deleted.Value INTO dbo.Customers_Predefined_Direct;
 
  SELECT @duration = DATEDIFF(MICROSECOND, @start, CONVERT(DATETIME2(7),SYSDATETIME()));
 
  INSERT dbo.CustomerLog(pid, duration) SELECT 5, @duration;
END
GO

Dan untuk menguji ini, saya akan menjalankan setiap prosedur tersimpan 1.000.000 kali:

EXEC dbo.AddCustomer_Runtime_Checksum;
EXEC dbo.AddCustomer_Runtime_CryptGen;
EXEC dbo.AddCustomer_Runtime_Rand;
EXEC dbo.AddCustomer_Predefined_TableVariable;
EXEC dbo.AddCustomer_Predefined_Direct;
GO 1000000

Tidak mengherankan, metode yang menggunakan tabel angka acak yang telah ditentukan membutuhkan waktu sedikit lebih lama *di awal pengujian*, karena mereka harus melakukan I/O baca dan tulis setiap saat. Ingatlah bahwa angka-angka ini dalam mikrodetik , berikut adalah durasi rata-rata untuk setiap prosedur, pada interval yang berbeda sepanjang proses (rata-rata selama 10.000 eksekusi pertama, 10.000 eksekusi tengah, 10.000 eksekusi terakhir, dan 1.000 eksekusi terakhir):


Durasi rata-rata (dalam mikrodetik) dari pembangkitan acak menggunakan pendekatan yang berbeda

Ini bekerja dengan baik untuk semua metode ketika ada beberapa baris di tabel Pelanggan, tetapi karena tabel semakin besar, biaya pemeriksaan nomor acak baru terhadap data yang ada menggunakan metode runtime meningkat secara substansial, baik karena peningkatan I /O dan juga karena jumlah tabrakan meningkat (memaksa Anda untuk mencoba dan mencoba lagi). Bandingkan durasi rata-rata saat dalam rentang jumlah tabrakan berikut (dan ingat bahwa pola ini hanya memengaruhi metode runtime):


Durasi rata-rata (dalam mikrodetik) selama rentang tumbukan yang berbeda

Saya berharap ada cara sederhana untuk membuat grafik durasi terhadap jumlah tabrakan. Saya akan meninggalkan Anda dengan berita gembira ini:pada tiga sisipan terakhir, metode runtime berikut harus melakukan banyak upaya ini sebelum akhirnya menemukan ID unik terakhir yang mereka cari, dan ini adalah berapa lama waktu yang dibutuhkan:

Jumlah tabrakan Durasi (mikrodetik)
CHECKSUM(NEWID()) baris ketiga hingga terakhir 63.545 639,358
baris ke-2 hingga terakhir 164.807 1.605.695
Baris terakhir 30.630 296.207
CRYPT_GEN_RANDOM() baris ketiga hingga terakhir 219.766 2.229.166
baris ke-2 hingga terakhir 255.463 2.681.468
Baris terakhir 136.342 1.434.725
RAND() baris ketiga hingga terakhir 129.764 1.215.994
baris ke-2 hingga terakhir 220.195 2.088.992
Baris terakhir 440.765 4,161,925

Durasi berlebihan dan tabrakan di dekat akhir baris

Menarik untuk dicatat bahwa baris terakhir tidak selalu menghasilkan jumlah tabrakan tertinggi, jadi ini bisa menjadi masalah nyata jauh sebelum Anda menggunakan 999.000+ nilai.

Pertimbangan lain

Anda mungkin ingin mempertimbangkan untuk menyiapkan semacam peringatan atau pemberitahuan ketika tabel RandomNumbers mulai berada di bawah beberapa baris (pada titik mana Anda dapat mengisi ulang tabel dengan set baru dari 1,000.001 – 2.000.000, misalnya). Anda harus melakukan hal serupa jika Anda membuat angka acak dengan cepat – jika Anda mempertahankannya dalam kisaran 1 – 1.000.000, maka Anda harus mengubah kode untuk menghasilkan angka dari rentang yang berbeda setelah Anda ' telah menggunakan semua nilai itu.

Jika Anda menggunakan nomor acak pada metode runtime, maka Anda dapat menghindari situasi ini dengan terus-menerus mengubah ukuran kumpulan dari mana Anda menggambar nomor acak (yang juga harus menstabilkan dan secara drastis mengurangi jumlah tabrakan). Misalnya, alih-alih:

DECLARE @CustomerID INT = ABS(CHECKSUM(NEWID())) % 1000000 + 1;

Anda dapat mendasarkan kumpulan pada jumlah baris yang sudah ada di tabel:

DECLARE @total INT = 1000000 + ISNULL(
   (SELECT SUM(row_count) FROM sys.dm_db_partition_stats
    WHERE [object_id] = OBJECT_ID('dbo.Customers') AND index_id = 1),0);

Sekarang satu-satunya kekhawatiran Anda adalah ketika Anda mendekati batas atas untuk INT

Catatan:Saya juga baru-baru ini menulis tip tentang ini di MSSQLTips.com.


  1. Database
  2.   
  3. Mysql
  4.   
  5. Oracle
  6.   
  7. Sqlserver
  8.   
  9. PostgreSQL
  10.   
  11. Access
  12.   
  13. SQLite
  14.   
  15. MariaDB
  1. Gambaran Umum Replikasi Streaming untuk TimescaleDB

  2. Bagaimana Rencana Paralel Memulai – Bagian 5

  3. Risiko Saat Menggunakan Memori Dinamis dalam Hyper-V

  4. Model Data Organisasi Pernikahan

  5. Cara Memangkas String di SQL