工作日志,多租戶模式下的數(shù)據(jù)備份和遷移
工作日志,多租戶模式下的數(shù)據(jù)備份和遷移
記錄和分享一篇工作中遇到的奇難雜癥。目前做的項(xiàng)目是多租戶模式。一套系統(tǒng)管理多個(gè)項(xiàng)目,用戶登錄不同的項(xiàng)目加載不同的數(shù)據(jù)。除了一些系統(tǒng)初始化的配置表外,各項(xiàng)目之間數(shù)據(jù)相互獨(dú)立。前期選擇了共享數(shù)據(jù)表的隔離方案,為后期的數(shù)據(jù)遷移挖了一個(gè)大坑。這里記錄填坑的思路。可能不優(yōu)雅,僅供參考。
多租戶
多租戶是一種軟件架構(gòu),在同一臺(tái)(組)服務(wù)器上運(yùn)行單個(gè)實(shí)例,能為多個(gè)租戶提供服務(wù)。以實(shí)際例子說(shuō)明,一套能源監(jiān)控系統(tǒng),可以為A產(chǎn)業(yè)園提供服務(wù),也可以為B產(chǎn)業(yè)園提供服務(wù)。A的管理員登錄能源監(jiān)控系統(tǒng)只會(huì)看到A產(chǎn)業(yè)園相關(guān)的數(shù)據(jù)。同樣的道理,B產(chǎn)業(yè)園也是一樣。多住戶模式最重要的就是數(shù)據(jù)之間的獨(dú)立。其最大的局限性在于對(duì)租戶定制化開(kāi)發(fā)困難很大。適合通用的業(yè)務(wù)場(chǎng)景。
數(shù)據(jù)隔離方案
獨(dú)立數(shù)據(jù)庫(kù)
顧名思義,一個(gè)租戶獨(dú)享一個(gè)數(shù)據(jù)庫(kù),其隔離級(jí)別最強(qiáng),數(shù)據(jù)安全性最高,數(shù)據(jù)的備份和恢復(fù)最方便。對(duì)數(shù)據(jù)獨(dú)立性要求很高,數(shù)據(jù)的擴(kuò)張性要求較多的租戶可以考慮使用。或者錢給的多也可以考慮。畢竟該模式下的硬件成本較高。代碼成本較低,Hibernate已經(jīng)提供DATABASE的實(shí)現(xiàn)。
共享數(shù)據(jù)庫(kù)、獨(dú)立 Schema
多個(gè)租戶共有一個(gè)數(shù)據(jù)庫(kù),每個(gè)租戶擁有屬于自己的Schema(Schema表示數(shù)據(jù)庫(kù)對(duì)象集合,它包含:表,視圖,存儲(chǔ)過(guò)程,索引等等對(duì)象)。其隔離級(jí)別較強(qiáng),數(shù)據(jù)安全性較高,數(shù)據(jù)的備份和恢復(fù)較為麻煩。數(shù)據(jù)庫(kù)出了問(wèn)題會(huì)影響到所有租戶。Hibernate也提供SCHEMA的實(shí)現(xiàn)。
共享數(shù)據(jù)庫(kù)、共享 Schema、共享數(shù)據(jù)表
多個(gè)租戶共享一個(gè)數(shù)據(jù)庫(kù),一個(gè)Schema,一張數(shù)據(jù)表。各租戶之間通過(guò)字段區(qū)分。其隔離級(jí)別最低,數(shù)據(jù)安全性最低,數(shù)據(jù)的備份和恢復(fù)最麻煩(讓我哭一分鐘😭)。若一張表出現(xiàn)問(wèn)題會(huì)影響到所有租戶。其代碼工作量也是最多,因?yàn)镠ibernate(5.0.3版本)并沒(méi)有支持DISCRIMINATOR模式,目前還只是計(jì)劃支持。其模式最大的好處就是用最少的服務(wù)器支持最多的租戶。
業(yè)務(wù)場(chǎng)景
在我們的能源管理的系統(tǒng)中,多個(gè)租戶就是多個(gè)項(xiàng)目。將需要數(shù)據(jù)獨(dú)立的數(shù)據(jù)表通過(guò)ProjectID區(qū)分。而一些系統(tǒng)初始化的配置表則可以數(shù)據(jù)共享。怎么用盡可能少的代碼來(lái)管理每個(gè)租戶呢?這里提出我個(gè)人的思路。
多租戶的實(shí)現(xiàn)
第一步:用戶登錄時(shí)獲取當(dāng)前項(xiàng)目,并保存到上下文中。
第二步:通過(guò)EntityListeners注解監(jiān)聽(tīng),在實(shí)體被創(chuàng)建時(shí)將當(dāng)前項(xiàng)目ID保存到數(shù)據(jù)庫(kù)中。
第三步:通過(guò)自定義攔截器,攔截需要數(shù)據(jù)隔離的sql語(yǔ)句,重新拼接查詢條件。
將當(dāng)前項(xiàng)目保存到上下文中,不同的安全框架實(shí)現(xiàn)的方法也有所不同,實(shí)現(xiàn)的方式也多種多樣,這里就不貼出代碼。
通過(guò)EntityListeners注解可以對(duì)實(shí)體屬性變化的跟蹤,它提供了保存前,保存后,更新前,更新后,刪除前,刪除后等狀態(tài),就像是攔截器一樣。這里我們可以用到PrePersist 在保存前將項(xiàng)目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攔截器,通過(guò)實(shí)現(xiàn)StatementInspector接口,實(shí)現(xiàn)inspect方法即可。不同的業(yè)務(wù)邏輯,實(shí)現(xiàn)的邏輯也不一樣,這里就不貼代碼了。
注意:
一)、以上是kotlin代碼,IDEA支持Kotlin和Java代碼的互轉(zhuǎn)。
二)、需要數(shù)據(jù)隔離的實(shí)體,繼承TenantModel類即可,沒(méi)有繼承的實(shí)體默認(rèn)為數(shù)據(jù)共享。
三)、ContextUtils是自定義獲取上下文的工具類。
數(shù)據(jù)備份
業(yè)務(wù)分析
到了文章的重點(diǎn)。數(shù)據(jù)的備份目的是數(shù)據(jù)遷移和數(shù)據(jù)的還原。友好的備份格式可以為數(shù)據(jù)遷移減少很多工作量。剛開(kāi)始覺(jué)得這個(gè)需求很簡(jiǎn)單,MySQL的數(shù)據(jù)備份做過(guò)很多次,也很簡(jiǎn)單。但數(shù)據(jù)備份不僅僅是數(shù)據(jù)恢復(fù),還有數(shù)據(jù)遷移的功能(A項(xiàng)目下的數(shù)據(jù)備份后,可以導(dǎo)入的B項(xiàng)目下)。這下就有意思了。我們理一理:
一)、數(shù)據(jù)備份是數(shù)據(jù)隔離的。A項(xiàng)目數(shù)據(jù)備份,只能備份A項(xiàng)目下的數(shù)據(jù)。
二)、備份的數(shù)據(jù)用于數(shù)據(jù)恢復(fù)。
三)、備份的數(shù)據(jù)用于數(shù)據(jù)遷移,之前存在的關(guān)聯(lián)數(shù)據(jù)要重新綁定關(guān)聯(lián)關(guān)系。
四)、數(shù)據(jù)恢復(fù)和遷移過(guò)程中,注意重復(fù)導(dǎo)入和事務(wù)問(wèn)題。
針對(duì)上面的分析,一般都有會(huì)三種解決思路:
一)、用MySQL自帶的命令導(dǎo)入和導(dǎo)出。
二)、找已經(jīng)做好的輪子。(如果有,請(qǐng)麻煩告知一下)
三)、自己實(shí)現(xiàn)將數(shù)據(jù)轉(zhuǎn)為JSON數(shù)據(jù),再由JSON數(shù)據(jù)導(dǎo)入的功能。
因?yàn)樾枨笕托枨笏牡奶厥庑裕琈ySQL自帶的命令很難滿足,也沒(méi)有合適的輪子。只能自己實(shí)現(xiàn),這樣做也更放心點(diǎn)。
實(shí)現(xiàn)流程
第一步:確定表的順序。項(xiàng)目之間數(shù)據(jù)遷移后,需要重新綁定表的關(guān)聯(lián)關(guān)系,優(yōu)先導(dǎo)入導(dǎo)出沒(méi)有外鍵關(guān)聯(lián)的表。
第二步:遍歷每張表,將數(shù)據(jù)轉(zhuǎn)成JSON格式數(shù)據(jù)一行行寫(xiě)入到文本文件中。
導(dǎo)出數(shù)據(jù)偽代碼:
- fun exportSystemData(request: HttpServletRequest, response: HttpServletResponse) {
- // 校驗(yàn)權(quán)限
- checkAuthority("導(dǎo)出系統(tǒng)數(shù)據(jù)")
- // 獲取當(dāng)前項(xiàng)目
- val currentProjectId = ContextUtils.getCurrentProjectId()
- val systemFilePath = "${attachmentPath}system${File.separator}$currentProjectId"
- val file = File(systemFilePath)
- if (!file.exists()) {
- file.mkdirs()
- }
- // 獲取數(shù)據(jù)獨(dú)立的表名(方便查詢)和類名的全路徑(方便反射)
- 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()
- }
- }
- }
- // 壓縮成一個(gè)文件
- fileUtil.zip(systemFilePath)
- file.listFiles().forEach { it.delete() }
- fileUtil.downloadAttachment("$systemFilePath.zip", response)
- }
數(shù)據(jù)遷移
業(yè)務(wù)分析
備份后的數(shù)據(jù)有兩個(gè)用途。第一是數(shù)據(jù)還原;最重要的是數(shù)據(jù)遷移。將A項(xiàng)目中的配置導(dǎo)入到B項(xiàng)目中,可以提高用戶的效率。數(shù)據(jù)還原最簡(jiǎn)單,這里重點(diǎn)介紹數(shù)據(jù)遷移的思路(可能不太合理)
數(shù)據(jù)遷移最麻煩的就是新創(chuàng)建后的數(shù)據(jù)如何重新綁定主外表的關(guān)系。其次就是如果導(dǎo)入過(guò)程中失敗,事務(wù)的處理問(wèn)題。為了處理這兩個(gè)問(wèn)題,我選擇新增一張表維護(hù)新舊ID的遷移記錄。每次導(dǎo)入成功后就在表中保存數(shù)據(jù)。這樣可以避免重復(fù)導(dǎo)入的情況。也為新數(shù)據(jù)重新綁定主外關(guān)系做準(zhǔn)備。
實(shí)現(xiàn)步驟
第一步:解壓上傳后的文件,并按照指定的排序順序讀取解壓后的文件。
第二步:一行行讀取數(shù)據(jù),通過(guò)反射將JSON格式字符串轉(zhuǎn)為對(duì)象。遍歷對(duì)象的值將舊ID根據(jù)數(shù)據(jù)遷移記錄替換成遷移后的新ID。
第三步:檢擦數(shù)據(jù)遷移記錄表中是否已經(jīng)存在遷移記錄,若沒(méi)有則插入數(shù)據(jù)并記錄日志。
第四步:若數(shù)據(jù)遷移記錄表中已經(jīng)存在記錄,則更新數(shù)據(jù)。
第五步:讀取第二行數(shù)據(jù),重復(fù)執(zhí)行。
數(shù)據(jù)恢復(fù)偽代碼
- fun importSystemData(file: MultipartFile, request: HttpServletRequest) {
- checkAuthority("導(dǎo)入系統(tǒng)數(shù)據(jù)")
- 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)
- // 獲取對(duì)象所有屬性
- val fieldMap = CommonUtils.getEntityAllField(data)
- // 獲取數(shù)據(jù)遷移的舊ID
- val id = fieldMap["id"]!!.get(data) as String
- val dataTransferHistory = dataTransferHistories.find { it.oldId == id }
- // 重新綁定遷移數(shù)據(jù)后的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的關(guān)系
- 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("數(shù)據(jù)導(dǎo)入失敗")
- } finally {
- tempFile.delete()
- files.values.forEach { it.delete() }
- recordDataTransferHistory(dataTransferHistories)
- }
- }
- // 記錄數(shù)據(jù)遷移
- private fun recordDataTransferHistory(dataTransferHistories: MutableList<DataTransferHistory>) {
- dataTransferHistoryRepository.saveAll(dataTransferHistories)
- }
- // 重新綁定主外關(guān)系表
- 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()
- // 一對(duì)多或多對(duì)多關(guān)系
- if (classPath == "java.util.List") {
- val listValue = field.get(sourceClass) as List<*>
- listValue.forEach { listObj ->
- listObj?.let { changeOldRelId4NewData(it, dataTransferHistories, currentProjectId) }
- }
- }
- // 一對(duì)一或多對(duì)一關(guān)系
- if (classPaths.contains(classPath)) {
- val value = field.get(sourceClass)?: return@forEach
- changeOldRelId4NewData(value, dataTransferHistories, currentProjectId)
- }
- // 字符串ID關(guān)聯(lián)
- 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)
- }
- /**
- * 數(shù)據(jù)遷移記錄表
- */
- @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 = ""
- )
到這里就結(jié)束了,以上思路僅供參考。
小結(jié)
一)、數(shù)據(jù)備份需要項(xiàng)目獨(dú)立
二)、通過(guò)項(xiàng)目ID 區(qū)分備份的數(shù)據(jù)是用來(lái)數(shù)據(jù)還原還是數(shù)據(jù)遷移
三)、數(shù)據(jù)遷移過(guò)程中需要考慮數(shù)據(jù)重復(fù)導(dǎo)入的問(wèn)題
四)、數(shù)據(jù)遷移過(guò)程中需要重新綁定主外鍵的關(guān)聯(lián)
五)、第三和第四點(diǎn)可以通過(guò)記錄數(shù)據(jù)遷移表做輔助
六)、數(shù)據(jù)遷移過(guò)程盡量避免刪除操作。避免對(duì)其他項(xiàng)目造成影響。