數據庫分區分片框架


序言  一直在做企業應用,目前要做一些互聯網應用,當然只是應用是放在互聯網的,數據量距離真正的互聯網應用還是有相當大的差距的。但是不可避免的,在數據庫出現瓶頸的情況還是有的,現在做互聯網上的應用,當然也要未雨綢繆,要考慮數據量大的時候的解決方案。 
這個目前開源的商用的也都有不少解決方案,一來,做技術的都有這么個臭毛病,即使是使用別人的方案,自己也要搞清楚內部的一些實現機制,這樣才會有真正的體會,否則去評估一個方案的時候,就只能盲人摸象了。 
為此,構建一個驗證型的分布式數據庫框架,來解決數據庫的垂直與水平擴展方面的問題,由於是驗證性開發,所以,思考不完善的地方肯定存在,歡迎批評指正。 
提升數據庫處理能力方案  讀寫分離方案 海量數據的存儲及訪問,通過對數據庫進行讀寫分離,來提升數據的處理能力。讀寫分離它的方案特點是數據庫產生多個副本,數據庫的寫操作都集中到一個數據庫上,而一些讀的操作呢,可以分解到其它數據庫上。這樣,只要付出數據復制的成本,就可以使得數據庫的處理壓力分解到多個數據庫上,從而大大提升數據處理能力。 

  • 優點:由於所有的數據庫副本,都有數據的全拷貝,因此所有的數據庫特性都可以實現,部分機器當機不影響系統的使用。
  • 缺點:數據的復制同步是一個問題,要么采用數據庫自身的復制方案,要么自行實現數據復制方案。需要考慮數據的遲滯性,一致性方面的問題。

數據分區方案 原來所有的數據都是在一個數據庫上的,網絡IO及文件IO都集中在一個數據庫上的,因此CPU、內存、文件IO、網絡IO都可能會成為系統瓶頸。而分區的方案就是把某一個或某幾張相關的表的數據放在一個獨立的數據庫上,這樣就可以把CPU、內存、文件IO、網絡IO分解到多個機器中,從而提升系統處理能力。 

  • 優點:不存在數據庫副本復制,性能更高。
  • 缺點:分區策略必須經過充分考慮,避免多個分區之間的數據存在關聯關系,每個分區都是單點,如果某個分區宕機,就會影響到系統的使用。


數據分表方案 不管是上面的讀寫分離方案還是數據分區方案,當數據量大到一定程度的時候,都會導致處理性能的不足,這個時候就沒有辦法了,只能進行分表處理。也就是把數據庫當中數據根據按照分庫原則分到多個數據表當中,這樣,就可以把大表變成多個小表,不同的分表中數據不重復,從而提高處理效率。 

  • 優點:數據不存在多個副本,不必進行數據復制,性能更高。
  • 缺點:分表之間的數據很少進行集合運算;分表都是單點,如果某個分表宕機,如果使用的數據不在此分表,不影響使用。

分表也有兩種方案: 
1. 同庫分表:所有的分表都在一個數據庫中,由於數據庫中表名不能重復,因此需要把數據表名起成不同的名字。 

  • 優點:由於都在一個數據庫中,公共表,不必進行復制,處理更簡單
  • 缺點:由於還在一個數據庫中,CPU、內存、文件IO、網絡IO等瓶頸還是無法解決,只能降低單表中的數據記錄數。表名不一致,會導后續的處理復雜。

2. 不同庫分表:由於分表在不同的數據庫中,這個時候就可以使用同樣的表名。 

  • 優點:CPU、內存、文件IO、網絡IO等瓶頸可以得到有效解決,表名相同,處理起來相對簡單
  • 缺點:公共表由於在所有的分表都要使用,因此要進行復制、同步。

混合方案 通過上面的描述,我們理解了讀寫分離,數據分區,數據分表三個解決方案,實際上都各有優點,也各有缺 ,因此,實踐當中,會把三種方案混合使用。由於數據不是一天長大的,實際上,在剛開始的時候,可能只采用其中一種方案,隨着應用的復雜,數據量的增長,會逐步采用多個方案混合的方案。以提升處理能力,避免單點。 
實現路線分析 正所謂條條大路通羅馬,解決這個問題的方案也有多種,但究其深源,都可以歸到兩種方案之上,一種是對用戶透明的方案,即用戶只用像普通的JDBC數據源一樣訪問即可,由框架解決所有的數據訪問問題。另外一種是應用層解決,具體一般是在Dao層進行封裝。 
JDBC層方案

  • 優點:開發人員使用非常方便,開發工作量比較小;可以實現數據庫無關。
  • 缺點:框架實現難度比較大,性能不一定能做到最優。

