【SkyWalking从入门到精通】第50篇:注册模型与明细模型:名字的管家和现场的记录官 上一篇【第49篇】SkyWalking存储模型全景图四大模型如何撑起海量观测数据下一篇【第51篇】指标模型与OAL聚合数据的工厂流水线未完待续一、开篇名字为什么需要身份证号在APM系统中user-service这个名字会出现无数次——每个Span里、每个指标里、每个告警中。如果在每条数据里都存完整的字符串user-service13字节 × 每秒10000次一天就浪费约11GB存储空间。SkyWalking的解决方案简单而高效给每个名字分配一个整数ID之后所有地方只存ID。第一次遇到 user-service | v ------------------ | 注册模型 | | | | name → user-service | serviceId → 42 | -- 分配整数ID ------------------ | v 后续所有地方只存 serviceId42 节约对比 存字符串: 13 字节/条 存整数: 4 字节/条 日省: 10,000/s × 9字节 × 86400秒 7.7 GB/天 图1注册模型的意义——以ID代名节流增效二、注册模型的详细设计2.1 服务注册的完整流程/** * 服务注册管理器 */publicclassServiceInventoryCache{// 两层缓存name → id// L1: 内存缓存Caffeine// L2: 存储查询ES/MySQLprivatefinalCacheString,IntegerserviceIdCacheCaffeine.newBuilder().maximumSize(10000)// 最多缓存10000个服务.expireAfterWrite(30,TimeUnit.MINUTES).build();/** * 获取或创建服务ID * 这是注册模型最核心的方法 */publicintgetOrCreateServiceId(StringserviceName){// 1. 先查L1内存缓存IntegercachedIdserviceIdCache.getIfPresent(serviceName);if(cachedId!null){returncachedId;// 命中缓存直接返回}// 2. 查L2存储ServiceInventoryexistingstorageDAO.findByName(ServiceInventory.class,serviceName);if(existing!null){// 存在放入缓存serviceIdCache.put(serviceName,existing.getServiceId());returnexisting.getServiceId();}// 3. 不存在分配新ID并写入存储intnewIdallocateNewId(service);ServiceInventoryinventorynewServiceInventory();inventory.setServiceId(newId);inventory.setName(serviceName);inventory.setIsAddress(0);inventory.setRegisterTime(System.currentTimeMillis());// 写入存储注册模型写入storageDAO.insert(inventory);// 放入缓存serviceIdCache.put(serviceName,newId);logger.info(新服务注册: {} - ID:{},serviceName,newId);returnnewId;}/** * 分配新ID * 使用数据库自增序列或分布式ID生成器 */privateintallocateNewId(StringentityType){// 方案1使用数据库自增// return sequenceDAO.nextVal(service_seq);// 方案2使用分布式IDSnowflake// return snowflakeIdGenerator.nextId();// 方案3使用单独的自增表returnstorageDAO.getNextSequence(service_id_seq);}}2.2 端点注册的特殊处理端点注册比服务注册稍复杂因为同一个服务下的端点名唯一即可publicintgetOrCreateEndpointId(intserviceId,StringendpointName){// 端点的唯一键是 (serviceId, endpointName)StringcacheKeyserviceId:endpointName;IntegercachedIdendpointCache.getIfPresent(cacheKey);if(cachedId!null){returncachedId;}// 查询按 serviceId name 查找EndpointInventoryexistingstorageDAO.findByServiceAndName(serviceId,endpointName);if(existingnull){intnewEndpointIdallocateNewId(endpoint);EndpointInventoryendpointnewEndpointInventory();endpoint.setEndpointId(newEndpointId);endpoint.setServiceId(serviceId);endpoint.setName(endpointName);endpoint.setRegisterTime(System.currentTimeMillis());endpoint.setDetectPoint(DetectPoint.SERVER.ordinal());storageDAO.insert(endpoint);endpointCache.put(cacheKey,newEndpointId);returnnewEndpointId;}endpointCache.put(cacheKey,existing.getEndpointId());returnexisting.getEndpointId();}2.3 注册模型的数据量估算典型中型系统 服务数: 100个 → 100行注册数据 端点数: ~5000个 → 5,000行注册数据 实例数: 100服务×5实例 → 500行注册数据 数据库: 可能有50个 → 50行注册数据 总注册模型数据: 6,000行 存储空间: 10 MB这也是为什么注册模型可以永久保留——数据量实在太小了。三、明细模型的完整设计3.1 SegmentRecordTrace的化身当探针完成一次请求追踪后会上报一个SegmentObject。OAP收到后把它反序列化成一个SegmentRecord/** * SegmentRecord的完整结构 */Stream(namesegment,scopeId1)publicclassSegmentRecordextendsRecord{// 标识字段 Column(columnNamesegment_id)IDColumnprivateStringsegmentId;// 3e1f4a2b.1.16500000000001Column(columnNametrace_id)privateStringtraceId;// 3e1f4a2b.1.16500000000000// 实体ID引用 Column(columnNameservice_id)privateintserviceId;// 引用的注册ID如 42Column(columnNameservice_instance_id)privateintserviceInstanceId;// 如 201Column(columnNameendpoint_id)privateintendpointId;// 如 1536// 时间字段 Column(columnNamestart_time)privatelongstartTime;// 毫秒时间戳Column(columnNameend_time)privatelongendTime;Column(columnNamelatency)privateintlatency;// 响应时间(ms)// 状态字段 Column(columnNameis_error)privateintisError;// 0成功, 1失败Column(columnNamestatus_code)privateintstatusCode;// HTTP状态码// 原始数据 Column(columnNamedata_binary,storageOnlytrue)// 仅存储不参与查询privatebyte[]dataBinary;// 序列化后的Span列表Column(columnNameversion)privateintversion;// 协议版本号// 标签 Column(columnNametags,listtrue,keywordtrue)privateListStringtags;// 自定义标签}3.2 dataBinary字段的秘密dataBinary是明细模型中最值钱的字段——它压缩存储了整个Segment的所有Span信息/** * dataBinary的序列化和反序列化 */publicclassSegmentBinaryCodec{/** * 序列化Segment → bytes */publicstaticbyte[]encode(ListSpanObjectspans){// 使用自定义紧凑编码而非Protobuf// 原因我们需要更紧凑的格式因为数据量极大ByteArrayOutputStreambaosnewByteArrayOutputStream();for(SpanObjectspan:spans){// 每个Span: [spanId(8B)][parentSpanId(8B)][startTime(8B)]// [endTime(8B)][operationNameRef(4B)]// [peerRef(4B)][spanType(1B)][componentId(4B)]// [isError(1B)][tagCount(2B)][tags...]writeLong(baos,span.getSpanId());writeInt(baos,span.getComponentId());// ... 更多字段}// 使用LZ4/Snappy压缩returncompress(baos.toByteArray());}/** * 反序列化bytes → Span列表 */publicstaticListSpanObjectdecode(byte[]data){byte[]decompresseddecompress(data);ByteBufferbufferByteBuffer.wrap(decompressed);ListSpanObjectspansnewArrayList();while(buffer.hasRemaining()){SpanObjectspannewSpanObject();span.setSpanId(buffer.getLong());span.setComponentId(buffer.getInt());// ... 更多字段spans.add(span);}returnspans;}}3.3 明细模型的容量估算单个SegmentRecord大小 基础字段(ID、时间、延迟等): ~100 bytes dataBinary压缩后的Span数据: ~2-10 KB 平均: ~5 KB 日增数据量10,000 SPS场景 10,000 × 86400 864,000,000 条/天 864M × 5KB 4,320 GB/天 这个数据量意味着 - ES至少需要15-20个节点来存储 - 必须设置短的TTL7天 - 需要使用高性能SSD - 压缩至关重要这就是为什么明细模型是SkyWalking中最消耗存储的模型。四、数据的写入链路Segment到达OAP | v [反序列化SegmentObject] | v [提取注册信息] | --- 服务名 → getOrCreateServiceId() --- 端点名 → getOrCreateEndpointId() | v [构建SegmentRecord] | v [放入写入缓冲区] | | (缓冲区满 or 定时flush) v [批量写入存储] | --- Elasticsearch Bulk API --- MySQL Batch INSERT --- H2 Batch INSERT 图2明细数据的完整写入链路/** * 明细模型的批量写入策略 */publicclassRecordStreamProcessor{// 写入缓冲区每个OAP节点一个privatefinalBlockingQueueRecordwriteBuffernewLinkedBlockingQueue(5000);// 定时Flush任务Scheduled(fixedDelay2_000)// 每2秒publicvoidflush(){if(writeBuffer.isEmpty())return;ListRecordbatchnewArrayList();writeBuffer.drainTo(batch,2000);// 最多2000条一批if(!batch.isEmpty()){try{storageDAO.batchInsert(batch);logger.debug(写入明细数据: {} 条,batch.size());}catch(Exceptione){logger.error(批量写入失败, 数据量: {},batch.size(),e);// 降级逐条写入for(Recordrecord:batch){try{storageDAO.insert(record);}catch(Exceptionex){logger.error(逐条写入失败,ex);}}}}}}五、数据的查询链路/** * Trace查询的典型流程 */publicclassTraceQueryService{/** * 根据TraceID查询完整链路 */publicTracequeryTrace(StringtraceId){// 1. 先从明细模型查询所有的SegmentListSegmentRecordsegmentsstorageDAO.query(SegmentRecord.class,newQueryBuilder().eq(trace_id,traceId).orderBy(start_time,Sort.Order.ASC).limit(100));// 2. 反序列化每个Segment中的Span列表ListSpanallSpansnewArrayList();for(SegmentRecordsegment:segments){byte[]dataBinarysegment.getDataBinary();ListSpanspansSegmentBinaryCodec.decode(dataBinary);allSpans.addAll(spans);}// 3. 还原服务名和端点名for(Spanspan:allSpans){// 通过注册模型的ID反查名字StringserviceNameserviceInventoryCache.getNameById(span.getServiceId());span.setServiceName(serviceName);// 显示用的名字StringendpointNameendpointInventoryCache.getNameById(span.getEndpointId());span.setEndpointName(endpointName);}// 4. 组装Trace树returnbuildTraceTree(allSpans,traceId);}/** * ID → 名字的反查缓存 */publicStringgetNameById(intserviceId){returnserviceNameCache.computeIfAbsent(serviceId,id-{ServiceInventoryinventorystorageDAO.findById(ServiceInventory.class,id);returninventory!null?inventory.getName():UNKNOWN-id;});}}六、注册ID分配算法的选择SkyWalking在实际使用中针对不同的存储后端有不同的ID分配策略存储后端ID分配方式性能特点H2SEQUENCE对象单机独占足够快MySQLAUTO_INCREMENT依赖DB中等性能Elasticsearch独立ID索引 CAS分布式友好但需额外索引// ES存储的ID分配实现publicintallocateServiceId(StringserviceName){// ES中维护一个特殊的ID分配索引StringidAllocKeyservice_id_seq;// 使用乐观锁CAS分配intnewId;do{// 读取当前最大IDintcurrentMaxgetCurrentMaxId(idAllocKey);newIdcurrentMax1;// 尝试CAS更新}while(!casUpdate(idAllocKey,currentMax,newId));returnnewId;}七、总结注册模型和明细模型是SkyWalking数据体系的两个基石维度注册模型明细模型核心价值以ID代名全局复用原始现场完整证据数据量极小MB极大TB/天读模式频繁读Dashboard渲染偶尔读问题排查写模式几乎不写稳定后持续高频写TTL永久7天理解这两个模型你就理解了SkyWalking如何处理名字→ID→原始数据→聚合指标这条完整的流水线。下一篇我们将深入指标模型揭开OAL编译生成的Metrics类如何与存储层无缝协作。上一篇【第49篇】SkyWalking存储模型全景图四大模型如何撑起海量观测数据下一篇【第51篇】指标模型与OAL聚合数据的工厂流水线未完待续