Lanjutan5 menit baca

Menegakkan keunikan pada beberapa atribut di DynamoDB

DynamoDB menjamin keunikan untuk persis satu hal: primary key. Tidak ada constraint UNIQUE (email), tidak ada UNIQUE (username), dan tak ada yang membentang dua atribut. Datang dari SQL, ketiadaan itu adalah kejutan pertama — dan tempat pertama orang diam-diam merilis sebuah race condition.

Bagaimana cara menegakkan constraint unik pada beberapa atribut di DynamoDB?

DynamoDB tidak memiliki constraint UNIQUE selain primary key, sehingga Anda menegakkan keunikan sendiri: modelkan setiap nilai yang dilindungi sebagai marker item tersendiri yang key-nya adalah nilai tersebut, lalu tulis record dan setiap marker bersama dalam satu TransactWriteItems, masing-masing put dijaga oleh attribute_not_exists. Tabrakan yang sudah ditegakkan engine menjadi constraint Anda.

  • Tidak ada constraint unik — hanya primary key yang ditegakkan unik oleh engine. Setiap atribut "harus unik" lainnya adalah tugas Anda.
  • Modelkan setiap aturan keunikan sebagai item-nya sendiri. Sebuah marker item khusus yang key-nya adalah nilai yang Anda lindungi mengubah "apakah email ini sudah dipakai?" menjadi sebuah tabrakan key yang sudah ditegakkan engine.
  • Tulis mereka secara atomik dengan TransactWriteItems. Satu transaksi, setiap put dijaga oleh attribute_not_exists, sehingga semua marker dan record asli ter-commit bersama atau tidak satu pun.
  • Jangan check-then-write. Sebuah read-before-insert adalah race buku teks; dua signup konkuren keduanya membaca "kosong" dan keduanya menulis.

Mengapa pendekatan yang jelas itu salah

Nalurinya adalah me-Query (atau lebih buruk, Scan) untuk email, melihat nihil, lalu PutItem akun baru. Itu adalah race check-then-act.

Dua orang mendaftar ada@lovelace.io pada milidetik yang sama. Kedua pembacaan mengembalikan kosong. Kedua penulisan berhasil. Anda kini punya dua akun pada satu email — dan tak ada di tabel yang menandainya.

Sebuah GSI pada email juga tak menyelamatkan Anda. GSI eventually consistent, jadi pembacaan yang menggerbangi penulisan Anda bisa basi by design. Solusinya bukan pemeriksaan yang lebih cepat; ia membuat penulisannya sendiri menolak mendarat pada nilai yang sudah dipakai.

Modelkan setiap constraint sebagai marker item

Engine sudah menegakkan satu aturan keunikan secara gratis: Anda tak bisa menulis dua item dengan key yang sama. Jadi encode setiap aturan keunikan sebagai sebuah key.

Di samping item akun asli, tulis satu marker item per atribut yang dilindungi. Partition key marker adalah nilai yang ber-namespace. Jika nilainya dipakai, key-nya ada, dan sebuah put yang dijaga tak bisa menimpanya.

Untuk sebuah signup yang harus menjaga email dan username keduanya unik, tiga item bergerak bersama — ber-key dalam tata letak single-table (lihat single-table design):

ItemPKSKTujuan
Record akunACCT#a1f9c3PROFILEAkun asli
Lock emailUNIQ#EMAIL#ada@lovelace.ioLOCKMemesan email
Lock usernameUNIQ#HANDLE#adaLOCKMemesan username

