Limax ZDB简单介绍及死锁分析

背景

limax是比较小众的应用服务器框架,zdb是limax采用的数据存储方式。zdb采用k-v存储,没有事务的概念,自带锁(加锁和解锁同操作封装在一起,对外隐藏),编程较为简单。
项目前期使用zdb进行数据存储,随着项目规模的扩大,暴露出一些问题。每个服务器都有一个zdb.xml文件,基于该文件,ant构建生成的table和xbean也只有当前服务器可以读取。为保证数据一致性,zdb使用方式若未严格注意,导致数据分散在不同职能的服务器上,当某个功能需要的数据需要从不同的服务器上读取时,这就很尴尬了,数据读取繁琐,服务器之间通信协议的繁琐导致编程的繁琐。现在正在逐步废弃zdb,采用主流的mysql+redis存储。
embarrassed

ZDB

zdb定义表时,主键无法定义键名,只需定义主键类型。以简单的手机绑定送钻功能为例,zdb设计如下,mobile_id_table为表名,string为主键类型,当前表主键为账户ID,value为记录类,MobileIdBean定义记录包括的字段。

1
2
3
4
5
6
7
8
<table name="mobile_id_table" key="string" value="MobileIdBean"/>
<xbean name="MobileIdBean">
<variable name="accountId" type="int"/><!-- 账号ID -->
<variable name="bindTime" type="long"/><!-- 绑定时间 -->
<variable name="status" type="string"/><!-- 验证状态 -->
<variable name="code" type="string"/><!-- 验证码 -->
<variable name="codeTime" type="long"/><!-- 验证码发放时间 -->
</xbean>

需求中除了通过手机号查绑定时间和账户ID,还有通过账号ID查绑定时间和手机号。由于zdb的局限性,导致第二张表的产生。zdb查特定数据只能通过主键,所以这里采取双向表的方式,但是有数据冗余的问题,都存储了绑定时间。但是查数据快,无论何种方式查,都仅需查一张表。

1
2
3
4
5
6
<table name="id_mobile_table" key="int" value="IdMobileBean"/>
<xbean name="IdMobileBean">
<variable name="accountMobile" type="string"/><!-- 账号手机号 -->
<variable name="bindTime" type="long"/><!-- 绑定时间 -->
<variable name="bindAward" type="int"/><!-- 绑定所得奖励,历史记录 -->
</xbean>

为解决上述数据冗余的问题,可采用如下方式:

1
2
3
4
<table name="id_mobile_table" key="int" value="IdMobileBean"/>
<xbean name="IdMobileBean">
<variable name="accountMobile" type="string"/><!-- 账号手机号 -->
</xbean>

通过手机查,直接查第一张表。通过账号ID查,查第二张表,拿到手机号后再查第一张表。通过账号ID查数据就变得比较繁琐,需要读两张表。
zdb定义定义完成后,cmd到对应路径下执行ant,即生成相应的table类及xbean类。
zdb遍历数据效率比较低。只有walk一种方式:

1
table.mobile_id_table.get().walk((key, value) -> {return true;});

操作是从硬盘中读取数据到内存,效率比较低。而且会导致数据不一致的情况,增删改数据后,可能出现内存数据尚未落地到硬盘,get().walk获取的数据是从硬盘直接获得的,获取到的是未执行增删改前的错误数据。

1
table.Channel_version_table.get().getCache().walk((key, value) -> { });

操作是从内存中读取数据,相对于第一种,效率肯定高。
这种操作只会读取内存的数据,不会读取硬盘的数据,会导致数据读取不完全。采取的解决方法是起服时将所需表的数据get().walk到内存中,后续数据都是操作内存,除了提升效率外,不会导致数据不一致或数据不完全的情况。
zdb读取数据时,能不使用walk遍历就不适用walk遍历数据,可采用配置文件或者哈希表存储主键(适用于记录较少的情况),然后逐一select。select操作是先从内存中读数据,若内存中无数据,再从硬盘中读取数据。

死锁

还未弃用zdb时,由于编程的错误导致死锁,这里介绍一下zdb死锁的情况及注意事项。

1
2
3
4
5
6
7
MobileIdBean mobileIdBean = table.Mobile_id_table.select(phoneNumber);
if (mobileIdBean == null)
{
mobileIdBean = table.Mobile_id_table.insert(phoneNumber);
mobileIdBean.setAccountId(roleId);
mobileIdBean.setCodeTime(0);
}

并发执行select(线程获取读锁)同一个phoneNumber时,会出现多线程进入到if里,执行最快的线程insert(升级为写锁),成功,后续setAccountId等操作正常进行。而其余线程insert失败,返回null,操作出错。
应该将select更改为update,初始就获取写锁,并发的后续线程就只能等待前一线程结束,不会出现多个线程同时进入到if的情况。

1
2
3
4
5
6
7
MobileIdBean mobileIdBean = table.Mobile_id_table.update(phoneNumber);
if (mobileIdBean == null)
{
mobileIdBean = table.Mobile_id_table.insert(phoneNumber);
mobileIdBean.setAccountId(roleId);
mobileIdBean.setCodeTime(0);
}

涉及到后续数据更改的情况,不要使用select,select获取的读锁,其他操作(insert/delete/update)获取的是写锁。

题外话,关于缓存

针对主流的mysql+redis存储。由于直接对mysql读写数据效率低,引入缓存可解决效率问题。引入缓存可能会出现与数据库数据不一致的情况,避免这种情况的发生,读写数据时可采取如下策略保证缓存与数据库数据保持一致。

  • 读数据保持一致
    先读取缓存,若不存在则从DB中读取,并将结果写入到缓存中;下次数据读取时便可以直接从缓存中获取数据。
  • 写数据保持一致(淘汰策略)
    数据的修改是直接失效缓存数据,再修改DB内容,避免DB修改成功,但由于网络或者其他问题导致缓存数据没有清理,造成了脏数据。
  • 淘汰策略简单容易编程, 但是性能不好,先写入 mysql的时候有可能耗时过长。
  • 可考虑双写策略:不但写入Redis,还会写入 Mysql。双写策略性在Redis上写入性能很好,此时数据立刻可用,在Mysql上可能会出现大量并发写入阻塞,一般会采用高可靠消息队列加强Mysql的写入。
  • 还有一种版本策略,每次写入Redis,做增量写入,不覆盖原有数据,将数据版本增加,采用消息队列延迟写入 Mysql。版本策略编程较为复杂。
  • 如果数据逻辑上是读多写少,采用淘汰策略足够。