从2到170:物联网数据平台的85倍性能优化之路

9 阅读12分钟

从2到170:物联网数据平台的85倍性能优化之路

一次完整的性能攻坚实战复盘


面试官问:“你做过最牛的性能优化是什么?”

我答:“把2条/秒干到了170条/秒,提升85倍。”

面试官:“细说说?”

这篇文章,就是那次对话的完整版。


小兵:麻雀,听说你之前做过一个牛逼的性能优化,把系统从2条/秒干到了170条/秒?快给我讲讲!

麻雀:哈哈,消息挺灵通啊。没错,那是我之前负责的一个物联网数据应用服务平台。今天正好可以跟你好好聊聊这段经历。

小兵:太好了!我就爱听实战案例,比看理论文章过瘾多了。从头开始讲呗?

麻雀:好,那咱们就从项目背景开始,一步步拆解。


一、背景:一个濒临崩溃的核心系统

小兵:先说说这是个什么系统?

麻雀:这是一个物联网数据应用服务平台,负责接收和处理现场设备上传的实时数据。我们的设备每天会向平台传输大量数据,接入速率高达100Mbps

小兵:100Mbps?那数据量岂不是很大?

麻雀:对,我给你算笔账:一个10分钟的数据窗口,就会产生约6GB的原始报文数据。这些数据需要被实时解析、计算,然后分发给下游的各种业务系统,比如设备状态监控、数据分析平台等。

小兵:那当时的系统能扛住吗?

麻雀:说好听点叫“性能瓶颈”,说直白点叫“快崩了”。我刚接手这个模块时,监控面板一片飘红:

  • 处理吞吐量:2条/秒(你没看错,2条)
  • CPU使用率:持续100%
  • 数据堆积:每次设备数据上报,数据积压越来越严重
  • 业务投诉:下游系统等不到数据,告警不断

小兵:2条/秒?100Mbps的数据流只能消化2条?这好比用吸管抽游泳池啊!

麻雀:就是这个比喻。我当时看着监控面板,心想:这系统迟早要出事。


二、问题诊断:用数据说话,而不是凭感觉

小兵:遇到这种问题,你第一步是怎么做的?

麻雀:我的原则是:先别急着改代码,先用数据找到真正的瓶颈

小兵:很多人不都是直接上手优化吗?

麻雀:对,但那叫“玄学调试”——这里加个缓存,那里改个线程,折腾半天问题还在。我的做法是:全链路压测 + Profiling分析

小兵:具体怎么做的?

麻雀:我用JProfiler在测试环境模拟真实数据流,盯着火焰图看。结果很快就发现了问题:

单线程解析是最大的瓶颈

解析模块 CPU 占比:95%+
其中 80% 的时间花在字符串处理和协议解析上
其他模块(存储、分发)基本都在等解析完成

小兵:所以整个系统就是一个串行流水线?

麻雀:对!解析完一条,才能处理下一条。CPU打满了,但大部分时间都在“等待”。

小兵:为什么会设计成单线程?

麻雀:历史原因。早期的数据量小,单线程足够了。但随着设备数量增加,数据量暴涨,原来的设计就撑不住了。这是一个典型的技术债务:当初的“够用就好”,变成了今天的“性能瓶颈”。


三、第一刀:并行解析,但乱序来了

小兵:那怎么解决单线程问题?

麻雀:最直接的想法:改成多线程并行解析。我设计了一个 “生产者-消费者”模型

  • 主线程负责读取数据流,分发到多个解析线程
  • 解析线程并行处理
  • 主线程收集结果,继续后续处理

代码大概长这样(简化版):

public class ParallelParser {
    private ExecutorService executor = Executors.newFixedThreadPool(8);
    private BlockingQueue<DataPacket> resultQueue = new LinkedBlockingQueue<>();
    
    public void parse(InputStream input) {
        while (input.hasMore()) {
            DataPacket raw = input.readPacket();
            executor.submit(() -> {
                ParsedData parsed = doParse(raw);
                resultQueue.offer(parsed);  // 解析结果入队
            });
        }
    }
}

