亚洲视频二区_亚洲欧洲日本天天堂在线观看_日韩一区二区在线观看_中文字幕不卡一区

公告:魔扣目錄網為廣大站長提供免費收錄網站服務,提交前請做好本站友鏈:【 網站目錄:http://www.430618.com 】, 免友鏈快審服務(50元/站),

點擊這里在線咨詢客服
新站提交
  • 網站:51998
  • 待審:31
  • 小程序:12
  • 文章:1030137
  • 會員:747

背景

部門中維護了一個老系統,功能都耦合在一個單體應用中(300+接口),表也放在同一個庫中(200+表),導致系統存在很多風險和缺陷。經常出現問題:如數據庫的單點、性能問題,應用的擴展受限,復雜性高等問題。

從下圖可見。各業務相互耦合無明確邊界,調用關系錯綜復雜。


 

隨著業務快速發展,各種問題越來越明顯,急需對系統進行微服務改造優化。經過思考,整體改造將分為三個階段進行:

 

  • 數據庫拆分:數據庫按照業務垂直拆分。
  • 應用拆分:應用按照業務垂直拆分。
  • 數據訪問權限收口:數據權限按照各自業務領域,歸屬到各自的應用,應用與數據庫一對一,禁止交叉訪問。

 


 


數據庫拆分

單體數據庫的痛點:未進行業務隔離,一個慢sql易導致系統整體出現問題;讀寫壓力大,性能下降;

數據庫改造


 

根據業務劃分,我們計劃將數據庫拆分為9個業務庫。數據同步方式采用主從復制的方式,我們提前整理好表和新數據庫的對應關系交給運維同學,運維同學通過binlog過濾將對應的表和數據同步到對應的新數據庫中,每個新數據庫中只包含自己業務的表。

代碼改造方案

如果一個接口中操作了多張表,之前這些表屬于同一個庫,數據庫拆分后可能會分屬于不同的庫。所以需要針對代碼進行相應的改造。

目前存在問題的位置:

 

  • 數據源選擇:系統之前是支持多數據源切換的,在service上添加注解來選擇數據源。數據庫拆分后出現的情況是同一個service中操作的多個mApper從屬于不同的庫。
  • 事務:事務注解目前是存在于service上的,并且事務會緩存數據庫鏈接,一個事務內不支持同時操作多個數據庫。

 

改造點梳理:

 

  • 同時寫入多個庫,且是同一事務的接口6個:需改造數據源,需改造事務,需要關注分布式事務;
  • 同時寫入多個庫,且不是同一事務的接口50+:需改造數據源,需改造事務,無需關注分布式事務;
  • 同時讀取多個庫 或 讀取一個庫寫入另一個庫的接口200+:需改造數據源,但無需關注事務;
  • 涉及多個庫的表的聯合查詢8個:需進行代碼邏輯改造

 

梳理方式:

采用部門中的切面工具,抓取入口和表的調用關系(可識別表的讀/寫操作),找到一個接口中操作了多個表,并且多個表分屬于不同業務庫的情況;

分布式事務:

進行應用拆分和數據訪問權限收口之后,是不存在分布式事務的問題的,因為操作第二個庫會調用對應系統的RPC接口進行操作。所以本次不會正式支持分布式事務,而是采用代碼邏輯保證一致性的方式來解決;

方案一


 

將service中分別操作多個庫的mapper,抽取成多個service。分別添加切換數據源注解和事務注解。

問題:改動位置多,涉及改動的每個方法都需要梳理歷史業務;service存在很多嵌套調用的情況,有時難以理清邏輯;修改200+位置改動工作量大,風險高;

方案二


 

如圖所示,方案二將數據源注解移動到Mapper上,并使用自定義的事務實現來處理事務。

