Techioz Blog

同時実行でテナントプレフィックスを使用して連続した請求書番号を生成します

概要

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