PK akun itu sendiri adalah id yang dihasilkan (ACCT#a1f9c3) — bukan email — sehingga pengguna bisa mengubah email mereka nanti tanpa menulis ulang primary key. Item lock tak membawa data profil; mereka ada semata agar key mereka terisi.

Tulis ketiganya secara atomik

TransactWriteItems menerapkan hingga 100 penulisan sebagai satu unit all-or-nothing. Jaga setiap put dengan attribute_not_exists(PK) agar ia gagal jika key itu sudah hadir.

Jika satu kondisi gagal — lock email, lock handle, atau akunnya sendiri — DynamoDB menggulung balik seluruh transaksi dan melempar TransactionCanceledException. Tak ada signup parsial, tak ada lock yatim.

{
  "TransactItems": [
    {
      "Put": {
        "TableName": "accounts",
        "Item": {
          "PK": {"S": "ACCT#a1f9c3"},
          "SK": {"S": "PROFILE"},
          "email": {"S": "ada@lovelace.io"},
          "username": {"S": "ada"}
        },
        "ConditionExpression": "attribute_not_exists(PK)"
      }
    },
    {
      "Put": {
        "TableName": "accounts",
        "Item": {
          "PK": {"S": "UNIQ#EMAIL#ada@lovelace.io"},
          "SK": {"S": "LOCK"}
        },
        "ConditionExpression": "attribute_not_exists(PK)"
      }
    },
    {
      "Put": {
        "TableName": "accounts",
        "Item": {
          "PK": {"S": "UNIQ#HANDLE#ada"},
          "SK": {"S": "LOCK"}
        },
        "ConditionExpression": "attribute_not_exists(PK)"
      }
    }
  ]
}

Kondisi itu adalah seluruh mekanismenya. Tanpa attribute_not_exists, sebuah signup kedua dengan email yang sama diam-diam menimpa lock pertama. Dengannya, put-nya menolak, transaksinya batal, dan aplikasi Anda memunculkan "email sudah dipakai."

Membangun ConditionExpression dan value map dengan tangan adalah tempat salah ketik menyelinap. DynamoDB Expression Builder mengeluarkan kondisi dan Item bertipe untuk setiap put sehingga Anda bisa menempel sebuah transaksi yang benar langsung ke panggilan SDK Anda.

Baca kegagalannya, jangan menebaknya

Saat transaksi dibatalkan, DynamoDB mengembalikan sebuah array CancellationReasons secara posisional — satu entri per item, dalam urutan permintaan. Sebuah ConditionalCheckFailed di slot 1 berarti email-nya dipakai; slot 2 berarti username-nya. Petakan slot-nya kembali ke sebuah error tingkat-field yang presisi alih-alih sebuah "signup gagal" generik.

Periksa lock di DynoTable

Marker item tak terlihat di UI aplikasi Anda — mereka adalah plumbing. Saat sebuah signup gagal secara misterius, Anda perlu melihat apakah lock-nya benar-benar ada.

Buka tabel di DynoTable dan Query prefix UNIQ#. Akun dan dua item lock-nya duduk bersama, jadi sebuah signup yang macet (sebuah lock yang tertinggal oleh delete yang gagal) jelas pada pandangan sekilas.

DynoTable memindai tabel — item akun diselingi dengan item lock UNIQ#EMAIL dan UNIQ#HANDLE mereka.
DynoTable memindai tabel — item akun diselingi dengan item lock UNIQ#EMAIL dan UNIQ#HANDLE mereka.

Jaga lock tetap jujur saat ubah dan hapus

Lock bukan write-once. Mereka mencerminkan nilai yang hidup, jadi siklus hidupnya harus menjaga mereka tetap sinkron — setiap operasi yang menyentuh atribut yang dilindungi adalah sebuah transaksi juga.

  • Ubah email. Satu transaksi: put lock UNIQ#EMAIL#… baru dengan attribute_not_exists, hapus lock lama, perbarui akunnya. Jaminan all-or-nothing yang sama.
  • Hapus akun. Hapus item akun dan kedua item lock dalam satu transaksi, atau Anda akan menelantarkan sebuah lock yang memblokir nilai itu selamanya.
  • Retry dengan aman. Lewatkan sebuah ClientRequestToken agar sebuah transaksi yang dikirim ulang (setelah sebuah blip jaringan) idempoten alih-alih sebuah penulisan ganda.

Jebakannya adalah memperlakukan lock sebagai fire-and-forget. Sebuah lock yang dibuat saat signup tapi tak pernah dihapus saat penghapusan akun adalah sebuah nilai yang tak seorang pun bisa pakai ulang — dan itu tak akan muncul sampai seorang pengguna asli tak bisa mengklaim handle lamanya sendiri.

Langkah berikutnya

Marker keunikan adalah pola single-table, jadi mereka duduk secara alami di samping item lain Anda — baca single-table design untuk tata letak key-nya, dan Query vs Scan agar Anda tak pernah menjangkau sebuah Scan untuk memeriksa sebuah lock. Polanya pertama kali ditelusuri dalam sesi AWS re:Invent / AWS Summit 2018 DAT374 — DynamoDB Transactions.

Draf put yang dijaga-kondisi dengan DynamoDB Expression Builder, lalu coba DynoTable untuk memeriksa item lock terhadap tabel Anda sendiri.

Diperbarui