kafka高吞吐持久化方案(1)

需求背景

kafka消费消息丢入线程池,upsert数据库(mysql)。随着线程池并发加大,mq tps升高,锁冲突会加剧

优化方案

核心思路

  1. batchUpsert提升吞吐量,降低并发冲突,弱化锁冲突问题
  2. db写入按照唯一key顺序分区执行,降低交叉间隙锁冲突

方案1

按照唯一key有序缓存至ConcurrentSkiplistMap。多线程并发截取Map进行batchUpsert

  • 利用 ConcurrentSkipListMap<UniqueKey, Data> 实现 按唯一索引有序缓存
  • 每个线程从 map.subMap(...) 中获取一个局部有序子集,例如每次提取 N=100 条。
  • 执行一次批量 upsert(避免单条写导致频繁锁竞争)。

✅ 优点

  1. 降低锁竞争:
    • 局部有序 + 批处理可以显著减少 InnoDB 的死锁和等待锁。
    • 避免多个线程同时更新同一组主键或唯一键。
  2. 提高吞吐量:
    • 数据局部有序 → 索引插入更高效。
    • batch upsert 替代单条 insert on duplicate key update
  3. 线程安全:
    • ConcurrentSkipListMap 支持高并发下的安全子集读取和移除。

🧠 实现建议

1. 定义唯一键类
java 复制代码
public class UniqueKey implements Comparable<UniqueKey> {
    private final Long uid;
    private final Long cid;

    // equals 和 hashCode 用于 map 去重
    @Override
    public int compareTo(UniqueKey o) {
        int cmp = uid.compareTo(o.uid);
        if (cmp != 0) return cmp;
        return cid.compareTo(o.cid);
    }

    // equals(), hashCode(), constructor 省略
}
2. 定义缓存结构
java 复制代码
ConcurrentSkipListMap<UniqueKey, YourDataDTO> buffer = new ConcurrentSkipListMap<>();
3. 批量提取 & 删除
java 复制代码
// 每个线程循环执行,提取 batchSize 条
int batchSize = 100;
List<Map.Entry<UniqueKey, YourDataDTO>> batch = new ArrayList<>();

// 多线程批量抢占式消费保证读写原子性
for (UniqueKey key : buffer.keySet()) {
    buffer.computeIfPresent(key, (k, v) -> {
        if (batch.size() < batchSize) {
            batch.add(Map.entry(k, v));
            return null; // 返回 null 表示删除该 key
        } else {
            return v; // 保留该 key
        }
    });
    if (batch.size() >= batchSize) {
        break;
    }
}

// 批量 upsert 到数据库
if (!batch.isEmpty()) {
    List<YourDataDTO> records = batch.stream()
    .map(Map.Entry::getValue)
    .collect(Collectors.toList());

    yourDao.batchUpsert(records);
}

🧱 数据库层优化建议

  1. 使用语句:
plsql 复制代码
INSERT INTO your_table(uid, cid, xxx) 
VALUES (?, ?, ?), (?, ?, ?) 
ON DUPLICATE KEY UPDATE xxx=VALUES(xxx)
  1. 若字段多且更新逻辑复杂,也可以使用:
plsql 复制代码
INSERT ... ON DUPLICATE KEY UPDATE col = IF(VALUES(col) > col, VALUES(col), col)
  1. uid+cid 索引添加 INCLUDE 字段可提升更新性能(MySQL 8.0+ 支持隐式覆盖索引)。

⚠️ 注意事项

项目 注意事项
唯一键对比 compareTo 必须准确,否则 SkipListMap 行为异常
并发提取 避免多个线程重复读取同一条数据(通过 iter.remove() 实现)
内存压力 建议设置 buffer 最大容量,防止数据积压
顺序稳定 如果想要完全控制 batch 顺序,可加锁或做批次调度

🧩 进阶建议

  • 可以用 ReentrantLock 控制同一 uid 分组处理(hash分片思想)。
  • 或者用 Disruptor / RingBuffer 等高性能缓冲结构替代 SkipListMap

方案2

