工做日誌,多租戶模式下的數據備份和遷移java
記錄和分享一篇工做中遇到的奇難雜症。目前作的項目是多租戶模式。一套系統管理多個項目,用戶登陸不一樣的項目加載不一樣的數據。除了一些系統初始化的配置表外,各項目之間數據相互獨立。前期選擇了共享數據表的隔離方案,爲後期的數據遷移挖了一個大坑。這裏記錄填坑的思路。可能不優雅,僅供參考。sql
多租戶是一種軟件架構,在同一臺(組)服務器上運行單個實例,能爲多個租戶提供服務。以實際例子說明,一套能源監控系統,能夠爲A產業園提供服務,也能夠爲B產業園提供服務。A的管理員登陸能源監控系統只會看到A產業園相關的數據。一樣的道理,B產業園也是同樣。多住戶模式最重要的就是數據之間的獨立。其最大的侷限性在於對租戶定製化開發困難很大。適合通用的業務場景。數據庫
顧名思義,一個租戶獨享一個數據庫,其隔離級別最強,數據安全性最高,數據的備份和恢復最方便。對數據獨立性要求很高,數據的擴張性要求較多的租戶能夠考慮使用。或者錢給的多也能夠考慮。畢竟該模式下的硬件成本較高。代碼成本較低,Hibernate已經提供DATABASE的實現。安全
多個租戶共有一個數據庫,每一個租戶擁有屬於本身的Schema(Schema表示數據庫對象集合,它包含:表,視圖,存儲過程,索引等等對象)。其隔離級別較強,數據安全性較高,數據的備份和恢復較爲麻煩。數據庫出了問題會影響到全部租戶。Hibernate也提供SCHEMA的實現。服務器
多個租戶共享一個數據庫,一個Schema,一張數據表。各租戶之間經過字段區分。其隔離級別最低,數據安全性最低,數據的備份和恢復最麻煩(讓我哭一分鐘😭)。若一張表出現問題會影響到全部租戶。其代碼工做量也是最多,由於Hibernate(5.0.3版本)並無支持DISCRIMINATOR模式,目前還只是計劃支持。其模式最大的好處就是用最少的服務器支持最多的租戶。架構
在咱們的能源管理的系統中,多個租戶就是多個項目。將須要數據獨立的數據表經過ProjectID區分。而一些系統初始化的配置表則能夠數據共享。怎麼用盡量少的代碼來管理每一個租戶呢?這裏提出我我的的思路。app
第一步:用戶登陸時獲取當前項目,並保存到上下文中。框架
第二步:經過EntityListeners註解監聽,在實體被建立時將當前項目ID保存到數據庫中。工具
第三步:經過自定義攔截器,攔截須要數據隔離的sql語句,從新拼接查詢條件。ui
將當前項目保存到上下文中,不一樣的安全框架實現的方法也有所不一樣,實現的方式也多種多樣,這裏就不貼出代碼。
經過EntityListeners註解能夠對實體屬性變化的跟蹤,它提供了保存前,保存後,更新前,更新後,刪除前,刪除後等狀態,就像是攔截器同樣。這裏咱們能夠用到PrePersist
在保存前將項目ID賦值
@MappedSuperclass @EntityListeners(ProjectIdListener::class) @Poko class TenantModel: AuditModel() { var projectId: String? = null }
class ProjectIdListener { @PrePersist fun setProjectId(resultObj: Any) { try { val projectIdProperty = resultObj::class.java.superclass.getDeclaredField("projectId") if (projectIdProperty.type == String::class.java) { projectIdProperty.isAccessible = true projectIdProperty.set(resultObj, ContextUtils.getCurrentProjectId()) } else { } } catch (ex: Exception) { } } }
自定義SQL攔截器,經過實現StatementInspector接口,實現inspect方法便可。不一樣的業務邏輯,實現的邏輯也不同,這裏就不貼代碼了。
注意:
一)、以上是kotlin代碼,IDEA支持Kotlin和Java代碼的互轉。
二)、須要數據隔離的實體,繼承TenantModel類便可,沒有繼承的實體默認爲數據共享。
三)、ContextUtils是自定義獲取上下文的工具類。
到了文章的重點。數據的備份目的是數據遷移和數據的還原。友好的備份格式能夠爲數據遷移減小不少工做量。剛開始以爲這個需求很簡單,MySQL的數據備份作過不少次,也很簡單。但數據備份不只僅是數據恢復,還有數據遷移的功能(A項目下的數據備份後,能夠導入的B項目下)。這下就有意思了。咱們理一理:
一)、數據備份是數據隔離的。A項目數據備份,只能備份A項目下的數據。
二)、備份的數據用於數據恢復。
三)、備份的數據用於數據遷移,以前存在的關聯數據要從新綁定關聯關係。
四)、數據恢復和遷移過程當中,注意重複導入和事務問題。
針對上面的分析,通常都有會三種解決思路:
一)、用MySQL自帶的命令導入和導出。
二)、找已經作好的輪子。(若是有,請麻煩告知一下)
三)、本身實現將數據轉爲JSON數據,再由JSON數據導入的功能。
由於需求三和需求四的特殊性,MySQL自帶的命令很難知足,也沒有合適的輪子。只能本身實現,這樣作也更放心點。
第一步:肯定表的順序。項目之間數據遷移後,須要從新綁定表的關聯關係,優先導入導出沒有外鍵關聯的表。
第二步:遍歷每張表,將數據轉成JSON格式數據一行行寫入到文本文件中。
導出數據僞代碼:
fun exportSystemData(request: HttpServletRequest, response: HttpServletResponse) { // 校驗權限 checkAuthority("導出系統數據") // 獲取當前項目 val currentProjectId = ContextUtils.getCurrentProjectId() val systemFilePath = "${attachmentPath}system${File.separator}$currentProjectId" val file = File(systemFilePath) if (!file.exists()) { file.mkdirs() } // 獲取數據獨立的表名(方便查詢)和類名的全路徑(方便反射) val moreProjectEntityMap = CommonUtils.getMoreProjectEntity() moreProjectEntityMap.remove(CommonUtils.toUnderline(SystemLog::class.simpleName)) moreProjectEntityMap.remove(CommonUtils.toUnderline(AlarmRecord::class.simpleName)) // 生成文件 moreProjectEntityMap.forEach { entry -> var tableFile: FileWriter? = null try { tableFile = FileWriter(File(systemFilePath, "${entry.key}.txt")) dataManagementService.findAll(Class.forName(entry.value)).forEach { tableFile.write("${JSONObject.toJSONString(it)} \n") } } catch (e: Exception) { e.printStackTrace() } finally { tableFile?.let { it.flush() it.close() } } } // 壓縮成一個文件 fileUtil.zip(systemFilePath) file.listFiles().forEach { it.delete() } fileUtil.downloadAttachment("$systemFilePath.zip", response) }
備份後的數據有兩個用途。第一是數據還原;最重要的是數據遷移。將A項目中的配置導入到B項目中,能夠提升用戶的效率。數據還原最簡單,這裏重點介紹數據遷移的思路(可能不太合理)
數據遷移最麻煩的就是新建立後的數據如何從新綁定主外表的關係。其次就是若是導入過程當中失敗,事務的處理問題。爲了處理這兩個問題,我選擇新增一張表維護新舊ID的遷移記錄。每次導入成功後就在表中保存數據。這樣能夠避免重複導入的狀況。也爲新數據從新綁定主外關係作準備。
第一步:解壓上傳後的文件,並按照指定的排序順序讀取解壓後的文件。
第二步:一行行讀取數據,經過反射將JSON格式字符串轉爲對象。遍歷對象的值將舊ID根據數據遷移記錄替換成遷移後的新ID。
第三步:檢擦數據遷移記錄表中是否已經存在遷移記錄,若沒有則插入數據並記錄日誌。
第四步:若數據遷移記錄表中已經存在記錄,則更新數據。
第五步:讀取第二行數據,重複執行。
數據恢復僞代碼
fun importSystemData(file: MultipartFile, request: HttpServletRequest) { checkAuthority("導入系統數據") val currentProjectId = ContextUtils.getCurrentProjectId() val systemFilePath = "${attachmentPath}system" val tempFile = File(systemFilePath, file.originalFilename) val fileOutputStream = FileOutputStream(tempFile) fileOutputStream.write(file.bytes) fileOutputStream.close() // 獲取排序後遷移表 val moreProjectEntityMap = CommonUtils.getMoreProjectEntity() moreProjectEntityMap.remove(CommonUtils.toUnderline(SystemLog::class.simpleName)) val files: MutableMap<String, File> = mutableMapOf() fileUtil.unzip(tempFile.absoluteFile, systemFilePath, "").forEach { files[it!!.nameWithoutExtension] = it } val dataTransferHistories = dataTransferHistoryRepository.findByProjectId(currentProjectId).toMutableList() try { moreProjectEntityMap.keys.forEach { fileName -> val tableFile = files.getOrDefault(fileName, null) ?: return@forEach val entity = Class.forName(moreProjectEntityMap[fileName]) tableFile.forEachLine { dataStr -> val data = JSONObject.parseObject(dataStr, entity) // 獲取對象全部屬性 val fieldMap = CommonUtils.getEntityAllField(data) // 獲取數據遷移的舊ID val id = fieldMap["id"]!!.get(data) as String val dataTransferHistory = dataTransferHistories.find { it.oldId == id } // 從新綁定遷移數據後的id handleEntityData(data, fieldMap, moreProjectEntityMap.values.toList(), dataTransferHistories) fieldMap["projectId"]!!.set(data, currentProjectId) if (null == dataTransferHistory || null == dataManagementService.getByIdElseNull(dataTransferHistory.newId, entity)) { val saved = dataManagementService.create(data, entity) // 綁定舊ID和新ID的關係 val savedId = CommonUtils.getEntityAllField(saved)["id"]!!.get(saved) as String if (null == dataTransferHistory) { dataTransferHistories.add(DataTransferHistory(id, savedId, currentProjectId, fileName)) } } else { fieldMap["id"]!!.set(data, dataTransferHistory.newId) dataManagementService.update(data, entity) } } } } catch (e: Exception) { e.printStackTrace() throw IllegalArgumentException("數據導入失敗") } finally { tempFile.delete() files.values.forEach { it.delete() } recordDataTransferHistory(dataTransferHistories) } } // 記錄數據遷移 private fun recordDataTransferHistory(dataTransferHistories: MutableList<DataTransferHistory>) { dataTransferHistoryRepository.saveAll(dataTransferHistories) } // 從新綁定主外關係表 fun handleEntityData(sourceClass: Any, fieldMap: MutableMap<String, Field>, classPaths: List<String>, dataTransferHistories: MutableList<DataTransferHistory>) { val currentProjectId = ContextUtils.getCurrentProjectId() fieldMap.values.forEach { field -> val classPath = field.type.toString().split(" ").last() // 一對多或多對多關係 if (classPath == "java.util.List") { val listValue = field.get(sourceClass) as List<*> listValue.forEach { listObj -> listObj?.let { changeOldRelId4NewData(it, dataTransferHistories, currentProjectId) } } } // 一對一或多對一關係 if (classPaths.contains(classPath)) { val value = field.get(sourceClass)?: return@forEach changeOldRelId4NewData(value, dataTransferHistories, currentProjectId) } // 字符串ID關聯 if (classPath == "java.lang.String" && null != field.get(sourceClass)) { var oldId = field.get(sourceClass).toString() dataTransferHistories.forEach { oldId = oldId.replace(it.oldId, it.newId) } field.set(sourceClass, oldId) } } } fun changeOldRelId4NewData(data: Any, dataTransferHistories: MutableList<DataTransferHistory>, currentProjectId: String) { val fieldMap = CommonUtils.getEntityAllField(data) fieldMap.values.forEach { field -> if (field.type.toString().contains("java.lang.String") && null != field.get(data)) { var oldId = field.get(data).toString() dataTransferHistories.forEach { oldId = oldId.replace(it.oldId, it.newId) } field.set(data, oldId) } } fieldMap["projectId"]!!.set(data, currentProjectId) }
/** * 數據遷移記錄表 */ @Entity @Table(uniqueConstraints = [UniqueConstraint(columnNames = ["oldId", "projectId"])]) data class DataTransferHistory ( var oldId: String = "", var newId: String = "", var projectId: String = "", var tableName: String = "", var createTime: Instant = Instant.now(), @Id @GenericGenerator(name = "idGenerator", strategy = "uuid") @GeneratedValue(generator = "idGenerator") var id: String = "" )
到這裏就結束了,以上思路僅供參考。
一)、數據備份須要項目獨立二)、經過項目ID 區分備份的數據是用來數據還原仍是數據遷移三)、數據遷移過程當中須要考慮數據重複導入的問題四)、數據遷移過程當中須要從新綁定主外鍵的關聯五)、第三和第四點能夠經過記錄數據遷移表作輔助六)、數據遷移過程儘可能避免刪除操做。避免對其餘項目形成影響。