將多數據源注解放到Mapper上的好處是,不需要梳理代碼邏輯,只需要在Mapper上添加對應數據源名稱即可。但是這樣又有新的問題出現,

 

  • 問題1:如上圖,事務的是配置在Service層,當事務開啟時,數據源的連接并沒有獲取到,因為真正的數據源配置在Mapper上。所以會報錯,這個錯誤可以通過多數據源組件的默認數據源功能解決。
  • 問題2:MyBatis的事務實現會緩存數據庫鏈接。當第一次緩存了數據庫鏈接后,后續配置在mapper上的數據源注解并不會重新獲取數據庫鏈接,而是直接使用緩存起來的數據庫鏈接。如果后續的mapper要操作其余數據庫,會出現找不到表的情況。鑒于以上問題,我們開發了一個自定義的事務實現類,用來解決這個問題。
下面將對方案中出現的兩個組件進行簡要說明原理。
多數據源組件

 

多數據源組件是單個應用連接多個數據源時使用的工具,其核心原理是通過配置文件將數據庫鏈接在程序啟動時初始化好,在執行到存在注解的方法時,通過切面獲取當前的數據源名稱來切換數據源,當一次調用涉及多個數據源時,會利用棧的特性解決數據源嵌套的問題。

/** * 切面方法 */ public Object switchdataSourceAroundAdvice(ProceedingJoinPoint pjp) throws Throwable { //獲取數據源的名字 String dsName = getDataSourceName(pjp); boolean dataSourceSwitched = false; if (StringUtils.isNotEmpty(dsName) && !StringUtils.equals(dsName, StackRoutingDataSource.getCurrentTargetKey())) { // 見下一段代碼 StackRoutingDataSource.setTargetDs(dsName); dataSourceSwitched = true; } try { // 執行切面方法 return pjp.proceed(); } catch (Throwable e) { throw e; } finally { if (dataSourceSwitched) { StackRoutingDataSource.clear(); } } }public static void setTargetDs(String dbName) { if (dbName == null) { throw new NullPointerException(); } if (contextHolder.get() == null) { contextHolder.set(new Stack()); } contextHolder.get().push(dbName); log.debug("set current datasource is " + dbName); }

StackRoutingDataSource繼承 AbstractRoutingDataSource類,AbstractRoutingDataSource是Spring-jdbc包提供的一個了AbstractDataSource的抽象類,它實現了DataSource接口的用于獲取數據庫鏈接的方法。

自定義事務實現

從方案二的圖中可以看到默認的事務實現使用的是mybatis的SpringManagedTransaction。


 

如上圖,Transaction和SpringManagedTransaction都是mybatis提供的類,他提供了接口供SQLSession使用,處理事務操作。通過下邊的一段代碼可以看到,事務對象中存在connection變量,首次獲得數據庫鏈接后,后續當前事務內的所有數據庫操作都不會重新獲取數據庫鏈接,而是會使用現有的數據庫鏈接,從而無法支持跨庫操作。

public class SpringManagedTransaction implements Transaction { private static final Log LOGGER = LogFactory.getLog(SpringManagedTransaction.class); private final DataSource dataSource; private Connection connection; private boolean isConnectionTransactional; private boolean autoCommit; public SpringManagedTransaction(DataSource dataSource) { notNull(dataSource, "No DataSource specified"); this.dataSource = dataSource; } // 下略 }

MultiDataSourceManagedTransaction是我們自定義的事務實現,繼承自SpringManagedTransaction類,并在內部支持維護多個數據庫鏈接。每次執行數據庫操作時,會根據數據源名稱判斷,如果當前數據源沒有緩存的鏈接則重新獲取鏈接。這樣,service上的事務注解其實控制了多個單庫事務,且作用域范圍相同,一起進行提交或回滾。

代碼如下:

public class MultiDataSourceManagedTransaction extends SpringManagedTransaction { private DataSource dataSource; public ConcurrentHashMap CON_MAP = new ConcurrentHashMap<>(); public MultiDataSourceManagedTransaction(DataSource dataSource) { super(dataSource); this.dataSource = dataSource; } @Override public Connection getConnection() throws SQLException { Method getCurrentTargetKey; String dataSourceKey; try { getCurrentTargetKey = dataSource.getClass().getDeclaredMethod("getCurrentTargetKey"); getCurrentTargetKey.setAccessible(true); dataSourceKey = (String) getCurrentTargetKey.invoke(dataSource); } catch (Exception e) { log.error("MultiDataSourceManagedTransaction invoke getCurrentTargetKey 異常", e); return null; } if (CON_MAP.get(dataSourceKey) == null) { Connection connection = dataSource.getConnection(); if (!TransactionSynchronizationManager.isActualTransactionActive()) { connection.setAutoCommit(true); } else { connection.setAutoCommit(false); } CON_MAP.put(dataSourceKey, connection); return connection; } return CON_MAP.get(dataSourceKey); } @Override public void commit() throws SQLException { if (CON_MAP == null || CON_MAP.size() == 0) { return; } Set> entries = CON_MAP.entrySet(); for (Map.Entry entry : entries) { Connection value = entry.getValue(); if (!value.isClosed() && !value.getAutoCommit()) { value.commit(); } } } @Override public void rollback() throws SQLException { if (CON_MAP == null || CON_MAP.size() == 0) { return; } Set> entries = CON_MAP.entrySet(); for (Map.Entry entry : entries) { Connection value = entry.getValue(); if (value == null) { continue; } if (!value.isClosed() && !value.getAutoCommit()) { entry.getValue().rollback(); } } } @Override public void close() throws SQLException { if (CON_MAP == null || CON_MAP.size() == 0) { return; } Set> entries = CON_MAP.entrySet(); for (Map.Entry entry : entries) { DataSourceUtils.releaseConnection(entry.getValue(), this.dataSource); } CON_MAP.clear(); } }

注:上面并不是分布式事務。在數據訪問權限收口之前,它只存在于同一個JVM中。如果項目允許,可以考慮使用Atomikos和Mybatis整合的方案。

數據安全性

本次進行了很多代碼改造,如何保證數據安全,保證數據不丟失,我們的機制如下,分為三種情況進行討論:

 

  • 跨庫事務:6處,采用了代碼保證一致性的改造方式;上線前經過重點測試,保證邏輯無問題;
  • 單庫事務:依賴于自定義事務實現,針對自定義事務實現這一個類進行充分測試即可,測試范圍小,安全性有保障;
  • 其余單表操作:相關修改是在mapper上添加了數據源切換注解,改動位置幾百處,幾乎是無腦改動,但也存在遺漏或錯改的可能;測試同學可以覆蓋到核心業務流程,但邊緣業務可能會遺漏;我們添加了線上監測機制,當出現找不到表的錯誤時(說明數據源切換注解添加錯誤),記錄當前執行sql并報警,我們進行邏輯修復與數據處理。

 

綜上,通過對三種情況的處理來保證數據的安全性。

應用拆分

系統接近單體架構,存在以下風險:

 

  1. 系統性風險:一個組件缺陷會導致整個進程崩潰,如內存泄漏、死鎖。
  2. 復雜性高:系統代碼繁多,每次修改代碼都心驚膽戰,任何一個bug都可能導致整個系統崩潰,不敢優化代碼導致代碼可讀性也越來越差。
  3. 測試環境沖突,測試效率低:業務都耦合在一個系統,只要有需求就會出現環境搶占,需要額外拉分支合并代碼。
  4.  
拆分方案

 

與數據庫拆分相同,系統拆分也是根據業務劃分拆成9個新系統。

方案一:搭建空的新系統,然后將老系統的相關代碼挪到新系統。

 

  • 優點:一步到位。
  • 缺點:需要主觀挑選代碼,然后挪到新系統,可視為做了全量業務邏輯的變動,需要全量測試,風險高,周期長。

 

方案二:從老系統原樣復制出9個新系統,然后直接上線,通過流量路由將老系統流量轉發到新系統,后續再對新系統的冗余代碼做刪減。

 

  • 優點:拆分速度快,首次上線前無業務邏輯改動,風險低;后續刪減代碼時依據接口調用量情況來判定,也可視為無業務邏輯的改動,風險較低,并且各系統可各自進行,無需整體排期,較為靈活。
  • 缺點:分為了兩步,拆分上線和刪減代碼

 


 

拆分方案對比

我們在考慮拆分風險和拆分效率后,最終選擇了方案二。


 

方案二原理

拆分實踐

  1. 搭建新系統

 

直接復制老系統代碼,修改系統名稱,部署即可

 

  1. 流量路由

 

路由器是拆分的核心,負責分發流量到新系統,同時需要支持識別測試流量,讓測試同學可以提前在線上測試新系統。我們這邊用filter來作為路由器的,源碼見下方。

@Override public void doFilter(Servletrequest request, ServletResponse response, FilterChain filterChain) throws ServletException, IOException { HttpServletRequest servletRequest = (HttpServletRequest) request; HttpServletResponse servletResponse = (HttpServletResponse) response; // 路由開關(0-不路由, 1-根據指定請求頭路由, 2-全量路由) final int systemRouteSwitch = configUtils.getInteger("system_route_switch", 1); if (systemRouteSwitch == 0) { filterChain.doFilter(request, response); return; } // 只路由測試流量 if (systemRouteSwitch == 1) { // 檢查請求頭是否包含測試流量標識 包含才進行路由 String systemRoute = ((HttpServletRequest) request).getHeader("systemRoute"); if (systemRoute == null || !systemRoute.equals("1")) { filterChain.doFilter(request, response); return; } } String systemRouteMapJsonStr = configUtils.getString("route.map", ""); Map map = JSONObject.parseobject(systemRouteMapJsonStr, Map.class); String rootUrl = map.get(servletRequest.getRequestURI()); if (StringUtils.isEmpty(rootUrl)) { log.error("路由失敗,本地服務內部處理。原因:請求地址映射不到對應系統, uri : {}", servletRequest.getRequestURI()); filterChain.doFilter(request, response); return; } String targetURL = rootUrl + servletRequest.getRequestURI(); if (servletRequest.getQueryString() != null) { targetURL = targetURL + "?" + servletRequest.getQueryString(); } RequestEntity requestEntity = null; try { log.info("路由開始 targetURL = {}", targetURL); requestEntity = createRequestEntity(servletRequest, targetURL); ResponseEntity responseEntity = restTemplate.exchange(requestEntity, byte[].class); if (requestEntity != null && requestEntity.getBody() != null && requestEntity.getBody().length > 0) { log.info("路由完成-請求信息: requestEntity = {}, body = {}", requestEntity.toString(), new String(requestEntity.getBody())); } else { log.info("路由完成-請求信息: requestEntity = {}", requestEntity != null ? requestEntity.toString() : targetURL); } HttpHeaders headers = responseEntity.getHeaders(); String resp = null; if (responseEntity.getBody() != null && headers != null && headers.get("Content-Encoding") != null && headers.get("Content-Encoding").contains("gzip")) { byte[] bytes = new byte[30 * 1024]; int len = new GZIPInputStream(new ByteArrayInputStream((byte[]) responseEntity.getBody())).read(bytes, 0, bytes.length); resp = new String(bytes, 0, len); } log.info("路由完成-響應信息: targetURL = {}, headers = {}, resp = {}", targetURL, JSON.toJSONString(headers), resp); if (headers != null && headers.containsKey("Location") && CollectionUtils.isNotEmpty(headers.get("Location"))) { log.info("路由完成-需要重定向到 {}", headers.get("Location").get(0)); ((HttpServletResponse) response).sendRedirect(headers.get("Location").get(0)); } addResponseHeaders(servletRequest, servletResponse, responseEntity); writeResponse(servletResponse, responseEntity); } catch (Exception e) { if (requestEntity != null && requestEntity.getBody() != null && requestEntity.getBody().length > 0) { log.error("路由異常-請求信息: requestEntity = {}, body = {}", requestEntity.toString(), new String(requestEntity.getBody()), e); } else { log.error("路由異常-請求信息: requestEntity = {}", requestEntity != null ? requestEntity.toString() : targetURL, e); } response.setCharacterEncoding("UTF-8"); ((HttpServletResponse) response).addHeader("Content-Type", "application/json"); response.getWriter().write(JSON.toJSONString(ApiResponse.failed("9999", "網絡繁忙哦~,請您稍后重試"))); } }

  1. 接口抓取&歸類

 

路由filter是根據接口路徑將請求分發到各個新系統的,所以需要抓取一份接口和新系統的映射關系。我們這邊自定義了一個注解@TargetSystem,用注解標識接口應該路由到的目標系統域名,

@TargetSystem(value = "http://order.demo.com") @GetMapping("/order/info") public ApiResponse orderInfo(String orderId) { return ApiResponse.success(); }

然后遍歷獲取所有controller根據接口地址和注解生成路由映射關系map

/** * 生成路由映射關系MAP * key:接口地址 ,value:路由到目標新系統的域名 */ public Map generateRouteMap() { Map handlerMethods = requestMappingHandlerMapping.getHandlerMethods(); Set> entries = handlerMethods.entrySet(); Map map = new HashMap<>(); for (Map.Entry entry : entries) { RequestMappingInfo key = entry.getKey(); HandlerMethod value = entry.getValue(); Class declaringClass = value.getMethod().getDeclaringClass(); TargetSystem targetSystem = (TargetSystem) declaringClass.getAnnotation(TargetSystem.class); String targetUrl = targetSystem.value(); String s1 = key.getPatternsCondition().toString(); String url = s1.substring(1, s1.length() - 1); map.put(url, targetUrl); } return map; }


 

路由映射關系MAP

 

  1. 測試流量識別

 

測試可以用利用抓包工具charles,為每個請求都添加固定的請求頭,也就是測試流量標識,路由器攔截請求后判斷請求頭內是否包含測試流量標,包含就路由到新系統,不包含就是線上流量留在老系統執行。


 

路由流程

 

  1. 需求代碼合并

 

執行系統拆分的過程中,還是有需求正在并行開發,并且需求代碼是寫在老系統的,系統拆分完成上線后,需要將這部分需求的代碼合并到新系統,同時要保證git版本記錄不能丟失,那應該怎么做呢?

我們利用了git可以添加多個多個遠程倉庫來解決需求合并的痛點,命令:git remote add origin 倉庫地址,把新系統的git倉庫地址添加為老系統git的遠程倉庫,老系統的git變動就可以同時push到所有新系統的倉庫內,新系統pull下代碼后進行合并。


 

需求代碼合并方案

 

  1. 上線風險

 

風險一:JOB在新老系統并行執行。新系統是復制的老系統,JOB也會復制過來,導致新老系統有相同的JOB,如果這時候上線新系統,新系統的JOB就會執行,老系統的JOB也一直在run,這樣一個JOB就會執行2次。新系統剛上線還沒經過測試驗證,這時候執行JOB是有可能失敗的。以上2種情況都會引起線上Bug,影響系統穩定性。

風險二:新系統提前消費MQ。和風險一一樣,新系統監聽和老系統一樣的topic,如果新系統直接上線,消息是有可能被新系統消費的,新系統剛上線還沒經過測試驗證,消費消息有可能會出異常,造成消息丟失或其他問題,影響系統穩定性。

如何解決以上2個上線風險呢?

我們用“動態開關”解決了上述風險,為新老系統的JOB和MQ都加了開關,用開關控制JOB和MQ在新/老系統執行。上線后新系統的JOB和MQ都是關掉的,待QA測試通過后,把老系統的JOB和MQ關掉,把新系統的JOB和MQ打開就可以了。


 

上線風險解決方案

系統瘦身

拆分的時候已經梳理出了一份“入口映射關系map”,每個新系統只需要保留自己系統負責的接口、JOB、MQ代碼就可以了,除此之外都可以進行刪除。

拆分帶來的好處

  1. 系統架構更合理,可用性更高:即使某個服務掛了也不會導致整個系統崩潰
  2. 復雜性可控:每個系統都是單一職責,系統邏輯清晰
  3. 系統性能提升上限大:可以針對每個系統做優化,如加緩存
  4. 測試環境沖突的問題解決,不會因為多個系統需求并行而搶占環境
數據訪問權限收口問題介紹

 

數據訪問權限未收口:一個業務的數據庫被其余業務應用直接訪問,未通過rpc接口將數據訪問權限收口到數據擁有方自己的應用。數據訪問邏輯分散,存在業務耦合,阻礙后續迭代和優化。

問題產生的背景:之前是單體應用和單體數據庫,未進行業務隔離。在進行數據庫拆分和系統拆分時,為解決系統穩定性的問題需快速上線,所以未優化拆分后跨業務訪問數據庫的情況。本階段是對數據庫拆分和應用拆分的延伸和補充。


 

業務改造前后對比

改造過程

  1. RPC接口統計(如圖一)

 

進行比對,如程序入口歸類和調用的業務DB歸類不一致,則認為Dao方法需提供RPC接口


 

圖一

經統計,應用訪問非本業務數據庫的位置有260+。由于涉及位置多,人工改造成本高、效率較低,且有錯改和漏掉的風險,我們采用了開發工具,用工具進行代碼生成和批量修改的方式進行改造。

 

  1. RPC接口生成(如圖二)
  • 讀取需要生成RPC接口的Dao文件,進行解析
  • 獲取文件名稱,Dao方法列表,import導包列表等,放入ClassContext上下文
  • 匹配api、rpc文件模板,從classContext內取值替換模板變量,通過package路徑生成JAVA文件到指定服務內
  • 批量將服務內Dao名稱后綴替換為Rpc服務名,減少人工改動風險,例:SettleRuleDao -> SettleRuleRpc

 


 

圖二

名詞解釋:

 

  • ftl:Freemarker模板的文件后綴名,FreeMarker是一個模版引擎,一個基于文本的模板輸出工具。
  • interfaceName:用存放api文件名稱
  • className:用于存放serviceImpl文件名稱
  • methodList:用于存放方法列表,包含入參、出參、返回值等信息
  • importList:用于存放api和impl文件內其他引用實體的導包路徑
  • apiPackage:用于存放生成的Api接口類包名
  • implPackage:用于存放生成的Api實現類包名
  • rpcPackage:用于存放生成的rpc調用類包名

 


 

代碼示例1


 

代碼示例2

 

  1. 灰度方案(如圖三)
  • 數據操作統一走RPC層處理,初期階段RPC層兼顧RPC調用,也有之前的DAO調用,使用開關切換。
  • RPC層進行雙讀,進行Api層和Dao層返回結果的比對,前期優先返回Dao層結果,驗證無問題后,在全量返回RPC的結果,清除其他業務數據庫連接。
  • 支持開關一鍵切換,按流量進行灰度,降低數據訪問權限收口風險

 


 

圖三

收益

  1. 業務數據解耦,數據操作統一由各自垂直系統進行,入口統一
  2. 方便后續在接口粒度上增加緩存和降級處理
總結

 

以上,是我們對單體系統的改造過程,經過了三步優化、上線,將單體系統平滑過渡到了微服務結構。解決了數據庫的單點問題、性能問題,應用業務得到了簡化,更利于分工,迭代。并且可以針對各業務單獨進行優化升級,擴容、縮容,提升了資源的利用率。

作者:徐強,張均杰,黃威

出處:https://mp.weixin.qq.com/s/WaD8Go8twTF3CyoZm1ZICQ

分享到:
標簽:微服
用戶無頭像

網友整理

注冊時間:

網站:5 個   小程序:0 個  文章:12 篇

  • 51998

    網站

  • 12

    小程序

  • 1030137

    文章

  • 747

    會員

趕快注冊賬號,推廣您的網站吧!
最新入駐小程序

數獨大挑戰2018-06-03

數獨一種數學游戲,玩家需要根據9

答題星2018-06-03

您可以通過答題星輕松地創建試卷

全階人生考試2018-06-03

各種考試題,題庫,初中,高中,大學四六

運動步數有氧達人2018-06-03

記錄運動步數,積累氧氣值。還可偷

每日養生app2018-06-03

每日養生,天天健康

體育訓練成績評定2018-06-03

通用課目體育訓練成績評定