同樣是JDBC方案,也有兩種解決方案,一種是有代理模式,一種是無代理模式。 
有代理模式,有一台專門的代理服務器,來接收用戶請求,然后發送請求給數據庫集群中的數據,並對數據進行匯集后再提交給請求方。 
無代理模式,就是說沒有代理服務器,集群框架直接部署在應用訪問端。 
有代理模式,能夠提供的功能更強大,甚至可買提供中間庫進行數據處理,無代理模式處理性能較強有代理模式少一次網絡訪問,相對來說性能更好,但是功能性不如有代理模式。 
DAO層方案

  • 優點:開發人員自由度非常大,性能調優更精准。
  • 缺點:開發人員在一定程度上受影響,與具體的Dao技術實現相關,較難做到數據庫無關。

由於需要對SQL腳本進行判斷,然后進行路由,因此DAO層優化方案一般都是選用iBatis或Spring Jdbc Template等方案進行封裝,而對於Hibernate等高度封裝的OR映射方案,實現起來就非常困難了。 
需求 需求決定了后續的解決方案及問題領域: 

  • 采用JDBC層解決方案:對於最終用戶來說,要完全透明
  • 采用無代理解決方案:數據庫集群框架代碼直接放在應用層
  • 支持讀寫分離、分區、分表三種方式及其混合使用方式:三種方式可以混用可以提供極大的靈活性及對未來的擴展性
  • 需要提供靈活的分區及分表規則支持
  • 對於讀寫分離的方案,需要提供靈活的路由規則,比如:平均路由規則、加權路由規則,可以提供寫庫的備用服務器,即主寫入服務器當機之后,即可寫入備用服務器當中。
  • 支持高性能分布式主鍵生成器
  • 有良好的集群事務功能
  • 可以通過擴展點來對框架進行擴展,以便於處理分區、分表相關的操作。
  • 支持各種類型支持JDBC驅動的數據庫
  • 支持異構數據庫集群
  • 支持count、sum、avg、min、max等統計函數
  • 支持排序
  • 支持光標移動
  • 支持結果集合並

明確不支持的內容或限定條件: 

  • 不支持分區之間的聯合查詢
  • 主鍵不支持自增長型,必須調用分布式主鍵生成器來生成
  • 對於與分區分表相關的處理,如果框架沒有實現,則需要根據框架接口擴展自行實現

結構設計 框架采用三層設計:最上層是Cluster,一個Cluster相當於我們常規的一個數據庫;一個Cluster當中可以包含一到多個Partition,也就是分區;而一個Partition中可以包含一到多個Shard,也就是分片。 
所以一個就形成了一個樹狀結構,通過Cluster->Partion->Shard就構成了整個數據庫集群。但是對於開發人員來說,實際上並不知道這個內部結構,他只是連接上了一個JDBC數據源,然后做它應該做的事情就可以了。 
Cluster 以完整的形態對外提供服務,它封裝了Cluster當中所有Partition及其Shard的訪問。把它打開是一個數據庫集群,對於使用者來說是一個完整的數據庫。 

屬性名 類型 說明
id String 集群標識
userName String 連接集群時的用戶名
Password String 連接集群時的密碼
dataSources List<DataSourceConfig> 集群中需要訪問的數據源列表
partitions List<Partition>; 集群中包含的分區列表

Partition 分區,分區有兩種模式,一種是主從模式,用於做讀寫分離;另外一種模式是分片模式,也就是說把一個表中的數據分解到多個表中。一個分區只能是其中的一種模式。但是一個Cluster可以包含多個分區,不同的分區可以是不同的模式。 

屬性名 類型 說明
id String 分區標識
mode int 分區類型,可以是主從,也可以是分表
Password String 連接集群時的密碼
shards List<Shard> 分區中包含的分片列表
partitionRules List<PartitionRule> 分區規則,當進行處理的時候,路由到哪個分區執行

Shard Shard與一個物理的數據源相關聯。 

屬性名 類型 說明
id String 分區標識
dataSourceId String 實際訪問的數據庫配置ID
readWeight int 讀權重,僅用於主從讀寫分離模式
writeWeight int 寫權重,僅用於主從讀寫分離模式
shardRules List<ShardRule> 分片規則,當進行處理的時候,路由到哪個分片執行,僅用於分模式
tableMappings List<TableMapping>; 表名映射列表,僅用於同庫不同表名分表模式

分布式主鍵接口 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**

* 分布式Key獲取器

*

* @param <T>

