1

死锁后数据丢失 - SQL Server 2008、Ruby on Rails、Phusion Passenger、Linux、FreeTDS

我遇到了一个神秘问题,该问题导致我负责的 Ruby on Rails 内部网应用程序中的数据丢失。如果这不是严格意义上的编程问题,我深表歉意——至少我维护了应用程序的 Ruby 代码。迄今为止,该问题已在两年内发生了 3 次。

环境:

  • Linux - 红帽企业服务器 5.2
  • Apache 2 网络服务器 (httpd-2.2.3-11.el5_2.4.rpm)
  • Phusion 乘客 2.2.15
  • Ruby 1.8.7,Rails 2.3.8,带有宝石:
    • actionmailer (2.3.8)
    • 动作包 (2.3.8)
    • 活动记录(2.3.8)
    • activerecord-sqlserver-适配器 (2.3.8)
    • 主动资源(2.3.8)
    • 主动支持 (2.3.8)
    • 阿卡米 (1.2.0)
    • 建设者(3.0.0)
    • 异常通知(2.3.3.0)
    • 快速线程(1.0.7)
    • 玉 (0.4.6)
    • httpi (1.1.1)
    • 哑剧类型 (1.16)
    • nokogiri (1.4.4)
    • 海苔 (1.1.3)
    • 乘客 (2.2.15)
    • 机架 (1.1.0)
    • 导轨 (2.3.8)
    • 耙子 (0.8.7)
    • ruby-net-ldap (0.0.4)
    • rubyjedi-actionwebservice (2.3.5.20100714122544)
    • 萨翁 (1.1.0)
    • 芥末 (2.5.1)
    • will_paginate (2.3.14)
  • SQL Server 2008 数据库服务器
  • 通过 ActiveRecord 访问数据库
  • 数据库驱动:freetds-0.82、unixODBC-2.3.0.tar.gz、ruby-odbc-0.99991.tar.gz

症状:

  • 请求锁定数据库资源的用户操作涉及死锁情况。
  • SQL Server 通过杀死死锁所涉及的进程来解决死锁,这样至少其中一些进程可以成功完成。
  • 在 Rails 应用程序端,死锁导致未处理的异常(我通过 exception_notification gem 得到通知)
  • 死锁之后,活动 Rails 进程的数量在增加(这触发了我们监控系统的另一个通知),进程似乎挂起
  • 发生这种情况的原因尚不清楚。这些进程似乎挂在数据库操作中(根据 Rails 日志)。通常我会期望 SQL Server 的死锁解决功能不会留下阻塞进程。
  • 在前两种情况下,我重新启动了 Web 服务器作为对异常/挂起进程的反应。在第三种情况下(我正在度假),没有人对通知做出反应,但周末运行的 cronjob 显然也停止了进程(通过触摸“restart.txt”通过乘客软重启,效果相同)
  • 网络服务器重启后,用户报告数据丢失。在 Web 服务器重新启动之前,从用户的角度来看,数据已按预期处理。与我们通信的其他系统中的 Rails 日志和数据似乎表明事务已正确提交。Web 服务器重新启动后,突然间,自死锁发生以来的所有数据库更改都丢失了。例如,我们有一个“users”表,其中有一个“last_access”列,该列在每个用户操作时都会更新。Web 服务器重新启动后,最新的“last_access”值是一天前的。所有事务似乎都丢失了,只有 @@IDENTITY 值继续使用数据丢失之前设置的值。
  • 我从我们的 IT(维护数据库服务器)那里收到的信息似乎表明所有丢失的数据库操作都是一个巨大事务的一部分,该事务缺少最终的 COMMIT。当然,我期望的是每个 Rails 用户操作都会运行一个或多个单独的事务,但 SQL Server 事务日志将所有操作显示为一个巨大事务的一部分。

在我看来,好像发生了这样的事情:

  • 其中一个涉及的组件(例如 Phusion Passenger、FreeTDS、SQL Server)中的错误导致并行运行的 Rails 进程共享数据库连接,并且可能还导致进程挂起。
  • 涉及的进程之一是在事务中并在提交之前挂在某处
  • 由于其他进程共享相同的连接(正如我假设的那样),它们也在同一个事务中
  • 由于进程共享连接,因此用户能够看到数据更改(在 Web 服务器重新启动之前),即使 COMMIT 处于未决状态。
  • Web 服务器重新启动迫使连接中止并回滚事务。

这有意义吗?我想知道是否有人有类似的经历或提示我可以进一步研究。我怀疑Passenger中的一个错误可能已经分叉了数据库连接的文件描述符,但我无法重现它。乘客似乎在每个分叉上正确地创建了新的数据库连接。

我正在考虑将数据库的隔离模型更改为“读取提交的快照”以减少死锁的数量,但我知道这并不能解决根本原因,而且这可能会给我带来其他问题。

4

2 回答 2

3

我现在已经能够自己追踪问题,并希望与现在或将来可能面临类似问题的任何人分享解决方案。