小兵:效果怎么样?

麻雀:性能确实上来了,从2条/秒直接蹦到了30条/秒,提升了15倍。

小兵:牛逼啊!那问题解决了?

麻雀:别急,新问题来了:数据乱序

小兵:乱序?什么意思?

麻雀:物联网数据是有严格时序要求的。比如一条数据是“设备位置A”,下一条是“设备位置A+1秒”。下游系统依赖这个顺序进行计算。

改成多线程后,解析快的线程可能先处理完后面的数据,导致顺序乱掉。下游拿到数据时,时间戳是乱的,计算结果全错。

小兵:这确实是个大问题!怎么解决的?

麻雀:我们想了一个方案: “标签化 + 重排序”

核心思路:

  1. 每条数据进入系统时,给它打上一个唯一的流水号(递增的序号)
  2. 多线程解析时,解析结果带着这个流水号
  3. 增加一个重排序组件,按照流水号顺序重组数据

代码实现:

public class OrderedProcessor {
    private ConcurrentSkipListMap<Long, ParsedData> buffer = new ConcurrentSkipListMap<>();
    private long nextSequence = 1;
    
    public void onParsed(long sequence, ParsedData data) {
        buffer.put(sequence, data);
        
        // 尝试按顺序取出
        while (buffer.containsKey(nextSequence)) {
            ParsedData ordered = buffer.remove(nextSequence);
            sendToNext(ordered);  // 发送给下游
            nextSequence++;
        }
    }
}

小兵:这个方案巧妙啊!ConcurrentSkipListMap是跳表实现,支持有序性,正好适合这种场景。

麻雀:对,这个方案的关键是:解析可以乱,但输出必须有序

效果:吞吐量保持30条/秒,同时保证了数据顺序。第一仗,打赢了


四、第二刀:多级缓存,斩断重复IO

小兵:解析问题解决了,下一个瓶颈在哪?

麻雀:压测发现,解析完数据后,系统需要查询一个外部服务获取 “设备配置参数” (比如当前的运行参数、采集频率等)。

小兵:每条数据都要查?

麻雀:对,每条都要查。而这个外部服务响应平均要50ms

小兵:50ms?那算一下:如果目标是170条/秒,每秒钟要查170次,光是等待外部服务就要8.5秒——根本不可能

麻雀:数学不错嘛!所以必须优化这个查询。

小兵:怎么优化的?

麻雀:我设计了 “两级缓存”

第一级:本地内存缓存(Guava Cache)

  • 缓存最近1分钟的热点数据
  • 命中率约95%,访问延迟<1ms
  • 自动过期,防止内存泄漏

第二级:分布式缓存(Redis)

  • 缓存所有配置参数数据
  • 本地缓存未命中时,从Redis读取
  • Redis也未命中,才回源到外部服务
@Component
public class DeviceConfigCache {
    
    // 一级缓存:本地内存(Guava Cache)
    private Cache<String, DeviceConfig> localCache = Caffeine.newBuilder()
            .expireAfterWrite(1, TimeUnit.MINUTES)
            .maximumSize(10000)
            .build();
    
    // 二级缓存:Redis
    @Autowired
    private RedisTemplate<String, DeviceConfig> redisTemplate;
    
    public DeviceConfig get(String deviceId) {
        // 1. 查本地
        DeviceConfig config = localCache.getIfPresent(deviceId);
        if (config != null) {
            return config;
        }
        
        // 2. 查Redis
        String redisKey = "device:config:" + deviceId;
        config = redisTemplate.opsForValue().get(redisKey);
        if (config != null) {
            localCache.put(deviceId, config);  // 回填本地
            return config;
        }
        
        // 3. 回源查询
        config = queryRemoteService(deviceId);
        if (config != null) {
            redisTemplate.opsForValue().set(redisKey, config, 1, TimeUnit.HOURS);
            localCache.put(deviceId, config);
        }
        
        return config;
    }
}

