我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

Python拥抱MongoDB:文档型数据库实战与常见坑规避

Python拥抱MongoDB:文档型数据库实战与常见坑规避 1. 为什么我建议Python项目优先考虑MongoDB先说一个我自己的经历。早些年做项目无论大小一律MySQL觉得数据库就该是关系型的直到有一次做一个内容型产品数据模型频繁调整——今天给文章加个标签数组明天给用户加个地址字段后天又要嵌套一层评论结构。每次改动都要写ALTER TABLE迁移脚本改完还要担心线上数据要不要回填那段时间光改表结构就改到心态崩溃。后来换到MongoDB情况立刻不一样了字段直接加旧文档没这个字段也不报错代码里做兼容就行。从那时候起只要项目的数据模型还在演进期或者压根就是半结构化数据我都会优先选MongoDB。这篇文章就是Python使用MongoDB系列的第一篇目标人群很明确——已经会Python基础语法但没正经用过非关系型数据库的开发者。我会从为什么选MongoDB讲起把安装、连接、增删改查这些最核心的实操过一遍再把我实际项目里踩过的坑一并交代清楚。看完这篇你应该能独立把一个Python脚本连上MongoDB并完成基本的数据读写。MongoDB本质上是文档型数据库存储的基本单位是文档对应到Python里就是字典dict。一个文档就是一条记录一个集合Collection就是一张表一个数据库里可以放很多集合。这种结构对Python开发者特别友好——你在代码里操作dict存到数据库里还是那个结构读出来又变回dict几乎没有心智负担。相比之下关系型数据库要求你把数据拆成行和列再通过外键关联起来数据一复杂光是ORM的映射关系就要写半天。还有一个很实际的理由MongoDB的横向扩展能力。关系型数据库的扩展通常要动主从复制、分库分表配置复杂运维成本高。MongoDB从设计上就支持分片数据自动打散到多台机器上对应用层透明。当然对小项目来说这算未来可期但它意味着你不用担心项目做大了要换数据库——起步用MongoDB后面就算真遇到了性能瓶颈扩展路径也是顺畅的。2. 环境准备装MongoDB和PyMongo时最容易被坑的细节2.1 安装顺序与版本匹配是关键先说服务端。MongoDB官网提供社区版Community Server直接下载对应操作系统的安装包即可。Windows下就是一路Next但有一个地方要留意安装向导里可以选择是否安装Compass——这是官方提供的图形化管理工具。新手建议勾选它后面查看数据、调试查询语句会方便很多。macOS用户推荐用Homebrew安装命令是brew tap mongodb/brew再brew install mongodb-community装完用brew services start mongodb-community启动服务。这里有一个版本匹配的坑我身边好几个同事都踩过MongoDB服务端版本和驱动版本如果差得太远连接时会报奇怪的错误——比如老版本驱动连接新版本服务端可能出现认证失败或者命令不识别反过来新驱动连接老服务端也可能触发不支持的命令这类报错。所以安装PyMongo前先确认服务端版本。在命令行执行mongod --version拿到版本号之后再去PyMongo的文档里对照兼容矩阵。一般来说PyMongo 4.x对应MongoDB 4.4以上版本这样配比较稳妥。Python端的驱动官方推荐的就是PyMongo。直接用pip安装pip install pymongo如果你想用MongoDB自带的对象关系映射框架类似SQLAlchemy之于MySQL那还需要装MongoEngine但那是后续系列文章的内容这一篇你先专注PyMongo即可。装完验证一下python -c import pymongo; print(pymongo.__version__)能打印出版本号说明驱动已经就绪。2.2 连接字符串写法与常用参数安装完成后写代码的第一件事是建立连接。PyMongo的入口是MongoClient它接收一个连接字符串。标准格式如下from pymongo import MongoClient client MongoClient(mongodb://localhost:27017/)本地默认服务跑在27017端口这个地址表示连接本机MongoDB。大多数情况下这行代码就够了。但我要提醒一个细节MongoClient是惰性连接的——它不会在创建时立刻建立网络连接而是等到第一次真正操作数据库时才建立。所以这行代码没报错不代表服务端一定通你最好接着执行一句client.admin.command(ping)返回True才算真正连上。连接字符串里还可以带参数常用的有这么几个参数作用建议serverSelectionTimeoutMS服务端选择超时时间默认30000毫秒局域网内可调到5000快速失败connectTimeoutMSTCP连接超时默认10000按需调整authSource认证数据库使用账号密码认证时必填replicaSet副本集名称连接副本集时必须带上比如带认证的连接串长这样client MongoClient( mongodb://admin:passwordlocalhost:27017/?authSourceadmin )其中authSourceadmin表示账号是在admin库下创建的。这个参数很多人忘了写结果连接串看着是对的实际认证却失败。3. 文档数据库的增删改查dict与BSON的一次握手3.1 插入数据前的隐式创建机制弄清楚连接之后就可以实际操作了。MongoDB一个有别于关系型数据库的特点是隐式创建——你不需要先用命令创建数据库和集合直接写入第一条数据数据库和集合就自动出现了。例如db client[mydatabase] # 这一步不创建库只是拿到引用 collection db[users] # 同样不创建集合 user { name: 张三, age: 28, tags: [python, mongodb], address: { city: 北京, district: 海淀 } } result collection.insert_one(user) print(result.inserted_id)执行完后mydatabase和users集合就被创建出来了。insert_one返回的InsertOneResult对象里有inserted_id属性它就是你插入文档的_id字段值。这里注意两个细节。第一_id是MongoDB每个文档都自带的唯一标识字段如果你没显式提供MongoDB会自动生成一个ObjectId类型的值。这个值由时间戳、机器标识、进程ID和随机数组成全球唯一。第二字段类型要合法MongoDB的文档本质是BSON格式Binary JSON它支持的字段类型比JSON多比如日期类型datetime.datetime、二进制类型bytes、正则表达式等。如果你用Python的int和str完全没问题直接映射到BSON的Int64和String。批量插入的场景用insert_many效率比循环调用insert_one高得多users [ {name: 李四, age: 25, dept: 技术部}, {name: 王五, age: 32, dept: 产品部}, {name: 赵六, age: 29, dept: 技术部}, ] result collection.insert_many(users) print(result.inserted_ids) # 返回所有插入的 _id 列表3.2 查询的两个入口find与find_one查询数据最基础的两个方法是find_one和find。find_one返回第一条匹配的文档适合按唯一字段查单条find返回一个游标Cursor你可以遍历它来获取所有匹配文档。# 查单条 user collection.find_one({name: 张三}) print(user[age]) # 28 # 查多条 cursor collection.find({dept: 技术部}) for doc in cursor: print(doc[name], doc[age])注意find()的返回结果不是列表而是一个游标对象。游标是惰性的——它在你开始遍历时才向服务端请求数据而且一次只拉一批。这种设计让内存占用保持可控即使集合有几百万条记录你也可以安全地遍历全表。但如果你确定结果集很小或者你需要对结果做随机访问可以强制转成列表list(cursor)。查询条件这块MongoDB的语法很容易上手。逻辑上类比SQL需求SQL写法MongoDB查询写法年龄等于28WHERE age 28{age: 28}年龄大于30WHERE age 30{age: {$gt: 30}}年龄在25到35之间WHERE age BETWEEN 25 AND 35{age: {$gte: 25, $lte: 35}}名字在列表里WHERE name IN (张三,李四){name: {$in: [张三,李四]}}多个条件叠加WHERE dept技术部 AND age25{dept: 技术部, age: {$gt: 25}}条件操作符$gt、$gte、$lte、$in等几乎涵盖了日常90%的过滤需求。对嵌套字段的查询用点号路径result collection.find_one({address.city: 北京})这行代码能在嵌套字典address里查找city字段等于北京的文档。3.3 更新操作中最关键的$set陷阱更新文档时有一个新手几乎必踩的坑直接对查出来的文档修改后调用save方法或者用update_one时把整个文档作为更新参数传进去。先说后果前者会把整个文档替换成新值如果文档里有些字段你忘了带上那这些字段就被抹掉了后者更麻烦——MongoDB的更新操作符是$set、$inc这类如果你直接把{age: 30}当作更新参数MongoDB会尝试把整个文档替换为{age: 30}其他字段全部丢失。正确且推荐的做法是# 将张三的年龄改为29 result collection.update_one( {name: 张三}, {$set: {age: 29}} ) print(result.modified_count) # 1$set的作用是只更新指定字段其他字段保持不动。类似的常用更新操作符还包括$inc字段自增适合计数器场景。{$inc: {age: 1}}表示年龄加1$push向数组字段追加元素$pull从数组字段移除匹配元素$unset删除指定字段举个组合场景给用户添加一个新标签同时给年龄加1collection.update_one( {name: 张三}, { $push: {tags: 数据库}, $inc: {age: 1} } )一次网络请求完成多个更新操作性能和语义都清晰。3.4 删除数据的两种姿势删除操作相对简单按条件删单条或多条即可# 删除单个匹配文档 result collection.delete_one({name: 张三}) print(result.deleted_count) # 删除所有匹配文档 result collection.delete_many({dept: 技术部}) print(result.deleted_count) # 清空集合 collection.delete_many({})这里有一个值得注意的行为如果delete_one的查询条件匹配到了多条文档它只会删除第一条按自然顺序不会报错。所以使用delete_one时查询条件最好能唯一定位到目标文档比如用_id。条件太宽泛的时候用delete_many其实更符合直觉。的确到这你会发现增删改查的语法基本就是传一个dict作为过滤条件这和Python的dict操作天然契合学习成本极低。4. 实际项目中用过才知道的MongoDB隐藏逻辑4.1 _id字段的生成规则与排序价值第一节提到过_id默认由ObjectId生成这个类型值本身有讲究。一个ObjectId是12字节的BSON类型前4字节是生成时间的时间戳中间5字节是机器标识最后3字节是自增计数器。换句话说从_id里可以直接提取文档的创建时间from bson.objectid import ObjectId obj_id ObjectId() # 自己生成一个 print(obj_id.generation_time)因为这个特性按创建时间排序可以直接按_id排序不需要额外的时间字段和索引# 获取最近注册的10个用户 cursor collection.find().sort(_id, -1).limit(10)这个技巧能少建一个索引少占一份存储空间。但要注意_id的时间精度是秒级同一秒内创建的多个文档_id的大小顺序和插入顺序不一定完全一致。如果业务要求严格的插入顺序还需要额外加一个自增序号字段。4.2 为什么说BSON比JSON更适合PythonPython的dict和MongoDB的BSON文档互相转换的时候有几个类型对应关系值得记住。datetime.datetime会转成BSON的UTC时间读取出来的datetime是naive datetime不带时区信息。如果你在代码里存了一个带时区的datetimePyMongo会抛TypeError。所以建议统一使用datetime.now(timezone.utc)生成带UTC时区的对象或者干脆用datetime.utcnow()生成naive UTC时间避免时区混乱。Python的float对应BSON的Doubleint对应Int64bool对应Booleanbytes对应BinaryNone对应Null。还有一个容易忽略的Python的大整数超过2^63-1时BSON没法直接存你得先把数值转成字符串否则写入时就报OverflowError。这在处理ID超过JavaScript安全整数范围的场景比如某些外部系统返回的雪花ID时很常见我实际项目里就用字符串存过这种大ID。4.3 游标遍历的先取后弃难题find()返回的游标有一个特性叫超时回收。MongoDB服务端为每个游标设置了一个10分钟的闲置超时如果客户端10分钟内没有从游标里取数据服务端会主动关闭游标你再次迭代时就会抛CursorNotFound异常。这个机制是为了防止客户端忘记关闭游标导致服务端资源泄漏。小数据量的场景不必担心遍历一次就结束了不存在闲置10分钟的可能。但如果你遇到了遍历一下处理一下其他事情再回来接着遍历的写法就必须小心游标超时。解决方案有几种在find()时传入no_cursor_timeoutTrue告诉服务端不要超时关闭但用完必须手动关闭游标把游标数据一次性取到列表里再处理数据量大时慎用用batch_size()控制每次取回的数据量避免单次网络传输过大按我的经验第一种方案最实用但有个前提——你确定自己会在合理时间内遍历完。如果中途程序崩了游标留在服务端会等到下次扫描才被清理这是可以接受的代价。4.4 批量插入的瘦身技巧insert_many虽然比循环快但它默认情况下是一口气把所有数据发过去的。如果数据量极大——比如一次性导入上百万条日志——直接insert_many可能触发超时或内存问题。PyMongo的insert_many支持orderedFalse参数让数据无序插入加快写入速度更重要的是你可以把数据拆成每5000条一个批次循环插入在吞吐量和单次请求时长之间找个平衡点。batch_size 5000 for i in range(0, len(data_list), batch_size): collection.insert_many(data_list[i:ibatch_size], orderedFalse)实测下来批量大小从几百调到几千写入性能会明显提升但再往上加提升就有限了反而单次请求的处理时长变长拖累整体。如果你还想要更快的写入速度可以后续把关注点放到非安全写入上——即设置write_concern0让服务端发完即走不等待持久化确认这个等系列后续文章再展开聊。5. 从会用到能用索引优化与聚合管道的第一课5.1 为什么你应该现在就创建索引很多初学者对索引的概念只停留在加了查询就变快但MongoDB里有一个更特殊的理由不建索引的查询会做全集合扫描Collection Scan数据量小无所谓一旦超过几万条查询延迟就会明显暴露。创建一个简单的单字段索引代码也就一行collection.create_index(age)这行代码为age字段创建了升序索引。查询{age: {$gt: 30}}时会自动命中这个索引速度会快很多。复合索引应对多条件查询collection.create_index([(dept, 1), (age, -1)])它表示先按dept升序再按age降序建立索引能同时加速某个部门且年龄范围内的查询。创建索引时有个优化细节create_index默认是阻塞式操作如果集合数据量大服务端会阻塞其他读写直到索引建完线上环境就比较危险。可以加backgroundTrue参数让索引在后台构建collection.create_index(age, backgroundTrue)注意MongoDB 4.4及以上版本中background参数已经被标记为弃用默认所有索引都在后台构建。但你的服务端版本如果偏老这个参数还是需要的。5.2 聚合管道比查询条件更灵活的数据处理方式聚合Aggregation是MongoDB里处理数据的核心能力它把一系列阶段操作串起来每个阶段接收上一阶段的输出处理后传给下一阶段。PyMongo里的使用入口是aggregate方法。举个实际例子统计每个部门的人数并筛选出人数大于5的部门。pipeline [ {$group: {_id: $dept, count: {$sum: 1}}}, {$match: {count: {$gt: 5}}}, {$sort: {count: -1}} ] results collection.aggregate(pipeline) for doc in results: print(doc)第一个阶段$group按dept分组$sum: 1统计每组文档数第二个阶段$match过滤出人数大于5的组第三个阶段$sort按人数降序。顺序很重要如果把$match放在前面先过滤再分组可以减少$group处理的数据量性能更好。这个调优思路和SQL的先WHERE再GROUP BY一样。再举一个关联查询的例子。MongoDB支持$lookup类似SQL的LEFT JOIN。比如有一个orders集合和一个products集合想查出每个订单关联的产品信息pipeline [ { $lookup: { from: products, localField: product_id, foreignField: _id, as: product_info } } ]结果里每条订单文档会多出一个product_info数组字段里面包含匹配到的产品文档。$lookup的from是指定关联集合名localField是当前集合的关联字段foreignField是被关联集合的对应字段。它是文档型数据库做关联操作最常用的手段但在设计数据模型时还是应该尽量少用——频繁的$lookup会带来性能问题这一点和关系型数据库里别到处JOIN是一个道理。5.3 MongoClient的生命周期管理最后说一个代码结构层面的问题MongoClient应该创建几次网上很多教程在函数内部直接MongoClient()每次调用都新建连接。这样做的问题是连接建立有开销频繁创建会导致大量TIME_WAIT状态的连接高并发下可能把服务端的连接池打满。正规做法是在模块级别创建一次全流程复用from pymongo import MongoClient client MongoClient(mongodb://localhost:27017/) db client[mydatabase] collection db[users]之后所有操作都引用这个全局的collection。MongoClient内部自带连接池多个线程可以并发安全地共享同一个实例。这个设计省掉了反复建连的系统开销也让代码结构更清爽。当程序退出时可以调用client.close()关闭连接但大多数脚本进程结束时操作系统会自行清理不写也没关系。6. 实操中绕不开的几类常见报错与排查思路6.1 ServerSelectionTimeoutError出现时先查网络这个报错大概是PyMongo新手遇到最多的。它字面意思是选择服务端超时但真正原因并不一定是网络不通。排查链路按顺序走先确认MongoDB服务有没有启动——命令行执行mongod --version如果没反应连服务端都没跑起来然后确认端口——Windows用netstat -ano | findstr 27017macOS/Linux用lsof -i:27017看端口是否处于监听状态最后确认连接字符串对不对——本地写localhost可能被解析成IPv6的::1而MongoDB服务只监听了IPv4的127.0.0.1这时候把连接串改成mongodb://127.0.0.1:27017/就能解决。这个IPv6的问题我在某次内网部署时真实遇到过。当时服务端在开发机上监听127.0.0.1客户端连接串用的localhost一连接就超时。排查了半天最后把localhost换成127.0.0.1瞬间连上。这就是环境差异导致的小坑但排查起来很费时间。6.2 InvalidDocument错误里的类型陷阱写入时如果字段值包含PyMongo不支持的Python类型——比如set、tuple、自定义类对象——就会报InvalidDocument错误。例如user {name: 张三, tags: {python, mongodb}} # set 类型 collection.insert_one(user) # 报错set是无序的BSON没有对应类型所以写入失败。解决方式是把set转成list。同理tuple也要转成list。这个坑看起来小但在处理API返回值、爬虫抓取的数据时很容易出现因为很多爬虫框架解析出来的是set类型。6.3 读取数据时莫名多出个_id字段从数据库读出来的文档里总会带一个_id字段如果你把文档直接返回给前端_id这个字段名既不美观也可能暴露内部结构。常见的处理是在读取时把_id替换掉doc collection.find_one({name: 张三}) doc[id] str(doc.pop(_id))如果项目前后端都统一了文档结构这个操作几乎肯定要做。但注意str()转换后你可以正常序列化但要注意它不再是一个ObjectId对象后续要用这个ID查询时还需要转回去。6.4 大批量导出数据时的内存前置陷阱最后提一个性能相关的坑。当集合里有几十万条数据你用list(collection.find())一次性读取时所有文档会先加载到内存再把列表返回中间的内存占用可能直接冲高到几百MB。对于跑在轻量云服务器上的脚本这足以触发OOM。推荐做法是把find结果当作流式数据处理cursor collection.find({}) for doc in cursor: process(doc) # 逐条处理这样游标会分批从服务端拉取数据内存占用固定在单个批次大小不随数据总量增长。如果确实需要随机访问所有数据再用list()但先用count_documents()估算一下数据量做到心里有数。7. 写在最后的实战建议7.1 先用小项目验证整套流程再上生产第一次用MongoDB做项目时不要直接拿线上大流量服务试水。我个人的做法是先用一个内部小工具跑通CRUD、索引和备份恢复再逐步扩大应用范围。比如写一个日志采集脚本把每天的运行日志写入MongoDB然后用聚合管道做简单统计最后加索引优化查询。这个过程中你会自然熟悉BSON类型的转换、游标使用习惯、连接池管理这些基础功后续做正式项目就有底气了。7.2 把官方文档和版本号刻在脑子里PyMongo的官方文档写得相当清晰每个方法都有示例更新也及时。但有一点要特别注意文档是针对最新版本写的你项目里的依赖版本未必是最新的。遇到API行为不一致时先用pymongo.__version__确认版本再查对应年份的文档别对着最新文档去排查老版本的问题。7.3 数据模型设计要留演化余地文档型数据库的优势就是灵活但灵活不意味着随性。我的建议是公共字段比如created_at、updated_at、status一定要统一命名风格嵌套层级不要超过三层太深的嵌套会让查询和聚合管道都变得复杂数组字段要评估是否会无限增长比如用户登录历史这种字段无限push会导致文档体积膨胀超过16MB的最大文档限制就麻烦了。这种字段该拆集合就拆集合别硬塞进一个文档里。我实际项目中有个教训早期把用户操作日志直接嵌套在用户文档里结果活跃用户的文档体积快速膨胀查询性能下降明显。后来把日志拆到独立集合数据量翻了几倍反而查询更快。这就是文档设计决定性能上限的典型例子。下一篇系列文章里我会写如何用MongoEngine定义数据模型、处理文档间的引用关系以及如何用复制集保证高可用。这一篇的内容如果你都实操过一遍基础的Python MongoDB开发应该不成问题了。
返回列表