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

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

 

 

一个在线2k的游戏,每秒钟并发都吓死人。传统的hibernate直接插库基本上是不可行的。我就一步步推导出一个无锁的数据库操作。

 

1. 并发中如何无锁。

一个很简单的思路,把并发转化成为单线程。Java的Disruptor就是一个很好的例子。如果用java的concurrentCollection类去做,原理就是启动一个线程,跑一个Queue,并发的时候,任务压入Queue,线程轮训读取这个Queue,然后一个个顺序执行。 

在这个设计模式下,任何并发都会变成了单线程操作,而且速度非常快。现在的node.js, 或者比较普通的ARPG服务端都是这个设计,“大循环”架构。

这样,我们原来的系统就有了2个环境:并发环境 + ”大循环“环境

并发环境就是我们传统的有锁环境,性能低下。

”大循环“环境是我们使用Disruptor开辟出来的单线程无锁环境,性能强大。

 

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

一旦并发转成单线程,那么其中一个线程一旦出现性能问题,必然整个处理都会放慢。所以在单线程中的任何操作绝对不能涉及到IO处理。那数据库操作怎么办?

增加缓存。这个思路很简单,直接从内存读取,必然会快。至于写、更新操作,采用类似的思路,把操作提交给一个Queue,然后单独跑一个Thread去一个个获取插库。这样保证了“大循环”中不涉及到IO操作。

 

问题再次出现:

如果我们的游戏只有个大循环还容易解决,因为里面提供了完美的同步无锁。

但是实际上的游戏环境是并发和“大循环”并存的,即上文的2种环境。那么无论我们怎么设计,必然会发现在缓存这块上要出现锁。

 

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

我们知道如果在“大循环”中要避免锁操作,那么就用“异步”,把操作交给线程处理。结合这2个特点,我稍微改下数据库架构。

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

public 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()
  { 

    // 这里可以任意的去修改内存数据。采用了异步。
  }
}

OK,看起来很漂亮。但是又有个问题出现了。在高速存取的过程中,非常有可能缓存还没有被更新,就被其他请求再次获取,得到了旧的数据。

 

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

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

我使用这个技巧,在缓存的上层,再加一层缓存,成为”一级缓存“,原来的就自然成为”二级缓存“。有点像CPU了对不?

一级缓存只能被”大循环“修改,但是可以被并发、”大循环“同时获取,所以是不需要锁的。

当发生数据库变动,分2种情况:

1)并发环境下的数据库变动,我们是允许有锁的存在,所以直接操作二级缓存,没有问题。

2)”大循环“环境下数据库变动,首先我们把变动数据存储在一级缓存,然后交给异步修正二级缓存,修正后删除一级缓存。

这样,无论在哪个环境下读取数据,首先判断一级缓存,没有再判断二级缓存。

这个架构就保证了内存数据的绝对准确。

而且重要的是:我们有了一个高效的无锁空间,去实现我们任意的业务逻辑。

 

最后,还有一些小技巧提升性能。

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

a)同一个表同一个主键的多次UPdate,取最后一次。

b)同一个表同一个主键,只要出现Delete,前面所有操作无效。

2. 既然我们要对操作排序,必然会存在一个根据时间排序,如何保证无锁呢?使用

private final static AtomicLong _seq = new AtomicLong(0);

即可保证无锁又全局唯一自增,作为时间序列。

我们团队的页游 达洛克战记3
 
 
 
绿色通道: 好文要顶 关注我 收藏该文与我联系 
17
0
 
(请您对文章做出评价)
 