小兵:这个设计很经典啊,用本地缓存扛热点,Redis做分布式共享,回源兜底。

麻雀:效果也很明显:

  • 查询耗时:从50ms → <1ms(本地命中)
  • 外部服务调用:减少95%以上
  • 系统吞吐量:从30条/秒 → 80条/秒

小兵:又翻了一倍多!第二刀,漂亮!

麻雀第二刀,又成了


五、第三刀:批量处理,突破消息队列瓶颈

小兵:缓存解决了查询问题,下一个瓶颈呢?

麻雀:数据解析、查询都搞定后,新的瓶颈出现在消息队列

小兵:用的什么MQ?

麻雀:RabbitMQ。单条消息发送的开销很大——网络往返、磁盘同步等。当吞吐量达到80条/秒时,RabbitMQ开始扛不住了。

小兵:怎么解决的?

麻雀:方案是:批量发送。我在内存中设计了一个 “批量发送器”

  • 积累一定数量(如100条)的消息
  • 或等待一个短暂的时间窗口(如100ms)
  • 达到任一条件,批量发送一次
@Component
public class BatchMessageSender {
    
    private List<Message> batch = new ArrayList<>();
    private ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
    private static final int BATCH_SIZE = 100;
    private static final int BATCH_TIME_WINDOW = 100; // ms
    
    @PostConstruct
    public void init() {
        scheduler.scheduleAtFixedRate(this::flush, BATCH_TIME_WINDOW, BATCH_TIME_WINDOW, TimeUnit.MILLISECONDS);
    }
    
    public synchronized void send(Message msg) {
        batch.add(msg);
        if (batch.size() >= BATCH_SIZE) {
            flush();
        }
    }
    
    private synchronized void flush() {
        if (batch.isEmpty()) return;
        
        // 批量发送
        rabbitTemplate.convertAndSend("exchange", "routing", batch);
        batch.clear();
    }
}

小兵:批量发送确实能大幅提升吞吐量,但会不会影响实时性?

麻雀:好问题!这就是吞吐量和实时性的权衡。我们选了100ms的时间窗口,对业务来说可以接受。最终效果:

  • RabbitMQ吞吐量:提升3倍
  • 系统整体吞吐:80条/秒 → 120条/秒

小兵:第三刀,也成了!

麻雀第三刀,到位


六、第四刀:分库分表,分散写入压力

小兵:消息队列解决了,下一个瓶颈该轮到数据库了吧?

麻雀:聪明!这回确实是数据库了。所有解析结果都要写入数据库。单库单表,高并发写入导致锁竞争激烈,写入速度上不去。

小兵:怎么解决的?

麻雀:引入Sharding-JDBC,按设备编号进行水平分库分表

# Sharding-JDBC配置
dataSources:
  ds0: !!org.apache.commons.dbcp.BasicDataSource
    url: jdbc:mysql://192.168.1.100:3306/device_0
  ds1: !!org.apache.commons.dbcp.BasicDataSource
    url: jdbc:mysql://192.168.1.101:3306/device_1

shardingRule:
  tables:
    device_data:
      actualDataNodes: ds${0..1}.device_data_${0..1}
      databaseStrategy:
        inline:
          shardingColumn: device_id
          algorithmExpression: ds${device_id % 2}
      tableStrategy:
        inline:
          shardingColumn: id
          algorithmExpression: device_data_${id % 2}

小兵:2个库各2张表,相当于把写入压力分散到4个地方。

麻雀:对,效果也很明显:

  • 写入性能:提升3倍
  • 系统吞吐:120条/秒 → 170条/秒

小兵:170条/秒!目标达成了!

麻雀第四刀,圆满收尾


七、贯穿全程:JVM调优,稳住大盘

小兵:除了这些架构层面的优化,还做了什么?

麻雀JVM调优贯穿始终。每次优化后,我都会用JProfiler监控JVM状态。发现几个问题:

  1. GC频繁:Young GC每秒好几次,影响响应时间
  2. 内存泄漏:某个Map一直在增长,没有清理
  3. 线程数过高:线程池参数不合理,创建了大量线程

