
去年年初我接手了一个城市交通数据平台的存储优化任务。平台的原始数据落地方案特别简单——Kafka里飘过来的每条过车记录JSON序列化之后直接丢进数据湖。最核心的卡口过车表一天新增2亿条记录单日原始数据量接近1.5TB。按这个速度一个季度就是上百TB账单出来的时候团队的脸色都不太好看。当时第一反应是加存储、上冷热分层但算了一笔账之后发现治标不治本。真正的问题在于交通数据里绝大多数字段的重复率极高时间戳单调递增、车牌反复出现、路段编号就那几千个、速度值集中在几个区间。这种数据用JSON存等于把黄金当废铁卖。后来我们重新设计了一套Java实现的分层压缩方案不依赖重型框架纯粹靠字段级编码加上块级压缩把核心表存储压掉了52%。这篇文章就是那次实战的完整复盘包括选型逻辑、编码器拆解、踩坑记录以及最终落地的代码思路。如果你手上的数据也是“高重复、强时序”的类型这套方法可以直接迁移。1. 交通数据的存储成本到底烧在哪里1.1 城市级交通数据的“四大家族”业内做交通数据平台其实每天处理的不是一种数据而是至少四类特征完全不同的数据存储开销也各不相同。第一类是卡口过车数据也就是路口电子警察或卡口设备抓拍后生成的结构化记录。一条记录通常包含过车时间、车牌号、车牌颜色、车道编号、方向编号、设备编号、车速、抓拍图片URL等字段。这类数据的特点是量极大、字段固定、单条记录小但一天下来累积的条数惊人。一个中大型城市每天几千万条到几亿条都很正常。第二类是浮动车GPS轨迹数据来自出租车、网约车、公交车或物流车上的定位终端。每次定位产生一条记录包含车辆ID、时间戳、经度、纬度、瞬时速度、方向角、运营状态等。这类数据的单条大小稍大因为经纬度需要用浮点数或更高精度整数表示但车辆的重复性极强同一辆车可能每3到5秒上报一次。第三类是交通流检测数据来自地磁、雷达、视频检测器或微波设备定期输出某个断面或路口的流量、平均速度、时间占有率、车道占有率等指标。这类数据的字段多为数值型数值范围小分布集中可压缩性是最好的。第四类是ETC门架交易数据或高速公路收费流水涉及通行介质编号、门架编号、通行时间、车型、计费金额、交易状态等字段。这类数据量相对前两者低一些但字段更长字符串占比高也有很强的规律性。这些数据如果都以原始文本形式落盘存储成本是线性上涨的。我见过不少团队第一版架构都是“Kafka到HDFSJSON存原始值”因为开发最快、排查方便但数据量上来之后这种方式的浪费会非常刺眼。1.2 原始日志落盘的三大浪费第一种浪费是文本格式本身的冗余。JSON为了可读性要为每个字段重复写出字段名。一条过车记录里“passTime”、“plateNo”、“laneId”这些键名出现一次就罢了问题是几亿条记录里同一个字段名反复出现。字段名本身占的空间可能比字段值还大。有人统计过一条典型的JSON结构记录中字段名和标点符号能占总字节数的40%左右。第二种浪费是字段类型的错配。时间戳存字符串比如“2024-05-12 16:30:45”19个字符19个字节如果转成long型Unix时间戳只有8个字节如果再结合差分编码甚至可以压到1到2个字节。经纬度存double8字节一个值但实际用车的定位精度只需要小数点后6位完全可以用整数来编码。车牌号看起来只有7到8个字符但一个城市几万辆车高频出现的车牌就那些用字典替换成int一段数据里可以省掉非常多。第三种浪费是压缩粒度和编码策略不对。很多团队直接在文本文件上跑gzip虽然能压掉不少但gzip这类算法对数据的“语义”一窍不通。它看不到时间戳连续递增不懂车牌重复的趋势只知道按字节找重复模式。结果就是把50%可压的压到了30%而真正懂数据的人可以把同样的数据压到更低。1.3 为什么通用压缩工具对交通数据“不够狠”我拿同一份卡口过车数据做过一次对比原始CSV格式约1GB直接上gzip -9压缩后约410MB压缩率59%看起来已经很不错但远远达不到50%空间节省的目标。为什么因为gzip是面向通用字节流的LZ77变体它对重复字符串的识别非常强但交通数据的可压缩性不止“重复字符串”还有“数值规律性”。数值规律性这种东西通用压缩算法几乎用不上。比如时间戳字段一行记录是和上一行差300毫秒还是差500毫秒gzip看不出来但差分编码后这些数值变成几十到几百的小整数再配合可变长整数编码8字节瞬间变成2字节甚至1字节。车牌字段也一样直接存储字符串gzip虽然能发现部分重复但字典编码是把它映射成自增ID从根本上改变信息表示方式。所以想真正省下50%以上的空间必须走领域编码的路子先针对每个字段的语义用最合理的编码方式压缩信息再让通用压缩算法去处理编码后的字节流。这个思路不是弃用gzip而是让后面那层压缩发挥更大价值。2. 领域编码选型先让数据变“小”再压缩2.1 通用压缩算法横向对比与Java选型在讨论领域编码之前先把通用压缩层的选型说清楚。Java生态里最常用的几种压缩算法我整理了一张表算法压缩比越高越好压缩速度解压速度CPU开销适用场景GZIP较高慢慢高离线批量、归档冷数据BZIP2最高很慢很慢极高极少用性价比低Snappy中等快快低大数据中间文件、实时写入LZ4中低极快极快低Kafka/内存序列化、热数据ZSTD高较快较快中综合性价比之王推荐优先考虑在我们的场景里数据是实时写入、定期查询既要有可观的空间节省又不能把写入和查询路径拖垮所以最终选了ZSTD。它的压缩级别可以通过参数调节从1到22级别越高压缩率越高但越慢。在线写入路径上我们用了level 3或level 6实测压缩率明显优于Snappy速度也没有差到影响写入吞吐。对于历史归档数据可以开到level 12以上反正是一次性压完解码慢一点无所谓。如果是在线写入并且要求低延迟Snappy也是可选项但空间节省会少不少。ZSTD还有一个优势是Java绑定成熟zstd-jni的性能接近C原生实现GC压力可控这点在后面的踩坑部分还会提到。2.2 交通数据里藏着哪些可压缩性要理解领域编码得先看清交通数据里到底有哪些规律可以利用。我总结了五个层面时间戳的单调性。按时间顺序写入的数据相邻记录的时间戳差值通常只有几十到几百毫秒。就算打乱了写入顺序在每批数据内排序之后差值的绝对值依然很小。差分编码天然适合这种数据。车牌和设备的收敛性。一个城市的活跃车辆规模是有限的一天内的过车数据里高频车牌反复出现。对一整批数据做字典统计高频车牌往往只占总车牌数的很小比例但出现的次数非常高。把车牌映射成字典ID再用整数编码收益极其明显。经纬度的空间局部性。浮动车轨迹数据里同一天内大量车辆的活动范围集中在城市建成区。即使经纬度以浮点数存储换算成整数并在批次内取基准点之后差值往往很小。空间分桶或差分编码都能大幅压缩。交通流数值的区间性。流量、速度、占有率不是随机散布的。工作日的早高峰流量集中在某个区间高速公路车速集中在80到120之间拥堵时段的占有率集中在0.6到0.9。这些字段的取值分布极度不均匀用更短的位宽表达高频区间可以显著降低平均字节数。字符串字段的枚举性。像车牌颜色、方向、车道类型、设备厂商这些字段本质上就是枚举值甚至可以直接映射成一个byte。这些规律单独看每一项都不复杂但组合起来效果惊人。核心思路是不要让压缩算法去猜数据的规律而是用领域知识先把规律显式地编码出来把数据变成对通用压缩算法友好的字节序列。2.3 三层压缩架构编码、块压、格式实际落地时我们采用了三层架构而不是只靠一层编码或只靠一层压缩。第一层是字段级编码。这一层面向每个字段的语义比如时间戳做差分、车牌做字典、经纬度做基准差、枚举字段直接转byte。这一层完成的是信息论意义上的冗余消除把“自然的表示”变成“紧凑的表示”。第二层是块级压缩。字段编码后的字节流拼接在一起按块切分例如64KB或256KB一个块用ZSTD或LZ4压缩。这一层的目的是消除编码后字节流里仍然存在的字节级重复模式同时统一管理压缩边界方便后续做随机访问。第三层是存储格式。编码和压缩完成后的块需要落盘并能被读取。最简单的做法是自己定义一个文件头记录元信息、字典表、块索引也可以直接复用Parquet、ORC等列式存储格式把字段编码器嵌入到它们的编码层。很多团队一上来就上Parquet省事但Parquet自带的编码器是通用型的不会为车牌Idiot做专门的字典至少不会针对“城市交通数据”做定制。我们在实践中发现Parquet底层的RLE和字典编码已经能覆盖大部分场景但如果想在现有HDFS或本地文件体系上做精细控制或者想嵌入到公司自研的存储引擎里自研三层架构更可控。3. 四个编码器拆解把TB级数据“抠”到一半3.1 时间戳差分化从8字节降级到1~2字节时间戳字段是所有交通数据里最高频、最占空间的字段之一。一条记录里时间戳必须存在但它的信息熵很低——因为相邻记录的时间戳差值非常小。最基础的思路是存差值。假设原始时间戳是long型的Unix毫秒值比如1715502000000下一行是1715502000350差分值是350远小于原始值。但仅仅做差分还不够350这个数值如果仍然用long固定8字节存储意义不大。要配合可变长整数编码。Java里最常见的可变长整数编码是类似Protocol Buffers的VarInt方式每个字节的高位表示是否还有后续字节低7位存数据。小于128的值用1字节小于16384的值用2字节以此类推。这样350只占2字节而8字节的原始时间戳被压缩到原来的25%。还有更进一步的方案如果采样间隔稳定可以算二阶差分即差分值的差值。GPS轨迹数据的采样间隔通常固定在3到5秒二阶差分经常是0或很小的值压到1字节的概率会更高。不过二阶差分对乱序数据非常敏感实现时必须保证块内数据先按时间排序。我给一个简单的Java示例public class TimestampDeltaCoder { private long lastTs 0L; public void encode(long ts, ByteBuffer out) { long delta ts - lastTs; lastTs ts; writeVarLong(out, delta); } public long decode(ByteBuffer in) { long delta readVarLong(in); lastTs delta; return lastTs; } private void writeVarLong(ByteBuffer out, long value) { // 使用 ZigZag 转换保证负数也能高效编码 long zigzag (value 1) ^ (value 63); while ((zigzag ~0x7FL) ! 0) { out.put((byte) ((zigzag 0x7F) | 0x80)); zigzag 7; } out.put((byte) zigzag); } private long readVarLong(ByteBuffer in) { long result 0; int shift 0; while (true) { byte b in.get(); result | (long) (b 0x7F) shift; if ((b 0x80) 0) { break; } shift 7; } long zigzag result; return (zigzag 1) ^ -(zigzag 1); } }这里有个细节值得说明。为什么差分之后还要做ZigZag转换因为数据一旦乱序或者跨批次边界时间戳差可能是负数。负数的二进制表示高位全是1直接按VarInt编码会占用大量字节。ZigZag把0映射成0、-1映射成1、1映射成2、-2映射成3让负数也变得“小”。这样无论正负只要绝对值小占用字节就小。3.2 经纬度缩放与整数化浮点带来的存储浪费GPS轨迹数据里的经纬度如果存成double每个值8字节。一条轨迹记录两个坐标就是16字节一天几亿条记录纯坐标信息就吃掉了海量空间。但现在主流的定位精度根本不要求小数点后十几位。按实际业务精度来算经纬度保留小数点后6位大约能精确到0.1米级别对城市道路级交通分析完全够用。做法是把纬度乘以1000000转成整数经度乘以1000000转成整数再用VarInt或ZigZag编码。以北京为例纬度大约在39.9左右转成整数后是39900000还是偏大。不要直接存大整数而是取批次内的基准经纬度用每条记录的经纬度减去基准值。城市内车辆活动的经纬度差异通常只有0.01到0.1的量级乘以1000000后也就10000到100000小了三个数量级。代码注释比代码更重要核心逻辑如下public class GpsDeltaCoder { private final int refLat; private final int refLng; public GpsDeltaCoder(double refLat, double refLng) { this.refLat (int) Math.round(refLat * 1_000_000); this.refLng (int) Math.round(refLng * 1_000_000); } public void encode(double lat, double lng, ByteBuffer out) { int latInt (int) Math.round(lat * 1_000_000) - refLat; int lngInt (int) Math.round(lng * 1_000_000) - refLng; writeVarInt(out, latInt); writeVarInt(out, lngInt); } }这里需要评估舍入误差。定基准经纬度时要注意不能选某一辆车的坐标因为那可能超出业务覆盖范围导致差值偏大。更好的做法是取整个批次内经纬度的最小值或包裹盒中心作为基准保证所有差值在较小范围内。如果数据覆盖整座城市差值可能达到0.2度乘以1000000后是200000VarInt需要3字节依然比原始float的4字节或double的8字节省。对于不需要亚米级精度的分析场景还可以进一步降到小数点后5位或4位。但要对业务需求做充分确认别为了省空间牺牲精度后面查数据时找不回定位就麻烦了。3.3 设备与车牌字典化高频值只需一个int车牌是卡口过车数据里最重要的业务字段也是压缩潜力最大的字符串字段。一个城市一天内出现的车牌可能有几十万个而记录数有几千万甚至上亿条意味着平均每块数据里同一个车牌会出现多次越是高频的车牌出现次数越多。字典编码的思路很简单在一个批次或一个文件范围内统计所有车牌的出现频次把高频车牌映射到自增ID低频车牌可以走另一条路。ID本身用VarInt存储高频车牌的ID很小占用字节只有1到2个。一个简单实现public class DictionaryCoder { private final MapString, Integer dict new HashMap(); private final ListString values new ArrayList(); public int encode(String value) { Integer index dict.get(value); if (index null) { index values.size(); dict.put(value, index); values.add(value); } return index; } public String decode(int index) { return values.get(index); } public ListString getDictionary() { return values; } }实际生产还有一个重要变形字典表本身会不断增长。如果拿全量车牌做字典几十万个字符串本身要占几百KB到几MB。为了控制字典表大小可以只对高频车牌建字典低频车牌直接存原始字符串并打一个特殊标记。还可以按天或按小时分文件建字典避免字典表无限膨胀。设备编号、路段编号、方向编号这些字段本质上是枚举值甚至可以直接用byte存不需要额外字典。比如方向只有0到15取值1字节足够。建字典时不要一刀切要根据字段的基数做选择。基数小于256的字段直接一位字节搞定别浪费字典表空间和查表时间。3.4 流量类字段的位打包与游程编码交通流检测数据里的流量、平均速度、时间占有率数值范围通常很小。比如一个5分钟窗口内通过的车辆数常见值集中在0到300之间8位甚至7位就能表达。平均速度通常在0到120之间7位足够。时间占有率是0到100之间的浮点值乘以10转成整数后0到1000的范围10位也足够。这种低基数的数值字段可以按位打包bit-packing把多个小整数紧密排列在没有间隙的连续位序列里。比如一个字段最大不超过1000用10位表达那么8个值刚好80位也就是10字节而如果直接用int8个值要32字节。位打包在Java里实现起来稍微繁琐因为Java的位运算操作基于整型。一个常用技巧是先把数据读入long数组再按位拼接。也可以用现成的库比如RoaringBitmap内部就有位打包的trait可以参考。对于连续重复的数值比如拥堵状态下占有率持续为0.85连续多条记录值相同这时可以用游程编码RLE记录一个值和它的连续重复次数。RLE和位打包可以组合使用先做RLE再对序列做位打包这两个编码器在不同数据分布下各有优势。实测下来流量字段在低峰期有大量连续的0值RLE能把那些时段压到接近零字节。3.5 编码器的统一接口与可插拔设计到这里你会发现每种字段都有自己最合适的编码方式但如果每个字段写一套独立的序列化逻辑代码会乱成一锅粥。所以我们给所有编码器定义了一个统一接口让上游数据写入时不用关心具体字段用的什么编码。public interface FieldCoder { void encode(Object value, ByteBuffer out); Object decode(ByteBuffer in); void flush(); void reset(); CompressionMeta getMeta(); }encode负责把对象写入ByteBufferdecode负责读出来flush在批次结束时调用用于把编码器内部缓存的状态比如字典、基准值补写到缓冲区末尾reset用于开启下一个批次。CompressionMeta存放字段名、编码类型、字典长度等信息方便解码端正确初始化。接口统一之后压缩管线的代码变得非常清爽。数据进来后按字段遍历把每个字段交给自己的编码器所有字段编码完再走ZSTD压缩。新增一个字段类型时只需要实现FieldCoder不用改主流程。这套设计在后期扩展时非常香后来我们加过ETC交易金额编码、车辆类型枚举编码都是半小时内搞定。4. 完整落地方案从Java API到磁盘上的压缩文件4.1 批式压缩的写链路设计领域编码终究不是一条流一条流地实时编码而是以批为单位的“攒批、排序、编码、压缩、落盘”五步流水线。为什么要排序因为差分编码和RLE都依赖数据的局部有序性。乱序数据的编码效果会大打折扣。实际项目里我们采用两个维度攒批时间和条数。达到时间阈值或条数阈值把当前批次刷出。刷出后第一步不是编码而是先按时间戳排序。排序开销不算小但收益很明显时间戳差分编码后的字节数能下降20%到30%RLE在流量字段上的表现也更好。写文件时按块来组织。一个批次的数据可能达到几十MB我们按64KB或256KB切片成多个块每个块独立编码和压缩。块与块之间写入块索引记录每个块在文件中的起始偏移量和解压后的记录条数。这样后续读取时可以只解压需要的块不必全量解压。一个压缩文件的结构大致是文件头 magic 版本号 元信息 字段编码配置 全局字典表 块数据区多个块 块索引表 文件末尾 footer这个结构的好处是文件头确定编码配置读取时先加载footer定位块索引再按需读取块。整个文件既是自描述的也支持随机访问。4.2 关键代码差分时间戳与字典编码的实现单独看编码器都是一小块代码但组合起来要留意一些细节。我以“一条卡口过车记录”为例写一个最小可运行的编码链。假设字段包括timestamplong、plateNoString、passLaneint、deviceIdint、speedint、plateColorint。public class PassRecord { long timestamp; String plateNo; int laneId; int deviceId; int speed; int plateColor; } public class PassRecordBlockEncoder { private final TimestampDeltaCoder tsCoder new TimestampDeltaCoder(); private final DictionaryCoder plateCoder new DictionaryCoder(); private final BitPackingCoder laneCoder new BitPackingCoder(4); private final BitPackingCoder deviceCoder new BitPackingCoder(16); private final BitPackingCoder speedCoder new BitPackingCoder(7); private final BitPackingCoder colorCoder new BitPackingCoder(2); public byte[] encodeBlock(ListPassRecord records) { ByteBuffer buf ByteBuffer.allocate(estimateSize(records.size())); for (PassRecord r : records) { tsCoder.encode(r.timestamp, buf); buf.putShort((short) plateCoder.encode(r.plateNo)); laneCoder.encode(r.laneId, buf); deviceCoder.encode(r.deviceId, buf); speedCoder.encode(r.speed, buf); colorCoder.encode(r.plateColor, buf); } plateCoder.writeDictionary(buf); // 字典表写到块尾 byte[] payload new byte[buf.position()]; System.arraycopy(buf.array(), 0, payload, 0, buf.position()); return Zstd.compress(payload); } }这段代码为了演示做过简化实际生产里不能把字典表写在每个块里那样字典重复开销太大要提到文件级别的全局字段。更合理的做法是全局字典表在文件头写一次编码时每个块的数据都引用全局字典ID文件加载时字典常驻内存。这样字典表空间占用可以忽略不计。编码链路里有一个容易被忽略的点字段顺序。不同的字段顺序会影响压缩效果吗会。ZSTD压缩时字节流中相似模式的重复位置越近压缩效果越好。如果把同一辆车的多条记录逐字段交错写入ZSTD能在较近范围找到重复模式。如果把所有时间戳放前面、所有车牌放后面编码器要用更大的滑动窗口才能发现重复。我们在实测中使用了“按记录交错”的布局压缩率比“按列分块”还要高一些这个结果可能有点反直觉但对于原始字节流压缩来说很合理。4.3 压缩算法与块大小LZ4还是ZSTD64KB还是1MB编码后的数据再压缩选什么算法、用多大的块都会影响空间和性能的平衡。先说块大小。块大小决定了压缩时的参考窗口和随机访问的粒度。块越大压缩率通常越好但随机访问时解压的数据量也越大。64KB的块压缩率比1MB的块低大约2到3个百分点但随机读取单条记录时只需解压64KB。对于我们的查询场景——按车牌或时间段查询通常一次要扫一批记录而不是单条所以块大小最终选在256KB。这个体积在压缩率、解压速度、随机访问粒度之间取得了平衡。块再大读写路径的延迟和内存占用就会明显上升。再说算法。ZSTD在level 3下压缩率大约比LZ4高50%左右解压速度略慢但在可接受范围内。ZSTD支持训练字典如果用于压缩的数据语义高度一致训练一个针对“交通卡口记录”的字典可以让压缩率再提升5%到10%。不过训练字典需要离线跑而且字典本身要随文件分发实现复杂度高了一些。我们在第一批优化中没有上ZSTD字典训练即使这样压缩率已经达到目标后续可以把这条路作为进一步优化的备选。4.4 保留排序索引压缩之后的随机访问压缩存储最大的风险是“压完就查不动了”。如果文件压缩成一个整体查询一条记录要把整个文件解压一遍性能不可接受。块级索引能够解决一部分问题但块的粒度是64KB到1MB对于精确查询来说还是太大了。我们在文件里额外维护了一层稀疏索引按时间戳每N条记录记录一次偏移范围为这N条记录所在的块或块集合。查询某个时间段的数据时先用稀疏索引定位到起始块和结束块再只解压这些块。这样查询的数据量被限制在索引范围内的少数几个块而不是整个文件。对于车牌查询字典表本身也充当了索引角色。按车牌过滤时可以通过字典表找到车牌对应的ID然后在每个块内用二分查找或线性扫描定位记录。不同数据分布下这个查询的代价差异很大如果某个车牌高频出现线性扫描反而更快。这块我们没有做过深的优化但对交通数据常见的“按时间段按路段”组合查询来说稀疏时间索引已经足够用了。5. 实测50%空间节省的真相与代价5.1 用三天真实卡口数据做测试方案上线前团队内部做了详细对比测试。数据集选取同城三个主要路口的卡口过车记录跨三个工作日共2.1亿条记录原始数据约38GB。测试环境是16核32GB内存的虚拟机Java 17ZSTD版本用的zstd-jni。对比场景分三组原始JSON直接gzip压缩、原始CSV直接ZSTD压缩、定制编码链后ZSTD压缩。每组的写入时间、存储体积、查询耗时都做了记录。写路径性能也不能忽略。实时数据管道每秒会进来几万条记录如果编码和压缩耗时太高Kafka消费者会拉爆延迟。所以测试时不仅看文件大小还记录了每条记录的平均编码耗时。5.2 压缩率、写入性能、查询性能三张对照表下面这三张表来自当时的测试报告数据细节做了脱敏但量级真实可信。存储体积对比方案原始体积压缩后体积压缩率相比原始节省JSON gzip -938GB16.8GB44.2%55.8%CSV ZSTD level 330GB11.5GB61.7%61.7%定制编码 ZSTD level 330GB8.2GB72.7%72.7%等等这里有个细节要解释。为什么CSV比JSON体积小那么多因为CSV没有重复的字段名这是文本格式切换到表格格式本身带来的收益。我们实际项目里早期是JSON落盘后来统一转成CSV或二进制再走压缩收益已经很大了。最终上线时我们切掉了文本解析环节直接以二进制编码写入所以最终数字是基于30GB的清洗后数据计算的。写入性能对比方案单条编码压缩耗时微秒吞吐万条/秒JSON写入未压缩约8约12定制编码 ZSTD level 3约25约4定制编码 LZ4约14约7定制编码 ZSTD level 1约18约5.5注意这里单位是微秒级次数实际吞吐受集群环境和Kafka消费并行度影响数值仅供参考。ZSTD level 3的压缩耗时占比很高如果写入链路瓶颈明显可以调低级别。查询性能对比按小时范围查询方案查询耗时秒原始JSON逐行解析约18秒/GBgzip压缩文件全量解压约25秒/次定制编码 块索引按需解压约2秒/次定制编码方案在查询上的优势非常明显。因为块索引让我们只解压了查询范围内的那部分块而不是整个文件这是结构化压缩存储相比普通压缩文件碾压级优势。5.3 不同数据子集的压缩率差异分析整体压缩率72.7%非常亮眼但分字段看差异很大。我们对编码后各字段的独立体积做过统计字段原始平均字节数编码后平均字节数降幅timestamp81.680%plateNo字典ID122.182.5%laneId40.587.5%deviceId41.270%speed40.880%plateColor20.385%时间戳和车牌的降幅最大因为它们原始表示浪费最多。laneId的降幅也很惊人因为取值范围本来就只有几十个位打包后几乎可以按bit算。deviceId的降幅相对小一些因为设备基数比车道大但依然明显。交通流检测数据和GPS轨迹数据的压缩率会略有不同。GPS轨迹数据因为包含经纬度单条记录的基础体积大但差分编码后同样能压到很低的水平。交通流检测数据因为字段本来就是数值型小整数编码后体积甚至可以压到原始体积的10%左右。6. 真实项目里踩过的坑以及如何绕开6.1 字典表越滚越大分段字典与LRU最初设计字典编码时我把字典表设计成“一整个文件一个字典”。测试阶段数据量小没发现问题但数据量上来以后字典表的体积变得越来越庞大。十万个车牌字符串累积起来有几十MB虽然会被ZSTD压缩但压缩字典表本身要占用大量CPU和内存。更麻烦的是解码端需要把整个字典表加载进内存才能解析任意一个块这在小服务器上非常吃力。这个问题我们最后用“分层字典”解决。文件级的全局字典只保存高频车牌上限设置为2万个。超出上限的新车牌走局部字典或直接存储原始字符串。每个块内部额外维护一个小小的块内字典用于存储该块特有但全局没收录的车牌。这么做的好处是全局字典控制内存上限块内字典照顾低频值解码时只需要加载全局字典和当前块的局部字典占用的内存可控。副作用是代码复杂了一些但换来的是稳定性和可扩展性。另一个经验是字典构建时机。在线编码场景下如果实时统计字典每个批次跑一遍全量统计吞吐会下降很多。我们的做法是“多批次共享字典”字典构建延迟一小时用上一个小时的字典编码当前一小时的数据。字典稍微陈旧只会造成低频值被当新值编码压缩率损失很小但吞吐收益显著。6.2 解压速度成为瓶颈压缩不是越强越好上线第一版时我把ZSTD压缩级别调到了level 9因为追求极致压缩率。结果写入耗时暴增实时管道消费速度跟不上Kafka积压查询时解压耗时也明显上升。最惨的是压缩率相比level 3只提升了不到3个百分点远远够不上性能损耗。后来总结的经验是压缩级别的选择要看数据的使用频率。热数据用level 1或level 3追求吞吐温数据用level 6平衡压缩率和速度冷数据归档用level 12以上因为冷数据几乎不会被频繁查询。不要用同一套参数处理所有数据。另外解压端性能也要纳入监控。我们发现部分查询接口的解压CPU占用到了60%以上后来通过加缓存、调整块大小、减少不必要的解压次数来解决。压缩是空间换时间但如果空间省了时间却爆炸了这个方案就是失败的。6.3 文件无法随机访问块级索引的必要性最初版本只做编码和压缩落盘后就是一个巨大的二进制流根本没有块索引。查询某一天的数据要把整个文件解压一个5GB的文件解压一次耗时近半分钟。上线测试第一次就翻车了。后来我们重构了文件结构加入了块索引和稀疏时间索引这一块在上面的4.4节已经讲过。这里想强调一个教训在设计压缩存储的第一天就要想清楚“数据会被怎么查”。如果只是归档冷数据全量解压无所谓如果是准实时分析必须做索引如果是点查粒度还要更细。索引本身会占额外空间通常不到总体的1%到2%但这个开销必须从一开始就考虑进去。6.4 Java堆外内存与GC抖动ByteBuffer的取舍项目中大量使用ByteBuffer进行编码。最初用的堆内ByteBuffer数据量一大GC压力立刻上来了。编码时高频创建的小ByteBuffer对象很快进入老年代YGC和Full GC频繁触发写入吞吐严重下降。后来改成堆外ByteBuffer或者直接复用ByteBuffer实例GC压力小了很多。但堆外内存的回收不受GC直接管理使用后必须手动释放否则会内存泄漏。线上还出过一次堆外内存泄漏排查了整整两天最后定位到是异常路径没有释放DirectBuffer。小技巧是给编码管线设计一个ByteBuffer池按批次复用缓冲区。每个编码器从池里取Buffer用完归还池大小固定。这样既能避免频繁创建对象也避免了堆外泄漏。代码结构对比// 不推荐每行编码都新建 ByteBuffer buf ByteBuffer.allocate(1024); // 推荐线程内复用 private static final ThreadLocalByteBuffer BUFFER ThreadLocal.withInitial(() - ByteBuffer.allocateDirect(1024 * 1024));这个优化改动不大但线上长时间运行的稳定性提升非常明显。内存分配从“每条记录一次”变成“每个线程一个缓冲区”吞吐和GC表现都上了一个台阶。6.5 关于“压缩后还能不能直接用SQL查”的思考很多团队在考虑压缩存储时最大的顾虑是压缩后会破坏查询能力。我们的方案确实没有保留完整的SQL能力但这不是问题因为我们本来就不需要。数据写入后主要被两类任务消费一类是实时流计算直接从Kafka读原始数据另一类是离线分析和统计报表从压缩文件读取后做聚合计算。对于后者自定义的块级读取接口已经完全够用。如果你的业务需要直接用SQL查询压缩后的数据可以考虑把这套编码器接入到Parquet或ORC的编码层而不是自研文件格式。Parquet本身支持delta encoding、dictionary encoding和RLE扩展点很多把交通领域的专用编码器嵌入进去既保留SQL能力又能享受压缩收益。这个方向我们后来在另一个项目中做过验证效果同样不错但接入复杂度会比自研方案高一截。最后关于这套方案的一个实用建议压缩存储这类优化真的不要一上来就照着别人的方案全套照搬。不同交通数据源、不同字段类型、不同查询模式适合的编码器组合完全不同。我的建议是先拿一周的历史数据做采样按字段统计每个字段的取值基数、重复率、数值分布优先解决重复率最高、占用空间最大的那20%字段往往就能拿到80%的收益。我当时踩过最大的坑就是一开始想做一个“万能压缩引擎”支持所有字段类型、所有编码组合结果开发周期拉了一个月还没上线。后来收敛思路只做了时间戳差分、车牌字典、基础位打包这三板斧两周内就达到了存储目标。如果以后你要在自己项目里做类似的事先从最核心的几个字段开始别贪多。