
能力说明:
精通JVM运行机制,包括类生命、内存模型、垃圾回收及JVM常见参数;能够熟练使用Runnable接口创建线程和使用ExecutorService并发执行任务、识别潜在的死锁线程问题;能够使用Synchronized关键字和atomic包控制线程的执行顺序,使用并行Fork/Join框架;能过开发使用原始版本函数式接口的代码。
暂时未有相关云产品技术能力~
阿里云技能认证
详细说明最近连着几天晚上在家总是接到一些奇奇怪怪的电话,“哥,你是 xxx 吧,我们这里是 xxx 高端男士私人会所...”,握草,我先是一愣,然后狠狠的骂了回去。一脸傲娇的转过头,面带微笑稍显谄媚:老婆你听我说,我真的啥也没干,你要相信我! 啪~ 过后揉揉脸细想想,肯定是哪个不道德的网站,又把我的个人信息给卖了,现在的人上网都处于一个裸奔的状态,个人信息已不再属于个人,时下这种事好像也见怪不怪了,不过,出现这种事大多是有内鬼。 而作为开发者的我们,能做的就是尽量避免经我们手的用户数据泄露,那今天就来讲讲互联网中内部防止隐私数据泄露的手段-数据脱敏。 什么是数据脱敏 先来看看什么是数据脱敏?数据脱敏也叫数据的去隐私化,在我们给定脱敏规则和策略的情况下,对敏感数据比如 手机号、银行卡号 等信息,进行转换或者修改的一种技术手段,防止敏感数据直接在不可靠的环境下使用。 像政府、医疗行业、金融机构、移动运营商是比较早开始应用数据脱敏的,因为他们所掌握的都是用户最核心的私密数据,如果泄露后果是不可估量的。 数据脱敏的应用在生活中是比较常见的,比如我们在淘宝买东西订单详情中,商家账户信息会被用 * 遮挡,保障了商户隐私不泄露,这就是一种数据脱敏方式。 数据脱敏又分为静态数据脱敏(SDM)和 动态数据脱敏(DDM): 静态数据脱敏 静态数据脱敏(SDM):适用于将数据抽取出生产环境脱敏后分发至测试、开发、培训、数据分析等场景。 有时我们可能需要将生产环境的数据 copy 到测试、开发库中,以此来排查问题或进行数据分析,但出于安全考虑又不能将敏感数据存储于非生产环境,此时就要把敏感数据从生产环境脱敏完毕之后再在非生产环境使用。 这样脱敏后的数据与生产环境隔离,满足业务需要的同时又保障了生产数据的安全。 如上图所示,将用户的真实 姓名、手机号、身份证、银行卡号 通过 替换、无效化、乱序、对称加密 等方案进行脱敏改造。 动态数据脱敏 动态数据脱敏(DDM):一般用在生产环境,访问敏感数据时实时进行脱敏,因为有时在不同情况下对于同一敏感数据的读取,需要做不同级别的脱敏处理,例如:不同角色、不同权限所执行的脱敏方案会不同。 注意:在抹去数据中的敏感内容同时,也需要保持原有的数据特征、业务规则和数据关联性,保证我们在开发、测试以及数据分析类业务不会受到脱敏的影响,使脱敏前后的数据一致性和有效性。总之一句话:你爱怎么脱就怎么脱,别影响我使用就行。 数据脱敏方案 数据脱敏系统可以按照不同业务场景自行定义和编写脱敏规则,可以针对库表的某个敏感字段,进行数据的不落地脱敏。 数据脱敏的方式有很多种,接下来以下图数据为准一个一个的演示每种方案。 1、无效化 无效化方案在处理待脱敏的数据时,通过对字段数据值进行 截断、加密、隐藏 等方式让敏感数据脱敏,使其不再具有利用价值。一般采用特殊字符(*等)代替真值,这种隐藏敏感数据的方法简单,但缺点是用户无法得知原数据的格式,如果想要获取完整信息,要让用户授权查询。 比如我们将身份证号用 替换真实数字就变成了 "220724 * 3523",非常简单。 2、随机值 随机值替换,字母变为随机字母,数字变为随机数字,文字随机替换文字的方式来改变敏感数据,这种方案的优点在于可以在一定程度上保留原有数据的格式,往往这种方法用户不易察觉的。 我们看到 name 和 idnumber 字段进行了随机化脱敏,而名字姓、氏随机化稍有特殊,需要有对应姓氏字典数据支持。 3、数据替换 数据替换与前边的无效化方式比较相似,不同的是这里不以特殊字符进行遮挡,而是用一个设定的虚拟值替换真值。比如说我们将手机号统一设置成 “13651300000”。 4、对称加密 对称加密是一种特殊的可逆脱敏方法,通过加密密钥和算法对敏感数据进行加密,密文格式与原始数据在逻辑规则上一致,通过密钥解密可以恢复原始数据,要注意的就是密钥的安全性。 5、平均值 平均值方案经常用在统计场景,针对数值型数据,我们先计算它们的均值,然后使脱敏后的值在均值附近随机分布,从而保持数据的总和不变。 对价格字段 price 做平均值处理后,字段总金额不变,但脱敏后的字段值都在均值 60 附近。 6、偏移和取整 这种方式通过随机移位改变数字数据,偏移取整在保持了数据的安全性的同时保证了范围的大致真实性,比之前几种方案更接近真实数据,在大数据分析场景中意义比较大。 比如下边的日期字段create_time中 2020-12-08 15:12:25 变为 2018-01-02 15:00:00。 数据脱敏规则在实际应用中往往都是多种方案配合使用,以此来达到更高的安全级别。 总结 无论是静态脱敏还是动态脱敏,其最终都是为了防止组织内部对隐私数据的滥用,防止隐私数据在未经脱敏的情况下从组织流出。所以作为一个程序员不泄露数据是最起码的操守。 整理了几百本各类技术电子书,有需要的同学可以,关注同名公号 「程序员内点事」 回复 「 666 」 自取。还有想要加技术群的同学可以加我好友,和大佬侃技术、不定期内推,程序员的内点事这都有。
好长时间没发文了,最近着实是有点忙,当爹的第 43 天,身心疲惫。这又赶上年底,公司冲 KPI 强制技术部加班到十点,晚上孩子隔两三个小时一醒,基本没睡囫囵觉的机会,天天处于迷糊的状态,孩子还时不时起一些奇奇怪怪的疹子,总让人担惊受怕的。 本就不多的写文章时间又被无限分割,哎~ 打工人真是太难了。 本来不知道写点啥,正好手头有个新项目试着用阿里的 Seata 中间件做分布式事务,那就做一个实践分享吧! 介绍 Seata 之前在简单回顾一下分布式事务的基本概念。 分布式事务的产生 我们先看看百度上对于分布式事务的定义:分布式事务是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。 额~ 有点抽象,简单的画个图好理解一下,拿下单减库存、扣余额来说举例: 当系统的体量很小时,单体架构完全可以满足现有业务需求,所有的业务共用一个数据库,整个下单流程或许只用在一个方法里同一个事务下操作数据库即可。此时做到所有操作要么全部提交 或 要么全部回滚很容易。 分库分表、SOA 可随着业务量的不断增长,单体架构渐渐扛不住巨大的流量,此时就需要对数据库、表做 分库分表处理,将应用 SOA 服务化拆分。也就产生了订单中心、用户中心、库存中心等,由此带来的问题就是业务间相互隔离,每个业务都维护着自己的数据库,数据的交换只能进行 RPC 调用。 当用户再次下单时,需同时对订单库 order、库存库 storage、用户库 account 进行操作,可此时我们只能保证自己本地的数据一致性,无法保证调用其他服务的操作是否成功,所以为了保证整个下单流程的数据一致性,就需要分布式事务介入。 Seata 优势 实现分布式事务的方案比较多,常见的比如基于 XA 协议的 2PC、3PC,基于业务层的 TCC,还有应用消息队列 + 消息表实现的最终一致性方案,还有今天要说的 Seata 中间件,下边看看各个方案的优缺点。 2PC 基于 XA 协议实现的分布式事务,XA 协议中分为两部分:事务管理器和本地资源管理器。其中本地资源管理器往往由数据库实现,比如 Oracle、MYSQL 这些数据库都实现了 XA 接口,而事务管理器则作为一个全局的调度者。 两阶段提交(2PC),对业务侵⼊很小,它最⼤的优势就是对使⽤⽅透明,用户可以像使⽤本地事务⼀样使⽤基于 XA 协议的分布式事务,能够严格保障事务 ACID 特性。 可 2PC的缺点也是显而易见,它是一个强一致性的同步阻塞协议,事务执⾏过程中需要将所需资源全部锁定,也就是俗称的 刚性事务。所以它比较适⽤于执⾏时间确定的短事务,整体性能比较差。 一旦事务协调者宕机或者发生网络抖动,会让参与者一直处于锁定资源的状态或者只有一部分参与者提交成功,导致数据的不一致。因此,在⾼并发性能⾄上的场景中,基于 XA 协议的分布式事务并不是最佳选择。 3PC 三段提交(3PC)是二阶段提交(2PC)的一种改进版本 ,为解决两阶段提交协议的阻塞问题,上边提到两段提交,当协调者崩溃时,参与者不能做出最后的选择,就会一直保持阻塞锁定资源。 2PC 中只有协调者有超时机制,3PC 在协调者和参与者中都引入了超时机制,协调者出现故障后,参与者就不会一直阻塞。而且在第一阶段和第二阶段中又插入了一个准备阶段(如下图,看着有点啰嗦),保证了在最后提交阶段之前各参与节点的状态是一致的。 虽然 3PC 用超时机制,解决了协调者故障后参与者的阻塞问题,但与此同时却多了一次网络通信,性能上反而变得更差,也不太推荐。 TCC 所谓的 TCC 编程模式,也是两阶段提交的一个变种,不同的是 TCC 为在业务层编写代码实现的两阶段提交。TCC 分别指 Try、Confirm、Cancel ,一个业务操作要对应的写这三个方法。 以下单扣库存为例,Try 阶段去占库存,Confirm 阶段则实际扣库存,如果库存扣减失败 Cancel 阶段进行回滚,释放库存。 TCC 不存在资源阻塞的问题,因为每个方法都直接进行事务的提交,一旦出现异常通过则 Cancel 来进行回滚补偿,这也就是常说的补偿性事务。 原本一个方法,现在却需要三个方法来支持,可以看到 TCC 对业务的侵入性很强,而且这种模式并不能很好地被复用,会导致开发量激增。还要考虑到网络波动等原因,为保证请求一定送达都会有重试机制,所以考虑到接口的幂等性。 消息事务(最终一致性) 消息事务其实就是基于消息中间件的两阶段提交,将本地事务和发消息放在同一个事务里,保证本地操作和发送消息同时成功。下单扣库存原理图: 订单系统向 MQ 发送一条预备扣减库存消息,MQ 保存预备消息并返回成功 ACK 接收到预备消息执行成功 ACK,订单系统执行本地下单操作,为防止消息发送成功而本地事务失败,订单系统会实现 MQ 的回调接口,其内不断的检查本地事务是否执行成功,如果失败则 rollback 回滚预备消息;成功则对消息进行最终 commit 提交。 库存系统消费扣减库存消息,执行本地事务,如果扣减失败,消息会重新投,一旦超出重试次数,则本地表持久化失败消息,并启动定时任务做补偿。 基于消息中间件的两阶段提交方案,通常用在高并发场景下使用,牺牲数据的强一致性换取性能的大幅提升,不过实现这种方式的成本和复杂度是比较高的,还要看实际业务情况。 Seata Seata 也是从两段提交演变而来的一种分布式事务解决方案,提供了 AT、TCC、SAGA 和 XA 等事务模式,这里重点介绍 AT模式。 既然 Seata 是两段提交,那我们看看它在每个阶段都做了点啥?下边我们还以下单扣库存、扣余额举例。 先介绍 Seata 分布式事务的几种角色: Transaction Coordinator(TC): 全局事务协调者,用来协调全局事务和各个分支事务(不同服务)的状态, 驱动全局事务和各个分支事务的回滚或提交。 Transaction Manager™ : 事务管理者,业务层中用来开启/提交/回滚一个整体事务(在调用服务的方法中用注解开启事务)。 Resource Manager(RM): 资源管理者,一般指业务数据库代表了一个分支事务(Branch Transaction),管理分支事务与 TC 进行协调注册分支事务并且汇报分支事务的状态,驱动分支事务的提交或回滚。 Seata 实现分布式事务,设计了一个关键角色 UNDO_LOG (回滚日志记录表),我们在每个应用分布式事务的业务库中创建这张表,这个表的核心作用就是,将业务数据在更新前后的数据镜像组织成回滚日志,备份在 UNDO_LOG 表中,以便业务异常能随时回滚。 第一个阶段 比如:下边我们更新 user 表的 name 字段。 update user set name = '小富最帅' where name = '程序员内点事' 首先 Seata 的 JDBC 数据源代理通过对业务 SQL 解析,提取 SQL 的元数据,也就是得到 SQL 的类型(UPDATE),表(user),条件(where name = '程序员内点事')等相关的信息。 先查询数据前镜像,根据解析得到的条件信息,生成查询语句,定位一条数据。 select name from user where name = '程序员内点事' 紧接着执行业务 SQL,根据前镜像数据主键查询出后镜像数据 select name from user where id = 1 把业务数据在更新前后的数据镜像组织成回滚日志,将业务数据的更新和回滚日志在同一个本地事务中提交,分别插入到业务表和 UNDO_LOG 表中。 回滚记录数据格式如下:包括 afterImage 前镜像、beforeImage 后镜像、 branchId 分支事务ID、xid 全局事务ID { "branchId":641789253, "xid":"xid:xxx", "undoItems":[ { "afterImage":{ "rows":[ { "fields":[ { "name":"id", "type":4, "value":1 } ] } ], "tableName":"product" }, "beforeImage":{ "rows":[ { "fields":[ { "name":"id", "type":4, "value":1 } ] } ], "tableName":"product" }, "sqlType":"UPDATE" } ] } 这样就可以保证,任何提交的业务数据的更新一定有相应的回滚日志。 在本地事务提交前,各分支事务需向 全局事务协调者 TC 注册分支 ( Branch Id) ,为要修改的记录申请 全局锁 ,要为这条数据加锁,利用 SELECT FOR UPDATE 语句。而如果一直拿不到锁那就需要回滚本地事务。TM 开启事务后会生成全局唯一的 XID,会在各个调用的服务间进行传递。 有了这样的机制,本地事务分支(Branch Transaction)便可以在全局事务的第一阶段提交,并马上释放本地事务锁定的资源。相比于传统的 XA 事务在第二阶段释放资源,Seata 降低了锁范围提高效率,即使第二阶段发生异常需要回滚,也可以快速 从UNDO_LOG 表中找到对应回滚数据并反解析成 SQL 来达到回滚补偿。 最后本地事务提交,业务数据的更新和前面生成的 UNDO LOG 数据一并提交,并将本地事务提交的结果上报给全局事务协调者 TC。 第二个阶段 第二阶段是根据各分支的决议做提交或回滚: 如果决议是全局提交,此时各分支事务已提交并成功,这时 全局事务协调者(TC) 会向分支发送第二阶段的请求。收到 TC 的分支提交请求,该请求会被放入一个异步任务队列中,并马上返回提交成功结果给 TC。异步队列中会异步和批量地根据 Branch ID 查找并删除相应 UNDO LOG 回滚记录。 如果决议是全局回滚,过程比全局提交麻烦一点,RM 服务方收到 TC 全局协调者发来的回滚请求,通过 XID 和 Branch ID 找到相应的回滚日志记录,通过回滚记录生成反向的更新 SQL 并执行,以完成分支的回滚。 注意:这里删除回滚日志记录操作,一定是在本地业务事务执行之后 上边说了几种分布式事务各自的优缺点,下边实践一下分布式事务中间 Seata 感受一下。 Seata 实践 Seata 是一个需独立部署的中间件,所以先搭 Seata Server,这里以最新的 seata-server-1.4.0 版本为例,下载地址:https://seata.io/en-us/blog/download.html 解压后的文件我们只需要关心 \seata\conf 目录下的 file.conf 和 registry.conf 文件。 Seata Server file.conf file.conf 文件用于配置持久化事务日志的模式,目前提供 file、db、redis 三种方式。 注意:在选择 db 方式后,需要在对应数据库创建 globalTable(持久化全局事务)、branchTable(持久化各提交分支的事务)、 lockTable(持久化各分支锁定资源事务)三张表。 -- the table to store GlobalSession data -- 持久化全局事务 CREATE TABLE IF NOT EXISTS `global_table` ( `xid` VARCHAR(128) NOT NULL, `transaction_id` BIGINT, `status` TINYINT NOT NULL, `application_id` VARCHAR(32), `transaction_service_group` VARCHAR(32), `transaction_name` VARCHAR(128), `timeout` INT, `begin_time` BIGINT, `application_data` VARCHAR(2000), `gmt_create` DATETIME, `gmt_modified` DATETIME, PRIMARY KEY (`xid`), KEY `idx_gmt_modified_status` (`gmt_modified`, `status`), KEY `idx_transaction_id` (`transaction_id`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8; -- the table to store BranchSession data -- 持久化各提交分支的事务 CREATE TABLE IF NOT EXISTS `branch_table` ( `branch_id` BIGINT NOT NULL, `xid` VARCHAR(128) NOT NULL, `transaction_id` BIGINT, `resource_group_id` VARCHAR(32), `resource_id` VARCHAR(256), `branch_type` VARCHAR(8), `status` TINYINT, `client_id` VARCHAR(64), `application_data` VARCHAR(2000), `gmt_create` DATETIME(6), `gmt_modified` DATETIME(6), PRIMARY KEY (`branch_id`), KEY `idx_xid` (`xid`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8; -- the table to store lock data -- 持久化每个分支锁表事务 CREATE TABLE IF NOT EXISTS `lock_table` ( `row_key` VARCHAR(128) NOT NULL, `xid` VARCHAR(96), `transaction_id` BIGINT, `branch_id` BIGINT NOT NULL, `resource_id` VARCHAR(256), `table_name` VARCHAR(32), `pk` VARCHAR(36), `gmt_create` DATETIME, `gmt_modified` DATETIME, PRIMARY KEY (`row_key`), KEY `idx_branch_id` (`branch_id`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8; registry.conf registry.conf 文件设置 注册中心 和 配置中心: 目前注册中心支持 nacos 、eureka、redis、zk、consul、etcd3、sofa 七种,这里我使用的 eureka作为注册中心 ; 配置中心支持 nacos 、apollo、zk、consul、etcd3 五种方式。 配置完以后在 \seata\bin 目录下启动 seata-server 即可,到这 Seata 的服务端就搭建好了。 Seata Client Seata Server 环境搭建完,接下来我们新建三个服务 order-server(下单服务)、storage-server(扣减库存服务)、account-server(账户金额服务),分别服务注册到 eureka。 每个服务的大体核心配置如下: spring: application: name: storage-server cloud: alibaba: seata: tx-service-group: my_test_tx_group datasource: driver-class-name: com.mysql.jdbc.Driver url: jdbc:mysql://47.93.6.1:3306/seat-storage username: root password: root # eureka 注册中心 eureka: client: serviceUrl: defaultZone: http://${eureka.instance.hostname}:8761/eureka/ instance: hostname: 47.93.6.5 prefer-ip-address: true 业务大致流程:用户发起下单请求,本地 order 订单服务创建订单记录,并通过 RPC 远程调用 storage 扣减库存服务和 account 扣账户余额服务,只有三个服务同时执行成功,才是一个完整的下单流程。如果某个服执行失败,则其他服务全部回滚。 Seata 对业务代码的侵入性非常小,代码中使用只需用 @GlobalTransactional 注解开启一个全局事务即可。 @Override @GlobalTransactional(name = "create-order", rollbackFor = Exception.class) public void create(Order order) { String xid = RootContext.getXID(); LOGGER.info("------->交易开始"); //本地方法 orderDao.create(order); //远程方法 扣减库存 storageApi.decrease(order.getProductId(), order.getCount()); //远程方法 扣减账户余额 LOGGER.info("------->扣减账户开始order中"); accountApi.decrease(order.getUserId(), order.getMoney()); LOGGER.info("------->扣减账户结束order中"); LOGGER.info("------->交易结束"); LOGGER.info("全局事务 xid: {}", xid); } 前边说过 Seata AT 模式实现分布式事务,必须在相关的业务库中创建 undo_log 表来存数据回滚日志,表结构如下: -- for AT mode you must to init this sql for you business database. the seata server not need it. CREATE TABLE IF NOT EXISTS `undo_log` ( `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT 'increment id', `branch_id` BIGINT(20) NOT NULL COMMENT 'branch transaction id', `xid` VARCHAR(100) NOT NULL COMMENT 'global transaction id', `context` VARCHAR(128) NOT NULL COMMENT 'undo_log context,such as serialization', `rollback_info` LONGBLOB NOT NULL COMMENT 'rollback info', `log_status` INT(11) NOT NULL COMMENT '0:normal status,1:defense status', `log_created` DATETIME NOT NULL COMMENT 'create datetime', `log_modified` DATETIME NOT NULL COMMENT 'modify datetime', PRIMARY KEY (`id`), UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`) ) ENGINE = InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8 COMMENT ='AT transaction mode undo table'; 到这环境搭建的工作就完事了,完整案例会在后边贴出 GitHub 地址,就不在这占用篇幅了。 测试 Seata 项目中的服务调用过程如下图: 启动各个服务后,我们直接请求下单接口看看效果,只要 order 订单表创建记录成功,storage 库存表 used 字段数量递增、account 余额表 used 字段数量递增则表示下单流程成功。 请求后正向流程是没问题的,数据和预想的一样 而且发现 TM 事务管理者 order-server 服务的控制台也打印出了两阶段提交的日志 那么再看看如果其中一个服务异常,会不会正常回滚呢?在 account-server 服务中模拟超时异常,看能否实现全局事务回滚。 发现数据全没执行成功,说明全局事务回滚也成功了 那看一下 undo_log 回滚记录表的变化情况,由于 Seata 删除回滚日志的速度很快,所以要想在表中看见回滚日志,必须要在某一个服务上打断点才看的更明显。 总结 上边简单介绍了 2PC、3PC、TCC、MQ、Seata 这五种分布式事务解决方案,还详细的实践了 Seata 中间件。但不管我们选哪一种方案,在项目中应用都要谨慎再谨慎,除特定的数据强一致性场景外,能不用尽量就不要用,因为无论它们性能如何优越,一旦项目套上分布式事务,整体效率会几倍的下降,在高并发情况下弊端尤为明显。 本案例 github 地址:https://github.com/chengxy-nds/Springboot-Notebook/tree/master/springboot-seata-transaction 如果有一丝收获,欢迎 点赞、转发 ,您的认可是我最大的动力。 整理了几百本各类技术电子书,有需要的同学可以,关注公号回复 [ 666 ] 自取。还有想要加技术群的可以加我好友,和大佬侃技术、不定期内推,一起学起来。
《sharding-jdbc 分库分表的 4种分片策略》 中我们介绍了 sharding-jdbc 4种分片策略的使用场景,可以满足基础的分片功能开发,这篇我们来看看分库分表后,应该如何为分片表生成全局唯一的主键 ID。 引入任何一种技术都是存在风险的,分库分表当然也不例外,除非库、表数据量持续增加,大到一定程度,以至于现有高可用架构已无法支撑,否则不建议大家做分库分表,因为做了数据分片后,你会发现自己踏上了一段踩坑之路,而分布式主键 ID 就是遇到的第一个坑。 不同数据节点间生成全局唯一主键是个棘手的问题,一张逻辑表 t_order 拆分成多个真实表 t_order_n,然后被分散到不同分片库 db_0、db_1... ,各真实表的自增键由于无法互相感知从而会产生重复主键,此时数据库本身的自增主键,就无法满足分库分表对主键全局唯一的要求。 db_0-- |-- t_order_0 |-- t_order_1 |-- t_order_2 db_1-- |-- t_order_0 |-- t_order_1 |-- t_order_2 尽管我们可以通过严格约束,各个分片表自增主键的 初始值 和 步长 的方式来解决 ID 重复的问题,但这样会让运维成本陡增,而且可扩展性极差,一旦要扩容分片表数量,原表数据变动比较大,所以这种方式不太可取。 步长 step = 分表张数 db_0-- |-- t_order_0 ID: 0、6、12、18... |-- t_order_1 ID: 1、7、13、19... |-- t_order_2 ID: 2、8、14、20... db_1-- |-- t_order_0 ID: 3、9、15、21... |-- t_order_1 ID: 4、10、16、22... |-- t_order_2 ID: 5、11、17、23... 目前已经有了许多第三放解决方案可以完美解决这个问题,比如基于 UUID、SNOWFLAKE算法 、segment号段,使用特定算法生成不重复键,或者直接引用主键生成服务,像美团(Leaf)和 滴滴(TinyId)等。 而sharding-jdbc 内置了两种分布式主键生成方案,UUID、SNOWFLAKE,不仅如此它还抽离出分布式主键生成器的接口,以便于开发者实现自定义的主键生成器,后续我们会在自定义的生成器中接入 滴滴(TinyId)的主键生成服务。 前边介绍过在 sharding-jdbc 中要想为某个字段自动生成主键 ID,只需要在 application.properties 文件中做如下配置: # 主键字段 spring.shardingsphere.sharding.tables.t_order.key-generator.column=order_id # 主键ID 生成方案 spring.shardingsphere.sharding.tables.t_order.key-generator.type=UUID # 工作机器 id spring.shardingsphere.sharding.tables.t_order.key-generator.props.worker.id=123 key-generator.column 表示主键字段,key-generator.type 为主键 ID 生成方案(内置或自定义的),key-generator.props.worker.id 为机器ID,在主键生成方案设为 SNOWFLAKE 时机器ID 会参与位运算。 在使用 sharding-jdbc 分布式主键时需要注意两点: 一旦 insert 插入操作的实体对象中主键字段已经赋值,那么即使配置了主键生成方案也会失效,最后SQL 执行的数据会以赋的值为准。 不要给主键字段设置自增属性,否则主键ID 会以默认的 SNOWFLAKE 方式生成。比如:用 mybatis plus 的 @TableId 注解给字段 order_id 设置了自增主键,那么此时配置哪种方案,总是按雪花算法生成。 下面我们从源码上分析下 sharding-jdbc 内置主键生成方案 UUID、SNOWFLAKE 是怎么实现的。 UUID 打开 UUID 类型的主键生成实现类 UUIDShardingKeyGenerator 的源码发现,它的生成规则只有 UUID.randomUUID() 这么一行代码,额~ 心中默默来了一句卧槽。 UUID 虽然可以做到全局唯一性,但还是不推荐使用它作为主键,因为我们的实际业务中不管是 user_id 还是 order_id 主键多为整型,而 UUID 生成的是个 32 位的字符串。 它的存储以及查询对 MySQL 的性能消耗较大,而且 MySQL 官方也明确建议,主键要尽量越短越好,作为数据库主键 UUID 的无序性还会导致数据位置频繁变动,严重影响性能。 public final class UUIDShardingKeyGenerator implements ShardingKeyGenerator { private Properties properties = new Properties(); public UUIDShardingKeyGenerator() { } public String getType() { return "UUID"; } public synchronized Comparable<?> generateKey() { return UUID.randomUUID().toString().replaceAll("-", ""); } public Properties getProperties() { return this.properties; } public void setProperties(Properties properties) { this.properties = properties; } } SNOWFLAKE SNOWFLAKE(雪花算法)是默认使用的主键生成方案,生成一个 64bit的长整型(Long)数据。 sharding-jdbc 中雪花算法生成的主键主要由 4部分组成,1bit符号位、41bit时间戳位、10bit工作进程位以及 12bit 序列号位。 符号位(1bit位) Java 中 Long 型的最高位是符号位,正数是0,负数是1,一般生成ID都为正数,所以默认为0 时间戳位(41bit) 41位的时间戳可以容纳的毫秒数是 2 的 41次幂,而一年的总毫秒数为 1000L * 60 * 60 * 24 * 365,计算使用时间大概是69年,额~,我有生之间算是够用了。 Math.pow(2, 41) / (365 * 24 * 60 * 60 * 1000L) = = 69年 工作进程位(10bit) 表示一个唯一的工作进程id,默认值为 0,可通过 key-generator.props.worker.id 属性设置。 spring.shardingsphere.sharding.tables.t_order.key-generator.props.worker.id=0000 序列号位(12bit) 同一毫秒内生成不同的ID。 时钟回拨 了解了雪花算法的主键 ID 组成后不难发现,这是一种严重依赖于服务器时间的算法,而依赖服务器时间的就会遇到一个棘手的问题:时钟回拨。 为什么会出现时钟回拨呢? 互联网中有一种网络时间协议 ntp 全称 (Network Time Protocol) ,专门用来同步、校准网络中各个计算机的时间。 这就是为什么,我们的手机现在不用手动校对时间,可每个人的手机时间还都是一样的。 我们的硬件时钟可能会因为各种原因变得不准( 快了 或 慢了 ),此时就需要 ntp 服务来做时间校准,做校准的时候就会发生服务器时钟的 跳跃 或者 回拨 的问题。 雪花算法如何解决时钟回拨 服务器时钟回拨会导致产生重复的 ID,SNOWFLAKE 方案中对原有雪花算法做了改进,增加了一个最大容忍的时钟回拨毫秒数。 如果时钟回拨的时间超过最大容忍的毫秒数阈值,则程序直接报错;如果在可容忍的范围内,默认分布式主键生成器,会等待时钟同步到最后一次主键生成的时间后再继续工作。 最大容忍的时钟回拨毫秒数,默认值为 0,可通过属性 max.tolerate.time.difference.milliseconds 设置。 # 最大容忍的时钟回拨毫秒数 spring.shardingsphere.sharding.tables.t_order.key-generator.max.tolerate.time.difference.milliseconds=5 下面是看下它的源码实现类 SnowflakeShardingKeyGenerator,核心流程大概如下: 最后一次生成主键的时间 lastMilliseconds 与 当前时间currentMilliseconds 做比较,如果 lastMilliseconds > currentMilliseconds则意味着时钟回调了。 那么接着判断两个时间的差值(timeDifferenceMilliseconds)是否在设置的最大容忍时间阈值 max.tolerate.time.difference.milliseconds内,在阈值内则线程休眠差值时间 Thread.sleep(timeDifferenceMilliseconds),否则大于差值直接报异常。 /** * @author xiaofu */ public final class SnowflakeShardingKeyGenerator implements ShardingKeyGenerator{ @Getter @Setter private Properties properties = new Properties(); public String getType() { return "SNOWFLAKE"; } public synchronized Comparable<?> generateKey() { /** * 当前系统时间毫秒数 */ long currentMilliseconds = timeService.getCurrentMillis(); /** * 判断是否需要等待容忍时间差,如果需要,则等待时间差过去,然后再获取当前系统时间 */ if (waitTolerateTimeDifferenceIfNeed(currentMilliseconds)) { currentMilliseconds = timeService.getCurrentMillis(); } /** * 如果最后一次毫秒与 当前系统时间毫秒相同,即还在同一毫秒内 */ if (lastMilliseconds == currentMilliseconds) { /** * &位与运算符:两个数都转为二进制,如果相对应位都是1,则结果为1,否则为0 * 当序列为4095时,4095+1后的新序列与掩码进行位与运算结果是0 * 当序列为其他值时,位与运算结果都不会是0 * 即本毫秒的序列已经用到最大值4096,此时要取下一个毫秒时间值 */ if (0L == (sequence = (sequence + 1) & SEQUENCE_MASK)) { currentMilliseconds = waitUntilNextTime(currentMilliseconds); } } else { /** * 上一毫秒已经过去,把序列值重置为1 */ vibrateSequenceOffset(); sequence = sequenceOffset; } lastMilliseconds = currentMilliseconds; /** * XX......XX XX000000 00000000 00000000 时间差 XX * XXXXXX XXXX0000 00000000 机器ID XX * XXXX XXXXXXXX 序列号 XX * 三部分进行|位或运算:如果相对应位都是0,则结果为0,否则为1 */ return ((currentMilliseconds - EPOCH) << TIMESTAMP_LEFT_SHIFT_BITS) | (getWorkerId() << WORKER_ID_LEFT_SHIFT_BITS) | sequence; } /** * 判断是否需要等待容忍时间差 */ @SneakyThrows private boolean waitTolerateTimeDifferenceIfNeed(final long currentMilliseconds) { /** * 如果获取ID时的最后一次时间毫秒数小于等于当前系统时间毫秒数,属于正常情况,则不需要等待 */ if (lastMilliseconds <= currentMilliseconds) { return false; } /** * ===>时钟回拨的情况(生成序列的时间大于当前系统的时间),需要等待时间差 */ /** * 获取ID时的最后一次毫秒数减去当前系统时间毫秒数的时间差 */ long timeDifferenceMilliseconds = lastMilliseconds - currentMilliseconds; /** * 时间差小于最大容忍时间差,即当前还在时钟回拨的时间差之内 */ Preconditions.checkState(timeDifferenceMilliseconds < getMaxTolerateTimeDifferenceMilliseconds(), "Clock is moving backwards, last time is %d milliseconds, current time is %d milliseconds", lastMilliseconds, currentMilliseconds); /** * 线程休眠时间差 */ Thread.sleep(timeDifferenceMilliseconds); return true; } // 配置的机器ID private long getWorkerId() { long result = Long.valueOf(properties.getProperty("worker.id", String.valueOf(WORKER_ID))); Preconditions.checkArgument(result >= 0L && result < WORKER_ID_MAX_VALUE); return result; } private int getMaxTolerateTimeDifferenceMilliseconds() { return Integer.valueOf(properties.getProperty("max.tolerate.time.difference.milliseconds", String.valueOf(MAX_TOLERATE_TIME_DIFFERENCE_MILLISECONDS))); } private long waitUntilNextTime(final long lastTime) { long result = timeService.getCurrentMillis(); while (result <= lastTime) { result = timeService.getCurrentMillis(); } return result; } } 但从 SNOWFLAKE 方案生成的主键ID 来看,order_id 它是一个18位的长整型数字,是不是发现它太长了,想要 MySQL 那种从 0 递增的自增主键该怎么实现呢?别急,后边已经会给出了解决办法! 自定义 sharding-jdbc 利用 SPI 全称( Service Provider Interface) 机制拓展主键生成规则,这是一种服务发现机制,通过扫描项目路径 META-INF/services 下的文件,并自动加载文件里所定义的类。 实现自定义主键生成器其实比较简单,只有两步。 第一步,实现 ShardingKeyGenerator 接口,并重写其内部方法,其中 getType() 方法为自定义的主键生产方案类型、generateKey() 方法则是具体生成主键的规则。 下面代码中用 AtomicInteger 来模拟实现一个有序自增的 ID 生成。 /** * @Author: xiaofu * @Description: 自定义主键生成器 */ @Component public class MyShardingKeyGenerator implements ShardingKeyGenerator { private final AtomicInteger count = new AtomicInteger(); /** * 自定义的生成方案类型 */ @Override public String getType() { return "XXX"; } /** * 核心方法-生成主键ID */ @Override public Comparable<?> generateKey() { return count.incrementAndGet(); } @Override public Properties getProperties() { return null; } @Override public void setProperties(Properties properties) { } } 第二步,由于是利用 SPI 机制实现功能拓展,我们要在 META-INF/services 文件中配置自定义的主键生成器类路劲。 com.xiaofu.sharding.key.MyShardingKeyGenerator 上面这些弄完我们测试一下,配置定义好的主键生成类型 XXX,并插入几条数据看看效果。 spring.shardingsphere.sharding.tables.t_order.key-generator.column=order_id spring.shardingsphere.sharding.tables.t_order.key-generator.type=XXX 通过控制台的SQL 解析日志发现,order_id 字段已按照有序自增的方式插入记录,说明配置的没问题。 举一反九 既然可以自定义生成方案,那么实现分布式主键的思路就很多了,又想到之前我写的这篇 《9种 分布式ID生成方案》,发现可以完美兼容,这里挑选其中的 滴滴(Tinyid)来实践一下,由于它是个单独的分布式ID生成服务,所以要先搭建环境了。 Tinyid 的服务提供Http 和 Tinyid-client 两种接入方式,下边使用 Tinyid-client 方式快速使用,更多的细节到这篇文章里看吧,实在是介绍过太多次了。 Tinyid 服务搭建 先拉源代码 https://github.com/didi/tinyid.git。 由于是基于号段模式实现的分布式ID,所以依赖于数据库,要创建相应的表 tiny_id_info 、tiny_id_token 并插入默认数据。 CREATE TABLE `tiny_id_info` ( `id` BIGINT (20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主键', `biz_type` VARCHAR (63) NOT NULL DEFAULT '' COMMENT '业务类型,唯一', `begin_id` BIGINT (20) NOT NULL DEFAULT '0' COMMENT '开始id,仅记录初始值,无其他含义。初始化时begin_id和max_id应相同', `max_id` BIGINT (20) NOT NULL DEFAULT '0' COMMENT '当前最大id', `step` INT (11) DEFAULT '0' COMMENT '步长', `delta` INT (11) NOT NULL DEFAULT '1' COMMENT '每次id增量', `remainder` INT (11) NOT NULL DEFAULT '0' COMMENT '余数', `create_time` TIMESTAMP NOT NULL DEFAULT '2010-01-01 00:00:00' COMMENT '创建时间', `update_time` TIMESTAMP NOT NULL DEFAULT '2010-01-01 00:00:00' COMMENT '更新时间', `version` BIGINT (20) NOT NULL DEFAULT '0' COMMENT '版本号', PRIMARY KEY (`id`), UNIQUE KEY `uniq_biz_type` (`biz_type`) ) ENGINE = INNODB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8 COMMENT 'id信息表'; CREATE TABLE `tiny_id_token` ( `id` INT (11) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增id', `token` VARCHAR (255) NOT NULL DEFAULT '' COMMENT 'token', `biz_type` VARCHAR (63) NOT NULL DEFAULT '' COMMENT '此token可访问的业务类型标识', `remark` VARCHAR (255) NOT NULL DEFAULT '' COMMENT '备注', `create_time` TIMESTAMP NOT NULL DEFAULT '2010-01-01 00:00:00' COMMENT '创建时间', `update_time` TIMESTAMP NOT NULL DEFAULT '2010-01-01 00:00:00' COMMENT '更新时间', PRIMARY KEY (`id`) ) ENGINE = INNODB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8 COMMENT 'token信息表'; INSERT INTO `tiny_id_token` (`id`, `token`, `biz_type`, `remark`, `create_time`, `update_time`) VALUES ('1', '0f673adf80504e2eaa552f5d791b644c', 'order', '1', '2017-12-14 16:36:46', '2017-12-14 16:36:48'); INSERT INTO `tiny_id_info` (`id`, `biz_type`, `begin_id`, `max_id`, `step`, `delta`, `remainder`, `create_time`, `update_time`, `version`) VALUES ('1', 'order', '1', '1', '100000', '1', '0', '2018-07-21 23:52:58', '2018-07-22 23:19:27', '1'); 并在 Tinyid 服务中配置上边表所在数据源信息 datasource.tinyid.primary.url=jdbc:mysql://47.93.6.e:3306/ds-0?autoReconnect=true&useUnicode=true&characterEncoding=UTF-8 datasource.tinyid.primary.username=root datasource.tinyid.primary.password=root 最后项目 maven install ,右键 TinyIdServerApplication 启动服务, Tinyid 分布式ID生成服务就搭建完毕了。 自定义 Tinyid 主键类型 Tinyid 服务搭建完下边在项目中引入它,新建个 tinyid_client.properties 文件其中添加 tinyid.server 和 tinyid.token 属性,token 为之前 SQL 预先插入的用户数据。 # tinyid 分布式ID # 服务地址 tinyid.server=127.0.0.1:9999 # 业务token tinyid.token=0f673adf80504e2eaa552f5d791b644c 代码中获取 ID更简单,只需一行代码,业务类型 order 是之前 SQ L 预先插入的数据。 Long id = TinyId.nextId("order"); 我们开始自定义 Tinyid 主键生成类型的实现类 TinyIdShardingKeyGenerator 。 /** * @Author: xiaofu * @Description: 自定义主键生成器 */ @Component public class TinyIdShardingKeyGenerator implements ShardingKeyGenerator { /** * 自定义的生成方案类型 */ @Override public String getType() { return "tinyid"; } /** * 核心方法-生成主键ID */ @Override public Comparable<?> generateKey() { Long id = TinyId.nextId("order"); return id; } @Override public Properties getProperties() { return null; } @Override public void setProperties(Properties properties) { } } 并在配置文件中启用 Tinyid 主键生成类型,到此配置完毕,赶紧测试一下。 # 主键字段 spring.shardingsphere.sharding.tables.t_order.key-generator.column=order_id # 主键ID 生成方案 spring.shardingsphere.sharding.tables.t_order.key-generator.type=tinyid 测试 Tinyid 主键 向数据库插入订单记录测试发现,主键ID字段 order_id 已经为趋势递增的了, Tinyid 服务成功接入,完美! 总结 后续的八种生成方式大家参考 《9种 分布式ID生成方案》 按需接入吧,整体比较简单这里就不依次实现了。 案例 GitHub 地址:https://github.com/chengxy-nds/Springboot-Notebook/tree/master/springboot-sharding-jdbc 整理了几百本各类技术电子书,送给小伙伴们。关注公号回复 666 自行领取。和一些小伙伴们建了一个技术交流群,一起探讨技术、分享技术资料,旨在共同学习进步,如果感兴趣就加入我们吧!
大家好,我是小富~ 之前有不少刚入坑 Java 的粉丝留言,想系统的学习一下分库分表相关技术,可我一直没下定决心搞,眼下赶上公司项目在使用 sharding-jdbc 对现有 MySQL 架构做分库分表的改造,所以借此机会出一系分库分表落地实践的文章,也算是自己对架构学习的一个总结。 我在网上陆陆续续的也看了一些有关于分库分表的文章,可发现网上同质化的资料有点多,而且知识点又都比较零碎,还没有详细的实战案例。为了更深入的学习下,我在某些平台买了点付费课程,看了几节课发现有点经验的人看还可以,但对于新手入门来说,其实学习难度还是蛮大的。 为了让新手也能看得懂,有些知识点我可能会用更多的篇幅加以描述,希望大家不要嫌我啰嗦,等这分库分表系列文章完结后,我会把它做成 PDF 文档开源出去,能帮一个算一个吧!如果发现文中有哪些错误或不严谨之处,欢迎大家交流指正。 具体实践分库分表之前在啰嗦几句,回头复习下分库分表的基础概念。 什么是分库分表 其实 分库 和 分表 是两个概念,只不过通常分库与分表的操作会同时进行,以至于我们习惯性的将它们合在一起叫做分库分表。 分库分表是为了解决由于库、表数据量过大,而导致数据库性能持续下降的问题。按照一定的规则,将原本数据量大的数据库拆分成多个单独的数据库,将原本数据量大的表拆分成若干个数据表,使得单一的库、表性能达到最优的效果(响应速度快),以此提升整体数据库性能。 如何分库分表 分库分表的核心理念就是对数据进行切分(Sharding),以及切分后如何对数据的快速定位与查询结果整合。而分库与分表都可以从:垂直(纵向)和 水平(横向)两种纬度进行切分。 下边我们就以订单相关的业务举例,看看如何做库、表的 垂直 和 水平 切分。 垂直切分 垂直切分有 垂直 分库 和 垂直分表。 1、垂直分库 垂直分库相对来说是比较好理解的,核心理念就四个字:专库专用。 按业务类型对表进行分类,像订单、支付、优惠券、积分等相应的表放在对应的数据库中。开发者不可以跨库直连别的业务数据库,想要其他业务数据,对应业务方可以提供 API 接口,这就是微服务的初始形态。 垂直分库很大程度上取决于业务的划分,但有时候业务间的划分并不是那么清晰,比如:订单数据的拆分要考虑到与其他业务间的关联关系,并不是说直接把订单相关的表放在一个库里这么简单。 在一定程度上,垂直分库似乎提升了一些数据库性能,可实际上并没有解决由于单表数据量过大导致的性能问题,所以就需要配合水平切分方式来解决。 2、垂直分表 垂直分表是基于数据表的列(字段)为依据切分的,是一种大表拆小表的模式。 例如:一张 order 订单表,将订单金额、订单编号等访问频繁的字段,单独拆成一张表,把 blob 类型这样的大字段或访问不频繁的字段,拆分出来创建一个单独的扩展表 work_extend ,这样每张表只存储原表的一部分字段,再将拆分出来的表分散到不同的库中。 我们知道数据库是以行为单位将数据加载到内存中,这样拆分以后核心表大多是访问频率较高的字段,而且字段长度也都较短,因而可以加载更多数据到内存中,来增加查询的命中率,减少磁盘IO,以此来提升数据库性能。 垂直切分的优点: 业务间数据解耦,不同业务的数据进行独立的维护、监控、扩展。 在高并发场景下,一定程度上缓解了数据库的压力。 垂直切分的缺点: 提升了开发的复杂度,由于业务的隔离性,很多表无法直接访问,必须通过接口方式聚合数据。 分布式事务管理难度增加。 数据库还是存在单表数据量过大的问题,并未根本上解决,需要配合水平切分。 水平切分 前边说了垂直切分还是会存在单库、表数据量过大的问题,当我们的应用已经无法在细粒度的垂直切分时,依旧存在单库读写、存储性能瓶颈,这时就要配合水平切分一起了,水平切分能大幅提升数据库性能。 1、水平分库 水平分库是把同一个表按一定规则拆分到不同的数据库中,每个库可以位于不同的服务器上,以此实现水平扩展,是一种常见的提升数据库性能的方式。 这种方案往往能解决单库存储量及性能瓶颈问题,但由于同一个表被分配在不同的数据库中,数据的访问需要额外的路由工作,因此系统的复杂度也被提升了。 例如下图,订单DB_1、订单DB_1、订单DB_3 三个数据库内有完全相同的表 order,我们在访问某一笔订单时可以通过对订单的订单编号取模的方式 订单编号 mod 3 (数据库实例数) ,指定该订单应该在哪个数据库中操作。 2、水平分表 水平分表是在同一个数据库内,把一张大数据量的表按一定规则,切分成多个结构完全相同表,而每个表只存原表的一部分数据。 例如:一张 order 订单表有 900万数据,经过水平拆分出来三个表,order_1、order_2、order_3,每张表存有数据 300万,以此类推。 水平分表尽管拆分了表,但子表都还是在同一个数据库实例中,只是解决了单一表数据量过大的问题,并没有将拆分后的表分散到不同的机器上,还在竞争同一个物理机的CPU、内存、网络IO等。要想进一步提升性能,就需要将拆分后的表分散到不同的数据库中,达到分布式的效果。 水平切分的优点: 解决高并发时单库数据量过大的问题,提升系统稳定性和负载能力。 业务系统改造的工作量不是很大。 水平切分的缺点: 跨分片的事务一致性难以保证。 跨库的join关联查询性能较差。 扩容的难度和维护量较大,(拆分成几千张子表想想都恐怖)。 一定规则是什么 我们上边提到过很多次 一定规则 ,这个规则其实是一种路由算法,就是决定一条数据具体应该存在哪个数据库的哪张表里。 常见的有 取模算法 和 范围限定算法 1、取模算法 按字段取模(对hash结果取余数 (hash() mod N),N为数据库实例数或子表数量)是最为常见的一种切分方式。 还拿 order 订单表举例,先对数据库从 0 到 N-1进行编号,对 order 订单表中 work_no 订单编号字段进行取模,得到余数 i,i=0存第一个库,i=1存第二个库,i=2存第三个库....以此类推。 这样同一笔订单的数据都会存在同一个库、表里,查询时用相同的规则,用 work_no 订单编号作为查询条件,就能快速的定位到数据。 优点: 数据分片相对比较均匀,不易出现请求都打到一个库上的情况。 缺点: 这种算法存在一些问题,当某一台机器宕机,本应该落在该数据库的请求就无法得到正确的处理,这时宕掉的实例会被踢出集群,此时算法变成hash(userId) mod N-1,用户信息可能就不再在同一个库中了。 2、范围限定算法 按照 时间区间 或 ID区间 来切分,比如:我们切分的是用户表,可以定义每个库的 User 表里只存10000条数据,第一个库只存 userId 从1 ~ 9999的数据,第二个库存 userId 为10000 ~ 20000,第三个库存 userId 为 20001~ 30000......以此类推,按时间范围也是同理。 优点: 单表数据量是可控的 水平扩展简单只需增加节点即可,无需对其他分片的数据进行迁移 能快速定位要查询的数据在哪个库 缺点: 由于连续分片可能存在数据热点,比如按时间字段分片,可能某一段时间内订单骤增,可能会被频繁的读写,而有些分片存储的历史数据,则很少被查询。 分库分表的难点 1、分布式事务 由于表分布在不同库中,不可避免会带来跨库事务问题。一般可使用 "三阶段提交 "和 "两阶段提交" 处理,但是这种方式性能较差,代码开发量也比较大。通常做法是做到最终一致性的方案,如果不苛求系统的实时一致性,只要在允许的时间段内达到最终一致性即可,采用事务补偿的方式。 这里我应用阿里的分布式事务框架Seata 来做分布式事务的管理,后边会结合实际案例。 2、分页、排序、跨库联合查询 分页、排序、联合查询是开发中使用频率非常高的功能,但在分库分表后,这些看似普通的操作却是让人非常头疼的问题。将分散在不同库中表的数据查询出来,再将所有结果进行汇总整理后提供给用户。 3、分布式主键 分库分表后数据库的自增主键意义就不大了,因为我们不能依靠单个数据库实例上的自增主键来实现不同数据库之间的全局唯一主键,此时一个能够生成全局唯一ID的系统是非常必要的,那么这个全局唯一ID就叫 分布式ID。 4、读写分离 不难发现大部分主流的关系型数据库都提供了主从架构的高可用方案,而我们需要实现 读写分离 + 分库分表,读库与写库都要做分库分表处理,后边会有具体实战案例。 5、数据脱敏 数据脱敏,是指对某些敏感信息通过脱敏规则进行数据转换,从而实现敏感隐私数据的可靠保护,如身份证号、手机号、卡号、账号密码等个人信息,一般这些都需要进行做脱敏处理。 分库分表工具 我还是那句话,尽量不要自己造轮子,因为自己造的轮子可能不那么圆,业界已经有了很多比较成熟的分库分表中间件,我们根据自身的业务需求挑选,将更多的精力放在业务实现上。 sharding-jdbc(当当) TSharding(蘑菇街) Atlas(奇虎360) Cobar(阿里巴巴) MyCAT(基于Cobar) Oceanus(58同城) Vitess(谷歌) 为什么选 sharding-jdbc sharding-jdbc 是一款轻量级 Java 框架,以 jar 包形式提供服务,是属于客户端产品不需要额外部署,它相当于是个增强版的 JDBC 驱动;相比之下像 Mycat 这类需要单独的部署服务的服务端产品,就稍显复杂了。况且我想把更多精力放在实现业务,不想做额外的运维工作。 sharding-jdbc的兼容性也非常强大,适用于任何基于 JDBC 的 ORM 框架,如:JPA, Hibernate,Mybatis,Spring JDBC Template 或直接使用的 JDBC。 完美兼容任何第三方的数据库连接池,如:DBCP, C3P0, BoneCP,Druid, HikariCP 等,几乎对所有关系型数据库都支持。 不难发现确实是比较强大的一款工具,而且它对项目的侵入性很小,几乎不用做任何代码层的修改,也无需修改 SQL 语句,只需配置待分库分表的数据表即可。 总结 简单的回顾一下分库分表的基础知识,接下来的文章会配合实战项目介绍 sharding-jdbc 在分库分表中的各个功能点。 整理了几百本各类技术电子书,送给小伙伴们。关注公号回复【666】自行领取。和一些小伙伴们建了一个技术交流群,一起探讨技术、分享技术资料,旨在共同学习进步,如果感兴趣就加入我们吧!
本文收录在个人博客:www.chengxy-nds.top,技术资料共享,同进步 时间格式化在项目中使用频率是非常高的,当我们的 API 接口返回结果,需要对其中某一个 date 字段属性进行特殊的格式化处理,通常会用到 SimpleDateFormat 工具处理。 SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd"); Date stationTime = dateFormat.parse(dateFormat.format(PayEndTime())); 可一旦处理的地方较多,不仅 CV 操作频繁,还产生很多重复臃肿的代码,而此时如果能将时间格式统一配置,就可以省下更多时间专注于业务开发了。 可能很多人觉得统一格式化时间很简单啊,像下边这样配置一下就行了,但事实上这种方式只对 date 类型生效。 spring.jackson.date-format=yyyy-MM-dd HH:mm:ss spring.jackson.time-zone=GMT+8 而很多项目中用到的时间和日期API 比较混乱, java.util.Date 、 java.util.Calendar 和 java.time LocalDateTime 都存在,所以全局时间格式化必须要同时兼容性新旧 API。 看看配置全局时间格式化前,接口返回时间字段的格式。 @Data public class OrderDTO { private LocalDateTime createTime; private Date updateTime; } 很明显不符合页面上的显示要求(有人抬杠为啥不让前端解析时间,我只能说睡服代码比说服人容易得多~) 一、@JsonFormat 注解 @JsonFormat 注解方式严格意义上不能叫全局时间格式化,应该叫部分格式化,因为@JsonFormat 注解需要用在实体类的时间字段上,而只有使用相应的实体类,对应的字段才能进行格式化。 @Data public class OrderDTO { @JsonFormat(locale = "zh", timezone = "GMT+8", pattern = "yyyy-MM-dd") private LocalDateTime createTime; @JsonFormat(locale = "zh", timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss") private Date updateTime; } 字段加上 @JsonFormat 注解后,LocalDateTime 和 Date 时间格式化成功。 二、@JsonComponent 注解(推荐) 这是我个人比较推荐的一种方式,前边看到使用 @JsonFormat 注解并不能完全做到全局时间格式化,所以接下来我们使用 @JsonComponent 注解自定义一个全局格式化类,分别对 Date 和 LocalDate 类型做格式化处理。 @JsonComponent public class DateFormatConfig { @Value("${spring.jackson.date-format:yyyy-MM-dd HH:mm:ss}") private String pattern; /** * @author xiaofu * @description date 类型全局时间格式化 * @date 2020/8/31 18:22 */ @Bean public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilder() { return builder -> { TimeZone tz = TimeZone.getTimeZone("UTC"); DateFormat df = new SimpleDateFormat(pattern); df.setTimeZone(tz); builder.failOnEmptyBeans(false) .failOnUnknownProperties(false) .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .dateFormat(df); }; } /** * @author xiaofu * @description LocalDate 类型全局时间格式化 * @date 2020/8/31 18:22 */ @Bean public LocalDateTimeSerializer localDateTimeDeserializer() { return new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(pattern)); } @Bean public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() { return builder -> builder.serializerByType(LocalDateTime.class, localDateTimeDeserializer()); } } 看到 Date 和 LocalDate 两种时间类型格式化成功,此种方式有效。 但还有个问题,实际开发中如果我有个字段不想用全局格式化设置的时间样式,想自定义格式怎么办? 那就需要和 @JsonFormat 注解配合使用了。 @Data public class OrderDTO { @JsonFormat(locale = "zh", timezone = "GMT+8", pattern = "yyyy-MM-dd") private LocalDateTime createTime; @JsonFormat(locale = "zh", timezone = "GMT+8", pattern = "yyyy-MM-dd") private Date updateTime; } 从结果上我们看到 @JsonFormat 注解的优先级比较高,会以 @JsonFormat 注解的时间格式为主。 三、@Configuration 注解 这种全局配置的实现方式与上边的效果是一样的。 注意:在使用此种配置后,字段手动配置@JsonFormat 注解将不再生效。 @Configuration public class DateFormatConfig2 { @Value("${spring.jackson.date-format:yyyy-MM-dd HH:mm:ss}") private String pattern; public static DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); @Bean @Primary public ObjectMapper serializingObjectMapper() { ObjectMapper objectMapper = new ObjectMapper(); JavaTimeModule javaTimeModule = new JavaTimeModule(); javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer()); javaTimeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer()); objectMapper.registerModule(javaTimeModule); return objectMapper; } /** * @author xiaofu * @description Date 时间类型装换 * @date 2020/9/1 17:25 */ @Component public class DateSerializer extends JsonSerializer<Date> { @Override public void serialize(Date date, JsonGenerator gen, SerializerProvider provider) throws IOException { String formattedDate = dateFormat.format(date); gen.writeString(formattedDate); } } /** * @author xiaofu * @description Date 时间类型装换 * @date 2020/9/1 17:25 */ @Component public class DateDeserializer extends JsonDeserializer<Date> { @Override public Date deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { try { return dateFormat.parse(jsonParser.getValueAsString()); } catch (ParseException e) { throw new RuntimeException("Could not parse date", e); } } } /** * @author xiaofu * @description LocalDate 时间类型装换 * @date 2020/9/1 17:25 */ public class LocalDateTimeSerializer extends JsonSerializer<LocalDateTime> { @Override public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException { gen.writeString(value.format(DateTimeFormatter.ofPattern(pattern))); } } /** * @author xiaofu * @description LocalDate 时间类型装换 * @date 2020/9/1 17:25 */ public class LocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> { @Override public LocalDateTime deserialize(JsonParser p, DeserializationContext deserializationContext) throws IOException { return LocalDateTime.parse(p.getValueAsString(), DateTimeFormatter.ofPattern(pattern)); } } } 总结 分享了一个简单却又很实用的 Springboot 开发技巧,其实所谓的开发效率,不过是一个又一个开发技巧堆砌而来,聪明的程序员总是能用最少的代码完成任务。 如果对你有用,欢迎 在看、点赞、转发 ,您的认可是我最大的动力。 原创不易,燃烧秀发输出内容,如果有一丢丢收获,点个赞鼓励一下吧! 整理了几百本各类技术电子书,送给小伙伴们。关注公号回复【666】自行领取。和一些小伙伴们建了一个技术交流群,一起探讨技术、分享技术资料,旨在共同学习进步,如果感兴趣就加入我们吧!
2020年12月
2020年11月
2020年09月
2020年08月