Data consistency in Rails: Understanding the different between transaction and locking

Tất cả bắt nguồn từ bài post này của bên Grokking VietNam. Đại ý của bài viết như sau:

Một ứng dụng chuyển tiền của ngân hàng có một đoạn code như dưới đây:

def transfer(A, B, amount)
  DB.transaction do
    if A.balance > amount
      A.balance -= amount
      A.save!
      B.balance += amount
      B.save!
    end
  end
end

Ứng dụng chạy một thời gian thì có bug là tài khoản của một vài người dùng bị âm tiền. Vấn đề ở đây là gì và cách giải quyết.

Mới đầu khi đọc bài viết tôi cũng thấy hơi khó hiểu khi đoạn code trên về mặt logic thì không thấy có gì sai cả. Mặc dù không biết tại sao lại bị âm tiền nhưng tôi lại phát hiện một vấn đề khác (có thể cùng một vấn đề của ở trên nhưng do diễn dịch sai) liên quan đến concurency. Tôi (và có thể là các bạn) đã từng nhiều lần viết code giống như kiểu trên và nghĩ rằng không có vấn đề gì xảy ra cả. Tuy nhiên nó lại có vấn đề khá nghiêm trọng và trong bài viết này tôi muốn chỉ ra nó.

Transaction và Locks

Các ứng dụng web có thể được sử dụng bởi nhiều người dùng cùng một lúc. Một application server như Passenger sẽ có nhiều worker process để phục vụ cho một web app, hay thậm chí là có thể có nhiều application server chạy đồng thời nên hệ thống có khả năng xử lý song song nhiều request của người dùng cùng một lúc.

Điều này có nghĩa là ứng dụng của chúng ta cần phải xử lý việc truy cập dữ liệu đồng thời trong hệ thống của mình. Có 2 công cụ chính để thực hiện điều này ở dưới tầng DB là Transaction và *locks. 2 công cụ này không thể thay thế cho nhau. Bạn không thể dùng transaction để thay thế lock cũng như lock không thể dùng để thay thế transaction.

Transaction

Transaction được dùng để đảm bảo một loạt các thay đổi trong DB được diễn ra hoàn toàn hoặc không có gì xảy ra cả. Ví dụ như method transfer ở trên nếu không có transaction như sau:

def transfer(A, B, amount)
  A.balance -= amount
  A.save!
  B.balance += amount
  B.save!
end

Đoạn code ở trên sẽ gặp vấn đề như sau: nếu A save thành công mà B gặp vấn đề nào đó không save được (validation error ...) thì chỉ có balance của A được thay đổi còn B thì không dẫn đến bug mất mát dữ liệu của hệ thống, hệ thống sẽ không còn độ tin cậy nữa.

Khi sử dụng transaction như implement ban đầu, toàn bộ các thao tác trên A và B đều được gộp lại và xử lý cùng một lúc. Tất cả các thay đổi đều được commit trong một tác vụ atomic (single atomic operation) hoặc là tất cả các thay đổi đều bị abort nếu như gặp bất kỳ vấn đề gì. Như ở trên nếu B.save! có lỗi thì thay đổi từ A.save! sẽ bị rollback lại.

Khi nào sử dụng transaction thì hợp lý ?

Bất cứ khi nào bạn thực hiện việc thay đổi mà ảnh hưởng nhiều row trong DB, bạn nên sử dụng transaction để đảm bảo việc thay đổi đó được xử lý đồng thời.

Vấn đề nào sẽ xảy ra khi 2 transaction được chạy cùng một lúc ?

Sẽ không có gì đặc biệt xảy ra khi cùng một transaction được chạy cùng lúc (ví dụ hàm transfer phía trên). Chúng sẽ cùng chạy một lúc và sẽ không có gì đảm bảo về thứ tự thực hiện của chúng. Khi 2 transaction chạy đồng thời thực hiện xong, chúng sẽ cố commit thay đổi của mình vào DB. Việc này có thể thành công hoặc không thành công. Ví dụ nếu cả 2 transaction đều thêm cùng một giá trị vào key unique, transaction nào commit trước sẽ thành công. Transaction chạy sau sẽ gặp lỗi từ DB và toàn bộ thay đổi của transaction đó sẽ bị rollback.

Khi 2 transaction chạy đồng thời trong 2 thread riêng biệt, mỗi thread không thể thấy được transaction của thread khác thay đổi như thế nào trừ khi thay đổi đó được commit vào DB. Như vậy các bạn có thể thắc mắc là tại sao đoạn code của bên Grokking VietNam phía trên vẫn gặp lỗi khi mà nó đã có transaction bao bọc ?

