工作日誌,多租戶模式下的資料備份和遷移

ITDragon龍發表於2019-07-10

工作日誌,多租戶模式下的資料備份和遷移

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

文章目錄

工作日誌,多租戶模式下的資料備份和遷移

多租戶

多租戶是一種軟體架構,在同一臺(組)伺服器上執行單個例項,能為多個租戶提供服務。以實際例子說明,一套能源監控系統,可以為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 區分備份的資料是用來資料還原還是資料遷移
三)、資料遷移過程中需要考慮資料重複匯入的問題
四)、資料遷移過程中需要重新繫結主外來鍵的關聯
五)、第三和第四點可以通過記錄資料遷移表做輔助
六)、資料遷移過程儘量避免刪除操作。避免對其他專案造成影響。

相關文章