之前做的一个资金业务专门有个服务在内存中进行用户余额计算,然后通过kafka消息将计算结果异步的同步到数据库中。为了给用户显示余额的正确性,之前的设计严格的保证了处理的顺序性。
为了严格有序,kafka使用的是单分区。同时,为了实现kafka顺序消费,在消费端使用zookeeper的leader选举逻辑实现只有一个节点在消费消息,然后持久化到数据库。
这个实现存在两个严重问题:使用单分区,一旦业务量上升kakfa的吞吐量却上不去,存在严重性能瓶颈无法通过扩分区解决。消费端采用主备模式,在进行重启等操作时会触发主备切换。借助zookeeper选主的过程很慢,测试发现要几分钟才能选举成功,这个过程中消息会积压。消费端因为采用了自动提交模式,每次都消费一批放到内存队列慢慢处理,会丢失消息,需要在部署时检查是否需要手动重放消息。
为了解决系统的扩展性问题和重启时手动运维的问题,我们进行了重新设计升级。
我之前也说过,系统优化亦或是性能优化,首先要做的是充分理解业务。
通过业务梳理发现:从本质上来看,我们的业务并不需要严格的顺序消费,只需要保证每个用户的余额新数据不被旧数据覆盖。因为kafka有offset等字段,针对每个用户余额也有相应的版本号。可以知道哪是新数据,哪是旧数据。所以做方案阶段,我们的方案是采用redis分布式锁,锁粒度为单个用户的余额。有数据更新时,用分布式锁同步数据,在锁内判断哪是新的,哪是旧的。新数据可以覆盖旧数据,旧数据可以直接返回不用处理。这样,我们可以以集群的方式各节点平等的运行服务,实现了从主备模式到分布式模式的切换。
新方案省去了选主过程,启动完即可提供服务;各节点平等运行,负载均衡,可使用多个分区多个消费者并行处理,实现了可扩展。消息处理能力上来了,因此可开启消费端手动提交commit,消息不容易丢失。
实现细节
现有方案
之前kafka消费端采用的是standalone模式,使用的是单分片。消费端指定分片0。使用了zookeeper进行选主。只在主节点进行消费。这个选主的过程要几分钟才能选出来。之后为了保证消息不丢失,已经执行完的kafkaOffset会保存到数据库。每次在重启时会拉取数据库里的kafkaOffset执行consumer.seek到此位置。然后才开始consumer.poll。
我们做过破坏性测试:在测试环境有2台服务器主备时,如果把两台服务器都完全杀死再启动起来。kafka消费端就无法正常消费了。原因是
Kafka log清理策略配置log.retention.minutes=10
默认kafka只保留10分钟的log。而将两个POD完全停掉再启动,因为加上较长的选主时间,所以数据库中存的kafkaOffset数据过期被清理了。造成大量invalid message。这会造成消费端的iterator损坏,致使消费进程挂掉。
这时候需要人工处理,将数据库的数据清理掉,重启服务。清理掉之后,策略是从最新的开始消费。所以历史的数据都会丢失。
改进方案
对于同一个分片,如果只是consumer.subscribe某个主题,多个消费端只有一个在消费,其他节点在故障转移时才会进行消费。理论上这种Kakfa默认的故障转移策略要比zookeeper选主要快。
我使用的是consumer.assign指定分片。这两个两个节点都会进行消费。为了不增加kafka流量负担。我使用了带过期时间的分布式锁,只有抢到锁才会进行消费。使用手动提交offset代替自动提交offset。异步多线程执行加速,但是需要等结果返回再提交offset,这样就可以保证消息不丢失。
一旦一台服务宕机,另外一台可以快速接管。就算两台都宕机,重启后可以毫秒级别继续消费。这样,只需要将
Kafka log清理策略配置log.retention.minutes=10
调大一些,比如保留1小时的,服务可以1小时内恢复,那就不会丢失消息。
其他事项
服务启动时可以设置consumer.seek从lastest还是earliest的offset开始,但是还是建议consumer.seekToEnd或者consumer.seekToBegin来明确。如果不想丢消息,还是建议除了第一次启动之后,其他情况直接consumer.poll。
consumer.assigment可以获取到当前consumer的分片,这个在多分片下进行监控会有用。但是这个只有在第一次执行consumer.poll之后才能获取到值。所以程序启动时获取到为空是正常现象。