Lí do là transaction không phải là mutex, tức là một thời điểm các transaction vẫn có thể access được vào dữ liệu trong DB và do việc không đảm bảo thứ tự thực hiện của transaction nên sẽ dẫn đến trường hợp dữ liệu của một transaction bị một transaction khác làm thay đổi từ đó gây ra bug.

Ta có thể diễn giải như sau:

Giả sử hàm transfer được chạy cùng một lúc 2 lần, gọi là transfer1 và transfer2. transfer1 sẽ chạy như sau:

#A.balance = 100, amount = 50
if A.balance > amount #A.balance can be used to transfer
  A.balance -= amount
  A.save!
# A.balance is now 50
  B.balance += amount
  B.save!
end

transfer2 lúc này cũng được thực hiện như sau:

#A.balance = 100, amount = 80
if A.balance > amount #transfer1 haven't finished. A.balance is still 100
  A.balance -= amount
  A.save!
# A.balance is now 20
  B.balance += amount
  B.save!
end

Như vậy, vấn đề ở đây là A có thể transfer cho B quá giới hạn balance của A.

Vậy cách giải quyết vấn đề này là như thế nào ? Đơn giản nhất là ta cần một cơ chế đảm bảo là chỉ một thread hoặc process được access vào dữ liệu để thực hiện thao tác trong cùng 1 thời điểm. Để thực hiện điều này thì ta cần một công cụ khác đó là lock.

Lock

Locks được sử dụng để đảm bảo nhiều process hay thread không thể access vào cùng một đối tượng (resource) tại cùng một thời điểm. Có 2 loại lock đó là OptimisticPessimistic.

Optimistic locking

Với loại này, dữ liệu có thể được access bởi nhiều process, tuy nhiên nếu các process đồng thời cập nhật dữ liệu thì sẽ xảy ra conflict, chỉ một process thành công và các process khác sẽ không thực hiện được.

p1 = Person.find(1)
p2 = Person.find(1)

p1.first_name = "Michael"
p1.save

p2.first_name = "should fail"
p2.save # Raises a ActiveRecord::StaleObjectError

Để tạo Optimistic locking, bạn cần tạo một field để làm lock version (lock_version) như sau:

class AddLockingColumns < ActiveRecord::Migration
   def self.up
      add_column :destinations, :lock_version, :integer
   end

   def self.down
      remove_column :destinations, :lock_version
   end
end

Sau khi tao cột này, mọi update trong model đều sẽ khiến giá trị lock_version này tăng thêm 1. Vậy nên, nếu 2 yêu cầu muốn thực hiện trên cùng một row, yêu cầu đầu tiên sẽ thành công do lock_version sẽ giống như khi nó được đọc. Yêu cầu tiếp theo sẽ fail vì lock_version được tăng lên trong DB.

Nếu bạn muốn dùng tên khác để lock thì bạn có thể thiết lập locking_column như sau:

class Destination
   self.locking_column = "my_custom_locking"
end

Optimistic api

Pessimistic locking

Với loại lock này, chỉ process đầu tiên truy cập đến đỗi tượng sẽ có thể cập nhật nó. Các process khác sẽ không được cập nhật và thậm chí là không thể đọc dữ liệu.

Lock có thể được chain với where để có thể lock một tập các row hay gọi trên model object để lock 1 row.

Account.where("name = 'xxx'").lock(true) # Lock các Account có tên là xxx
acc = Account.first
acc.lock #lock account đầu tiên

Việc lock này sẽ chỉ kết thúc khi bạn hoàn thành việc cập nhật:

account = Account.find_by_user_id(5)
account.lock! #no other processes can read this account, they have to wait until the lock is released
account.save! #lock is released, other processs can read this account

Hàm transfer có thể viết lại như sau:

def transfer(A, B, amount)
  A.lock
  B.lock
  DB.transaction do
    if A.balance > amount
      A.balance -= amount
      A.save!
      B.balance += amount
      B.save!
    end
  end
end

Kết luận

Khi các bạn viết ứng dụng, ngoài việc sử dụng transaction thì chúng ta cũng nên quan tâm tới việc locking này. Hi vọng bài viết sẽ là guideline giúp các bạn chú ý hơn khi viết code sau này cũng như trong quá trình debug.

References:

https://kipalog.com/posts/Locking-ActiveRecord-Cua-Rails) https://makandracards.com/makandra/31937-differences-between-transactions-and-locking https://blog.engineyard.com/2011/a-guide-to-optimistic-locking

All Rights Reserved