如何避免多个Tornado应用实例在MongoDB中插入重复数据?

1 投票
3 回答
980 浏览
提问于 2025-04-18 12:30

我用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 个回答

0

我按照@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')
0

一个简单的解决办法是使用 updateupsert 选项,这个选项的意思是如果没有找到就插入。这样做的话,就不会出现重复的记录。

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,然后继续处理请求。

3

就像我在Tornado邮件列表上说的那样:我看到你在用Tornado配合PyMongo。我不太推荐这种做法,因为如果MongoDB的操作时间很长,会导致整个Tornado进程都被阻塞,无法处理其他工作。(我在维护Motor,这是一个非阻塞的MongoDB驱动,专门为Tornado设计的。)不过,PyMongo有个好处,就是可以避免竞争条件。

在你的代码中,你先查询产品,如果没有找到,就插入一个。这在单个Tornado进程中运行得很好,因为它一次只处理一个请求,查询和插入之间不会被打断。如果它没有找到文档,那么在尝试插入的时候,还是没有文档。

但是如果有多个Tornado进程,用户可以快速按两次按钮,这样就会发生一些问题:

  1. 进程A查询,发现没有产品。
  2. 进程B查询,发现没有产品。
  3. 进程A插入一个文档。
  4. 进程B插入一个重复的文档。

我建议:

  • 在开始AJAX POST之前,禁用“提交”按钮。确保显示一个加载动画,并在超时或出现HTTP 500等错误后重新启用按钮。
  • 在产品集合中创建一个唯一的复合索引,确保这些字段是唯一的。
  • 决定你的Python代码如何处理pymongo.errors.DuplicateKeyError,如果发生这种情况的话。(给用户抛出一个有用的错误提示可能是个好主意。)

撰写回答