如何避免多个Tornado应用实例在MongoDB中插入重复数据?
我用Tornado做了一个简单的愿望清单应用。用户可以添加产品的链接,应用会持续跟踪这个产品的价格。流程很简单,用户登录后,会看到一个表单,用户把链接粘贴到表单里,然后点击提交。这样就会向我的服务器发送一个请求,这个链接就会被添加到数据库中。
我使用了ajax来提交请求。这样一来,提交请求后,页面上的愿望清单表格会刷新。不过因为这个过程需要一些时间,用户可能会觉得应用没有反应,进而多次点击“提交”按钮。这个过程需要时间,因为我的服务器要验证链接,获取价格、图片和其他信息。
在最初的阶段,我是直接和Tornado服务器进行交互的。当我多次点击提交按钮时,Tornado只会处理一次请求。我不知道它是怎么忽略重复请求的,或者它是如何判断同一个请求正在被处理的。因为这种情况从未发生过,所以我也没想过这个问题。
现在我有四个Tornado实例在一个Nginx服务器后面运行。所以我猜当用户多次点击提交时,Nginx会把请求分发给不同的Tornado实例。
那么,我该如何避免这种情况呢?
我可以在浏览器中为每个会话创建一个本地存储,维护一个链接列表。当用户点击提交时,如果链接已经在列表中,就不发送请求。每当关闭标签页时,我会清除这个存储。
在提交链接后禁用提交按钮,直到收到服务器的回复。
在点击提交时给用户一个类似Facebook的通知,希望用户不要再次点击提交。
配置Nginx负载均衡器使用IP哈希模式,这样用户总是会被同一个Tornado实例处理。
也许可以配置Nginx,让它像之前的单个Tornado实例那样忽略重复的POST请求?
这是相关的代码(不确定这是否真的重要):
class ProductsHandler(BaseHandler):
@tornado.web.authenticated
def get(self):
# ...
# ...
def post(self):
user_email = self.get_secure_cookie('trakr')
user_db = self.application.db.users
product_db = self.application.db.products
product_url = self.get_argument('product-url', None)
if not product_url:
self.redirect('/products')
return
vendor, url = utils.get_vendor(product_url)
if not vendor:
self.redirect('/products')
return
# this is where it checks whether the URL is already present in DB or not
product_doc = product_db.find_one({'url': url})
if not product_doc:
# ...
# ...
product_url, product_name, product_img_url, product_price = vendor_func_map[vendor_id](url)
product_id = str(product_db.insert({'vendor_name': vendor_name,
'vendor_id': vendor_id,
'name': product_name, 'url': url,
'img_url': upload_to_imgur(product_img_url),
'price': product_price
# ...
# ...
}))
else:
product_id = product_doc['_id']
# add this product to users db
# ...
self.redirect('/products')
还有用于ajax的jquery代码:
<script type="text/javascript">
$(document).ready(function(){
$("#product-add-form").on('submit',function(){
var product_url = $("#product-url").val();
console.log(product_url)
var dataString = 'product-url='+ product_url
$.ajax({
type: "POST",
url: "products",
data: dataString,
success: function() {
$('#product-table-div').load("/products #product-table-div")
}
});
return false;
});
});
</script>
HTML表单:
<div id="product-add">
<form action="/products" method="post" id="product-add-form">
<fieldset>
<label for="product-url">Product-Url</label>
<input class="text-input" id="product-url" name="product-url" tabindex="1" type="text" value="">
</fieldset>
<div id="form_btn">
<input id="prodadd-btn" class="btn btn-blue" type="submit" value="Submit" tabindex="3">
</div>
</form>
</div>
3 个回答
我按照@A. Jesse Jiryu Davis的建议进行了修改,下面是我所做的更改。
首先,我创建了一个独特的单一索引(我不需要复合索引,因为_id
已经被索引了,我只想让product
文档中的url
字段是唯一的)。
product_db.create_index('url', unique=True, dropDups=True)
请注意,上面的操作会删除所有具有相同url
的重复文档。如果你不想这样做,可以按照以下步骤进行:
product_db.create_index('url', unique=True)
如果存在任何重复的键,PyMongo会抛出DuplicateKeyError
异常。
我修改了我的JavaScript代码,现在在ajax请求开始时会禁用提交按钮,稍后再重新启用:
<script type="text/javascript">
$(document).ready(function()
{
$("#product-add-form").on('submit',function()
{
var product_url = $("#product-url").val();
console.log(product_url)
var dataString = 'product-url='+ product_url
$.ajax({
type: "POST",
url: "products",
data: dataString,
success: function() {
$('#product-table-div').load("/products #product-table-div")
}
});
return false;
});
})
.ajaxStart(function(){
$("#prodadd-btn").attr("disabled", "disabled");
NProgress.start();
})
.ajaxStop(function(){
$("#prodadd-btn").removeAttr("disabled");
NProgress.done();
});
我还使用了Nprogress来显示一个很酷的进度条。
这些更改应该足以防止用户多次输入相同的url。不过,有可能两个用户会同时输入相同的url。现在,由于我创建了一个独特的单一索引,第二次插入时会抛出DuplicateKeyError
。在这种情况下,我会从数据库中查找该url并将其添加到用户中。以下是修改后的代码:
def post(self):
user_email = self.get_secure_cookie('trakr')
user_db = self.application.db.users
product_db = self.application.db.products
tracker_db = self.application.db.trackers # this has product_id to users_id
product_url = self.get_argument('product-url', None)
# ...
product_doc = product_db.find_one({'url': url})
if not product_doc:
# ...
# ...
product_url, product_name, product_img_url, product_price = vendor_func_map[vendor_id](url)
try:
product_id = str(product_db.insert({
# ...
'url': url,
'current_price': product_price,
}))
except pymongo.errors.DuplicateKeyError:
product_doc = product_db.find_one({'url': url})
product_id = product_doc['_id']
else:
product_id = product_doc['_id']
user_db.update({'email_id': user_email}, {'$addToSet': {'tracked_products': ObjectId(product_id)}})
# ...
self.redirect('/products')
一个简单的解决办法是使用 update
和 upsert
选项,这个选项的意思是如果没有找到就插入。这样做的话,就不会出现重复的记录。
update({
"product_url": url
}, {
'vendor_name': vendor_name,
'vendor_id': vendor_id,
'name': product_name, 'url': url,
'img_url': upload_to_imgur(product_img_url),
'price': product_price
}, upsert=True)
但是,这并不是你应该采取的做法。在添加产品网址之前,你应该先检查一下这个网址是否已经在数据库里了?如果已经存在,就应该提醒用户“这个网址已经添加过了”。
另外,你的问题是,你需要锁定在这种情况下创建新请求的能力。所以,你可以在某个地方设置一个变量叫“inProgress”,并进行检查。当用户按下按钮时,检查这个变量是否是 true
。如果是 true
,就提醒用户。如果不是,就把它设置为 true
,然后继续处理请求。
就像我在Tornado邮件列表上说的那样:我看到你在用Tornado配合PyMongo。我不太推荐这种做法,因为如果MongoDB的操作时间很长,会导致整个Tornado进程都被阻塞,无法处理其他工作。(我在维护Motor,这是一个非阻塞的MongoDB驱动,专门为Tornado设计的。)不过,PyMongo有个好处,就是可以避免竞争条件。
在你的代码中,你先查询产品,如果没有找到,就插入一个。这在单个Tornado进程中运行得很好,因为它一次只处理一个请求,查询和插入之间不会被打断。如果它没有找到文档,那么在尝试插入的时候,还是没有文档。
但是如果有多个Tornado进程,用户可以快速按两次按钮,这样就会发生一些问题:
- 进程A查询,发现没有产品。
- 进程B查询,发现没有产品。
- 进程A插入一个文档。
- 进程B插入一个重复的文档。
我建议:
- 在开始AJAX POST之前,禁用“提交”按钮。确保显示一个加载动画,并在超时或出现HTTP 500等错误后重新启用按钮。
- 在产品集合中创建一个唯一的复合索引,确保这些字段是唯一的。
- 决定你的Python代码如何处理pymongo.errors.DuplicateKeyError,如果发生这种情况的话。(给用户抛出一个有用的错误提示可能是个好主意。)