工做日志,多租户模式下的数据备份和迁移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 区分备份的数据是用来数据还原仍是数据迁移 三)、数据迁移过程当中须要考虑数据重复导入的问题 四)、数据迁移过程当中须要从新绑定主外键的关联 五)、第三和第四点能够经过记录数据迁移表作辅助 六)、数据迁移过程尽可能避免删除操做。避免对其余项目形成影响。