如何在高并发环境下设计出无锁的数据库操作(Java 版本)

在面对在线用户量高达 2000 人并发(CCU)的游戏场景时,每秒的请求并发量极高。传统的 Hibernate 直接操作数据库的方式在这种压力下基本上不可行。本文将一步步推导出一种适用于高并发环境的无锁数据库操作设计方案。

1. 并发中如何无锁

一个核心思路是:将并发转化为单线程

Java 中的 Disruptor 框架就是一个很好的例子。如果使用 Java 的 concurrentCollection 类来实现,原理是启动一个线程维护一个 Queue(队列)。在高并发场景下,任务被压入 Queue,由该线程轮询读取并顺序执行。

在这种设计模式下,任何并发请求最终都变成了单线程操作,且处理速度非常快。目前的 Node.js 事件循环,或者许多常见的 ARPG 服务端架构,均采用这种“大循环”(Big Loop)设计。

至此,我们的系统环境中存在两种模式:

  1. 并发环境:传统的有锁环境,性能相对较低。
  2. “大循环”环境:使用 Disruptor 或单线程队列开辟出的无锁环境,性能强大。

2. “大循环”环境中如何提升处理性能

一旦将并发转化为单线程,该线程一旦出现性能瓶颈,整个处理流程都会变慢。因此,单线程中的任何操作绝对不能涉及阻塞式 IO 处理。那么,数据库操作该如何处理?

解决方案:增加缓存。

思路很简单:直接从内存读取数据必然快。至于写、更新操作,采用类似思路,将操作提交给一个 Queue,然后单独启动一个 Thread 去逐个获取并执行数据库插入。这样保证了“大循环”中不涉及到 IO 操作。

此时会出现新的问题:

如果游戏逻辑完全运行在“大循环”中,问题容易解决,因为内部提供了完美的同步无锁机制。但实际游戏环境往往是并发环境“大循环”环境并存的。无论怎么设计,必然会发现缓存这块上要出现锁竞争。

3. 并发与“大循环”如何共处,消除锁?

我们知道,如果在“大循环”中要避免锁操作,就必须使用“异步”,把操作交给线程处理。结合这两个特点,我们需要调整数据库架构。

原本的缓存层必然存在锁,例如:

public class TableCache {
    private HashMap<String, Object> caches = new ConcurrentHashMap<String, Object>();
}

这个结构是必然的,保证了在并发环境下能够准确地操作缓存。但是“大循环”不能直接操作这个缓存进行修改(以避免锁竞争),所以必须启动一个线程去更新缓存,例如:

private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();

EXECUTOR.execute(new LatencyProcessor(logs));

class LatencyProcessor implements Runnable {
    public void run() {
        // 这里可以任意地修改内存数据,采用了异步处理
    }
}

看似可行,但又有个问题出现了:在高速存取的过程中,非常有可能缓存还没有被更新,就被其他请求再次获取,导致得到旧数据。

4. 如何保证并发环境下缓存数据的唯一正确?

我们知道,如果只有读操作,没有写操作,那么这个行为是不需要加锁的。

利用这个技巧,我们在缓存的上层再加一层缓存,成为“一级缓存”,原来的缓存自然成为“二级缓存”。这有点像 CPU 的缓存架构。

  • 一级缓存:只能被“大循环”修改,但是可以被并发环境、“大循环”同时获取。由于只涉及单线程写入和原子引用更新,读取是不需要锁的。
  • 二级缓存:作为持久化数据的缓冲。

当发生数据库变动时,分两种情况处理:

  1. 并发环境下的数据库变动:允许有锁的存在,直接操作二级缓存,没有问题。
  2. “大循环”环境下的数据库变动:首先把变动数据存储在一级缓存,然后交给异步任务修正二级缓存,修正完成后删除一级缓存。

这样,无论在哪个环境下读取数据,流程均为:首先判断一级缓存,如果没有再判断二级缓存

这个架构保证了内存数据在架构内的一致性。更重要的是:我们拥有了一个高效的无锁空间,去实现任意的业务逻辑。

5. 性能优化小技巧

最后,还有一些小技巧可以进一步提升性能。

  1. 合并无效操作
    既然数据库操作已经被异步处理,那么某个时间点需要插库的数据可能很多。通过对表、主键、操作类型的排序,我们可以删除一些无效操作。例如:

    • 同一个表同一个主键的多次 Update,只取最后一次。
    • 同一个表同一个主键,只要出现 Delete,前面所有操作无效。
  2. 无锁全局序列
    既然要对操作排序,必然会存在一个根据时间排序的需求。如何保证无锁呢?使用 AtomicLong 即可保证无锁又全局唯一自增,作为时间序列:

    private final static AtomicLong _seq = new AtomicLong(0);
说明:本文所述架构基于传统 Java 服务端设计模式(如 Hibernate、手动管理 ExecutorService 等)。在实际现代开发中,可结合响应式编程(Reactive Programming)或更新的中间件方案进行优化,但核心的“并发转串行”与“多级缓存一致性”思路依然具有参考价值。