同時実行でテナントプレフィックスを使用して連続した請求書番号を生成します
概要
before_create :generate_invoice_number コールバックを持つ Invoice モデルがあります。 EU (プレフィックス 11) と US (プレフィックス 12) の 2 つのテナントもあります。生成される形式は tenant-00000001 である必要があります。通常の請求書の場合は問題ないようですが、私はサブスクリプションサービスを実行しているため、更新間隔が始まると(月の最初)、バックグラウンドジョブによって大量の請求書(〜1000)が同時に生成されます。
def generate_invoice_number
retries = 0
begin
ActiveRecord::Base.transaction do
Invoice.where(tenant_identifier: tenant.name).lock(true)
latest_invoice = Invoice.where(tenant_identifier: tenant.name).order(invoice_number: :desc).first
next_sequence_number = latest_invoice ? latest_invoice.invoice_number.split('-').last.to_i + 1 : 1
padded_sequence = next_sequence_number.to_s.rjust(8, '0')
self.invoice_number = "#{tenant.invoice_number_prefix}-#{padded_sequence}"
end
rescue ActiveRecord::RecordNotUnique, ActiveRecord::Deadlocked => e
raise e if retries >= 10
puts "RESCUED #{retries}"
retries += 1
sleep(0.2 * (2 ** retries))
retry
end
end
これは私のテスト設定です:
it "generates unique invoice numbers under concurrency" do
threads = []
generated_invoice_numbers = Concurrent::Array.new
50.times do
threads << Thread.new do
ActiveRecord::Base.connection_pool.with_connection do
invoice = create(:invoice)
generated_invoice_numbers << invoice.invoice_number
end
ActiveRecord::Base.clear_active_connections!
end
end
threads.each(&:join)
expect(generated_invoice_numbers.uniq.length).to eq(generated_invoice_numbers.length)
end
私はデータベースサーバーとしてmysqlを使用しており、invoice_numberには一意のインデックスがあります。
問題: レスキューエラーやタイムアウトエラーを実行しても、依然として重複エラーが発生します。 before_create はおそらくそれを行うのに最適な場所ではないでしょうか?あるいはそれを最適化するにはどうすればよいでしょうか?
mysql トリガーを使用した EDIT バリアント (ヘアトリガーを使用)
trigger.before(:insert) do
<<-SQL
DECLARE seq_number INT;
SELECT next_sequence_number INTO seq_number
FROM tenant_invoice_sequence_numbers
WHERE tenant_identifier = NEW.tenant_identifier
FOR UPDATE;
IF seq_number IS NOT NULL THEN
SET NEW.invoice_number = CONCAT(NEW.tenant_identifier, '-', LPAD(seq_number, 8, '0'));
UPDATE tenant_invoice_sequence_numbers
SET next_sequence_number = seq_number + 1, updated_at = NOW()
WHERE tenant_identifier = NEW.tenant_identifier;
ELSE
INSERT INTO tenant_invoice_sequence_numbers(tenant_identifier, next_sequence_number, created_at, updated_at)
VALUES (NEW.tenant_identifier, 2, NOW(), NOW());
SET NEW.invoice_number = CONCAT(NEW.tenant_identifier, '-', LPAD(1, 8, '0'));
END IF;
SQL
end
これの問題は、テーブルをロックするとデッドロックが発生することです。そして、Rails はトリガーについて何も知らないため、オブジェクトをリロードする必要があります(Rails は作成後に ID をどのようにして知るのでしょうか)。
解決策
結局、分散ロックと再試行による一意でない番号の救済に redlock を使用することになりました。とりあえず問題は解決したようです。
def generate_invoice_number
retries = 0
begin
$redlock_client.lock("invoice_number_lock_#{tenant.name}", 2000, retries: 10) do |locked|
if locked
ActiveRecord::Base.transaction do
tenant_sequence = TenantInvoiceSequenceNumber.find_or_create_by!(tenant_identifier: tenant_identifier)
padded_sequence = tenant_sequence.next_sequence_number.to_s.rjust(8, '0')
self.invoice_number = "#{tenant.invoice_number_prefix}-#{padded_sequence}"
tenant_sequence.increment!(:next_sequence_number)
end
else
raise "Unable to acquire lock to generate invoice number"
end
end
rescue ActiveRecord::RecordNotUnique => e
raise e if retries >= 10
retries += 1
sleep(0.2 * (2 ** retries))
end
end
次の番号を追跡するために追加のモデルも追加されました。
create_table "tenant_invoice_sequence_numbers", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "tenant_identifier", null: false
t.integer "next_sequence_number", default: 1, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["tenant_identifier"], name: "index_tenant_invoice_sequence_numbers_on_tenant_identifier", unique: true
end