Flask+MongoDB REST API并发PUT请求下如何避免同书名子文档重复?
问题
使用Flask+MongoDB(pymongo)开发REST API时,并发PUT请求为同一用户的同书名书籍设置不同字段,出现重复子文档的竞态条件问题。
集合初始状态
[ { "name": "Mike", "books": [ { "title": "Book 1", "minutes-spent-reading": 100, "date-bought": "2024-09-23" } ] } ]
并发PUT请求
- 请求1:
PUT /name/Mike/books/Book 2 Content-Type: application/json {"minutes-spent-reading": 40}
- 请求2:
PUT /name/Mike/books/Book 2 Content-Type: application/json {"date-bought": "2024-09-26"}
预期结果
集合中仅存在一个title为"Book 2"的子文档,包含两个字段。
实际结果
生成两个同书名的子文档:
[ { "name": "Mike", "books": [ { "title": "Book 1", "minutes-spent-reading": 100, "date-bought": "2024-09-23" }, { "title": "Book 2", "minutes-spent-reading": 40 }, { "title": "Book 2", "date-bought": "2024-09-26" } ] } ]
复现代码
import threading import pymongo import time import json def put_book_2_minutes_spend_reading(value): # Find book in the array of books book = db.experiment.find_one({ 'name': 'Mike', 'books.title': 'Book 2' }) # Simulate processing delay time.sleep(1) if book == None: db.experiment.find_one_and_update({ 'name': 'Mike', }, { '$push': { 'books': { 'title': 'Book 2', 'minutes-spent-reading': value } } }) else: db.experiment.find_one_and_update({ 'name': 'Mike' }, { '$set': { 'books.$[book].minutes-spent-reading': value } }, array_filters = [{'book.title': 'Book 2'}]) def put_book_2_date_bought(value): book = db.experiment.find_one({ 'name': 'Mike', 'books.title': 'Book 2' }) time.sleep(1) if book == None: db.experiment.find_one_and_update({ 'name': 'Mike', }, { '$push': { 'books': { 'title': 'Book 2', 'date-bought': value } } }) else: db.experiment.find_one_and_update({ 'name': 'Mike' }, { '$set': { 'books.$[book].date-bought': value } }, array_filters = [{'book.title': 'Book 2'}]) db = pymongo.MongoClient('localhost', 27017)['experiment'] db.experiment.delete_many({}) # Sample item db.experiment.insert_one({ 'name': 'Mike', 'books': [ { 'title': 'Book 1', 'minutes-spent-reading': 100, 'date-bought': '2024-09-23' } ] }) t1 = threading.Thread(target=put_book_2_minutes_spend_reading, args=[40]) t2 = threading.Thread(target=put_book_2_date_bought, args=['2024-01-20']) t1.start() t2.start() t1.join() t2.join() print(json.dumps(list(db.experiment.find({}, {"_id": 0})), indent=2))
解决方案
问题根源是先查询后更新的非原子操作:两个并发请求都先查到目标子文档不存在,随后都执行$push插入操作。解决核心是将判断和更新合并为原子操作,利用MongoDB单写操作的原子性避免竞态。
推荐实现方式
修改更新逻辑,先尝试原子更新已存在的子文档;若更新未命中(说明子文档不存在),再执行原子性插入(仅当子文档确实不存在时才插入):
def update_book_minutes_spent(username, book_title, value): # 原子更新已存在的子文档 update_result = db.experiment.update_one( {"name": username}, {"$set": {"books.$[book].minutes-spent-reading": value}}, array_filters=[{"book.title": book_title}] ) # 若未找到可更新的子文档,原子插入新文档(仅当该title不存在时) if update_result.modified_count == 0: db.experiment.find_one_and_update( {"name": username, "books.title": {"$ne": book_title}}, {"$push": {"books": {"title": book_title, "minutes-spent-reading": value}}} ) def update_book_date_bought(username, book_title, value): update_result = db.experiment.update_one( {"name": username}, {"$set": {"books.$[book].date-bought": value}}, array_filters=[{"book.title": book_title}] ) if update_result.modified_count == 0: db.experiment.find_one_and_update( {"name": username, "books.title": {"$ne": book_title}}, {"$push": {"books": {"title": book_title, "date-bought": value}}} )
原理说明
MongoDB的单个写操作具备原子性,上述逻辑中:
- 第一步更新操作会原子性地匹配并修改已存在的子文档
- 第二步插入操作带有条件(
books.title != 目标书名),只有当子文档确实不存在时才执行插入 - 并发场景下,第一个请求完成插入后,第二个请求的插入条件会不满足,转而执行更新操作,最终只会存在一个目标子文档
内容的提问来源于stack exchange,提问作者Rodrigo Morales
相关产品推荐
相关产品推荐