« 上一篇:达洛克战记3 即将开服! What's New!
posted @ 2013-11-18 22:38  阅读(4770) 评论(39编辑 收藏

 

 
#1楼 2013-11-19 00:01 | ChobitsSP  
#2楼 2013-11-19 08:54 | DecleorMX  
有点意思……
nHibernate中也有一级、二级缓存的概念,不知道是不是与楼主的思想一致。
#3楼 2013-11-19 08:59 | sfy  
但是你这样还会有个问题,你所谓的二级缓存被请求读取之后,你的一级缓存才更新二级缓存,那那个已经被请求了的不是还是旧的数据?时间差还是存在的
#4楼 2013-11-19 09:12 | 羽之  
如何发挥多核处理器的能力呢?
#5楼 2013-11-19 09:31 | 深蓝医生  
在高速存取的过程中,非常有可能缓存还没有被更新,就被其他请求再次获取,得到了旧的数据。
-----
缓存还没有被更新,其它请求自然获得的就是旧数据,这看起来不妥,但看起来又是自然的,我想了很久,一直很犹豫,这样到底妥不妥?啥情况下是允许这样做的,啥情况又是不允许这样做的?望楼主明示,非常感谢!
#6楼[楼主2013-11-19 09:56 |   
@sfy
一级缓存只会被“大循环”操作。
当数据库数据变动,“大循环”首先更新一级缓存,然后交给异步更新二级缓存,最后删除一级缓存。
读取的时候,先读取一级缓存,如果没有再读取2级缓存。

在一个极短的时间内,当“大循环”内存修改了数据还没有来得及更新一级缓存,这个时候并发需要读取操作,自然从二级缓存获取,那就有脏数据。

这个时候有2种策略:
1. 悲观锁,从数据被获取的时候一直加锁。这个方法我不推荐,性能低下并且容易有死锁。
2. 乐观锁。当需要修改数据的时候,判断是否脏数据,如果是就抛异常。这个做法推荐,而且现在大部分的高并发都是这个做法。
#7楼[楼主2013-11-19 09:57 |   
@深蓝医生

在一个极短的时间内,当“大循环”内存修改了数据还没有来得及更新一级缓存,这个时候并发需要读取操作,自然从二级缓存获取,那就有脏数据。

这个时候有2种策略:
1. 悲观锁,从数据被获取的时候一直加锁。这个方法我不推荐,性能低下并且容易有死锁。
2. 乐观锁。当需要修改数据的时候,判断是否脏数据,如果是就抛异常。这个做法推荐,而且现在大部分的高并发都是这个做法。
#8楼 2013-11-19 11:29 | szjay  
读写库分离 + 消息队列MQ + 分布式缓存?
#9楼 2013-11-19 11:59 | sunnychenjuan  
好东东
#10楼 2013-11-19 12:21 | 王清培  
很不错,跟LMAX架构很像;
#11楼 2013-11-19 12:54 | 互联网Fans  
#12楼 2013-11-19 13:06 | share/java  
@羽之
运行多个
单线程了还怎么并发?

就好比有一百万人去买火车票,但是只有一个售票窗口。
#14楼 2013-11-19 13:21 | Ace8793  
#15楼 2013-11-19 13:34 | Coppola  
版主用的哪种mq?
#16楼 2013-11-19 13:44 | BillGan  
@金色海洋(jyk)阳光男孩
你没认真看人家写的文章,他写的很详细了,是一个问题一个问题引出来的方案。
单线程里面IO操作异步处理,增加缓存来应对IO耗时操作,但他实际的环境是还有个并发的环境和他的新的“大循环”环境并存,这样在内存就出现并发冲突了。
#17楼 2013-11-19 13:47 | 黑侠客  
高并发环境下 就得群集服务,群集服务就得锁,
Zookeeper 分布式锁服务 不错
#18楼 2013-11-19 14:04 | netfocus  
@
引用@sfy
一级缓存只会被“大循环”操作。
当数据库数据变动,“大循环”首先更新一级缓存,然后交给异步更新二级缓存,最后删除一级缓存。
读取的时候,先读取一级缓存,如果没有再读取2级缓存。

在一个极短的时间内,当“大循环”内存修改了数据还没有来得及更新一级缓存,这个时候并发需要读取操作,自然从二级缓存获取,那就有脏数据。

这个时候有2种策略:
1. 悲观锁,从数据被获取的时候一直加锁。这个方法我不推荐,性能低下并且容易有死锁。
2. 乐观锁。当需要修改数据的时候,判断是否脏数据,如果是就抛异常。这个做法推荐,而且现在大部分的高并发都是这个做法。

问一下楼主,为什么乐观锁的时候,不使用重试?如果直接抛异常,那这个异常如何反馈给用户?因为此时在单线程中,相对用户来说,已经是异步处理了,因为你把多线程先转换为单线程处理。
#19楼 2013-11-19 14:07 | netfocus  
另外,楼主有说到关于内存数据如何持久化到db的设计吗?在何时以什么样的方式持久化到db呢?
#20楼 2013-11-19 14:18 | john23.net  
感谢分享
#21楼 2013-11-19 14:18 | customevalidator  
sorry, “大循环”是不是单线程的工作线程?读写,简单画个图吧。
#22楼[楼主2013-11-19 14:20 |   
@netfocus
我们首先要有个大前提,就是在内存级别的运算,是绝对不会有脏数据的。

脏数据只可能发生在IO操作部分。例如玩家点击升级装备。这个就涉及到了socket的IO。这种并发才会考虑脏数据。

第二种情况很简单,直接返回高速用户操作失效,请刷新。无论单循环还是并发,都是容易实现的。

剩下第一种情况。要看你给我一个例子了。目前没发现,毕竟我们自己写业务逻辑,本身就保证了数据一致,不至于写出一个本来就有可能并发出错的逻辑 吧
#23楼[楼主2013-11-19 14:21 |   
@netfocus

持久到DB,我用SQL啊。比较简单,操作mysql。用定时器刷就好了。这个操作可以有锁的。
#24楼 2013-11-19 14:31 | netfocus  
持久化这一块我明白了,定时持久化到db。
#25楼 2013-11-19 14:31 | netfocus  
关于内存操作,你的内存时本地内存还是分布式缓存?
#26楼 2013-11-19 15:09 | xx念  
还是按照以前的多线程方式, 只把数据库和IO操作变成顺序执行写入缓存,
然后定时把缓存持久化,不知道是不是会更简单啊,呵呵,不太懂?
#27楼 2013-11-19 15:33 | Ryan Huang  
对于游戏服务器来说, DB完全不应该直接进行访问, 这种高并发的操作应该隔离在单独的数据服务器上, 游戏里的数据在服务器启动的时候加载到内存中构成独立的模型, 所有的游戏访问修改都直接定向到这个模型上, 数据库服务器只是定时的检查哪些需要重新写回到数据库中.
#28楼 2013-11-19 15:34 | Alvin  
亮点是concurrentCollection
#29楼 2013-11-19 16:02 | 吴瑞祥  
要解决并发问题方法有很多,LZ用的就是强制队列的方法.
优化之后就是读写分离.然后强制写操作队列.
读操作并发.并且读操作不产生任何锁
基本上这是我能想的最远的对并发的优化方案了
#30楼[楼主2013-11-19 16:50 |   
@netfocus
目前使用java的本地内存,用google的开源concurrenthashmap mru算法。
#31楼[楼主2013-11-19 16:51 |   
@xx念
恩。第一部分是这个思路。但是读取的时候要增加缓存,就会出现锁。所以要解决这个锁的问题。
#32楼[楼主2013-11-19 16:53 |   
@Ryan Huang
这个只是第一部分的问题。
第二部分的问题就是,这些内存的数据如果要修改,不可避免的会有锁。我现在就要消除锁。
#33楼 2013-11-19 17:19 | sfy  
@
如果按你说的乐观锁,只是让程序能正常运转,但是用户体验那边就不好了,明白你的意思,极端情况就是保证程序正常运转,牺牲用户体验

引用@sfy
一级缓存只会被“大循环”操作。
当数据库数据变动,“大循环”首先更新一级缓存,然后交给异步更新二级缓存,最后删除一级缓存。
读取的时候,先读取一级缓存,如果没有再读取2级缓存。

在一个极短的时间内,当“大循环”内存修改了数据还没有来得及更新一级缓存,这个时候并发需要读取操作,自然从二级缓存获取,那就有脏数据。

这个时候有2种策略:
1. 悲观锁,从数据被获取的时候一直加锁。这个方法我不推荐,性能低下并且容易有死锁。
2. 乐观锁。当需要修改数据的时候,判断是否脏数据,如果是就抛异常。这个做法推荐,而且现在大部分的高并发都是这个做法。
#34楼[楼主2013-11-19 17:38 |   
@sfy
其实看我们怎么设计。如果我们发现出错,立刻重试,那么就没有体验的差别。
这个重试是业务逻辑层面的,而不是数据库层面的。
因为数据库拿到了脏数据,无论如何重试,也一定是出错的。

和银行交易系统一样吧,如果交易发成错误,一般都会重试x次。
#35楼 2013-11-19 17:51 | pulihe  
很多时候锁死都是争用问题,我们这边没考虑这么复杂。
1、内存表 2、服务器用Intel SSD固态硬盘
就这样,IO上去了只要代码不出问题;负载还是很平稳的。
#36楼 2013-11-19 22:23 | zsea  
很不错的思路
#37楼[楼主2013-11-19 23:22 |   
@pulihe

主要是内存表要加锁,并发的时候性能非常低下。起码慢30%以上。
其实就是把数据弄到内存里。
#39楼 2013-11-20 17:45 | SeaSunK  
和之前做的一个项目架构非常接近;
其实大循环根据项目模型分析可以分为几个“大循环”,每个大循环是一条线程。
我地系iis+rabbitmq+quarz+redis+mysql
不过在写数据之前(业务之前)获取数据也是先从内存获得,内存没有才在redis获取。redis只保存热点数据。这样的并发已经足够了。
哦,对了,我们只用一台服务器内存。
楼主想得更深一层,用二级缓存
posted @ 2013-11-25 23:44  小马科技团队博客  阅读(249)  评论(0)    收藏  举报