Disruptor多 EventHandler + Routing

  • 每个 EventHandler独占线程
  • 可以让某个 handler 只处理 (uid+cid).hash % N == i 的事件;
  • 在 handler 中**同步调用 ****handleUpsert()**,无需线程池

✅ 核心目标

按照 uid+cid 为唯一键,做到 同一个 **uid+cid** 的 upsert 严格顺序、有序处理,同时允许不同键的并发处理。


✳️ 实现思路概览(支持局部有序)

  1. 数据结构设计:将业务数据封装为事件对象:
java 复制代码
class UpsertEvent {
    UniqueKey key;         // uid + cid
    YourDataDTO data;
}
  1. Disruptor 初始化 :设置为 多生产者、单消费者模型 或者 多消费者但按 key 分区消费模型
java 复制代码
Disruptor<UpsertEvent> disruptor = new Disruptor<>(
    UpsertEvent::new,
    ringBufferSize,
    Executors.defaultThreadFactory(),
    ProducerType.MULTI,
    new BlockingWaitStrategy()
);
  1. EventHandler 分区处理(局部有序)
    • 核心:将事件根据 **uid+cid** 进行 hash 分片处理
    • 每个 handler 只消费自己的分区,天然就做到该 key 的"有序处理"

具体方式有两种:


🚀 方式一:多 EventHandler + Routing(推荐)

拆分思路:
  • 创建 NEventHandler(例如 8 个)。
  • 根据 key.hashCode() % N 将事件路由到某个 handler。
  • 每个 handler 内部队列是串行消费的,所以同一个 key 总在同一个 handler 中串行处理不同 key 可并发
示例代码(概念版):
java 复制代码
int handlerCount = 8;
for (int i = 0; i < handlerCount; i++) {
    final int index = i;
    disruptor.handleEventsWith((event, sequence, endOfBatch) -> {
        if ((event.getKey().hashCode() & Integer.MAX_VALUE) % handlerCount == index) {
            // ✅ 在分区线程中直接执行
            // 处理这个分区的数据
            handleUpsert(event.getData());
        }
    });
}

🧠 优点:线程复用良好,避免全局串行,同 key 保证严格有序,不同 key 并发。


🌀 方式二:自定义 WorkPool + 内部分片缓存(低级方案)

这种方式使用 WorkPool 并发消费,但自己在每个消费者内部管理 Map<UniqueKey, Queue<Data>>,对于同一个 key,必须队列顺序处理,不如方式一清晰可靠。


🔐 为什么这样能保证局部有序?

  • Disruptor 本身就是有序队列:其事件是严格顺序发布的(ringBuffer 顺序)。
  • 我们人为将同一个 key 始终路由到一个 handler(分区)
    • handler 是串行执行的 → 保证该 key 的操作严格按照到达顺序执行。
  • 不同 key 的 handler 不同 → 允许并发处理

方案3

使用ReentrantLock实现分段锁

ReentrantLock 方案是一个更轻量、易于控制的方式,特别适用于这种:

uid+cid 为唯一索引,需要实现相同 key 顺序处理、不同 key 可并发处理的 upsert 场景。


✅ 目标:基于 ReentrantLock 实现局部有序 upsert

  • 同一个 (uid, cid):串行处理(避免唯一索引冲突)
  • 不同的 (uid, cid):可以并发处理(提高吞吐)
  • 可支持 batchUpsert(提高 DB 性能)

🧩 实现思路

一、定义一个可复用的锁容器(分段锁)

我们不为每个 key 分配一个 ReentrantLock(太耗内存),而是用固定数量的 ReentrantLock 数组 ,根据 uid+cid hash 映射到某一个锁。

java 复制代码
public class KeyLockManager {

    private final int lockSize = 256; // 根据并发量设置
    private final ReentrantLock[] locks = new ReentrantLock[lockSize];

    public KeyLockManager() {
        for (int i = 0; i < lockSize; i++) {
            locks[i] = new ReentrantLock();
        }
    }

