什么原因会导致Rails创建重复记录?

6
我们注意到我们数据库中的各种表格都出现了很多重复记录,但是不知道为什么会这样。有趣的是,虽然记录本身是重复的(包括created_at时间戳),但是在我们的用户表中,每个记录的密码盐和哈希值都不同,这让我相信Rails在某些情况下会运行两次事务/保存操作。显而易见的是,我们在应用程序代码中没有多次调用savecreate

并不是每个保存在数据库中的记录都会出现这种复制现象,我们还无法推断出模式。在用户模型上还有一个validates_uniqueness_of验证(尽管表格上没有唯一键;我们需要清除所有重复记录才能做到这一点)--因此,如果记录已经存在,Rails应该停止插入,但是如果请求同时发生,那么就是竞态条件。

目前我们正在使用Passenger 3.0.11/nginx在我们的应用服务器(目前有2台)后面运行Rails 3.2.2,并且有一个中央nginx web服务器,将请求发送到一个应用服务器。这种设置会导致进程被复制或者其他的问题吗?请求没有锁定到一个上游服务器上,是否会有影响?(例如,如果一个用户请求包括静态内容(如图像)的页面,则可能使用一个或两个应用服务器)?(我觉得这是在抓住稻草,但我想覆盖每个可能性)

还有什么其他原因会导致这种情况发生?

更新:例如,今天创建了一个用户,它得到了重复记录。两个记录都有created_at时间戳为“2012-03-28 16:48:11”,除hashed_passwordsalt以外的所有列都相同。从请求日志中可以看到以下内容:

应用服务器1:

Started POST "/en/apply/create_user" for 1.2.3.4 at 2012-03-28 12:47:19 -0400
[2012-03-28 12:47:19] INFO : Processing by ApplyController#create_user as HTML
[2012-03-28 12:47:20] INFO :   Rendered apply/new_user.html.erb within layouts/template (192.8ms)

