Computer >> คอมพิวเตอร์ >  >> การเขียนโปรแกรม >> Ruby

การใช้ ActiveRecords #update_counters เพื่อป้องกันสภาพการแข่งขัน

Rails เป็นเฟรมเวิร์กขนาดใหญ่พร้อมเครื่องมือที่มีประโยชน์มากมายในตัวสำหรับสถานการณ์เฉพาะ ในชุดนี้ เรากำลังดูเครื่องมือที่ไม่ค่อยมีใครรู้จักซึ่งซ่อนอยู่ในฐานโค้ดขนาดใหญ่ของ Rails

ในบทความนี้ในซีรีส์ เราจะมาดู update_counters ของ ActiveRecord กระบวนการ. ในกระบวนการ เราจะดูกับดักทั่วไปของ "สภาวะการแข่งขัน" ในโปรแกรมแบบมัลติเธรดและวิธีที่วิธีนี้สามารถป้องกันได้

กระทู้

เมื่อตั้งโปรแกรม เรามีหลายวิธีในการรันโค้ดแบบขนาน รวมถึงกระบวนการ เธรด และล่าสุด (ใน Ruby) ไฟเบอร์ และเครื่องปฏิกรณ์ ในบทความนี้ เราจะพูดถึงแต่เธรดเท่านั้น เนื่องจากเป็นรูปแบบทั่วไปที่นักพัฒนา Rails จะพบเจอ ตัวอย่างเช่น Puma เป็นเซิร์ฟเวอร์แบบมัลติเธรด และ Sidekiq เป็นตัวประมวลผลงานพื้นหลังแบบมัลติเธรด

เราจะไม่เจาะลึกเข้าไปในเธรดและความปลอดภัยของเธรดที่นี่ สิ่งสำคัญที่ควรทราบคือ เมื่อสองเธรดทำงานบนข้อมูลเดียวกัน ข้อมูลจะหลุดออกจากการซิงค์ได้อย่างง่ายดาย นี่คือสิ่งที่เรียกว่า "สภาพการแข่งขัน"

สภาพการแข่งขัน

สภาวะการแย่งชิงเกิดขึ้นเมื่อสองเธรด (หรือมากกว่า) ทำงานบนข้อมูลเดียวกันในเวลาเดียวกัน ซึ่งหมายความว่าเธรดอาจจบลงด้วยการใช้ข้อมูลเก่า เรียกว่า "สภาวะการแข่งขัน" เพราะเหมือนกับว่าทั้งสองเธรดกำลังแข่งกัน และสถานะสุดท้ายของข้อมูลอาจแตกต่างกันไปขึ้นอยู่กับว่าเธรดใด "ชนะการแข่งขัน" บางทีที่แย่ที่สุดคือ สภาพการแข่งขันเป็นเรื่องยากมากที่จะทำซ้ำ เนื่องจากปกติแล้วจะเกิดขึ้นก็ต่อเมื่อเธรด "ผลัดกัน" ในลำดับเฉพาะและ ณ จุดใดจุดหนึ่งในโค้ด

ตัวอย่าง

สถานการณ์ทั่วไปที่ใช้เพื่อแสดงสภาพการแข่งขันคือการอัปเดตยอดเงินในธนาคาร เราจะสร้างคลาสทดสอบอย่างง่ายภายในแอปพลิเคชัน Rails พื้นฐาน เพื่อให้เราเห็นว่าเกิดอะไรขึ้น:

class UnsafeTransaction
  def self.run
    account = Account.find(1)
    account.update!(balance: 0)

    threads = []
    4.times do
      threads << Thread.new do
        balance = account.reload.balance
        account.update!(balance: balance + 100)

        balance = account.reload.balance
        account.update!(balance: balance - 100)
      end
    end

    threads.map(&:join)

    account.reload.balance
  end
end

UnsafeTransactionของเรา ค่อนข้างเรียบง่าย เรามีวิธีเดียวที่จะค้นหา Account (โมเดล Rails มาตรฐานหุ้นที่มี BigDecimal balance คุณลักษณะ). เรารีเซ็ตยอดคงเหลือเป็นศูนย์เพื่อให้การทดสอบซ้ำง่ายขึ้น

