工作日志,多租戶模式下的數據備份和遷移


工作日志,多租戶模式下的數據備份和遷移

記錄和分享一篇工作中遇到的奇難雜症。目前做的項目是多租戶模式。一套系統管理多個項目,用戶登錄不同的項目加載不同的數據。除了一些系統初始化的配置表外,各項目之間數據相互獨立。前期選擇了共享數據表的隔離方案,為后期的數據遷移挖了一個大坑。這里記錄填坑的思路。可能不優雅,僅供參考。

文章目錄

多租戶

多租戶是一種軟件架構,在同一台(組)服務器上運行單個實例,能為多個租戶提供服務。以實際例子說明,一套能源監控系統,可以為A產業園提供服務,也可以為B產業園提供服務。A的管理員登錄能源監控系統只會看到A產業園相關的數據。同樣的道理,B產業園也是一樣。多住戶模式最重要的就是數據之間的獨立。其最大的局限性在於對租戶定制化開發困難很大。比較適合通用的業務場景。

數據隔離方案

獨立數據庫

顧名思義,一個租戶獨享一個數據庫,其隔離級別最強,數據安全性最高,數據的備份和恢復最方便。對數據獨立性要求很高,數據的擴張性要求較多的租戶可以考慮使用。或者錢給的多也可以考慮。畢竟該模式下的硬件成本較高。代碼成本較低,Hibernate已經提供DATABASE的實現。

共享數據庫、獨立 Schema

多個租戶共有一個數據庫,每個租戶擁有屬於自己的Schema(Schema表示數據庫對象集合,它包含:表,視圖,存儲過程,索引等等對象)。其隔離級別較強,數據安全性較高,數據的備份和恢復較為麻煩。數據庫出了問題會影響到所有租戶。Hibernate也提供SCHEMA的實現。

共享數據庫、共享 Schema、共享數據表

多個租戶共享一個數據庫,一個Schema,一張數據表。各租戶之間通過字段區分。其隔離級別最低,數據安全性最低,數據的備份和恢復最麻煩(讓我哭一分鍾😭)。若一張表出現問題會影響到所有租戶。其代碼工作量也是最多,因為Hibernate(5.0.3版本)並沒有支持DISCRIMINATOR模式,目前還只是計划支持。其模式最大的好處就是用最少的服務器支持最多的租戶。

業務場景

在我們的能源管理的系統中,多個租戶就是多個項目。將需要數據獨立的數據表通過ProjectID區分。而一些系統初始化的配置表則可以數據共享。怎么用盡可能少的代碼來管理每個租戶呢?這里提出我個人的思路。

多租戶實現

第一步:用戶登錄時獲取當前項目,並保存到上下文中。

第二步:通過EntityListeners注解監聽,在實體被創建時將當前項目ID保存到數據庫中。

第三步:通過自定義攔截器,攔截需要數據隔離的sql語句,重新拼接查詢條件。

保存用戶登錄信息

將當前項目保存到上下文中,不同的安全框架實現的方法也有所不同,實現的方式也多種多樣,這里就不貼出代碼。

數據保存前設置當前項目ID

通過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語句

自定義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 區分備份的數據是用來數據還原還是數據遷移
三)、數據遷移過程中需要考慮數據重復導入的問題
四)、數據遷移過程中需要重新綁定主外鍵的關聯
五)、第三和第四點可以通過記錄數據遷移表做輔助
六)、數據遷移過程盡量避免刪除操作。避免對其他項目造成影響。


免責聲明!

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



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