元数据定义框架metadef的并发演进:从全局锁到多级缓存

元数据定义框架metadef的并发演进:从全局锁到多级缓存 做计算平台开发这些年我最有感触的一件事就是不管上层是数据湖、AI训练平台还是实时计算底下永远绕不开一套稳定可靠的元数据定义框架。我们团队前几年自研了一套叫metadef的元数据定义框架把平台上所有元数据对象统一收编并围绕并发场景做了多轮演进。这篇文章想把metadef的核心逻辑和这条并发演进路径完整讲清楚顺便记录我们踩过的坑。如果你正在搭计算平台、数据中台或者正在被元数据读写冲突、接口超时、缓存不一致这些老问题折磨那这篇内容应该能帮你少走不少弯路。文章不会只讲概念而是把类型系统、存储模型、版本链、缓存策略、分布式锁和JMeter压测结果都摊开来讲。文中的压测数据来自我们实际环境虽然场景不一定能完整复刻但思路和方法可以直接复用。1. 计算平台里的元数据框架到底治的是什么病1.1 元数据定义混乱是平台的“隐形杀手”元数据这个词听起来抽象但在计算平台里它具体得不能再具体你有一张数据表表的名称、字段、类型、分区、归属项目、权限等级、数据更新时间、生命周期策略这些都是元数据你有一个跑批任务任务的依赖关系、调度时间、重试次数、运行集群、资源配额也是元数据你有一个算法模型模型的输入输出、版本号、训练框架、部署状态、监控指标同样是元数据。计算平台本质上是“元数据驱动”的计算引擎只是按元数据的描述去执行。在没有统一框架之前平台里每个子团队都有一套自己的写法。调度系统自己建表存任务信息数据服务自己用JSON串存表结构AI平台干脆把模型配置塞进文件里。表面上大家各干各的等要打通数据链路时才发现同一张表在调度系统里叫table_meta在数据服务里叫table_info字段名对不上类型对不上甚至一份元数据更新后要等半个小时才能同步到其他系统。这些问题在规模小的时候还能靠人肉协调平台一变大排查成本就高到离谱。拿生活里的场景类比这就像一家大型图书馆每个分馆都用自己的一套方式编目有的按中图法有的用自编号还有个分馆直接记在Excel表里。当你需要跨馆检索一本书时根本不知道去哪查更不敢信查到的结果。计算平台也是一样元数据定义不统一上层所有能力都会失真。所以metadef要治的第一个病就是定义混乱平台上所有元数据对象必须按照同一套描述语言来定义否则无法注册、无法存储、无法被查询。1.2 metadef的设计定位统一描述、统一存取metadef初期的定位很简单让所有元数据对象都变成“一等公民”。所谓一等公民就是你能像操作一个标准对象一样去创建、查询、更新、删除任何一种元数据而不需要关心底层是MySQL还是Redis也不需要关心它到底存到哪张表。这个定位意味着metadef需要具备几块核心能力类型定义能力Type System、数据校验能力Validation、存取能力Storage Index、版本管理能力Versioning、缓存能力Caching。这些能力组合起来再向平台暴露一组统一的Restful API上层系统就不需要各自为战了。我当时在架构评审时画过一张很朴素的图最底层是存储引擎上面是索引与缓存层再往上是定义与校验层最外层是API网关。所有元数据操作都从最外层进来经过统一语义处理之后落到底层存储。这套分层表面看没什么技术含量但它最大的价值是给后续并发演进留出了空间。如果一开始就把缓存、锁、版本这些逻辑散落在各个业务代码里后面想优化并发基本就得推倒重来。设计目标具体含义对应能力统一所有元数据使用同一套描述语言类型系统可靠数据不丢、不脏、不误删校验 版本链高性能读多写少场景下低延迟多级缓存 并发控制可扩展新增元数据对象无需改框架代码插件式字段定义2. metadef核心逻辑拆解从类型系统到版本校验2.1 类型系统一切皆可定义metadef的第一个核心概念是Type。一个Type代表一类元数据对象比如Table、Task、Model、Resource。定义一个Type时你需要声明它的字段集合每个字段都有自己的名称、类型、是否必填、默认值、约束条件。举个例子我们要定义一张数据表的元数据结构Type定义大概长这样{ type: Table, version: 1, fields: [ {name: catalog, type: string, required: true, default: default}, {name: schema, type: string, required: true}, {name: table_name, type: string, required: true}, {name: owner, type: string, required: true}, {name: fields, type: arrayField, required: true}, {name: partitions, type: arrayPartition, default: []}, {name: lifecycle, type: int, default: 365, constraints: min1,max3650}, {name: tags, type: mapstring,string, default: {}} ] }这段定义里最关键的是field支持复合类型比如arrayField也就是一张表的字段本身又是一个Field类型的数组。这样元数据才能描述“嵌套”的结构。Field类型又由field_name、field_type、nullable、comment等基础属性组合而成所有复杂对象都可以由基础类型组合出来。实现这套类型系统的时候有一个容易忽略的点类型定义本身也需要做版本管理。如果某个Type要调整字段结构而线上已经存在大量基于旧Type写入的数据你不可能把历史数据全部重写。所以Type的版本更新采用兼容性校验只允许加字段、加可选约束不允许删字段或者改字段类型。这个设计后来救了我们很多次尤其是AI平台不断往模型元数据里加新属性的时候老的Type版本依然能正常读取。2.2 存储模型与索引设计类型定好了数据往哪里放我们最开始用的方案是“通用元数据表”而不是给每种Type单独建表。单独建表的问题在于每新增一个Type都要做一次迁移框架代码也跟着改动完全违背了可扩展的目标。通用表把Type作为一行记录的一个属性业务含义全部放到JSON列里查询时配合索引列来做过滤。建表SQL大致是这样CREATE TABLE meta_object ( tenant_id VARCHAR(64) NOT NULL, type_name VARCHAR(64) NOT NULL, object_id VARCHAR(64) NOT NULL, version BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 1, attributes JSON NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (tenant_id, type_name, object_id), KEY idx_tn_version (tenant_id, type_name, version), KEY idx_update_time (tenant_id, updated_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这张表的核心是attributes列所有Type的字段都序列化到JSON里。你别小看这个“暴力”设计它在并发演进的时候帮了大忙因为数据模型高度统一后续加缓存、加版本控制时可以完全覆盖所有元数据类型而不需要对每一种Type写一套特判逻辑。JSON列虽然灵活但没法直接在SQL里做复杂的条件过滤所以我们会把高频查询字段冗余出来作为普通列比如tenant_id、type_name、status、updated_at。这就是典型的“通用存储 关键索引冗余”策略。索引不能乱加索引太多会拖慢写入而在读多写少的元数据场景里把最常用的过滤条件租户、类型、状态、更新时间做成二级索引就足够了。2.3 创建、更新、删除背后的校验与版本链说完了存储再看一次完整的更新请求在metadef里会经历什么。当客户端调用PUT /metadata/v1/object/{type}/{id}时请求先进入校验层。校验层会做三件事第一根据type找到对应的Type definition第二用Type definition里的字段定义逐一校验请求体的类型、必填项、约束条件第三做业务级校验比如当前用户对这个元数据对象是否有写权限。校验通过之后进入版本链逻辑。每个元数据对象在表里都有version字段更新时框架会执行UPDATE ... SET attributes #{newAttrs}, version version 1 WHERE object_id #{id} AND version #{expectVersion}。如果影响行数为0说明版本冲突客户端需要重新读取最新版本再做合并。这个机制就是典型的乐观锁它没有像悲观锁那样长时间占用数据库连接而是把冲突检测放在最后的写路径上。删除操作我们几乎不用物理删除而是把status从1改成-1做一个软删除。原因很现实元数据往往有下游引用如果直接物理删除很多运行中的计算任务会因为找不到上游表定义而直接失败。软删除保留了数据即使删除错了也能秒级恢复只需要把status再改回来。配合历史版本记录误删后的恢复逻辑非常成熟只要还保留着历史version记录你随时可以回滚到任意历史版本。3. 并发演进从全局锁到多级缓存的四个阶段3.1 V1阶段全局写锁保障正确性metadef诞生之初平台整体规模不大每天就几百个元数据操作所以第一版实现非常朴素用一个全局锁保护所有写操作读写都走数据库不做任何缓存。当时这么做最大的好处是正确性绝对可靠不会出现两个任务同时更新同一个元数据对象导致数据错乱的问题。顺序上就是“先取锁再操作完成后释放”简单到不需要写任何复杂的并发调试代码。这个阶段的坑也很明显。随着平台接入的引擎变多调度、数据服务、AI平台都开始高频查询元数据哪怕只是打开一次任务配置页面背后都会产生十几次元数据查询。全局锁开始变成一个瓶颈读操作也要等锁哪怕它们之间根本没有冲突。有一次压测并发数升到100接口的P99延迟直接飙到2秒以上数据库连接池先被打满然后整个平台其他模块跟着雪崩。这次事故直接推动了V2的改造。总结V1阶段的教训在“读多写少”的场景里把读写混在一起用同一把锁是最省力但最不划算的方案。元数据读取请求和请求之间互不影响它们需要的不是锁而是并发读的能力。3.2 V2阶段读写锁与进程内缓存V2阶段做了两件事第一把全局互斥锁升级为读写锁读请求之间可以并发第二加了进程内缓存我们用的Caffeine把高频查询的元数据对象直接放在应用内存里避免每次查询都打到数据库。读写锁这个方案在Java里实现起来其实不难本质上就是JUC包里的ReentrantReadWriteLock底层依赖AQS。读锁可以多个线程同时持有写锁是互斥的并且写入时阻塞新的读请求。对应到代码里就是读接口先拿读锁查缓存命中就返回写接口拿写锁先更新数据库再清掉对应缓存。进程内缓存带来的提升非常明显。因为元数据是典型的读多写少同一个对象的读频率可能是写频率的几十倍甚至上百倍。把热数据放到Caffeine后读接口在内存中就能直接命中P99延迟从几百毫秒降到了个位数毫秒。我们在压测环境用JMeter模拟100用户并发读TPS从改造前的每秒200多提升到2000多数据库的QPS压力瞬间下来了。但这阶段的方案还有个天然缺陷多实例部署时每个实例的本地缓存各自维护A实例更新了数据B实例的缓存里可能还是旧的。针对这个问题我们当时的缓解办法是缩短TTL比如A类元数据缓存30秒、B类元数据缓存5秒但代价是有时候读不到最新数据。所以在V2的后期我们其实一直在寻找一种既高效又能保证一致性的方案这就引出了V3的Redis缓存。3.3 V3阶段Redis缓存与缓存三大坑V3的核心思路是把缓存从进程内搬到分布式缓存也就是Redis。架构变成三层应用本地缓存Caffeine容量很小- Redis缓存主体- MySQL兜底。读请求优先查本地缓存没命中再查RedisRedis再没命中才查数据库查完之后逐层回填。因为Redis是独立于应用进程之外的服务多个应用实例共享同一份Redis数据缓存一致性从“进程间要同步”变成了“只用管好一份Redis”。但把缓存放到Redis之后经典的缓存三大坑开始暴露。第一个坑是缓存穿透客户端请求一个完全不存在的元数据对象比如一个被删掉的表ID每次请求都绕过缓存直接打数据库。攻击者只需要用一堆不存在的ID刷接口数据库就会被打爆。解决办法是缓存空值当数据库查询结果为空时也在Redis里写一个短TTL的空对象防止同类无效请求穿透到底层。第二个坑是缓存击穿某个热点元数据对象的缓存恰好过期一瞬间大量请求同时涌向数据库去回填。解决办法是互斥重建在发现缓存过期后先尝试拿一把分布式锁只有拿到锁的线程去查数据库并回填缓存其他线程短暂自旋后重新读缓存。第三个坑是缓存雪崩大量key在同一时间过期导致缓存整体失效。解决办法是TTL加随机偏移量让过期时间分散开避免整片缓存同时失守。除了三大坑V3还遇到了缓存和数据库一致性难题到底是先更新数据库再删缓存还是先删缓存再更新数据库。我们最终选择的是“先更新数据库再延迟双删缓存”更新数据库之后立即删除一次Redis中的缓存过几百毫秒再删一次。第一次删除是为了让后续读请求尽快回源拿最新数据第二次删除是为了兜住第一次删除之后、数据库更新完成之前可能被一个并发读回填进Redis的旧值。这个方案不是绝对完美但在计算平台的元数据场景下配合版本号和TTL已经足够满足业务对一致性的要求了。3.4 V4阶段乐观锁、多版本与最终一致到了V4阶段并发问题的重心从“读性能”转向了“写冲突下的正确性”。平台规模变大之后经常出现两个不同的作业同时在改同一个元数据对象的场景一个是任务A在改表字段另一个是任务B在读表结构做数据同步。如果还是靠数据库行锁串行化所有写操作复杂作业的整个事务会被长时间锁住数据库连接压力也很大。我们最终采用的方案是乐观锁 版本号也就是2.3节里写到的UPDATE ... WHERE version #{expectVersion}。这个方案最大的好处在于它让读请求完全不需要等待写请求读永远走缓存写冲突只在提交时才暴露。对大部分元数据对象来说并发写同一对象是低频事件用乐观锁代价极小还避免了悲观锁长时间占用连接的问题。同时我们还引入了多版本快照MVCC的思路更新时把旧版本的快照写入meta_object_history表主表始终保留最新版本。查询时默认只查最新版本但允许客户端带上versionxxx参数读取历史快照。这样一来下游计算任务在读取元数据时不会因为上游正在更新而看到“一半新一半旧”的中间状态天然获得了“正在改还是已经改完”的一致视图。在跨集群场景下我们靠消息队列做异步复制把一次写操作变成“本地事务 异步事件”的组合实现最终一致性。这里提醒一句不要一上来就上分布式事务。元数据场景下你需要的绝大多数时候是“最终一致”而非“强一致”。强一致意味着每一次写都同步到所有副本延迟和复杂度都会指数级上升。设定一个可接受的一致性窗口比如跨集群数据延迟不超过5秒会大大简化系统设计。4. 实操验证2C4G Pod能撑起多少并发4.1 部署配置与调优参数理论讲了一堆回到实操层面我们自己把metadef跑在2C4G规格的容器里最终压测结果其实很能说明问题。先说部署规格应用为一个Spring Boot服务单Pod 2C4GJVM堆内存设置2GBRedis一个实例MySQL一个实例。对一个小型计算平台来说这套配置已经能承载日常元数据管理需求。JVM参数里比较关键的是堆内存和GC策略。2G堆内存对于缓存量比较大的场景其实有点紧所以我们把Caffeine本地缓存的容量控制在1万个对象以内单对象平均大小算下来大概占用100到200MB剩下的堆内存留给业务对象和JVM本身。GC用的G1压测时观察过停顿控制得还算稳定。核心JVM参数参考如下-Xms2g -Xmx2g -XX:UseG1GC -XX:MaxGCPauseMillis200线程池方面Tomcat的最大线程数设为200Redis连接池maxTotal设为50MySQL HikariCP连接池maximumPoolSize设为20。这几个数字不是拍脑袋定的核心逻辑是让整个调用链路的线程资源形成一个金字塔并发请求到Tomcat线程池往下访问Redis时复用有限连接再往下访问DB时使用更少的连接。如果入口线程和底层连接比例差距太大很容易出现入口一堆线程等着拿数据库连接然后把连接池打满最终拖垮数据库。4.2 JMeter并发压测报告怎么读很多新手拿到JMeter的Aggregate Report会不知所措这里说下我们重点看的关键指标Sample数、Average响应时间、90%响应时间或者P95/P99、Error%、Throughput单位是req/s。这几个指标组合起来基本能判断一个接口当前的瓶颈在哪里。压测时线程组的配置要符合真实流量特征。元数据场景是读多写少所以我们会把读接口和写接口分开压读接口模拟100/200/500个并发用户每个用户循环查询不同的元数据对象写接口单独压模拟场景是并发的元数据更新和版本冲突。JMeter里有个容易踩的坑如果你使用固定Thread Group对数据库和缓存的压力是持续的这没有问题。但要注意取样器的思考时间不要设置成0。真实用户操作之间是有间隔的全链路压测时用0思考时间会放大实际压力导致压出来的性能指标比线上差很多。另外压测变量要控制好一次只改一个变量不然报告出了问题你根本没法定位是参数的问题还是代码的问题。4.3 压测现场100/200/500并发实测对比下面这组数据是我们V3后期、V4初期的实测结果接口是最常用的“查询元数据对象详情”每个请求查询的元数据对象固定为同一个热点对象以验证热点场景下的表现。并发用户数Average(ms)P99(ms)Throughput(req/s)Error%10082111000.00%200143813500.00%500268216500.15%从数据可以看到并发从100涨到500吞吐量并没有线性涨到5500而是在1600附近基本封顶了。为什么因为2C4G的Pod只有两个CPU核单核处理能力决定了应用的吞吐上限。再加上Redis和MySQL的网络往返即使CPU没完全打满线程上下文切换开销也已经很明显。500并发时开始出现少量错误基本都来自数据库连接池获取连接的超时说明MySQL连接池的20个连接已经被占满了属于连接池容量见顶的典型信号。这个测试数据给我们的直接结论是单Pod 2C4G部署metadef读接口的稳定承载能力在1000到1500 req/s之间。如果业务峰值预期超过这个量级加Pod横向扩容比加大单机规格更划算。我们后来在最上层的接入层加了负载均衡三台2C4G的Pod共同承担流量读吞吐轻松到了4000 req/s以上。5. 常见问题排查与并发设计避坑实录5.1 高频问题速查表在metadef的开发和运维过程中我们踩过的坑和接手过的问题不少挑几个高频的整理成速查表方便你直接对照问题现象可能原因解决方案读接口偶发报缓存超时Redis命令超时连接池被打满调大Redis连接池热点操作改用pipeline更新后查询到旧数据本地缓存未及时失效缩短TTL或引入Redis双删写接口出现版本冲突同一对象被并发更新增加重试策略客户端感知后合并压测时P99抖动大GC停顿或连接池排队调G1参数检查连接池最大等待时间快速创建大量元数据对象时DB CPU飙升写入量过大、索引重算频繁批量写入、控制单条JSON大小某条元数据被误删物理删除或软删除被绕过全部走软删除保留版本链5.2 分布式锁的过期与误删在缓存击穿防护里我提到用分布式锁这里详细说说一个非常常见的坑锁的过期时间设长了会导致持有锁的线程崩溃后锁一直不释放其他线程永远拿不到锁设短了又会导致持锁线程还没执行完锁就过期了另一个线程拿到锁两个线程同时干活。更隐蔽的问题是锁误删线程A拿到锁后因为GC停顿锁过期了线程B拿到锁开始执行这时线程A醒来执行完逻辑后想把锁释放如果释放时直接DEL就把线程B的锁给误删了。解决办法也很经典设置锁的时候写入一个唯一标识比如UUID释放锁的时候先查询这个value是不是自己写的是才删除。注意“查询 删除”必须是原子操作否则两个步骤之间有窗口期依然会误删。用Lua脚本是标准做法if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end5.3 缓存一致性先更新数据库还是先删缓存这个问题我们争论过很多轮。先更DB后删缓存优点是缓存一定能拿到最新数据缺点是DB更新和缓存删除之间存在一个短暂窗口旧缓存可能被读走先删缓存后更DB缺点更明显如果DB更新失败缓存已经删了大量请求直接穿透到DB甚至可能读到旧值回填缓存把问题放大。在metadef里最终选择的是“先更新DB再延迟双删缓存”并且给每个缓存对象加上数据版本号。读请求回填缓存时会连带版本号一起存如果从DB读到的是新版本而缓存里还是旧版本框架会以DB版本为准触发一次异步刷新。这套组合拳可以覆盖绝大多数一致性风险。元数据平台对一致性的容忍度通常比交易系统高一些所以“最终一致 极少时间窗口读到旧值”是可以接受的前提是窗口必须控制在秒级以下。顺带一提更新DB之后不要立即走消息队列去刷新缓存。消息队列有延迟在这段延迟里旧缓存依然可能被读走。延迟双删的延迟时间一般设置为300到500毫秒足够数据库行锁释放又不至于让缓存空窗太久。5.4 扩容之后连接风暴与线程池打满最后说一个非常容易在扩容后踩的坑。原来单Pod部署Tomcat最大线程200Redis连接池50MySQL连接池20每个连接数加在一起刚好是系统能承受的范围。但当你横向扩容到3个Pod时Redis连接数变成150MySQL连接数变成60如果数据库侧连接数上限比较紧可能直接报“Too many connections”。更隐蔽的是扩容后多个Pod同时遇到缓存击穿或热点key消失会对Redis和MySQL同时发起大量请求形成连接风暴。我们在某次压测中遇到的情况是本地缓存刚到TTL三个Pod同时回源查同一个热点元数据对象数据库连接池被打满整个写接口跟着受影响。解决方案有两层第一层就是3.3节说的互斥重建同一时刻只允许一个线程去回源第二层是把Redis连接池的创建模式从按需创建改成预创建避免扩容瞬间因为连接创建太慢导致超时。线程池方面Tomcat的最大线程数别调得太大。很多人误以为线程数越高吞吐越高实际上线程太多反而会让CPU时间都消耗在线程切换上。2C4G的Pod里我们把Tomcat max-threads从默认200调到300吞吐反而下降最后又调回200。这是典型的依赖硬件环境的经验值问题建议你每次调整参数后都跑一轮JMeter用数据说话别凭感觉调。metadef这套框架从最初一把锁走到V4的多级缓存与版本控制前后经历了大半年的迭代。我最大的体会是并发优化这件事没有银弹每个阶段的方案都是为了解决当下最痛的那个问题V1解决正确性V2解决读性能V3解决缓存下的并发风险V4解决写冲突与一致性。如果你也在设计类似的元数据定义框架我建议你先把唯一性和版本链做好再考虑缓存和分布式锁因为版本链是数据正确性的基石缓存只是性能加速器。最后再分享一个小技巧每次优化后都保留一轮JMeter压测报告和历史参数配置把压测场景、参数、结果三项记录在文档里下次排查问题和做容量评估时这些数据比任何经验都管用。