工作日志,多租户模式下的数据备份和迁移

存储 存储软件
记录和分享一篇工作中遇到的奇难杂症。目前做的项目是多租户模式。一套系统管理多个项目,用户登录不同的项目加载不同的数据。

 工作日志,多租户模式下的数据备份和迁移

记录和分享一篇工作中遇到的奇难杂症。目前做的项目是多租户模式。一套系统管理多个项目,用户登录不同的项目加载不同的数据。除了一些系统初始化的配置表外,各项目之间数据相互独立。前期选择了共享数据表的隔离方案,为后期的数据迁移挖了一个大坑。这里记录填坑的思路。可能不优雅,仅供参考。

[[272545]]

多租户

多租户是一种软件架构,在同一台(组)服务器上运行单个实例,能为多个租户提供服务。以实际例子说明,一套能源监控系统,可以为A产业园提供服务,也可以为B产业园提供服务。A的管理员登录能源监控系统只会看到A产业园相关的数据。同样的道理,B产业园也是一样。多住户模式最重要的就是数据之间的独立。其最大的局限性在于对租户定制化开发困难很大。适合通用的业务场景。

数据隔离方案

独立数据库

顾名思义,一个租户独享一个数据库,其隔离级别最强,数据安全性最高,数据的备份和恢复最方便。对数据独立性要求很高,数据的扩张性要求较多的租户可以考虑使用。或者钱给的多也可以考虑。毕竟该模式下的硬件成本较高。代码成本较低,Hibernate已经提供DATABASE的实现。

共享数据库、独立 Schema

多个租户共有一个数据库,每个租户拥有属于自己的Schema(Schema表示数据库对象集合,它包含:表,视图,存储过程,索引等等对象)。其隔离级别较强,数据安全性较高,数据的备份和恢复较为麻烦。数据库出了问题会影响到所有租户。Hibernate也提供SCHEMA的实现。

共享数据库、共享 Schema、共享数据表

多个租户共享一个数据库,一个Schema,一张数据表。各租户之间通过字段区分。其隔离级别最低,数据安全性最低,数据的备份和恢复最麻烦(让我哭一分钟😭)。若一张表出现问题会影响到所有租户。其代码工作量也是最多,因为Hibernate(5.0.3版本)并没有支持DISCRIMINATOR模式,目前还只是计划支持。其模式最大的好处就是用最少的服务器支持最多的租户。

业务场景

在我们的能源管理的系统中,多个租户就是多个项目。将需要数据独立的数据表通过ProjectID区分。而一些系统初始化的配置表则可以数据共享。怎么用尽可能少的代码来管理每个租户呢?这里提出我个人的思路。

多租户的实现

第一步:用户登录时获取当前项目,并保存到上下文中。

第二步:通过EntityListeners注解监听,在实体被创建时将当前项目ID保存到数据库中。

第三步:通过自定义拦截器,拦截需要数据隔离的sql语句,重新拼接查询条件。

将当前项目保存到上下文中,不同的安全框架实现的方法也有所不同,实现的方式也多种多样,这里就不贴出代码。

通过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) { 
        } 
    } 

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.

自定义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) 

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.

数据迁移

业务分析

备份后的数据有两个用途。第一是数据还原;最重要的是数据迁移。将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 = "" 
 

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.
  • 59.
  • 60.
  • 61.
  • 62.
  • 63.
  • 64.
  • 65.
  • 66.
  • 67.
  • 68.
  • 69.
  • 70.
  • 71.
  • 72.
  • 73.
  • 74.
  • 75.
  • 76.
  • 77.
  • 78.
  • 79.
  • 80.
  • 81.
  • 82.
  • 83.
  • 84.
  • 85.
  • 86.
  • 87.
  • 88.
  • 89.
  • 90.
  • 91.
  • 92.
  • 93.
  • 94.
  • 95.
  • 96.
  • 97.
  • 98.
  • 99.
  • 100.
  • 101.
  • 102.
  • 103.
  • 104.
  • 105.
  • 106.
  • 107.
  • 108.
  • 109.
  • 110.
  • 111.
  • 112.
  • 113.
  • 114.
  • 115.
  • 116.
  • 117.

到这里就结束了,以上思路仅供参考。

小结

一)、数据备份需要项目独立

二)、通过项目ID 区分备份的数据是用来数据还原还是数据迁移

三)、数据迁移过程中需要考虑数据重复导入的问题

四)、数据迁移过程中需要重新绑定主外键的关联

五)、第三和第四点可以通过记录数据迁移表做辅助

六)、数据迁移过程尽量避免删除操作。避免对其他项目造成影响。

责任编辑:武晓燕 来源: Segmentfault
相关推荐

2015-08-12 15:46:02

SaaS多租户数据存储

2015-04-02 11:04:27

云应用SaaSOFBIZ

2011-03-31 12:17:07

Cacti备份

2017-10-23 21:19:10

数据中心SDN软件定义网络

2023-11-06 08:26:11

Spring微服务架构

2024-04-02 09:01:45

2013-11-26 17:29:43

思科呼叫中心多租户

2011-10-21 12:29:38

IPv6双协议DNS

2023-12-14 12:26:16

SaaS数据库方案

2020-09-15 07:00:00

SaaS架构架构

2019-10-25 14:17:00

边缘计算数据中心多租户数据中心

2020-10-16 08:57:51

云平台之多租户的实践

2013-04-15 09:52:13

程序员

2020-07-30 09:44:26

数据中心IT技术

2023-06-07 13:50:00

SaaS多租户系统

2022-05-13 07:26:28

策略模式设计模式

2022-02-23 08:55:06

数据迁移分库分表数据库

2021-03-17 08:11:21

SQL工作日数据

2022-01-12 17:39:16

Spring多租户数据

2010-05-21 12:52:55

点赞
收藏

51CTO技术栈公众号