去年年初,我接手了一个城市交通数据平台的存储优化任务。平台的原始数据落地方案特别简单——Kafka里飘过来的每条过车记录,JSON序列化之后直接丢进数据湖。最核心的卡口过车表,一天新增2亿条记录,单日原始数据量接近1.5TB。按这个速度,一个季度就是上百TB,账单出来的时候,团队的脸色都不太好看。
当时第一反应是加存储、上冷热分层,但算了一笔账之后发现治标不治本。真正的问题在于:交通数据里绝大多数字段的重复率极高,时间戳单调递增、车牌反复出现、路段编号就那几千个、速度值集中在几个区间。这种数据用JSON存,等于把黄金当废铁卖。
后来我们重新设计了一套Java实现的分层压缩方案,不依赖重型框架,纯粹靠字段级编码加上块级压缩,把核心表存储压掉了52%。这篇文章就是那次实战的完整复盘,包括选型逻辑、编码器拆解、踩坑记录,以及最终落地的代码思路。如果你手上的数据也是“高重复、强时序”的类型,这套方法可以直接迁移。
1. 交通数据的存储成本到底烧在哪里
1.1 城市级交通数据的“四大家族”
业内做交通数据平台,其实每天处理的不是一种数据,而是至少四类特征完全不同的数据,存储开销也各不相同。
第一类是卡口过车数据,也就是路口电子警察或卡口设备抓拍后生成的结构化记录。一条记录通常包含过车时间、车牌号、车牌颜色、车道编号、方向编号、设备编号、车速、抓拍图片URL等字段。这类数据的特点是量极大、字段固定、单条记录小,但一天下来累积的条数惊人。一个中大型城市每天几千万条到几亿条都很正常。
第二类是浮动车GPS轨迹数据,来自出租车、网约车、公交车或物流车上的定位终端。每次定位产生一条记录,包含车辆ID、时间戳、经度、纬度、瞬时速度、方向角、运营状态等。这类数据的单条大小稍大,因为经纬度需要用浮点数或更高精度整数表示,但车辆的重复性极强,同一辆车可能每3到5秒上报一次。
第三类是交通流检测数据,来自地磁、雷达、视频检测器或微波设备,定期输出某个断面或路口的流量、平均速度、时间占有率、车道占有率等指标。这类数据的字段多为数值型,数值范围小,分布集中,可压缩性是最好的。
第四类是ETC门架交易数据或高速公路收费流水,涉及通行介质编号、门架编号、通行时间、车型、计费金额、交易状态等字段。这类数据量相对前两者低一些,但字段更长,字符串占比高,也有很强的规律性。
这些数据如果都以原始文本形式落盘,存储成本是线性上涨的。我见过不少团队第一版架构都是“Kafka到HDFS,JSON存原始值”,因为开发最快、排查方便,但数据量上来之后,这种方式的浪费会非常刺眼。
1.2 原始日志落盘的三大浪费
第一种浪费是文本格式本身的冗余。JSON为了可读性,要为每个字段重复写出字段名。一条过车记录里,“passTime”、“plateNo”、“laneId”这些键名出现一次就罢了,问题是几亿条记录里同一个字段名反复出现。字段名本身占的空间可能比字段值还大。有人统计过,一条典型的JSON结构记录中,字段名和标点符号能占总字节数的40%左右。
第二种浪费是字段类型的错配。时间戳存字符串,比如“2024-05-12 16:30:45”,19个字符,19个字节;如果转成long型Unix时间戳,只有8个字节,如果再结合差分编码,甚至可以压到1到2个字节。经纬度存double,8字节一个值,但实际用车的定位精度只需要小数点后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后是200000,VarInt需要3字节,依然比原始float的4字节或double的8字节省。
对于不需要亚米级精度的分析场景,还可以进一步降到小数点后5位或4位。但要对业务需求做充分确认,别为了省空间牺牲精度,后面查数据时找不回定位就麻烦了。
3.3 设备与车牌字典化:高频值只需一个int
车牌是卡口过车数据里最重要的业务字段,也是压缩潜力最大的字符串字段。一个城市一天内出现的车牌可能有几十万个,而记录数有几千万甚至上亿条,意味着平均每块数据里同一个车牌会出现多次,越是高频的车牌出现次数越多。
字典编码的思路很简单:在一个批次或一个文件范围内,统计所有车牌的出现频次,把高频车牌映射到自增ID,低频车牌可以走另一条路。ID本身用VarInt存储,高频车牌的ID很小,占用字节只有1到2个。
一个简单实现:
public class DictionaryCoder { private final Map<String, Integer> dict = new HashMap<>(); private final List<String> 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 List<String> 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字节,而如果直接用int,8个值要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负责把对象写入ByteBuffer,decode负责读出来,flush在批次结束时调用,用于把编码器内部缓存的状态(比如字典、基准值)补写到缓冲区末尾,reset用于开启下一个批次。CompressionMeta存放字段名、编码类型、字典长度等信息,方便解码端正确初始化。
接口统一之后,压缩管线的代码变得非常清爽。数据进来后按字段遍历,把每个字段交给自己的编码器,所有字段编码完再走ZSTD压缩。新增一个字段类型时,只需要实现FieldCoder,不用改主流程。这套设计在后期扩展时非常香,后来我们加过ETC交易金额编码、车辆类型枚举编码,都是半小时内搞定。
4. 完整落地方案:从Java API到磁盘上的压缩文件
4.1 批式压缩的写链路设计
领域编码终究不是一条流一条流地实时编码,而是以批为单位的“攒批、排序、编码、压缩、落盘”五步流水线。为什么要排序?因为差分编码和RLE都依赖数据的局部有序性。乱序数据的编码效果会大打折扣。
实际项目里,我们采用两个维度攒批:时间和条数。达到时间阈值或条数阈值,把当前批次刷出。刷出后第一步不是编码,而是先按时间戳排序。排序开销不算小,但收益很明显:时间戳差分编码后的字节数能下降20%到30%,RLE在流量字段上的表现也更好。
写文件时按块来组织。一个批次的数据可能达到几十MB,我们按64KB或256KB切片成多个块,每个块独立编码和压缩。块与块之间写入块索引,记录每个块在文件中的起始偏移量和解压后的记录条数。这样后续读取时可以只解压需要的块,不必全量解压。
一个压缩文件的结构大致是:
文件头 magic + 版本号 + 元信息 字段编码配置 全局字典表 块数据区(多个块) 块索引表 文件末尾 footer这个结构的好处是文件头确定编码配置,读取时先加载footer,定位块索引,再按需读取块。整个文件既是自描述的,也支持随机访问。
4.2 关键代码:差分时间戳与字典编码的实现
单独看编码器都是一小块代码,但组合起来要留意一些细节。我以“一条卡口过车记录”为例,写一个最小可运行的编码链。
假设字段包括:timestamp(long)、plateNo(String)、passLane(int)、deviceId(int)、speed(int)、plateColor(int)。
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(List<PassRecord> 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还是ZSTD,64KB还是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 17,ZSTD版本用的zstd-jni。
对比场景分三组:原始JSON直接gzip压缩、原始CSV直接ZSTD压缩、定制编码链后ZSTD压缩。每组的写入时间、存储体积、查询耗时都做了记录。
写路径性能也不能忽略。实时数据管道每秒会进来几万条记录,如果编码和压缩耗时太高,Kafka消费者会拉爆延迟。所以测试时不仅看文件大小,还记录了每条记录的平均编码耗时。
5.2 压缩率、写入性能、查询性能三张对照表
下面这三张表来自当时的测试报告,数据细节做了脱敏,但量级真实可信。
存储体积对比:
| 方案 | 原始体积 | 压缩后体积 | 压缩率 | 相比原始节省 |
|---|---|---|---|---|
| JSON + gzip -9 | 38GB | 16.8GB | 44.2% | 55.8% |
| CSV + ZSTD level 3 | 30GB | 11.5GB | 61.7% | 61.7% |
| 定制编码 + ZSTD level 3 | 30GB | 8.2GB | 72.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秒/GB |
| gzip压缩文件(全量解压) | 约25秒/次 |
| 定制编码 + 块索引(按需解压) | 约2秒/次 |
定制编码方案在查询上的优势非常明显。因为块索引让我们只解压了查询范围内的那部分块,而不是整个文件,这是结构化压缩存储相比普通压缩文件碾压级优势。
5.3 不同数据子集的压缩率差异分析
整体压缩率72.7%,非常亮眼,但分字段看差异很大。我们对编码后各字段的独立体积做过统计:
| 字段 | 原始平均字节数 | 编码后平均字节数 | 降幅 |
|---|---|---|---|
| timestamp | 8 | 1.6 | 80% |
| plateNo(字典ID) | 12 | 2.1 | 82.5% |
| laneId | 4 | 0.5 | 87.5% |
| deviceId | 4 | 1.2 | 70% |
| speed | 4 | 0.8 | 80% |
| plateColor | 2 | 0.3 | 85% |
时间戳和车牌的降幅最大,因为它们原始表示浪费最多。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 ThreadLocal<ByteBuffer> 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%的收益。
我当时踩过最大的坑就是一开始想做一个“万能压缩引擎”,支持所有字段类型、所有编码组合,结果开发周期拉了一个月,还没上线。后来收敛思路,只做了时间戳差分、车牌字典、基础位打包这三板斧,两周内就达到了存储目标。如果以后你要在自己项目里做类似的事,先从最核心的几个字段开始,别贪多。