*/

public interface ClusterKeyGenerator<T> {

T getKey(String tableName);

}



主鍵接口可以用來生成各種主鍵類型,如:字符串、整型、長整型,入口參數必須是表名,框架已經實現了字符串、整型、長整型的分布式高效主鍵生成器,當然,也可以自行實現。 
集群管理器 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
public interface ClusterManager {

/**

* 返回是否是分片語句

*

* @param partition

* @param sql

* @return

*/

boolean isShardSql(Partition partition, String sql);

/**

* 添加語句處理器

*

* @param statementProcessor

*/

void addStatementProcessor(StatementProcessor statementProcessor);

/**

* 返回語句處理器列表

*

* @return

*/

List<StatementProcessor> getStatementProcessorList();

/**

* 給某個集群的數據表產生主鍵

*

* @param cluster

* @param tableName

* @param <T>

* @return

*/

< T> T getPrimaryKey(Cluster cluster, String tableName);

/**

* 返回SQL對應的Statement

*

* @param sql

* @return

*/

Statement getSqlStatement(String sql);

/**

* 添加集群

*

* @param cluster

*/

void addCluster(Cluster cluster);

/**

* 獲取集群

*

* @param clusterId

* @return

*/

Cluster getCluster(String clusterId);

/**

* 返回某個分區與sql是否匹配

*

* @param partition

* @param sql

* @return

*/

boolean isMatch(Partition partition, String sql);

/**

* 返回某個分片是否匹配

*

* @param shard

* @param sql

* @return

*/

boolean isMatch(Partition partition, Shard shard, String sql);

/**

* 返回分片執行語句

*

* @param partition

* @param shard

* @param sql

* @return

*/

String getSql(Partition partition, Shard shard, String sql);

/**

* 獲取匹配的分區<br>

*

* @param clusterId

* @param sql

* @return

*/

Collection<Partition> getPartitions(String clusterId, String sql);

/**

* 獲取匹配的首個分區

*

* @param clusterId

* @param sql

* @return

*/

Partition getPartition(String clusterId, String sql);

/**

* 獲取匹配的首個分區

*

* @param cluster

* @param sql

* @return

*/

Partition getPartition(Cluster cluster, String sql);

/**

* 獲取匹配的分區

*

* @param cluster

* @param sql

* @return

*/

List<Partition> getPartitions(Cluster cluster, String sql);

/**

* 獲取匹配的分片

*

* @param partition

* @param sql

* @return

*/

List<Shard> getShards(Partition partition, String sql);

/**

* 返回分片均衡器

*

* @return

*/

ShardBalance getShardBalance();

/**

* 設置分片均衡器

*

* @param balance

*/

void setShardBalance(ShardBalance balance);

}



分區規則 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**

* 分區規則接口<br>

* 規則參數在實現類中定義

*

*/

public interface PartitionRule {

/**

* 返回是否命中,如果有多個命中,則只用第一個進行處理

*

* @param sql

* @return

*/

boolean isMatch(String sql);

}



框架自帶了常用分區規則,但是也可以根據情況自已擴展。 分片規則 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**

* 分片規則

*

*/

public interface ShardRule {

/**

* 返回是否屬於當前分片處理

*

* @param sql

* @return

*/

boolean isMatch(Partition partition, String sql);

}



框架自帶了常用的分片規則,但是也可以根據情況自已擴展 語句處理器 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
/**

* 用於對SQL進行特殊處理並進行結果合並等<br>

* <p/>

* 比如sql語句是select count(*) from abc<br>

* 則會到所有的shard執行,並對結果相加后返回

*

*/

public interface StatementProcessor {

/**

* 返回是否由此SQL處理器進行處理

*

* @param sql

* @return

*/

boolean isMatch(String sql);

/**

* 返回處理器轉換過之后的SQL

*

* @param sql

* @return

*/

String getSql(String sql);

/**

* 對結果進行合並

*

* @param results

* @return

* @throws SQLException

*/

ResultSet combineResult(List<ResultSet> results) throws SQLException;

}



