
文章目录每日一句正能量1. 背景与问题2. 环境与数据3. 复现过程3.1 复现“大文档万能表”3.2 复现内嵌事件数组3.3 复现缺少版本治理4. 方案实施4.1 建模原则4.2 建模 DDL4.2.1 内容主表4.2.2 素材明细表4.2.3 时序事件表4.2.4 热度汇总表4.2.5 向量特征表4.3 数据写入4.4 真实查询一栏目内容列表4.5 真实查询二模板属性与标签筛选4.6 真实查询三内容与热度联合4.7 真实查询四关系过滤 文档过滤 向量排序4.8 Schema 版本升级5. 结果对比6. 风险与复盘6.1 把 MongoDB 兼容理解为“所有语义完全一致”6.2 JSONB 索引失控6.3 大数组继续回到文档中6.4 向量能力被当成通用搜索替代6.5 时序事件与汇总不一致6.6 回退方案总结每日一句正能量人生本就是一场取舍有得必有失有舍才有得。很多人痛苦的原因是“什么都要”——既要安稳又要自由既要陪伴又要独处既要选择A的好处又不愿承担A的代价。取舍不是人生的无奈而是人生的结构本身。你不可能什么都攥在手里松开拳头才能接住新的东西。1. 背景与问题某内容管理平台早期使用 MongoDB 保存文章、专题页和活动页。最初的选择很合理不同栏目模板字段差异明显运营人员经常新增属性文档整体读写也比频繁改表更灵活。随着系统进入规模化运营问题逐渐从“能否存下”变成“能否稳定查询、审计和治理”。线上最常见的查询不是简单地按_id取一篇文档而是按租户、栏目、作者和发布状态做权限过滤按发布时间查询最近三十天内容按标签、渠道、模板字段筛选汇总浏览、收藏、转发等时序事件根据标题、摘要和正文向量做语义检索返回内容主数据、实时热度、审核状态和相似度。如果把这些能力都塞进单个大文档会出现几个典型矛盾栏目、作者、角色等强关系数据难以维护一致性浏览事件持续增长内嵌数组会不断放大文档JSON 深层路径查询可读性差索引数量迅速膨胀每次修改一个局部属性也可能造成较大的写放大语义向量与正文生命周期不同重算和回滚困难数据结构没有明确版本历史文档很难统一升级。本次改造的目标不是把 MongoDB 文档“翻译”为一张 JSON 表而是保留文档模型的灵活性同时把稳定关系、时序明细和向量特征放到更合适的结构中。最终模型采用四类能力组合关系模型租户、栏目、作者、权限、发布状态文档模型模板扩展字段、标签、素材描述、渠道参数时序模型浏览、点赞、审核、发布状态变更向量模型标题与正文的语义特征。2. 环境与数据验证环境采用脱敏复现实验数据规模如下对象规模主要用途内容主表120 万行文章、专题页、活动页文档扩展字段平均 2.8 KB/行模板字段、标签、渠道属性行为事件1.6 亿行浏览、收藏、分享内容向量120 万条语义检索租户数86权限隔离日新增内容2.5 万发布与更新MongoDB 原始文档大致如下{_id:CNT2026000188,tenantId:12,columnId:301,authorId:90021,status:PUBLISHED,title:数据库迁移实践,publishedAt:ISODate(2026-06-18T08:30:00Z),tags:[数据库,迁移,性能],template:{coverType:large,showAuthor:true,channel:portal},materials:[{type:image,url:/a/1.png},{type:video,url:/v/2.mp4}],metrics:{views:18320,likes:760}}这个结构在小规模时使用方便但metrics会频繁更新materials可能持续增长tenantId、columnId和authorId又需要强约束与 JOIN。于是同一文档同时承担了主数据、扩展属性、事件聚合和缓存快照四种职责。3. 复现过程3.1 复现“大文档万能表”先用一个极简表模拟“整篇文档直接落库”的做法CREATETABLEcms_content_raw(idvarchar(40)PRIMARYKEY,doc jsonbNOTNULL,created_attimestampNOTNULLDEFAULTcurrent_timestamp);CREATEINDEXidx_content_raw_doc_ginONcms_content_rawUSINGgin(doc);查询“租户 12、已发布、数据库标签、最近三十天”的内容SELECTid,doc-titleAStitleFROMcms_content_rawWHERE(doc-tenantId)::bigint12ANDdoc-statusPUBLISHEDANDdoc-tags[数据库]::jsonbAND(doc-publishedAt)::timestampcurrent_timestamp-interval30 dayORDERBY(doc-publishedAt)::timestampDESCLIMIT20;这段 SQL 可以运行但暴露出四个问题。第一数值与时间字段需要转换表达式复杂第二租户与状态都是高频条件却隐藏在 JSON 中第三时间排序依赖表达式普通 B-tree 索引无法直接覆盖第四一个通用 GIN 索引并不能同时解决范围、排序和多租户隔离问题。3.2 复现内嵌事件数组将浏览事件持续追加到文档UPDATEcms_content_rawSETdocjsonb_set(doc,{events},coalesce(doc-events,[]::jsonb)||jsonb_build_array(jsonb_build_object(type,VIEW,at,current_timestamp,userId,600018)))WHEREidCNT2026000188;这种方式的问题不是语法而是数据生命周期。内容正文一年可能只改几次浏览事件却可能每秒增加。把二者放在同一个文档中相当于让一个热点计数器不断重写冷数据。3.3 复现缺少版本治理历史文档中template.channel可能是字符串新版本改成对象channel:{code:portal,name:门户}如果没有schema_version应用只能通过“字段是字符串还是对象”猜测版本。查询、更新和回滚都会变得不确定。4. 方案实施4.1 建模原则本次采用“稳定字段上提、变化字段文档化、增长明细拆表、语义特征独立”的原则。判断一个字段是否应保留在 JSONB至少看四个维度判断项更适合关系列更适合 JSONB结构稳定性稳定、长期不变模板差异大、经常扩展约束要求主外键、唯一、非空弱约束或按模板校验查询频率高频过滤、排序、JOIN偶发筛选、整体展示更新模式单字段频繁更新多属性整体更新4.2 建模 DDL4.2.1 内容主表CREATETABLEcms_content(content_idbigintGENERATEDBYDEFAULTASIDENTITYPRIMARYKEY,external_idvarchar(40)NOTNULL,tenant_idbigintNOTNULL,column_idbigintNOTNULL,author_idbigintNOTNULL,content_typevarchar(20)NOTNULL,statusvarchar(20)NOTNULL,titlevarchar(300)NOTNULL,summaryvarchar(1000),published_attimestamp,created_attimestampNOTNULLDEFAULTcurrent_timestamp,updated_attimestampNOTNULLDEFAULTcurrent_timestamp,schema_versionintegerNOTNULLDEFAULT1,ext_doc jsonbNOTNULLDEFAULT{}::jsonb,CONSTRAINTuk_content_externalUNIQUE(tenant_id,external_id),CONSTRAINTck_content_statusCHECK(statusIN(DRAFT,REVIEWING,PUBLISHED,OFFLINE)));CREATEINDEXidx_content_listONcms_content(tenant_id,status,published_atDESC,content_idDESC);CREATEINDEXidx_content_columnONcms_content(tenant_id,column_id,published_atDESC);CREATEINDEXidx_content_ext_ginONcms_contentUSINGgin(ext_doc jsonb_path_ops);主表只保留高频过滤、排序、关联和治理字段。ext_doc保存模板扩展属性{tags:[数据库,迁移,性能],template:{coverType:large,showAuthor:true,channel:portal},seo:{keywords:[国产数据库,迁移实践]}}这里选择jsonb_path_ops是因为主要查询是包含匹配若大量使用键存在、任意路径或不同操作符应在验证后选择默认jsonb_ops不能机械套用。4.2.2 素材明细表CREATETABLEcms_content_material(material_idbigintGENERATEDBYDEFAULTASIDENTITYPRIMARYKEY,content_idbigintNOTNULL,material_typevarchar(20)NOTNULL,resource_urlvarchar(1000)NOTNULL,sort_nointegerNOTNULLDEFAULT0,attributes jsonbNOTNULLDEFAULT{}::jsonb,created_attimestampNOTNULLDEFAULTcurrent_timestamp,CONSTRAINTfk_material_contentFOREIGNKEY(content_id)REFERENCEScms_content(content_id));CREATEINDEXidx_material_contentONcms_content_material(content_id,sort_no);素材是可增长集合且需要独立排序、审核和替换因此不继续塞进主文档数组。4.2.3 时序事件表CREATETABLEcms_content_event(event_timetimestampNOTNULL,tenant_idbigintNOTNULL,content_idbigintNOTNULL,event_typevarchar(20)NOTNULL,user_idbigint,session_idvarchar(64),event_props jsonbNOTNULLDEFAULT{}::jsonb)PARTITIONBYRANGE(event_time);按月建立范围分区CREATETABLEcms_content_event_202607PARTITIONOFcms_content_eventFORVALUESFROM(2026-07-01)TO(2026-08-01);CREATEINDEXidx_event_content_time_202607ONcms_content_event_202607(content_id,event_timeDESC);CREATEINDEXidx_event_tenant_type_202607ONcms_content_event_202607(tenant_id,event_type,event_timeDESC);如果目标版本提供 TimescaleDB 等时序扩展可进一步使用 hypertable、时间分块和压缩能力但扩展是否可用应先查询SELECTname,default_version,installed_versionFROMsys_available_extensionsWHERElower(name)LIKE%time%ORlower(name)LIKE%vector%;4.2.4 热度汇总表实时查询不直接扫描全部事件而是维护分钟或小时级汇总CREATETABLEcms_content_metric_hourly(bucket_timetimestampNOTNULL,tenant_idbigintNOTNULL,content_idbigintNOTNULL,view_countbigintNOTNULLDEFAULT0,like_countbigintNOTNULLDEFAULT0,share_countbigintNOTNULLDEFAULT0,PRIMARYKEY(bucket_time,tenant_id,content_id));4.2.5 向量特征表向量类型和索引语法依赖目标版本及已安装扩展因此生产脚本必须按实际环境替换。下面给出逻辑结构不把扩展名称写死CREATETABLEcms_content_embedding(content_idbigintPRIMARYKEY,model_namevarchar(100)NOTNULL,model_versionvarchar(40)NOTNULL,embedding_dimintegerNOTNULL,embedding_data/* 目标版本支持的向量类型 */NOTNULL,source_hashvarchar(64)NOTNULL,generated_attimestampNOTNULL,CONSTRAINTfk_embedding_contentFOREIGNKEY(content_id)REFERENCEScms_content(content_id));source_hash用于判断正文是否变化model_version用于向量重算和灰度切换。向量不直接嵌进ext_doc原因是它体积大、生成成本高、版本独立而且需要专门索引。4.3 数据写入INSERTINTOcms_content(external_id,tenant_id,column_id,author_id,content_type,status,title,summary,published_at,schema_version,ext_doc)VALUES(CNT2026000188,12,301,90021,ARTICLE,PUBLISHED,数据库迁移实践,一次可复现的迁移与性能优化记录,timestamp2026-06-18 16:30:00,2,{ tags:[数据库,迁移,性能], template:{ coverType:large, showAuthor:true, channel:portal } }::jsonb);写入后补充素材和事件INSERTINTOcms_content_material(content_id,material_type,resource_url,sort_no,attributes)SELECTcontent_id,IMAGE,/a/1.png,1,{width:1200,height:675,alt:迁移流程图}::jsonbFROMcms_contentWHEREtenant_id12ANDexternal_idCNT2026000188;INSERTINTOcms_content_event(event_time,tenant_id,content_id,event_type,user_id,session_id)SELECTcurrent_timestamp,tenant_id,content_id,VIEW,600018,S-90001FROMcms_contentWHEREtenant_id12ANDexternal_idCNT2026000188;4.4 真实查询一栏目内容列表SELECTc.content_id,c.title,c.summary,c.published_at,c.ext_doc-tagsAStagsFROMcms_content cWHEREc.tenant_id12ANDc.column_id301ANDc.statusPUBLISHEDANDc.published_atcurrent_timestamp-interval30 dayORDERBYc.published_atDESC,c.content_idDESCLIMIT20;这类查询完全依赖关系列和 B-tree 索引不必触碰 JSONB。4.5 真实查询二模板属性与标签筛选SELECTc.content_id,c.title,c.published_atFROMcms_content cWHEREc.tenant_id12ANDc.statusPUBLISHEDANDc.ext_doc { tags:[数据库], template:{channel:portal} }::jsonbORDERBYc.published_atDESCLIMIT20;若channel成为全站高频条件应上提为关系列或建立表达式索引而不是无限增加 GIN 索引CREATEINDEXidx_content_channelONcms_content(tenant_id,(ext_doc# {template,channel}),published_atDESC);4.6 真实查询三内容与热度联合WITHhotAS(SELECTcontent_id,sum(view_count)ASviews_24h,sum(like_count)ASlikes_24hFROMcms_content_metric_hourlyWHEREtenant_id12ANDbucket_timedate_trunc(hour,current_timestamp)-interval24 hourGROUPBYcontent_id)SELECTc.content_id,c.title,coalesce(h.views_24h,0)ASviews_24h,coalesce(h.likes_24h,0)ASlikes_24hFROMcms_content cLEFTJOINhot hONh.content_idc.content_idWHEREc.tenant_id12ANDc.statusPUBLISHEDORDERBYcoalesce(h.views_24h,0)DESC,c.published_atDESCLIMIT50;4.7 真实查询四关系过滤 文档过滤 向量排序向量操作符和索引写法以实际扩展为准推荐的查询顺序是先通过租户、状态、时间和标签缩小候选集再做向量排序WITHcandidateAS(SELECTcontent_id,title,summary,published_atFROMcms_contentWHEREtenant_id:tenant_idANDstatusPUBLISHEDANDpublished_atcurrent_timestamp-interval180 dayANDext_doc :tag_filter::jsonbORDERBYpublished_atDESCLIMIT5000)SELECTc.content_id,c.title,c.summary,/* e.embedding_data 与 :query_vector 的距离 */ASdistanceFROMcandidate cJOINcms_content_embedding eONe.content_idc.content_idWHEREe.model_name:model_nameANDe.model_version:model_versionORDERBYdistanceLIMIT20;4.8 Schema 版本升级新增schema_version后每次文档结构变化都有明确迁移路径。UPDATEcms_contentSEText_docjsonb_set(ext_doc-channel,{template,channel},jsonb_build_object(code,coalesce(ext_doc# {template,channel}, unknown),name,coalesce(ext_doc# {template,channel}, 未知渠道)),true),schema_version3,updated_atcurrent_timestampWHEREschema_version2;升级应分批执行并记录批次、开始时间、结束时间、影响行数和失败样本。应用在灰度期同时兼容版本 2 和版本 3确认稳定后再停止旧结构写入。5. 结果对比以下为脱敏复现实验示例正式投稿应替换为真实压测记录。指标单 JSON 大文档多模组合模型变化栏目列表 P95286 ms24 ms-91.6%标签筛选 P95430 ms61 ms-85.8%24 小时热度榜 P952.8 s95 ms-96.6%单次浏览事件写入 P9538 ms4.6 ms-87.9%主文档平均大小18.4 KB3.1 KB-83.2%主表更新 WAL/日志量基线 1.000.29-71%语义检索候选量120 万5000-99.6%优化收益主要来自三点。第一租户、状态、栏目和时间从 JSON 中上提后B-tree 能完成过滤和排序第二浏览事件从主文档拆出避免热点更新反复重写正文第三向量检索先做关系与 JSONB 过滤把高成本距离计算限制在候选集合内。但组合模型也不是“表越多越好”。拆分后写入链路更复杂需要事务边界、异步任务、汇总延迟和失败补偿。建模收益必须通过真实查询和维护成本验证。6. 风险与复盘6.1 把 MongoDB 兼容理解为“所有语义完全一致”应用使用习惯相似不等于 BSON 类型、空值、缺失字段、数组比较、排序、事务和索引行为完全一致。迁移前应建立语义对照集至少覆盖字段不存在、JSONnull与 SQLNULL数字、字符串和布尔类型混用数组包含、数组顺序与重复元素嵌套路径不存在日期时区与毫秒精度_id生成与唯一约束更新操作的原子边界驱动参数绑定类型。6.2 JSONB 索引失控GIN 索引适合包含查询但写入成本和空间占用不能忽略。治理规则应是只给真实高频路径建索引先采样 SQL再决定索引定期检查索引大小和扫描次数低频模板字段不建独立索引高频稳定字段优先上提为关系列。6.3 大数组继续回到文档中评论、素材、审核轨迹、行为事件都可能持续增长。判断标准不是“它是否属于文章”而是“它是否与文章同生命周期、同更新频率、同查询方式”。持续增长或需要单独分页、排序、审计的集合应拆成子表。6.4 向量能力被当成通用搜索替代向量检索擅长语义相似不负责精确权限、状态、时间和业务约束。正确顺序应是结构化过滤在前、向量排序在后同时保留关键词检索或全文检索作为可解释补充。6.5 时序事件与汇总不一致事件明细是事实源小时汇总是派生数据。需要建立可重算机制DELETEFROMcms_content_metric_hourlyWHEREbucket_time:start_timeANDbucket_time:end_timeANDtenant_id:tenant_id;INSERTINTOcms_content_metric_hourly(bucket_time,tenant_id,content_id,view_count,like_count,share_count)SELECTdate_trunc(hour,event_time),tenant_id,content_id,count(*)FILTER(WHEREevent_typeVIEW),count(*)FILTER(WHEREevent_typeLIKE),count(*)FILTER(WHEREevent_typeSHARE)FROMcms_content_eventWHEREtenant_id:tenant_idANDevent_time:start_timeANDevent_time:end_timeGROUPBYdate_trunc(hour,event_time),tenant_id,content_id;6.6 回退方案灰度上线分四步新模型建立并完成历史数据回填应用双写旧文档与新模型持续比对读流量按 5%、20%、50%、100% 切换停止旧模型写入前保留完整回放水位。关键校验 SQL-- 行数SELECTcount(*)FROMcms_content;-- 业务主键重复SELECTtenant_id,external_id,count(*)FROMcms_contentGROUPBYtenant_id,external_idHAVINGcount(*)1;-- 文档标签抽样SELECTexternal_id,ext_doc-tagsFROMcms_contentWHEREtenant_id12ORDERBYcontent_idLIMIT100;-- 孤儿素材SELECTcount(*)FROMcms_content_material mLEFTJOINcms_content cONc.content_idm.content_idWHEREc.content_idISNULL;一旦新模型出现结果偏差、P95 恶化、写入失败率升高或汇总延迟超标应用路由立即切回旧读路径新表继续保留不直接删除用差异日志补齐后重新灰度。总结MongoDB 风格文档模型的价值在于灵活不在于把所有数据都变成一个 JSON。内容管理系统真正稳定的做法是根据数据关系、变化速度、查询方式和生命周期选择模型关系模型守住租户、权限、栏目、状态和一致性JSONB 承载模板差异和稀疏扩展字段时序表保存持续增长的事件事实汇总表提供稳定热度查询向量表独立管理模型版本和语义特征。最终效果并不是“关系数据库模拟 MongoDB”而是让文档能力成为多模数据体系中的一部分。真正决定模型好坏的不是 DDL 看起来是否新颖而是它能否用更少的扫描、更清晰的约束和更可控的回退支撑真实业务查询。转载自https://blog.csdn.net/u014727709/article/details/163365986欢迎 点赞✍评论⭐收藏欢迎指正