วงในคือสิ่งที่น่าสนใจขึ้นเล็กน้อย เรากำลังสร้างสี่เธรดที่จะดึงยอดเงินปัจจุบันของบัญชี เพิ่ม 100 เข้าไป (เช่น เงินฝาก 100 ดอลลาร์) จากนั้นลบ 100 ดอลลาร์ทันที (เช่น ถอน 100 ดอลลาร์) เรายังใช้ reload ทั้งสองครั้งเพื่อ พิเศษ ตรวจสอบให้แน่ใจว่าเรามียอดคงเหลือที่เป็นปัจจุบัน

บรรทัดที่เหลือเป็นเพียงบางส่วนที่เป็นระเบียบเรียบร้อย Thread.join หมายความว่าเราจะรอให้เธรดทั้งหมดยุติลงก่อนที่จะดำเนินการต่อ จากนั้นเราจะคืนยอดคงเหลือสุดท้ายเมื่อสิ้นสุดเมธอด

หากเรารันสิ่งนี้ด้วยเธรดเดียว (โดยเปลี่ยนลูปเป็น 1.times do ) เราสามารถทำได้อย่างมีความสุขนับล้านครั้งและต้องแน่ใจว่ายอดเงินในบัญชีสุดท้ายจะเป็นศูนย์เสมอ เปลี่ยนเป็นสองเธรด (หรือมากกว่า) และสิ่งต่าง ๆ ก็ไม่แน่นอน

การรันการทดสอบของเราครั้งเดียวในคอนโซลอาจจะให้คำตอบที่ถูกต้องแก่เรา:

UnsafeTransaction.run
=> 0.0

แต่ถ้าเราวิ่งซ้ำแล้วซ้ำเล่า สมมติว่าเราวิ่งสิบครั้ง:

(1..10).map { UnsafeTransaction.run }.map(&:to_f)
=> [0.0, 300.0, 300.0, 100.0, 100.0, 100.0, 300.0, 300.0, 100.0, 300.0]

ในกรณีที่ไม่คุ้นเคยไวยากรณ์ที่นี่ (1..10).map {} จะรันโค้ดในบล็อก 10 ครั้ง โดยผลลัพธ์จากการรันแต่ละครั้งจะใส่ลงในอาร์เรย์ .map(&:to_f) ในตอนท้ายเป็นเพียงการทำให้ตัวเลขอ่านง่ายยิ่งขึ้น เนื่องจากโดยปกติแล้วค่า BigDecimal จะถูกพิมพ์ในรูปแบบเลขชี้กำลัง เช่น 0.1e3 .

โปรดจำไว้ว่า รหัสของเราใช้ยอดเงินปัจจุบัน บวก 100 แล้วลบ 100 ทันที ดังนั้นผลลัพธ์สุดท้าย ควร เป็น 0.0 . เสมอ . 100.0เหล่านี้ และ 300.0 ผลงานที่เป็นเครื่องพิสูจน์ว่าเรามีสภาพการแข่งขัน

ตัวอย่างที่มีคำอธิบายประกอบ

มาขยายรหัสปัญหาที่นี่และดูว่าเกิดอะไรขึ้น เราจะแยกการเปลี่ยนแปลงเป็น balance เพื่อความชัดเจนยิ่งขึ้น

threads << Thread.new do
  # Thread could be switching here
  balance = account.reload.balance
  # or here...
  balance += 100
  # or here...
  account.update!(balance: balance)
  # or here...

  balance = account.reload.balance
  # or here...
  balance -= 100
  # or here...
  account.update!(balance: balance)
  # or here...
end

ตามที่เราเห็นในความคิดเห็น เธรดสามารถสลับกันได้เกือบทุกจุดในโค้ดนี้ หาก Thread 1 อ่านยอดคงเหลือ แสดงว่าคอมพิวเตอร์เริ่มดำเนินการ Thread 2 ดังนั้นจึงค่อนข้างเป็นไปได้ที่ข้อมูลจะล้าสมัยเมื่อถึงเวลาเรียก update! . กล่าวอีกนัยหนึ่ง เธรด 1 เธรด 2 และฐานข้อมูล ล้วนมีข้อมูลอยู่ในนั้น แต่ข้อมูลเหล่านี้ไม่ซิงค์กัน