在分片之后,許多時候單純查找回的數據已經是不正確的了,這個時候就需要進行二次處理才能保證返回的結果是正確的。比如:用戶輸入的SQL語句是:Select count(*) from aaa 
這個時候就會用分片指令到各個分片去查找並返回結果,默認的處理結果是簡單合並結果集的方式。這個時候,如果有5個分片,會返回5條記錄給最終用戶,這當然不是他想要的結果。這個時候就是語句處理器大顯身手的時候了,他可以偷梁換柱,也可以改頭換面,通過它的處理,就可以返回正確的結果了。 
JDBC層封裝 當然,要想外部程序用JDBC的方式進行訪問,就得做JDBC層的實現。這個部分做了大量的處理,使得即高效又與用戶期望的方式相匹配。 
可以說上面所有的准備都是為了這一層做准備的,畢竟最終要落到真正的數據庫訪問上。由於接口就是標准的JDBC接口,因此就不再詳述。 
事務問題 在分區或分表模式中,由於寫操作會被分解到不同的物理數據庫上去,這就會導致出現事務問題。因此框架內部集成了JTA,使得事務保持一致。 
代碼實現 沒什么好說的,噼里啪啦,噼里啪啦,一陣亂響,代碼就緒了,下面看看測試場景。 
測試用例 JDBC方式訪問集群 

1
2
3
4
5
6
7
Class.forName("org.tinygroup.dbcluster.jdbc.TinyDriver");

Connection conn = DriverManager.getConnection("jdbc:dbcluster://cluster1", "username", "password");

Statement stmt = conn.createStatement();

stmt.execute(“select * from aaa”);



代碼解釋: 上面完全是按照JDBC的方式訪問數據庫的,url必須以“jdbc:dbcluster://”開始,后面跟着的是集群的ID名稱,上面示例中就是“cluster1”;用戶名、密碼必須與集群中配置的相一致。接下來就與普通的jdbc數據源沒有任何區別了。 
同庫分表  創建測試表 在同一個數據庫中創建同樣結構的表,比如: 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
CREATE TABLE `aaa0` (

`id` int(11) NOT NULL,

`aaa` varchar(45) DEFAULT NULL,

PRIMARY KEY (`id`)

);

CREATE TABLE `aaa1` (

`id` int(11) NOT NULL,

`aaa` varchar(45) DEFAULT NULL,

PRIMARY KEY (`id`)

);

CREATE TABLE `aaa2` (

`id` int(11) NOT NULL,

`aaa` varchar(45) DEFAULT NULL,

PRIMARY KEY (`id`)

);



測試代碼: 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static void main(String[] args) throws Throwable {

Class.forName("org.tinygroup.dbcluster.jdbc.TinyDriver");

Connection conn = DriverManager.getConnection("jdbc:dbcluster://cluster1", "username", "password");

Statement stmt = conn.createStatement();

String sql;

//插入100條數據

for (int i = 0; i < 100; i++) {

sql = "insert into aaa(id,aaa) values (" + clusterManager.getPrimaryKey(cluster, "aaa") + ",'ppp')";

boolean result = stmt.execute(sql);

}

}



運行結果: 

1
2
3
4
5
6
7
8
9
10
11
12
13
Using shard:shard1 to execute sql:insert into aaa(id,aaa) values (1,'ppp')

Using shard:shard2 to execute sql:insert into aaa(id,aaa) values (2,'ppp')

Using shard:shard0 to execute sql:insert into aaa(id,aaa) values (3,'ppp')

Using shard:shard1 to execute sql:insert into aaa(id,aaa) values (4,'ppp')

Using shard:shard2 to execute sql:insert into aaa(id,aaa) values (5,'ppp')

Using shard:shard0 to execute sql:insert into aaa(id,aaa) values (6,'ppp')

…….



可以看出,插入的數據確實分到了三個分片中。 
再用Select語句查找插入的數據: 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static void main(String[] args) throws Throwable {

Class.forName("org.tinygroup.dbcluster.jdbc.TinyDriver");

Connection conn =

DriverManager.getConnection("jdbc:dbcluster://cluster1", "username", "password");

Statement stmt = conn.createStatement();

String sql = "select * from aaa order by id";

ResultSet resultSet = stmt.executeQuery(sql);

while (resultSet.next()) {

System.out.printf(" id: %d, aaa: %s \n", resultSet.getInt(1), resultSet.getString(2));

}

}



運行結果如下: 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Using shard:shard0 to execute sql:select * from aaa order by id

Using shard:shard1 to execute sql:select * from aaa order by id

Using shard:shard2 to execute sql:select * from aaa order by id

id: 1, aaa: ppp

id: 2, aaa: ppp

id: 3, aaa: ppp

id: 4, aaa: ppp

id: 5, aaa: ppp

id: 6, aaa: ppp

……



從上面的結果可以看到,明顯已經合並了結果並且是按順序顯示的 
接下來,把測試的數據刪除掉: 

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) throws Throwable {

Class.forName("org.tinygroup.dbcluster.jdbc.TinyDriver");

Connection conn = DriverManager.getConnection("jdbc:dbcluster://cluster1", " username ", "password");

Statement stmt = conn.createStatement();

String sql = "delete from aaa";

stmt.execute(sql);

}



