微信公衆號:內核小王子 關注可瞭解更多關於數據庫,JVM內核相關的知識; 若是你有任何疑問也能夠加我pigpdong[^1]java
通常來講,影響數據庫最大的性能問題有兩個,一個是對數據庫的操做,一個是數據庫中的數據太大,對於前者咱們能夠藉助緩存來減小一部分讀操做,針對一些複雜的報表分析和搜索能夠交給hadoop和elasticsearch,對於後者,咱們就只能分庫分表,讀寫分離。mysql
互聯網行業隨着業務的複雜化,大多數應用都會經歷數據的垂直分區,一個複雜的流程會按照領域拆分紅不一樣的服務,每一個服務中心都擁有本身獨立的數據庫,拆分後服務共享,業務更清晰,系統也更容易擴展,同時減小了單庫數據庫鏈接數的壓力,也在必定程度上提升了單表大數據量下索引查詢的效率,固然業務隔離,也能夠避免一個業務把數據庫拖死致使全部業務都死掉,咱們將這種按照業務維度,把一個庫拆分爲多個不一樣的庫的方式叫作垂直拆分。git
垂直拆分也包含針對長表(屬性不少)作冷熱分離的拆分,例如,在商品系統設計中,一個商品的生產商,供銷商,以及特有屬性,這些字段變化頻率低,查詢次數多,叫作冷數據,而商品的份額,關注量等相似的統計信息變化頻率較高,叫作活躍數據或者熱數據,在MYSQL中,冷數據查詢多更新少,適合用MyISAM存儲引擎,而熱數據更新比較頻繁適合用InnoDB,這也是垂直拆分的一種.github
當單表數據量隨着業務發展繼續膨脹,在MYSQL中當數據量達到千萬級時,就須要考慮進行水平拆分了,這樣數據就分散到不一樣的表上,單表的索引大小獲得控制,能夠提高查詢性能,當數據庫的實例吞吐量達到性能瓶頸後,咱們須要水平擴展數據庫的實例,讓多個數據庫實例分擔請求,這種根據分片算法,將一個庫拆分紅多個同樣結構的庫,將多個表拆分紅多個結構相同的表就叫作水平拆分。redis
數據拆分也有不少缺點,數據分散,數據庫的Join操做變得更加複雜,分片後數據的事務一致性很難保證,同時數據的擴容和維護難度增長,拆分規則也可能致使某個業務須要同時查詢全部的表而後進行聚合,若是須要排序和函數計算則更加複雜,因此不到萬不得已能夠先沒必要拆分。算法
根據分庫分表方案中實施切片邏輯的層次不一樣,咱們將分庫分表的實現方案分紅如下3種spring
這種方式將分片規則直接放在應用層,雖然侵入了業務,開發人員不只既須要實現業務邏輯也須要實現分庫分表的配置的開發,可是實現起來簡單,適合快速上線,經過編碼方式也更容易實現跨表遍歷的狀況,後期故障也更容易定位,大多數公司都會在業務早期採用此種方式過渡,後期分表需求增多,則會尋求中間件來解決,如下代碼爲銅板街早期訂單表在DAO層將分片信息以參數形式傳到mybatis的mapper文件中的實現方案。sql
@Override
public OrderDO findByPrimaryKey(String orderNo) {
Assert.hasLength(orderNo, "訂單號不能爲空");
Map<String, Object> map = new HashMap<String, Object>(3);
map.put("tableSuffix", orderRouter.routeTableByOrderNo(orderNo));
map.put("dbSuffix", orderRouter.routeDbByOrderNo(orderNo));
map.put("orderNo", orderNo);
Object obj = getSqlSession().selectOne("NEW_ORDER.FIND_BY_PRIMARYKEY", map);
if (obj != null && obj instanceof OrderDO) {
return (OrderDO) obj;
}
return null;
}
複製代碼
這種方式經過擴展第三方ORM框架,將分片規則和路由機制嵌入到ORM框架中,如hibernate和mybatis,也能夠基於spring jdbctemplate來實現,目前實現方案較少。數據庫
這種方式比較常見,對業務侵入低,經過定製JDBC協議,針對業務邏輯層提供與JDBC一致的接口,讓開發人員沒必要要關心分庫分表的具體實現,分庫分表在JDBC內部搞定,對業務層透明。目前流行的ShardingJDBC,TDDL便採用了這種方案。這種方案須要開發人員熟悉JDBC協議,研發成本較低,適合大多數中型企業緩存
此種分片方式,是在應用層和數據庫層增長一個代理,把分片的路由規則配置在代理層,代理層提供與JDBC兼容的接口給應用層,開發人員不用關心分片邏輯實現,只須要在代理層配置便可。增長代理服務器,須要解決代理的單點問題增長硬件成本,同時全部的數據庫請求增長了一層網絡傳輸影響性能,固然維護也須要更資深的專家,目前採用這種方式的框架有cobar和mycat.
分片後,若是查詢的標準是根據分片的字段,則根據切片算法,能夠路由到對應的表進行查詢,若是查詢條件中不包含分片的字段,則須要將全部的表都掃描一遍而後在進行合併,因此在設計分片的時候咱們通常會選擇一個查詢頻率較高的字段做爲分片的依據,後續的分片算法會基於該字段的值進行,例如根據建立時間字段取對應的年份,每一年一張表,取電話號碼裏面的最後一位進行分表等,這個分片的字段咱們通常會根據查詢頻率來選擇,例如在互金行業,用戶的持倉數據,咱們通常選擇用戶id進行分表,而用戶的交易訂單也會選擇用戶id進行分表,可是若是咱們要查詢某個供應商下在某段時間內的全部訂單就須要遍歷全部的表,因此有時候咱們可能會須要根據多個字段同時進行分片,數據進行冗餘存儲。
分片規則必須保證路由到每張物理表的數據量大體相同,否則上線後某一張表的數據膨脹的特別快,而其餘表數據相對不多,這樣就失去了分表的意義,後期數據遷移也有很高的複雜度,經過分片字段定位到對應的數據庫和物理表有哪些算法呢?(咱們將分表後在數據庫上物理存儲的表名叫物理表,如trade_order_01,trade_order_02,將未進行切分前的表名稱做邏輯表如trade_order)大體能夠有如下分類
分庫和分表算法須要保證不相關,上線前必定要用線上數據作預測,例如分庫算法用 用戶id%64 分64個庫 分表算法也用 用戶id%64 分64張表,總計 64 * 64 張表,最終數據都將落在 如下 64張表中 00庫00表,01庫01表... 63庫63表, 其餘 64 * 63張表則沒有數據。這裏能夠推薦一個算法,分庫用 用戶ID/64 % 64 , 分表用 用戶ID%64 測試1億筆用戶id發現分佈均勻.
在分庫分表前須要規劃好業務增加量,以預備多大的空間,計算分表後能夠支持按某種數據增加速度能夠維持多久。
客戶端須要定製JDBC協議,在拿到待執行的sql後,解析sql,根據查詢條件判斷是否存在分片字段,若是存在,再根據分片算法獲取到對應的數據庫實例和物理表名,重寫sql,而後找到對應的數據庫datasource並獲取物理鏈接,執行sql,將結果集進行合併篩選後返回,若是沒有分片字段,則須要查詢全部的表,注意,即便存在分片字段,可是分片字段在一個範圍內,可能也須要查詢多個表,針對select之外的sql若是沒有傳分片字段建議直接拋出異常。
咱們先回顧下一個完整的經過JDBC執行一條查詢sql的流程,其實druid也是在jdbc上作加強來作監控的,因此咱們也能夠適當參考druid的實現
@Test
public void testQ() throws SQLException,NamingException{
Context context = new InitialContext();
DataSource dataSource = (DataSource)context.lookup("java:comp/env/jdbc/myDataSource");
Connection connection = dataSource.getConnection();
PreparedStatement preparedStatement = connection.prepareStatement("select * from busi_order where id = ?");
preparedStatement.setString(1,"1");
ResultSet resultSet = preparedStatement.executeQuery();
while(resultSet.next()){
String orderNo = resultSet.getString("order_no");
System.out.println(orderNo);
}
preparedStatement.close();
connection.close();
}
複製代碼
sql解析通常藉助druid框架裏面的SQLStatementParser類,解析好的數據都在SQLStatement 中,固然有條件的能夠本身研究SQL解析,不過可能工做量有點大。
分片算法,主要經過一個表達式,從分片字段對應的值獲取到分片結果,能夠提供簡單地EL表達式,就能夠實現從值中截取某一段做爲分表數據,也能夠提供通用的一致性哈希算法的實現,應用方只須要在xml或者註解中配置便可,如下爲一致性哈希在銅板街的實現
/**
* 最大真實節點數
*/
private int max;
/**
* 真實節點的數量
*/
private int current;
private int[] bucket;
private Set suffixSet;
public void init() {
bucket = new int[max];
suffixSet = new TreeSet();
int length = max / current;
int lengthIndex = 0;
int suffix = 0;
for (int i = 0; i < max; i++) {
bucket[i] = suffix;
lengthIndex ++;
suffixSet.add(suffix);
if (lengthIndex == length){
lengthIndex = 0;
suffix = i + 1;
}
}
}
public VirtualModFunction(int max, int current){
this.current = current;
this.max = max;
this.init();
}
@Override
public Integer execute(String columnValue, Map<String, Object> extension) {
return bucket[((Long) (Long.valueOf(columnValue) % max)).intValue()];
}
複製代碼
這裏也能夠順帶作一下讀寫分離,配置一些讀操做路由到哪一個實例,寫操做路由到哪一個實例,而且作到負載均衡,對應用層透明
若是須要在多個物理表上執行查詢,則須要對結果集進行合併處理,此處須要注意返回是一個迭代器resultset
1.統計類 針對sum count ,max,min 只須要將每一個結果集的返回結果在作一個max和min,count和sum直接相加便可,針對avg須要經過上面改寫的sql獲取sum和count 而後相除計算平均值
2.排序類 大部分的排序都伴隨着limit限制查詢條數,例如返回結果須要查詢最近的2000條記錄,而且根據建立時間倒序排序,根據路由結果須要查詢全部的物理表,假設是4張表,若是此時4張表的數據沒有時間上的排序關係,則須要每張表都查詢2000條記錄,而且按照建立時間倒序排列,如今要作的就是從4個已經排序好的鏈表,每一個鏈表最多2000條數據,從新排序,選擇2000條時間最近的,咱們能夠經過插入排序的算法,每次分別從每一個鏈表中取出時間最大的一個,在新的結果集裏找到位置並插入,直到結果集中存在2000條記錄,可是這裏可能存在一個問題,若是某一個鏈表的數據廣泛比其餘鏈表數據偏大,這樣每一個鏈表取500條數據確定排序不許確,因此咱們還須要保證當前全部鏈表中剩下的數據的最大值比新結果集中的數據小。 而實際上業務層的需求可能並非僅僅取出2000條數據,而是要遍歷全部的數據,這種要遍歷全部數據集的狀況,建議在業務層控制一張表一張表的遍歷,若是每次都要去每張表中查詢在排序嚴重影響效率,若是在應用層控制,咱們在後面在聊。
3.聚合類 group by 應用層須要儘可能避免這種操做,這些需求最好能交給搜索引擎和數據分析平臺進行,可是做爲一箇中間件,對於group by 這種咱們常常須要統計數據的類型仍是應該儘可能支持的,目前的作法是 和統計類處理相似,針對各個子集進行合併處理。
以上流程基本能夠實現一個簡易版本的數據庫分庫分表中間件,爲了讓咱們的中間件更方便開發者使用,爲平常工做提供更多地遍歷性,咱們還能夠從如下幾點作優化
針對哪些表須要進行分片,分片規則等,這些須要定製化的配置,咱們能夠在程序裏面手工編碼,可是這樣業務層又耦合了分表的邏輯,咱們能夠藉助spring的配置文件,直接將xml裏的內容映射成對應的bean實例。
1.咱們首先要設計好對應的配置文件的格式,有哪些節點,每一個節點包含哪些屬性,而後設計本身命名空間,和對應的XSD校驗文件,XSD文件放在META-INF下.
2.編寫 NamespaceHandlerSupport 類,註冊每一個節點元素對應的解析器
public class BaymaxNamespaceHandler extends NamespaceHandlerSupport {
//com.alibaba.dubbo.config.spring.schema.DubboNamespaceHandler
@Override
public void init() {
registerBeanDefinitionParser("table", new BaymaxBeanDefinitionParser(TableConfig.class, false));
registerBeanDefinitionParser("context", new BaymaxBeanDefinitionParser(BaymaxSpringContext.class, false));
registerBeanDefinitionParser("process", new BaymaxBeanDefinitionParser(ColumnProcess.class, false));
}
}
複製代碼
3.在META-INF文件中增長配置文件 spring.handlers 中配置 spring遇到某個namespace下的節點後 經過哪一個解析器解析,最終返回配置實例
http\://baymax.tongbanjie.com/schema/baymax-3.0=com.tongbanjie.baymax.spring.BaymaxNamespaceHandler
複製代碼
4.在META-INF文件中增長配置文件 spring.schema 中配置 spring遇到某個namespace下的節點後 經過哪一個XSD文件進行校驗
http\://baymax.tongbanjie.com/schema/baymax-3.0.xsd=META-INF/baymax-3.0.xsd
複製代碼
5.能夠藉助ListableBeanFactory的getBeansOfType(Class clazz) 來獲取某個class類型的全部實例,從而得到全部的配置信息
固然也能夠經過自定義註解進行申明,這種方式咱們能夠藉助BeanPostProcessor的時候判斷類上是否包含指定的註解,可是這種方式比較笨重,並且所加註解的類必須在spring容器管理中,也能夠藉助 ClassPathScanningCandidateComponentProvider 和AnnotationTypeFilter 實現,或者直接經過 classloader掃描指定的包路徑。
因爲框架自己經過定製JDBC協議實現,雖然最終執行sql的是經過原生JDBC,可是對上層應用透明,同時也對上層基於JDBC實現的事物透明,spring的事物管理器能夠直接使用,
咱們考慮下如下問題,若是咱們針對多張表在一個線程池內併發的區執行sql,而後在合併結果,這是否會影響spring的事物管理器?
首先,spring的聲明式事物是經過aop在切面作加強,事物開始先獲取connection並設置setAutocommit爲fasle,事物結束調用connection進行commit或者rollback,經過threadlocal保存事物上下文和所使用的connection,來保證事物內多個sql共用一個connection操做, 可是若是咱們在解析sql後發現要執行多條sql語句,咱們經過線程池併發執行,而後等全部的結果返回後進行合併,(這裏先不考慮,多個sql可能須要在不一樣的數據庫實例上執行),雖然經過線程池將致使threadlocal失效,可是咱們在threadlocal維護的是咱們本身定製的connection,並非原生的JDBC裏的connection,並且這裏併發執行並不會讓事物處理器沒辦法判斷是否全部的線程都已經結束,而後進行commit或者rollback,由於這裏的線程池是在咱們定製的connection執行sql過程當中運用的,確定會等到全部線程處理結束後而且合併數據集纔會返回。因此在本地事物層面,經過定製化JDBC能夠作到對上層事物透明.
若是咱們進行了分庫,同一個表可能在多個數據庫實例裏,這種若是要對不一樣實例裏的表進行更新,那麼將沒法在使用本地事物,這裏咱們不在討論分佈式事物的實現,因爲二階段提交的各類缺點,目前不多有公司會基於二階段作分佈式事物,因此咱們的中間件也能夠根據本身的具體業務考慮是否要實現XA,目前銅板街大部分分佈式事物需求都是經過基於TCC的事物補償作的,這種方式對業務冪等要求較高,同時要基於業務層實現回滾邏輯。
爲何要提供一個發號器,咱們在單表的時候,可能會用到數據庫的自增ID,但當分紅多表後,每一個表都進行單獨的ID自增,這樣一個邏輯表內的ID就會出現重複。
咱們能夠提供一個基於邏輯表自增的主鍵ID獲取方式,若是沒有分庫,只分表,能夠在數據庫中增長一個表維護每張邏輯表對應的自增ID,每次須要獲取ID的時候都先查詢這個標當前的ID而後加一返回,而後在寫入數據庫,爲了併發獲取的狀況,咱們能夠採用樂觀鎖,相似於CAS,update的時候傳人之前的ID,若是被人修改過則從新獲取,固然咱們也能夠一次性獲取一批ID例如一次獲取100個,等這100個用完了在從新獲取,爲了不這100個還沒用完,程序正常或非正常退出,在獲取這100個值的時候就將數據庫經過CAS更新爲已經獲取了100個值之和的值
不推薦用UUID,無序,太長佔內存影響索引效果,不攜帶任何業務含義
藉助ZOOKEEPER 的zone的版本號來作序列號,
藉助REDIS的INCR命令,進行自增,每臺redis設置不一樣的初始值,可是設置相同的歩長。
A:1,6,11,16,21
B:2,7,12,17,22
C:3,8,13,18,23
D:4,9,14,19,24
E:5,10,15,20,25
複製代碼
snowflake算法:其核心思想是:使用41bit做爲毫秒數,10bit做爲機器的ID(5個bit是數據中心,5個bit的機器ID),12bit做爲毫秒內的流水號(意味着每一個節點在每毫秒能夠產生 4096 個 ID)
銅板街目前所使用的訂單號規則:
15位時間戳,4位自增序列,2位區分訂單類型,7位機器ID,2位分庫後綴,2位分表後綴 共32位
7位機器ID 經過IP來獲取
15位時間戳精確到毫秒,4位自增序列,意味着單JVM1毫秒能夠生成9999個訂單
最後4位能夠方便的根據訂單號定位到物理表,這裏須要注意分庫分表若是是根據一致性哈希算法,這個地方最好存最大值, 例如 用戶id % 64 取餘 最多能夠分64張表,而目前可能用不到這麼多,每相鄰4個數字分配到一張表,共16張表,既 userID % 64 / 4 * 4 ,而這個地方存儲 userID % 64 便可,沒必要存最終分表的結果,這種方式方便後續作擴容,可能分表的結果變動了,可是訂單號卻沒法進行變動
@Override
public String routeDbByUserId(String userId) {
Assert.hasLength(userId, "用戶ID不能爲空");
Integer userIdInteger = null;
try {
userIdInteger = Integer.parseInt(userId);
} catch (Exception ex) {
logger.error("解析用戶ID爲整數失敗" + userId, ex);
throw new RuntimeException("解析用戶ID爲整數失敗");
}
//根據路由規則肯定,具體在哪一個庫哪一個表 例如根據分庫公式最終結果在0到63之間 若是要分兩個庫 mod爲32 分1個庫mod爲64 分16個庫 mod爲4
//規律爲 64 = mod * (最終的分庫數或分表數)
int mod = orderSplitConfig.getDbSegment();
Integer dbSuffixInt = userIdInteger / 64 % 64 / mod * mod ;
return StringUtils.leftPad(String.valueOf(dbSuffixInt), 2, '0');
}
@Override
public String routeTableByUserId(String userId) {
Assert.hasLength(userId, "用戶ID不能爲空");
Integer userIdInteger = null;
try {
userIdInteger = Integer.parseInt(userId);
} catch (Exception ex) {
logger.error("解析用戶ID爲整數失敗" + userId, ex);
throw new RuntimeException("解析用戶ID爲整數失敗");
}
//根據路由規則肯定,具體在哪一個庫哪一個表 例如根據分表公式最終結果在0到63之間 若是要分兩個庫 mod爲32 分1個庫mod爲64 分16個庫 mod爲4
//規律爲 64 = mod * (最終的分庫數或分表數)
int mod = orderSplitConfig.getTableSegment();
Integer tableSuffixInt = userIdInteger % 64 / mod * mod;
return StringUtils.leftPad( String.valueOf(tableSuffixInt), 2, '0');
}
複製代碼
若是業務需求是遍歷全部知足條件的數據,而不是隻是爲了取某種條件下前面一批數據,這種建議在應用層實現,一張表一張表的遍歷,每次查詢結果返回下一次查詢的起始位置和物理表名,查詢的時候建議根據 大於或小於某一個ID進行分頁,不要limit500,500這種,如下爲銅板街的實現方式
public List<T> select(String tableName, SelectorParam selectorParam, E realQueryParam) {
List<T> list = new ArrayList<T>();
// 定位到某張表
String suffix = partitionManager.getCurrentSuffix(tableName, selectorParam.getLocationNo());
int originalSize = selectorParam.getLimit();
while (true) {
List<T> ts = this.queryByParam(realQueryParam, selectorParam, suffix);
if (!CollectionUtils.isEmpty(ts)) {
list.addAll(ts);
}
if (list.size() == originalSize) {
break;
}
suffix = partitionManager.getNextSuffix(tableName, suffix);
if (StringUtils.isEmpty(suffix)) {
break;
}
// 查詢下一張表 不須要定位單號 並且也只須要查剩下的size便可
selectorParam.setLimit(originalSize - list.size());
selectorParam.setLocationNo(null);
}
return list;
}
複製代碼
1.監控能夠藉助druid,也能夠在定製的jdbc層本身作埋點,將數據以報表的形式進行展現,也能夠針對特定的監控指標進行配置,例如執行次數,執行時間大於某個指定時間
2.管理控制檯,因爲目前配置是在應用層,固然也能夠把配置獨立出來放在獨立的服務器上,因爲分片配置基本上沒法在線修改,每次修改可能都伴隨着數據遷移,因此基本上只能作展現,可是分表後咱們在測試環境執行sql去進行邏輯查詢的時候,傳統的sql工具沒法幫忙作到自動路由,這樣咱們每次查詢可能都須要手工計算下分片結果,或者要連續寫好幾個sql以後在聚合,經過這個管理控制檯咱們就能夠直接根據邏輯表名寫sql,這樣咱們在測試環境或者在線上覈對數據的時候,就提升了效率
3.擴容工具,笨辦法只能先從老表查詢在insert到新表,等到新表數據徹底同步完後,在切換到新的切片規則,因此咱們設計分片算法的時候,須要考慮到後面擴容,例如一致性哈希就須要遷移一半的數據(擴容一倍的話) 數據遷移若是出現故障,那將是個災難,若是咱們要在不停機的狀況下完成擴容,能夠經過配置文件按如下流程來
以上流程適用於,訂單這種歷史數據在達到終態後將不會在被修改,若是歷史數據也可能被修改,則可能須要停機,或者經過canel進行數據同步