小兵:这些问题怎么解决的?

麻雀

  • 调整堆内存结构:-Xms4g -Xmx4g -Xmn2g(新生代给足)
  • GC算法:改用G1,设置目标停顿时间 -XX:MaxGCPauseMillis=100
  • 内存泄漏修复:用ThreadLocal没remove,导致内存泄漏,加上try-finally清理
  • 线程池调优:根据CPU核心数和IO等待时间,重新计算线程池大小

小兵:效果如何?

麻雀:Full GC频率降低90% ,系统更稳定。


八、最终成果:85倍提升

小兵:优化结束,最终数据是多少?

麻雀:来看这个表格:

指标优化前优化后提升
吞吐量2条/秒170条/秒85倍
CPU使用率100%70%下降30%
查询耗时50ms<1ms50倍
Full GC频率每小时几次每天几次90%↓
系统稳定性频繁崩溃持续稳定99.9%+

更重要的是:再也没有数据堆积告警了。每次设备数据上报高峰,10分钟6GB数据,系统稳稳消化,下游业务部门再也不用半夜被叫起来处理问题。

小兵:太牛了!这不仅仅是技术上的成功,更是对整个业务的价值。


九、反思:从工程师到架构师的跃迁

小兵:这次优化让你收获最大的是什么?

麻雀:三点,也是我从“工程师”到“架构师”的跃迁:

1. 性能优化必须是数据驱动的系统工程

不能靠猜。必须用Profiling工具找到真正的瓶颈,从全链路视角去优化,而不是“头痛医头”。

2. 并发设计的核心是状态管理

“标签化重排序”这个方案,本质是解决了多线程环境下状态的一致性和有序性。这是高并发设计的核心难点,也是最有价值的地方。

3. 架构的扩展性需要提前布局

如果早期设计时就考虑分片键,分库的代价会更小。这提醒我:在架构设计初期,就必须为‘量变引起质变’的时刻预留扩展点


十、可复用的方法论

小兵:能不能总结一个通用的方法论,以后我遇到性能问题也能用?

麻雀:当然!这是我总结的性能优化六步法

步骤做什么工具/方法
1. 定义基线先跑一次测试,记录当前数据JMeter、压测脚本
2. 定位瓶颈用Profiling工具找到最慢的环节JProfiler、Arthas、火焰图
3. 设计方案针对瓶颈设计优化方案并行、缓存、批量、分片
4. 小步验证每优化一个点,重新跑测试对比优化前后的数据
5. 灰度上线先小范围验证,再全量灰度发布、A/B测试
6. 持续监控防止性能退化监控大盘、告警机制

小兵:这个好!我截图保存了。


写在最后

小兵:麻雀,今天听你讲完,我收获太大了!不仅是技术方案,更重要的是思考问题的方法。

麻雀:这次优化已经过去两年,但每次回想起来,依然觉得热血沸腾。它让我明白:没有解决不了的技术问题,只有还没找到的瓶颈。

小兵:那如果你的系统也面临性能困境,有什么建议?

麻雀:试试这套方法论。用数据说话,系统性思考,一步一个脚印。也许下一个“85倍”的奇迹,就在你手中。

小兵:谢谢麻雀!今天的聊天太有价值了。

麻雀:客气了,有问题随时来找我聊。


作者:麻雀

微信公众号/b站:麻雀聊技术(欢迎关注公众号领取配套资料,后续会写SSE、分布式调度、动态属性等系列文章)

如果你觉得这篇文章对你有帮助,欢迎点赞、评论、转发。有任何问题,评论区见,我会逐一回复。


【附录】文中涉及的技术栈

  • 语言:Java 8/11
  • 工具:JProfiler、JMeter
  • 中间件:RabbitMQ、Redis、Sharding-JDBC
  • 缓存:Caffeine、Guava Cache
  • 并发:线程池、ConcurrentSkipListMap、BlockingQueue