发生了以下情况:

  • 作为事务的一部分运行的 UPDATE 操作涉及死锁
  • SQL Server 选择了 UPDATE 操作作为死锁牺牲品并回滚了事务
  • 导致ActiveRecord::StatementInvalid这样的异常:

    A ActiveRecord::StatementInvalid occurred in (...):
    
    ODBC::Error: 37000 (1205) [FreeTDS][SQL Server]Transaction (Process ID 55) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction.: UPDATE [(....tablename....)] SET [position] = 1 WHERE [id] = 795419
    /usr/lib/ruby/gems/1.8/gems/activerecord-2.3.8/lib/active_record/connection_adapters/abstract_adapter.rb:221:in `log'
    
  • ActiveRecord::ConnectionAdapaters::DatabaseStatements.transaction()中,异常处理方式如下:

    rescue Exception => database_transaction_rollback
      if transaction_open && !outside_transaction?
        transaction_open = false
        decrement_open_transactions
        if open_transactions == 0
          rollback_db_transaction
        else
          rollback_to_savepoint
        end
      end
      raise unless database_transaction_rollback.is_a?(ActiveRecord::Rollback)
    end
    
  • transaction_opentrue在这一点上。该方法outside_transaction?在 SQL Server 适配器中以这种方式实现:

    def outside_transaction?
      info_schema_query { select_value("SELECT @@TRANCOUNT") == 0 }
    end
    
  • @@TRANCOUNT此时为 0,因为 SQL Server 已经回滚了事务 - 如我的调试输出所示:

    SQL (1.0ms)   SELECT @@TRANCOUNT
    => TRANCOUNT=0
    
  • 因此outside_transaction?返回true,并且上面显示的异常处理代码不执行回滚。到现在为止还挺好。

  • 异常由上面显示的代码重新抛出,并被我捕获,ApplicationController因为:

    class ApplicationController < ActionController::Base
      rescue_from Exception, :with => :render_error
    
  • render_error方法将异常消息存储在 flash 变量中:

    flash[:exception_message] = exception.message
    
  • flash 变量存储在会话中。而且由于我使用的是active_record_store,会话数据将存储在数据库表中sessions。(我实际上正在使用smart_session_store,但这在这方面没有区别)。于是另一笔交易开始了……

    EXECUTE (1.2ms)   BEGIN TRANSACTION
    SQL (1.1ms)   SELECT session_id, data,id FROM sessions WHERE id=150091
    EXECUTE (1.3ms)   UPDATE sessions SET updated_at=CURRENT_TIMESTAMP, data='BAh7FDoWdW9faGlk(........)' WHERE id=150091
    CACHE (0.0ms)   SELECT @@TRANCOUNT
    => TRANCOUNT=0
    
  • 虽然事务已经开始,但SELECT @@TRANCOUNT返回 0 - 该值是从缓存中获取的!这就是灾难发生的地方。

  • transaction方法现在认为事务不再处于活动状态,因此不执行COMMIT:

    if outside_transaction? # (this does the SELECT @@TRANCOUNT)
      @open_transactions = 0      # Getting here!
    elsif transaction_open
      decrement_open_transactions
      begin
        if open_transactions == 0
          commit_db_transaction   # NOT getting here!
        else
          release_savepoint
        end
      rescue Exception => database_transaction_rollback
        if open_transactions == 0
          rollback_db_transaction
        else
          rollback_to_savepoint
        end
        raise
      end
    end
    

    结尾

  • 由于数据库连接保持打开状态(这似乎只在生产模式下完成,而不是在开发模式下完成),由同一工作进程处理的后续 Rails 操作都附加到此处保持打开状态的事务中。对于用户来说,看起来数据仍在正常处理中,因为他们通过交易的眼睛看到了一切。只有这一个工作进程保持活动状态 - 任何其他启动的工作进程都挂起,因为打开的事务锁定了各种数据库资源。当我重新启动 Web 服务器时,一个响应式工作进程被停止并且它的事务被回滚。这是数据丢失在应用程序中变得可见的地方。

我简要查看了上面显示的代码的较新(Rails 3.x)版本,在我看来,问题可能不再出现。该transaction方法似乎不再调用outside_transaction?,而是依赖于内部维护的事务状态。但是,由于我目前无法升级,我将在本地解决问题,可能通过transaction以与 Rails 3.x 类似的方式更改方法。

于 2013-04-12T08:04:10.743 回答
1

首先,我承认我没有仔细阅读这个问题,但我有直觉和建议。我见过这样的问题,它们通常发生在具有许多模型的大型遗留应用程序中,其中一些模型具有到另一个数据库的连接池。在上述场景中,很容易在 ActiveRecord 的回调阶段引用使用另一个连接的模型。这会导致 ActiveRecord 开箱即用不支持的跨数据库事务。

如果您认为这可能是原因,请查看以下代码。确保您从不使用#establish_connection,如果使用,请仅在其他人继承的基类或连接类中使用它一次。我在这里有一个 github 要点(https://gist.github.com/metaskills/4065702),它显示了我在说什么。其次,确保您的模型在保存/事务期间没有与另一个连接池中的另一个模型通信。

于 2013-04-15T11:41:47.273 回答