    public ReentrantLock getLock(Object key) {
        int index = (key.hashCode() & Integer.MAX_VALUE) % lockSize;
        return locks[index];
    }
}

二、在并发 upsert 中使用锁保护同一组 key
java 复制代码
KeyLockManager lockManager = new KeyLockManager();

public void safeUpsert(YourDataDTO data) {
    UniqueKey key = new UniqueKey(data.getUid(), data.getCid());
    ReentrantLock lock = lockManager.getLock(key);

    lock.lock();
    try {
        // 这里可以进行 batchUpsert 也可以单条
        doUpsert(data);
    } finally {
        lock.unlock();
    }
}

如果希望每次一个线程处理一组数据(而不是一条),可以在锁内操作 batch:


三、批量获取并处理同一个 key 的多个数据
java 复制代码
public void safeBatchUpsert(List<YourDataDTO> dataList) {
    // 分组,同一个 key 一起处理
    Map<UniqueKey, List<YourDataDTO>> groupMap = dataList.stream()
        .collect(Collectors.groupingBy(d -> new UniqueKey(d.getUid(), d.getCid())));

    for (Map.Entry<UniqueKey, List<YourDataDTO>> entry : groupMap.entrySet()) {
        UniqueKey key = entry.getKey();
        List<YourDataDTO> records = entry.getValue();

        ReentrantLock lock = lockManager.getLock(key);
        lock.lock();
        try {
            batchUpsert(records);
        } finally {
            lock.unlock();
        }
    }
}

⚠️ 注意事项

项目 建议
锁粒度 采用"分段锁"方式,不会为每个 key 建锁,避免内存暴涨
锁数量 一般 64、128、256 是不错的选择(和并发线程数量接近)
hash冲突 不影响功能,但会串行化多个 key(性能略降)
锁住操作范围 尽量只锁住数据库写操作,避免长时间业务逻辑阻塞

🧠 为什么这个方案可行?

  • ReentrantLock 自身是线程安全、可重入的;
  • Hash 分段锁可以显著减少锁的数量开销;
  • 避免死锁:只对一个 key 获取一个锁,不需要多个锁组合;
  • 保证有序:锁内处理同一个 key 的数据,天然是串行有序的。

✅ 总结

特性 Disruptor ReentrantLock
实现复杂度 低 ✅
控制粒度 中等(分区) 精确到 key ✅
并发性能 极高 中高 ✅
有序保证 分区串行 精确串行 ✅
适合场景 高并发写、事件驱动 数据库唯一键写入冲突优化 ✅

总结

3个方案各有利弊根据实际业务场景选择最适合的就是最好的方案。针对分段锁实现的进阶方案,由于篇幅问题,下篇文章再继续讨论进阶实现: 巧用弱引用缓存锁对象实现自动回收

相关推荐
Light602 小时前
Spark OA 系统深度分析与改造报告(整合版 + 领码 SPARK 改造计划 + 功能缺口)
大数据·分布式·spark
计算机毕设指导62 小时前
基于微信小程序的心理咨询预约系统【源码文末联系】
java·spring boot·mysql·微信小程序·小程序·tomcat·maven
生成论实验室4 小时前
即事是道:一种基于生成论的分布式体验存在论
人工智能·分布式·科技·神经网络·信息与通信
焦糖布丁的午夜10 小时前
MySQL数据库大王小练习
数据库·mysql
无心水11 小时前
【分布式利器:大厂技术】4、字节跳动高性能架构:Kitex+Hertz+BytePS,实时流与AI的极致优化
人工智能·分布式·架构·kitex·分布式利器·字节跳动分布式·byteps
Dxy123931021613 小时前
MySQL如何做读写分离架构
数据库·mysql·架构
卿雪18 小时前
Redis 线程模型:Redis为什么这么快?Redis为什么引入多线程?
java·数据库·redis·sql·mysql·缓存·golang
爬山算法18 小时前
Redis(167)如何使用Redis实现分布式缓存?
redis·分布式·缓存
梁萌18 小时前
MySQL中innerDB引擎的锁机制
数据库·mysql·索引·表锁·行锁