運行結果如下: 


1
2
3
4
5
Using shard:shard0 to execute sql:delete from aaa

Using shard:shard1 to execute sql:delete from aaa

Using shard:shard2 to execute sql:delete from aaa



再去數據庫中查看,數據確實已經被刪除。 
不同庫分表 在不同的數據庫中創建同樣結構的表,比如: 


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
CREATE TABLE test0. aaa (

`id` int(11) NOT NULL,

`aaa` varchar(45) DEFAULT NULL,

PRIMARY KEY (`id`)

);

CREATE TABLE test1. aaa(

`id` int(11) NOT NULL,

`aaa` varchar(45) DEFAULT NULL,

PRIMARY KEY (`id`)

);

CREATE TABLE test2. aaa(

`id` int(11) NOT NULL,

`aaa` varchar(45) DEFAULT NULL,

PRIMARY KEY (`id`)

);



測試用例同同庫分表,結果測試同樣OK。 
讀寫分離 插入與刪除等比較簡單,就不再展示了,下面看看讀指令的執行過程。 


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static void main(String[] args) throws Throwable {

Class.forName("org.tinygroup.dbcluster.jdbc.TinyDriver");

Connection conn =

DriverManager.getConnection("jdbc:dbcluster://cluster1", "username", "password");

Statement stmt = conn.createStatement();

for (int i = 1; i <= 100; i++) {

boolean result = stmt.execute(“select * from aaa”);

}

}



運行結果: 


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Using shard:shard3 to execute sql:select * from aaa

Using shard:shard2 to execute sql:select * from aaa

Using shard:shard1 to execute sql:select * from aaa

Using shard:shard1 to execute sql:select * from aaa

Using shard:shard1 to execute sql:select * from aaa

Using shard:shard1 to execute sql:select * from aaa

Using shard:shard2 to execute sql:select * from aaa

Using shard:shard1 to execute sql:select * from aaa

Using shard:shard1 to execute sql:select * from aaa

Using shard:shard1 to execute sql:select * from aaa

Using shard:shard1 to execute sql:select * from aaa

Using shard:shard1 to execute sql:select * from aaa

Using shard:shard3 to execute sql:select * from aaa



可以看到,讀的SQL已經由三個分片進行了均衡執行。 
記錄集遍歷 對於ResultSet的遍歷,也有良好的支持,對於各種移動光標的方法都有支持,而且支持排序的移動,同時對於性能也有良好支持,性能接近於單表操作。 
下面展示一下絕對定位: 


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Class.forName("org.tinygroup.dbcluster.jdbc.TinyDriver");

Connection conn = DriverManager.getConnection(

"jdbc:dbcluster://cluster1", "luog", "123456");

Statement stmt = conn.createStatement();

String sql = "select * from aaa order by id";

ResultSet resultSet = stmt.executeQuery(sql);

resultSet.absolute(10);

System.out.printf(" id: %d, aaa: %s \n", resultSet.getInt(1),

resultSet.getString(2));

while (resultSet.next()) {

System.out.printf(" id: %d, aaa: %s \n", resultSet.getInt(1),

resultSet.getString(2));

}



運行結果: 


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Using shard:shard0 to execute sql:select * from aaa order by id

Using shard:shard1 to execute sql:select * from aaa order by id

Using shard:shard2 to execute sql:select * from aaa order by id

id: 10, aaa: ppp

id: 11, aaa: ppp

id: 12, aaa: ppp

id: 13, aaa: ppp

id: 14, aaa: ppp

id: 15, aaa: ppp

id: 16, aaa: ppp

id: 17, aaa: ppp

id: 18, aaa: ppp

id: 19, aaa: ppp

…….



可以看到確實是從第10條開始顯示。 
總結 分區分片通用解決方案,確實有相當的通用性,支持各種數據庫,提供了非常大的靈活性,支持多種集群單獨或混合使用的場景,同時還可以保持數據訪問的事務一致性,為用戶的訪問提供與JDBC一樣的用戶接口,這也會大大降低開發人員的開發難度。基本上(違反需求中指定的限制條件的除外)可以做到原有業務代碼透明訪問,降低了系統的遷移成本。同時它在性能方面也是非常傑出的,與原生的JDBC驅動程序相比,性能沒有顯著降低。當然它的配置也是非常簡單的,學習成本非常低。由於做在JDBC層,因此可以對Hibernate,iBatis等各種框架有良好支持。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM