需求背景
kafka消费消息丢入线程池,upsert数据库(mysql)。随着线程池并发加大,mq tps升高,锁冲突会加剧
优化方案
核心思路
- batchUpsert提升吞吐量,降低并发冲突,弱化锁冲突问题
- db写入按照唯一key顺序分区执行,降低交叉间隙锁冲突
方案1
按照唯一key有序缓存至ConcurrentSkiplistMap。多线程并发截取Map进行batchUpsert
- 利用
ConcurrentSkipListMap<UniqueKey, Data>实现 按唯一索引有序缓存。 - 每个线程从
map.subMap(...)中获取一个局部有序子集,例如每次提取N=100条。 - 执行一次批量
upsert(避免单条写导致频繁锁竞争)。
✅ 优点
- 降低锁竞争:
- 局部有序 + 批处理可以显著减少 InnoDB 的死锁和等待锁。
- 避免多个线程同时更新同一组主键或唯一键。
- 提高吞吐量:
- 数据局部有序 → 索引插入更高效。
batch upsert替代单条insert on duplicate key update。
- 线程安全:
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);
}
🧱 数据库层优化建议
- 使用语句:
plsql
INSERT INTO your_table(uid, cid, xxx)
VALUES (?, ?, ?), (?, ?, ?)
ON DUPLICATE KEY UPDATE xxx=VALUES(xxx)
- 若字段多且更新逻辑复杂,也可以使用:
plsql
INSERT ... ON DUPLICATE KEY UPDATE col = IF(VALUES(col) > col, VALUES(col), col)
- 对
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 严格顺序、有序处理,同时允许不同键的并发处理。
✳️ 实现思路概览(支持局部有序)
- 数据结构设计:将业务数据封装为事件对象:
java
class UpsertEvent {
UniqueKey key; // uid + cid
YourDataDTO data;
}
- Disruptor 初始化 :设置为 多生产者、单消费者模型 或者 多消费者但按 key 分区消费模型。
java
Disruptor<UpsertEvent> disruptor = new Disruptor<>(
UpsertEvent::new,
ringBufferSize,
Executors.defaultThreadFactory(),
ProducerType.MULTI,
new BlockingWaitStrategy()
);
- EventHandler 分区处理(局部有序)
- 核心:将事件根据
**uid+cid**进行 hash 分片处理 - 每个 handler 只消费自己的分区,天然就做到该 key 的"有序处理"
- 核心:将事件根据
具体方式有两种:
🚀 方式一:多 EventHandler + Routing(推荐)
拆分思路:
- 创建
N个EventHandler(例如 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个方案各有利弊根据实际业务场景选择最适合的就是最好的方案。针对分段锁实现的进阶方案,由于篇幅问题,下篇文章再继续讨论进阶实现: 巧用弱引用缓存锁对象实现自动回收