ตัวอย่างที่นี่มีเจตนาเล็กน้อยเพื่อให้ง่ายต่อการแยกแยะ อย่างไรก็ตาม ในโลกแห่งความเป็นจริง สภาวะทางเชื้อชาติอาจวินิจฉัยได้ยากขึ้น โดยเฉพาะอย่างยิ่งเนื่องจากมักจะไม่สามารถทำซ้ำได้อย่างน่าเชื่อถือ

โซลูชัน

มีตัวเลือกสองสามทางในการป้องกันสภาพการแข่งขัน แต่เกือบทั้งหมดหมุนรอบแนวคิดเดียว:ตรวจสอบให้แน่ใจว่ามีเพียงเอนทิตีเดียวเท่านั้นที่เปลี่ยนแปลงข้อมูลในเวลาใดก็ตาม

ตัวเลือกที่ 1:Mutex

ตัวเลือกที่ง่ายที่สุดคือ "ล็อกการยกเว้นร่วมกัน" หรือที่เรียกกันทั่วไปว่า mutex คุณสามารถคิดว่า mutex เป็นกุญแจล็อคได้เพียงปุ่มเดียว หากเธรดหนึ่งถือคีย์ไว้ ก็สามารถเรียกใช้สิ่งที่อยู่ใน mutex ได้ เธรดอื่นๆ ทั้งหมดจะต้องรอจนกว่าจะสามารถกดคีย์ได้

การใช้ mutex กับโค้ดตัวอย่างของเราสามารถทำได้ดังนี้:

class MutexTransaction
  def self.run
    account = Account.find(1)
    account.update!(balance: 0)

    mutex = Mutex.new

    threads = []
    4.times do
      threads << Thread.new do
        mutex.lock
        balance = account.reload.balance
        account.update!(balance: balance + 100)
        mutex.unlock

        mutex.lock
        balance = account.reload.balance
        account.update!(balance: balance - 100)
        mutex.unlock
      end
    end

    threads.map(&:join)

    account.reload.balance
  end
end

ที่นี่ทุกครั้งที่เราอ่านและเขียนถึง account ก่อนอื่นเราเรียก mutex.lock เสร็จแล้วก็เรียก mutex.unlock เพื่อให้เธรดอื่น ๆ หมุนได้ เราเรียก mutex.lock . ได้ ที่จุดเริ่มต้นของบล็อกและ mutex.unlock ในตอนท้าย; อย่างไรก็ตาม นี่หมายความว่าเธรดจะไม่ทำงานพร้อมกันอีกต่อไป ซึ่งค่อนข้างจะขัดต่อเหตุผลของการใช้เธรดตั้งแต่แรก เพื่อประสิทธิภาพ ควรเก็บโค้ดไว้ใน mutex ให้เล็กที่สุดเท่าที่จะเป็นไปได้ เนื่องจากช่วยให้เธรดสามารถรันโค้ดได้มากขนานกันมากที่สุด

เราใช้ .lock และ .unlock เพื่อความชัดเจนที่นี่ แต่ Mutex . ของ Ruby คลาสให้ synchronize . ที่ดี วิธีการที่จะบล็อกและจัดการสิ่งนี้ให้กับเรา ดังนั้นเราจึงสามารถทำสิ่งต่อไปนี้:

mutex.synchronize do
  balance = ...
  ...
end

Mutex ของ Ruby ทำในสิ่งที่เราต้องการ แต่อย่างที่คุณคงจินตนาการได้ เป็นเรื่องปกติธรรมดาในแอปพลิเคชัน Rails ที่จะต้องล็อกแถวฐานข้อมูลบางแถว และ ActiveRecord ได้ครอบคลุมถึงสถานการณ์นี้

ตัวเลือกที่ 2:การล็อก ActiveRecord

ActiveRecord มีกลไกการล็อกที่แตกต่างกันสองสามแบบ และเราจะไม่เจาะลึกลงไปในกลไกเหล่านี้ทั้งหมดที่นี่ สำหรับจุดประสงค์ของเรา เราสามารถใช้ lock! . ได้ เพื่อล็อคแถวที่เราต้องการอัปเดต:

class LockedTransaction
  def self.run
    account = Account.find(1)
    account.update!(balance: 0)

    threads = []
    4.times do
      threads << Thread.new do
        Account.transaction do
          account = account.reload
          account.lock!
          account.update!(balance: account.balance + 100)
        end

        Account.transaction do
          account = account.reload
          account.lock!
          account.update!(balance: account.balance - 100)
        end
      end
    end

    threads.map(&:join)

    account.reload.balance
  end
end

ในขณะที่ Mutex "ล็อก" ส่วนของโค้ดสำหรับเธรดเฉพาะ lock! ล็อคแถวฐานข้อมูลเฉพาะ ซึ่งหมายความว่าโค้ดเดียวกันสามารถทำงานพร้อมกันในหลายบัญชีได้ (เช่น ในงานพื้นหลังจำนวนมาก) เฉพาะเธรดที่จำเป็นต้องเข้าถึงระเบียนเดียวกันเท่านั้นที่ต้องรอ ActiveRecord ยังมี #with_lock ที่สะดวก วิธีที่ให้คุณทำธุรกรรมและล็อคได้ในคราวเดียว ดังนั้นการอัปเดตด้านบนจึงสามารถเขียนให้กระชับขึ้นอีกเล็กน้อยดังนี้:

account = account.reload
account.with_lock do
  account.update!(account.balance + 100)
end
...

โซลูชันที่ 3:วิธีอะตอมมิก

วิธีการ 'อะตอมมิก' (หรือฟังก์ชัน) ไม่สามารถหยุดระหว่างการดำเนินการได้ ตัวอย่างเช่น += . ทั่วไป การดำเนินการใน Ruby ไม่ใช่ atomic แม้ว่าจะดูเหมือนการดำเนินการเพียงครั้งเดียว:

value += 10

# equivalent to:
value = value + 10

# Or even more verbose:
temp_value = value + 10
value = temp_value

ถ้าจู่ๆ กระทู้ "หลับ" ระหว่างหาว่า value + 10 . อะไร คือและเขียนผลลัพธ์กลับไปเป็น value จากนั้นจะเปิดโอกาสของสภาพการแข่งขัน อย่างไรก็ตาม ลองจินตนาการว่า Ruby ไม่อนุญาตให้เธรดเข้าสู่โหมดสลีประหว่างการดำเนินการนี้ หากเราสามารถพูดได้อย่างแน่นอนว่าเธรดจะไม่เข้าสู่โหมดสลีป (เช่น คอมพิวเตอร์จะไม่สลับการทำงานไปยังเธรดอื่น) ระหว่างการดำเนินการนี้ ก็อาจถือเป็นการดำเนินการ "อะตอมมิก"

บางภาษามีค่าดั้งเดิมในเวอร์ชันอะตอมมิกสำหรับความปลอดภัยของเธรดประเภทนี้ (เช่น AtomicInteger และ AtomicFloat) ไม่ได้หมายความว่าเราไม่มีการดำเนินการ "อะตอมมิก" บางอย่างสำหรับเราในฐานะนักพัฒนา Rails เมื่อตัวอย่างคือ update_counters . ของ ActiveRecord วิธีการ

แม้ว่าสิ่งนี้จะตั้งใจให้แคชตัวนับเป็นปัจจุบันมากกว่า แต่ก็ไม่มีอะไรหยุดเราไม่ให้ใช้งานในแอปพลิเคชันของเรา สำหรับข้อมูลเพิ่มเติมเกี่ยวกับแคชตัวนับ คุณสามารถดูบทความก่อนหน้าของฉันเกี่ยวกับการแคช)

การใช้วิธีการนั้นง่ายมาก:

class CounterTransaction
  def self.run
    account = Account.find(1)
    account.update!(balance: 0)

    threads = []
    4.times do
      threads << Thread.new do
        Account.update_counters(account.id, balance: 100)

        Account.update_counters(account.id, balance: -100)
      end
    end

    threads.map(&:join)

    account.reload.balance
  end
end

ไม่มี mutexes ไม่มีการล็อค เพียงสองบรรทัดของ Ruby; update_counters รับ ID ของเร็กคอร์ดเป็นอาร์กิวเมนต์แรก จากนั้นเราจะบอกว่าคอลัมน์ใดที่จะเปลี่ยน (balance: ) และต้องเปลี่ยนเท่าไหร่ (100 หรือ -100 ). สาเหตุที่ใช้งานได้คือตอนนี้วงจรการอ่าน-อัปเดต-เขียนเกิดขึ้นในฐานข้อมูลในการเรียก SQL ครั้งเดียว ซึ่งหมายความว่าเธรด Ruby ของเราไม่สามารถขัดจังหวะการทำงานได้ แม้จะอยู่ในโหมดสลีปก็ไม่เป็นไรเพราะฐานข้อมูลกำลังคำนวณตามจริงอยู่

SQL ที่เกิดขึ้นจริงออกมาแบบนี้ (อย่างน้อยก็สำหรับ postgres บนเครื่องของฉัน):

Account Update All (1.7ms)  UPDATE "accounts" SET "balance" = COALESCE("balance", 0) + $1 WHERE "accounts"."id" = $2  [["balance", "100.0"], ["id", 1]]

วิธีนี้ยังทำงานได้ดีขึ้นมาก ซึ่งไม่น่าแปลกใจเลยที่การคำนวณจะเกิดขึ้นอย่างสมบูรณ์ในฐานข้อมูล เราไม่ต้อง reload บันทึกเพื่อรับค่าล่าสุด ความเร็วนี้มาในราคาแม้ว่า เนื่องจากเราทำสิ่งนี้ใน SQL ดิบ เราจึงข้ามโมเดล Rails ซึ่งหมายความว่าการตรวจสอบความถูกต้องหรือการเรียกกลับจะไม่ถูกดำเนินการ (หมายถึงไม่มีการเปลี่ยนแปลง updated_at เหนือสิ่งอื่นใด ประทับเวลา)

บทสรุป

สภาพการแข่งขันอาจเป็นเด็กโปสเตอร์ Heisenbug ได้เป็นอย่างดี ปล่อยได้ง่าย มักไม่สามารถแพร่พันธุ์ได้ และคาดเดาได้ยาก อย่างน้อย Ruby and Rails ได้มอบเครื่องมือที่เป็นประโยชน์เพื่อจัดการกับปัญหาเหล่านี้เมื่อเราพบแล้ว

สำหรับรหัสทับทิมทั่วไป Mutex เป็นตัวเลือกที่ดีและอาจเป็นสิ่งแรกที่นักพัฒนาซอฟต์แวร์ส่วนใหญ่นึกถึงเมื่อได้ยินคำว่า "ความปลอดภัยของเธรด"

ด้วย Rails ข้อมูลจะมาจาก ActiveRecord มากกว่า ในกรณีเหล่านี้ lock! (หรือ with_lock ) ใช้งานง่ายและให้ปริมาณงานมากกว่า mutex เนื่องจากจะล็อกเฉพาะแถวที่เกี่ยวข้องในฐานข้อมูล

พูดตามตรง ไม่แน่ใจว่าจะเข้าถึง update_counters หรือไม่ มากในโลกแห่งความเป็นจริง เป็นเรื่องปกติที่นักพัฒนารายอื่นอาจไม่คุ้นเคยกับลักษณะการทำงาน และไม่ได้ทำให้เจตนาของโค้ดมีความชัดเจนเป็นพิเศษ หากต้องเผชิญกับข้อกังวลด้านความปลอดภัยของเธรด การล็อกของ ActiveRecord (lock! หรือ with_lock ) มีทั้งแบบธรรมดาและชัดเจนมากขึ้นในการสื่อสารถึงเจตนาของผู้เขียนโค้ด

อย่างไรก็ตาม หากคุณมีงาน 'เพิ่มหรือลบ' แบบง่ายๆ จำนวนมากที่สำรองข้อมูลไว้ และคุณต้องการความเร็วแบบแป้นเหยียบแบบดิบ update_counters สามารถเป็นเครื่องมือที่มีประโยชน์ในกระเป๋าหลังของคุณ