Started POST "/en/apply/create_user" for 1.2.3.4 at 2012-03-28 12:48:10 -0400
[2012-03-28 12:48:10] INFO : Processing by ApplyController#create_user as HTML
[2012-03-28 12:48:11] INFO : Redirected to apply/initialize_job_application/3517
[2012-03-28 12:48:11] INFO :  /app/controllers/apply_controller.rb:263:in `block (2 levels) in create_user'

应用服务器2:

Started POST "/en/apply/create_user" for 1.2.3.4 at 2012-03-28 12:48:10 -0400
[2012-03-28 12:48:10] INFO : Processing by ApplyController#create_user as HTML

Web服务器:

1.2.3.4 - - [28/Mar/2012:12:48:10 -0400] "POST /en/apply/create_user HTTP/1.1" 499 0 "en/apply/create_user" "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Win64; x64; Trident/5.0)" "-"
1.2.3.4 - - [28/Mar/2012:12:48:11 -0400] "POST /en/apply/create_user HTTP/1.1" 302 147 "en/apply/create_user" "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Win64; x64; Trident/5.0)" "-"

因此,创建操作被触发了三次(第一次由于错误返回到表单,可能是这个原因),并且每个服务器至少执行了一次。后两个都被Web服务器注册为单独的请求,但第一个获得状态代码499 Client Closed Request(根据维基百科是Nginx的扩展),而第二个得到了预期的302。这里的499是否会导致问题?


你是否查看过应用程序日志文件?当数据库中存在重复用户时,您能否在日志文件中找到对应该点的两个请求,还是只有一个? - Frederick Cheung
我们在生产环境中没有完整的Rails查询日志,只有请求日志。插入操作存在于MySQL二进制日志中,但对于至少一个示例,创建操作在请求日志中显示了多次(尽管这可能不会导致多个完全重复的记录,是吧?) - Daniel Vandersluis
但是您能看到匹配重复记录的请求对吗,还是仅单个请求? - Frederick Cheung
@Robin 是的,确实如此,但在清理表中已经存在的重复项之前我们无法这样做。 - Daniel Vandersluis
Validates_uniquesness 存在竞争条件漏洞:如果两个请求同时到达,它们都会检查现有行,发现没有,然后创建一行。如果您真的关心,请添加唯一索引。 - Frederick Cheung
显示剩余5条评论
2个回答

5
两种可能性应该是:
第一个可能是Nginx在作为负载均衡器时的奇怪行为(这违反了RFC规定)。它会重试任何失败的请求并发送到下一个后端服务器。RFC只允许对安全方法执行此操作(例如GETHEAD)。结果是,如果您的nginx认为由于某些原因请求失败了,那么它可能会被重新发送到下一个服务器。但是如果两个服务器都完成了事务,您就会有重复记录。从您的Web服务器日志(以及Nginx使用499状态代码表示用户在浏览器中点击中止的情况)来看,这似乎是最可能的原因。
第二个可能性是您的用户双击了发送按钮。在正确的时间内,他们的浏览器几乎同时发送了两个完整的请求。
为确保您的用户记录真正唯一,您应该在数据库上创建唯一索引。这样就可以确保它们是唯一的(尽管与ActiveRecord检查相比,错误消息可能更糟糕)。因此,您应该始终在数据库模式和模型上定义唯一性约束。
此外,您还可以考虑使用更严格的负载平衡器来替换前端nginx。我建议使用haproxy

请求在用户中止请求并且Web服务器Nginx将请求标记为“499”之前,是否有可能该请求已经从Web服务器上游传递到应用服务器?在这种情况下,即使Web服务器知道请求已终止,请求是否仍会在应用服务器上存在? - Daniel Vandersluis
我这么想。一旦请求被发送到Rack,它就不会再中止了。因此,尽管您的应用程序服务器Nginx可能检测到连接中止,但您的Rails堆栈并不知道这一点,并且无法采取任何措施。它完成其事务,而nginx则丢弃了答案。所有这些都在您的前端负载均衡器已经错误地重新调度请求的同时进行。 - Holger Just
这对我也有意义。有什么可以做来防止这种情况发生吗(或者您认为将haproxy放入混合中是解决方案)?此外,假设只存在一个应用程序服务器,这个问题是否仍然可能存在?换句话说,如果两个请求都被传递到同一个应用程序服务器,是否仍会出现竞争条件,或者Passenger的排队是否会消除这种情况? - Daniel Vandersluis
我喜欢我的haproxy :) 但是你至少可以配置proxy_next_upstream来避免重新分配。我不确定在只有一个应用服务器上是否有任何区别。这取决于passenger使用代理代码的程度。但我猜你会很安全(虽然并不确定)。 - Holger Just
还有一个 https://github.com/xetorthio/nginx-upstream-idempotent/(在之前的链接线程中提到)可能会解决POST请求的问题,但仍然无法解决其他非幂等动词(例如PUT、DELETE等)的问题。 - Holger Just
如果用户关闭连接然后重新打开一个新连接,那么这不算是重新分配,对吗?从我的日志中看到的情况,我认为这主要(如果不是完全)是由于仍在处理的错误499引起的,但我想我们可以尝试使用“proxy_next_upstream off”来查看是否有所改善,但由于日志中出现了具有不同状态代码的不同请求,我猜测它可能不会有所改变。 - Daniel Vandersluis

0

这似乎是竞态条件。请确保在请求之间进行锁定。很容易发生偶尔会复制一两个请求的情况。在没有事务的情况下交换项目时也可能发生相同的情况,因此请确保您的请求之间没有竞争。


我应该锁定什么?我相信(如果我错了请纠正),Rails和/或mysql会锁定在事务中使用的表格 -- 我们之前遇到过由于某个表格被锁定时间太长而导致的mysql锁定错误。 - Daniel Vandersluis
是的,但我不是在谈论交易。我说的是发送到服务器的请求。如果您的请求管理器没有正确地将请求分配给外部服务器,那么很可能会出现竞争条件,从而复制条目。这取决于您如何设置管理器,但我认为这是一个很强的可能性。 - Spyros

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接