-
Notifications
You must be signed in to change notification settings - Fork 1
/
content.json
1 lines (1 loc) · 304 KB
/
content.json
1
{"posts":[{"title":"架构师应具备什么能力?","text":"要回答这个问题,我们首先要搞清楚,为什么要做架构设计?不做行不行? 1、架构设计的目的早在 1960 年代,诸如艾兹格·迪杰斯特拉就已经涉及软件架构这个概念了。自1990年代以来,软件架构这个概念开始越来越流行起来。 卡内基·梅隆大学的玛丽·肖(Mary Shaw)和戴维·加兰(David Garlan)对软件架构做了很多研究,他们在 1994 年的一篇文章《软件架构介绍》(An Introduction to Software Architecture)中写到: “When systems are constructed from many components, the organization of the overall system-the software architecture-presents a new set of design problems.” 译:随着软件系统规模的增加,计算相关的算法和数据结构不再构成主要的设计问题;当系统由许多部分组成时,整个系统的组织,也就是所说的“软件架构”,导致了一系列新的设计问题。 这一系列新的问题诸如: 系统规模庞大,内部耦合严重,开发效率低; 系统耦合严重,牵一发动全身,后续修改和扩展困难; 系统逻辑复杂,容易出问题,出问题后很难排查和修复。 可以看到,软件技术其实就是与“复杂度”作斗争的,架构的出现也不例外。简而言之,架构也是为了应对软件系统复杂度而提出的一个解决方案,通过回顾架构产生的历史背景和原因,我们可以基本推导出答案:架构设计的主要目的是为了解决软件系统复杂度带来的问题。 据《第50次中国互联网络发展状况统计报告》显示,截至 2022 年 6 月,我国网民规模数达 10.51 亿。架构复杂度越来越高,已成必然。 架构复杂度主要体现在以下方面。 2、架构设计的复杂度来源2.1 高性能性能反应了系统的使用体验,想象一下,同样承担每秒一万次请求的两个系统,一个响应时间是毫秒级,一个响应时间在秒级别,它们带给用户的体验肯定是不同的。 高性能系统的复杂度可以概括为两个方面,一方面是单台计算机内部为了高性能带来的复杂度,涉及的技术点有:进程、进程间通信、线程等。 另一方面是多台计算机集群为了高性能带来的复杂度,涉及的技术复杂点有:任务分配、负载均衡。 2.2 高可用高可用的关键在于“无中断”,“无中断”的难点在于:硬件故障,软件 bug,外部环境。所以,系统的高可用方案五花八门,但万变不离其宗,本质上都是通过“冗余”来实现高可用。 2.3 可扩展设计具备良好可扩展性的系统,有两个基本条件:正确预测变化、完美封装变化。但要达成这两个条件,本身也是一件复杂的事情。 2.4 低成本低成本本质上是与高性能和高可用冲突的,所以低成本很多时候不会是架构设计的首要目标,而是架构设计的附加约束。 2.5 安全性安全本身是一个庞大而又复杂的技术领域,从技术的角度来讲,安全可以分为两类:一类是功能上的安全,例如,常见的 XSS 攻击、CSRF 攻击、SQL 注入、Windows 漏洞、密码破解等; 另一类是架构上的安全,传统的架构安全主要依靠防火墙,但在互联网领域,防火墙的应用场景并不多。互联网系统的架构安全目前并没有太好的设计手段来实现,更多地是依靠运营商或者云服务商强大的带宽和流量清洗的能力,较少自己来设计和实现。 2.6 规模规模带来复杂度的主要原因就是“量变引起质变”,当数量超过一定的阈值后,复杂度会发生质的变化。常见的规模带来的复杂度有: 功能越来越多,导致系统复杂度指数级上升。 数据越来越多,系统复杂度发生质变。大数据就是在这种背景下诞生的。 3、架构师应具备什么能力?围绕上述复杂度问题,程序员们来施展拳脚,能解决就是优秀的架构师。具体来说,可以往下面几个方面进行修炼。 3.1 透过问题看本质的能力透过问题看本质是由事物的表象到实质,往深层次挖掘。比如,看到一段 Java 代码,知道它在 JVM 中如何执行;一个跨网络调用,知道数据是如何通过各种介质(比如网卡端口)到达目标位置。 3.2 多领域知识和技术前瞻性架构师要有技术的广度(多领域知识)和深度(技术前瞻)。对主流公司的系统设计非常了解,知道优劣长短,碰到实际问题,很快就能提供多种方案供评估。 3.3 沟通交流能落地的架构才是好架构,架构师还需要具备良好的沟通能力,能确保各方对架构达成共识,愿意采取一致的行动。 最后引用《火影忍者》里面的一句话:并不是成为火影的人就会被大家所认可,而是被大家所认可的人才能成为火影。(被程序员们认可的人,才能成为架构师。) 与你共勉! 参考 软件架构-维基百科:https://zh.wikipedia.org/zh-hans/%E8%BD%AF%E4%BB%B6%E6%9E%B6%E6%9E%84 极客时间-《从0开始学架构》 极客时间-《架构实战案例解析》 第50次《中国互联网络发展状况统计报告》:http://www.cnnic.net.cn/n4/2022/0914/c88-10226.html","link":"/2023/1.html"},{"title":"Redis String类型的内存开销都花在哪儿了?","text":"1、场景介绍假设现在我们要开发一个图片存储系统,要求这个系统能够根据图片 ID 快速查找到图片存储对象 ID。图片 ID 和图片存储对象 ID 的样例数据如下: 12photo_id: 1101000060photo_obj_id: 3302000080 在这种场景下,图片 ID 和图片存储对象 ID 刚好是一对一的关系,是典型的“键 - 单值”模式,Redis 的 String 类型提供了“一个键对应一个值的数据”的保存形式,在这种场景下刚好适用。 确定使用 String 类型后,接下来我们通过实战,来看看它的内存使用情况。首先通过下面命令连接上 Redis。 本文我使用的 Redis Server 及下文源码都是 6.2.4 版本。 1redis-cli -h 127.0.0.1 -p 6379 然后执行下面的命令查看 Redis 的初始内存使用情况。 123127.0.0.1:6379> info memory# Memoryused_memory:871840 接着插入 10 条数据: 1234567891010.118.32.170:0> set 1101000060 330200008010.118.32.170:0> set 1101000061 330200008110.118.32.170:0> set 1101000062 330200008210.118.32.170:0> set 1101000063 330200008310.118.32.170:0> set 1101000064 330200008410.118.32.170:0> set 1101000065 330200008510.118.32.170:0> set 1101000066 330200008610.118.32.170:0> set 1101000067 330200008710.118.32.170:0> set 1101000068 330200008810.118.32.170:0> set 1101000069 3302000089 再次查看内存: 123127.0.0.1:6379> info memory# Memoryused_memory:872528 可以看到,存储 10 个图片,内存使用了 688 个字节。一个图片 ID 和图片存储对象 ID 的记录平均用了 68 字节。 但问题是,一组图片 ID 及其存储对象 ID 的记录,实际只需要 16 字节就可以了。图片 ID 和图片存储对象 ID 都是 10 位数,而 8 字节的 Long 类型最大可以表示 2 的 64 次方的数值,肯定可以表示 10 位数。这样算下来只需 16 字节就可以了,为什么 String 类型却用了 68 字节呢? 为了一探究竟,我们不得不从 String 类型的底层实现扒起。 2、String 类型的底层实现当你保存的数据中包含字符时,String 类型就会用简单动态字符串(Simple Dynamic String,SDS)结构体来保存。 2.1 SDSSDS 的结构定义在sds.h文件中,在 Redis 3.2 版本之后,SDS 由一种数据结构变成了 5 种数据结构。 123456789101112131415161718192021222324252627282930/* Note: sdshdr5 is never used, we just access the flags byte directly. * However is here to document the layout of type 5 SDS strings. */struct __attribute__ ((__packed__)) hisdshdr5 { unsigned char flags; /* 3 lsb of type, and 5 msb of string length */ char buf[];};struct __attribute__ ((__packed__)) hisdshdr8 { uint8_t len; /* used */ uint8_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[];};struct __attribute__ ((__packed__)) hisdshdr16 { uint16_t len; /* used */ uint16_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[];};struct __attribute__ ((__packed__)) hisdshdr32 { uint32_t len; /* used */ uint32_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[];};struct __attribute__ ((__packed__)) hisdshdr64 { uint64_t len; /* used */ uint64_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[];}; 这 5 种数据结构依次存储不同长度的内容,Redis 会根据 SDS 存储的内容长度来选择不同的结构。 sdshdr5:存储大小为 32 字节(2 的 5 次方),只被应用在了 Redis 中的 key 中。 sdshdr8:存储大小为 256 字节(2 的 8 次方)。 sdshdr16:存储大小为 64KB(2 的 16 次方)。 sdshdr32:存储大小为 4GB(2 的 32 次方)。 sdshdr64:存储大小为 2 的 64 次方字节。 以 sdshdr8 为例。 buf:字节数组,保存实际数据。为了表示字节数组的结束,Redis 会自动在数组最后加一个'\\0',这就会额外占用 1 个字节的开销。 len:uint8_t 是 8 位无符号整型,会占用 1 字节的内存空间。表示 buf 的已用长度,不包括'\\0'。 alloc:占 1 个字节,表示 buf 的实际分配长度,不包括'\\0'。 flags:占 1 个字节,标记当前字节数组的属性,是sdshdr8还是sdshdr16等。(flags 值的定义可以看下面代码) 在源码sds.h中,flags 值定义如下: 12345#define HI_SDS_TYPE_5 0 #define HI_SDS_TYPE_8 1#define HI_SDS_TYPE_16 2#define HI_SDS_TYPE_32 3#define HI_SDS_TYPE_64 4 2.2 RedisObject因为 Redis 的数据类型有很多,而且,不同数据类型都有些相同的元数据要记录,所以,值对象并不是直接存储,而是被包装成redisObject对象,它的定义如下。 1234567typedef struct redisObject { unsigned type:4;//对象类型(4位=0.5字节) unsigned encoding:4;//编码(4位=0.5字节) unsigned lru:LRU_BITS;//记录对象最后一次被应用程序访问的时间(24位=3字节) int refcount;//引用计数。等于0时表示可以被垃圾回收(32位=4字节) void *ptr;//指向底层实际的数据存储结构,如:sds等(8字节)} robj; 下面可以帮助我们理解: 为了节省内存空间,Redis 还做了一些优化。 当保存的是 Long 类型整数时,RedisObject 中的指针就直接赋值为整数数据了,这样就不用额外的指针再指向整数了。这种保存方式通常也叫作 int 编码方式。 当保存的是字符串数据,并且字符串小于等于 44 字节时,RedisObject 中的元数据、指针和 SDS 是一块连续的内存区域,这样就可以避免内存碎片。这种布局方式也被称为 embstr 编码方式。 当字符串大于 44 字节时,SDS 的数据量就开始变多了,Redis 就不再把 SDS 和 RedisObject 布局在一起了,而是会给 SDS 分配独立的空间,并用指针指向 SDS 结构。这种布局方式被称为 raw 编码模式。 使用 OBJECT ENCODING 命令可以查看一个数据库键的值对象的编码: 12345678910111213141516127.0.0.1:6379> SET msg "hello world"OK127.0.0.1:6379> OBJECT ENCODING msg"embstr"127.0.0.1:6379> SET story "long long long ago..."OK127.0.0.1:6379> OBJECT ENCODING story"raw"127.0.0.1:6379> SADD numbers 1 3 5(integer) 3127.0.0.1:6379> OBJECT ENCODING numbers"intset"127.0.0.1:6379> SADD numbers "seven"(integer) 1127.0.0.1:6379> OBJECT ENCODING numbers"hashtable" 注意这个命令SET story "long long long ago...",省略号指的是省略了很多字符。 知道了 SDS 和 RedisObject 额外元数据开销,现在,我们就可以计算 String 类型的内存使用量了。 图片存储对象 ID 是 Long 类型整数,所以可以直接用 int 编码的 RedisObject 保存。每个 int 编码的 RedisObject 元数据部分占 8 字节,指针部分被直接赋值为 8 字节的整数了。图片 ID 使用 sdshdr5 数据结构来保存,会为 10 位的图片 ID 分配 16 个字节,结束符 ‘\\0’ 占 1 个字节。 共占用 34 个字节。与上文所说的一个图片 ID 和图片存储对象 ID 的记录平均用了 68 字节相差有点大啊,另外的开销去哪儿了? 2.3 全局哈希表为了实现从键到值的快速访问,Redis 使用了一个哈希表来保存所有键值对。因为这个哈希表保存了所有的键值对,所以,也称为全局哈希表。哈希表的每一项是一个 dictEntry 的结构体,用来指向一个键值对。dictEntry 结构中有三个 8 字节的指针,分别指向 key、value 以及下一个 dictEntry,三个指针共 24 字节,如下图所示: jemalloc 在分配内存时,会分配一个最接近 2 的 N 次方的数值。举个例子。如果你申请 6 字节空间,jemalloc 实际会分配 2 的 4 次方即 8 字节空间;如果你申请 24 字节空间,jemalloc 则会分配 32 字节。 最终我们分析出来的内存开销,为 66 字节,比较接近上文场景中的平均值 68 了。 既然 String 类型这么占内存,那么你有好的方案来节省内存吗? 封面 参考资料 文中的一些命令,参考菜鸟教程:https://www.runoob.com/redis/redis-tutorial.html Redis 的 key 也是 SDS 类型的,参考:https://www.cnblogs.com/lonely-wolf/p/14261486.html SDS 的定义,参考:https://juejin.cn/post/6844903936520880135#heading-6 文章大纲,参考极客时间《Redis核心技术与实战》 《Redis设计与实现》","link":"/2023/2.html"},{"title":"索引失效了?看看这几个常见的原因!","text":"索引是 MySQL 数据库中优化查询性能的重要工具,通过对查询条件和表数据的索引,MySQL可以快速定位数据,提高查询效率。但是,在实际的数据库开发和维护中,我们经常会遇到一些情况,导致索引失效,从而使得查询变得非常缓慢,甚至无法使用索引来优化查询,这会严重影响系统的性能。那么,是什么原因导致了索引失效呢? 常见的情况有: 索引中断 数据类型不匹配 查询条件使用函数操作 前模糊查询 OR 查询 建立索引时使用函数 索引区分度不高 下面我通过实际的例子来具体说说。假设现在我们有一张人物表,建表语句如下: 12345678CREATE TABLE `person` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(64) NOT NULL, `score` int(11) NOT NULL, `age` int(11) NOT NULL, `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`)) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4; 1、联合索引中断在使用联合索引进行查询时,如果联合索引中的某一个列出现了索引中断的情况,那么整个联合索引都会失效,无法继续使用索引来优化查询。 例如:对于联合索引 (name, score),如果条件中如果只有 score,则会导致索引失效。 12CREATE INDEX idx_name_score ON person (`name`,`score`);select * from person where score = 90 而下面的情况都会使用索引: 123select * from person where name = '31a'select * from person where score = 90 and name = '31a'select * from person where name = '31a' and score = 90 2、数据类型不匹配如果我们在查询条件中使用了一个不匹配索引的数据类型的值,那么 MySQL 将无法使用该索引来优化查询,从而导致索引失效。 例如:如果列类型是字符串,那一定要在条件中将数据使用引号引用起来,否则会导致索引失效。 123CREATE INDEX idx_name ON person (`name`);-- 这里 name 是 varchar 类型select * from person where name = 31 但是如果索引是 int 类型,而查询参数是 varchar 类型,因为字符串隐式转为数值,不存在歧义,所以会走索引。 123CREATE INDEX idx_age ON person (`age`);-- 这里 age 是 int 类型select * from person where age = '90' MySQL 为什么不把 31 隐式转换字符串呢?这个问题在 MySQL 官方文档中给出了答案。 针对数值1,与字符串’1’, ‘1a’, ‘001’, ‘1 ‘等多种情况均相等,会存在歧义。不妨看个例子: 我们插入两条数据: 12INSERT INTO test.person (id, name, score, age, create_time) VALUES(1, '00031', 90, 18, '2023-04-15 16:29:39');INSERT INTO test.person (id, name, score, age, create_time) VALUES(2, '31a', 96, 19, '2023-04-15 16:29:39'); 然后执行查询操作: 1select * from persion where name = 31; 3、查询条件使用函数操作当我们在查询条件中使用函数操作时,这将导致索引失效。例如: 12CREATE INDEX idx_name ON person (`name`);select * from person where UPPER(name) = '31A'; 4、前模糊查询如果我们在查询条件中使用了前模糊查询,那么 MySQL 将无法使用 B-Tree 索引的前缀匹配查询,从而导致索引失效。例如: 12CREATE INDEX idx_name ON person (`name`);select * from person where name LIKE '%a'; 5、OR 查询当我们在查询条件中使用 OR 连接多个条件时,OR 前后条件都包含索引则走索引,OR 前后有一个不包含索引则索引失效。例如: 12CREATE INDEX idx_age ON person (`age`);select * from person where name = 'John' OR age > 20; 6、建立索引时使用函数如果在建立索引时使用了函数操作,即使使用了索引列,索引也不会生效。例如: 123CREATE INDEX idx_name ON person (LOWER(name));-- 如果使用 LOWER(name) 函数建立索引,那么下面查询将导致索引失效select * from person where name = 'John'; 7、索引区分度不高如果索引列的值区分度不高,MySQL 可能会放弃使用索引,选择全表扫描,导致索引失效。例如我们创建了下面两条索引: 12CREATE INDEX idx_name ON person (`name`);CREATE INDEX idx_create_time ON person (`create_time`); 然后插入 100000 条数据: 123456789create PROCEDURE `insert_person`()begin declare c_id integer default 3; while c_id <= 100000 do insert into person values(c_id, concat('name',c_id), c_id + 100, c_id + 10, date_sub(NOW(), interval c_id second)); set c_id = c_id + 1; end while;end;CALL insert_person(); 接着执行: 1explain select * from person where NAME>'name84059' and create_time>'2023-04-15 13:00:00' 结果如下: 通过上面的执行计划可以看到:type=All,说明是全表扫描。","link":"/2023/8.html"},{"title":"23种设计模式的必备结构图","text":"这里总结了23种设计模式的结构图及定义,样例代码在 Github:studeyang/design-pattern。 一、创建型模式1.1 简单工厂模式 1.2 工厂方法模式工厂方法模式,定义一个用于创建对象的接口,让子类决定实例化哪一个类。工厂方法使一个类的实例化延迟到其子类。 1.3 抽象工厂模式抽象工厂模式,提供一个创建一系列相关或相互依赖对象的接口,而无需指定它们具体的类。 1.4 原型模式原型模式,用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。 1.5 建造者模式建造者模式,将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。 1.6 单例模式单例模式,保证一个类仅有一个实例,并提供一个访问它的全局访问点。 二、结构型模式2.1 适配器模式适配器模式,将一个类的接口转换成客户希望的另外一个接口。Adapter 模式使得原本由于接口不兼容而不能一起工作的那些类可以一起工作。 2.2 桥接模式桥接模式,将抽象部分与它的实现部分分离,使它们都可以独立地变化。 2.3 组合模式组合模式,将对象组合成树形结构以表示“部分-整体”的层次结构。组合模式使得用户对单个对象和组合对象的使用具有一致性。 2.4 装饰模式装饰模式,动态地给一个对象添加一些额外的职责,就增加功能来说,装饰模式比生成子类更为灵活。 2.5 外观模式外观模式,为子系统中的一组接口提供一个一致的界面,此模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。 2.6 享元模式享元模式,运用共享技术有效地支持大量细粒度的对象。 2.7 代理模式代理模式,为其他对象提供一种代理以控制对这个对象的访问。 三、行为模式3.1 解释器模式解释器模式,给定一个语言,定义它的文法的一种表示,并定义一个解释器,这个解释器使用该表示来解释语言中的句子。 3.2 模板方法模式模板方法模式,定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。 3.3 策略模式场景:商场促销。简单工厂模式虽然也能解决这个问题,但这个模式只是解决对象的创建问题,而且由于工厂本身包括了所有的收费方式,商场是可能经常性地更改打折额度和返利额度,每次维护或扩展收费方式都要改动这个工厂。所以它不是最好的办法。 面对算法的时常变动,应该有更好的办法。 策略模式:它定义了算法家族,分别封装起来,让它们之间可以互相替换,此模式让算法的变化,不会影响到使用算法的客户。 3.4 观察者模式观察者模式定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个主题对象。这个主题对象在状态发生变化时,会通知所有观察者对象,使它们能够自动更新自己。 3.5 状态模式状态模式,当一个对象的内在状态改变时允许改变其行为,这个对象看起来像是改变了其类。 3.6 备忘录模式备忘录:在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可将该对象恢复到原先保存的状态。 3.7 迭代器模式迭代器模式,提供一种方法顺序访问一个聚合对象中各个元素,而又不暴露该对象的内部表示。 3.8 命令模式命令模式,将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化:对请求排队或记录请求日志,以 及支持可撤销的操作。 3.9 责任链模式责任链模式,使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。将这个对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。 3.10 中介者模式中介者模式,用一个中介对象来封装一系列的对象交互。中介者使各对象不需要显式地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。 3.11 访问者模式(附)访问者模式,表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。 其他相关文档 程序设计6大原则 UML类图和类之间的关系 封面","link":"/2023/10.html"},{"title":"基于start.spring.io,定制你的Java脚手架","text":"一、背景:为什么要做脚手架? 创建工程的痛点2020 年,我们公司迎来了业务发展的迅猛期,滋生大量创建工程的需求。总体来说,创建工程面临着以下几个问题。 在创建工程时,多采用 copy 历史工程,并在上面进行修改的方式,造成了新工程里遗留了一些老旧的“垃圾”; 各团队所建工程分层方式不一,结构混乱,甚至有的包职责相同,命名却不一样,难以形成共识传递下去; 所依赖组件版本不一,比如jackson、guava包,难以形成技术演进,或者说技术演进兼容性问题很难解决; 业内方案参考start.spring.io整合了Gradle, Maven工程,语言支持Java,Kotlin,Groovy。 start.aliyun.com在start.spring.io基础上增加了不同应用架构的选择:MVC, 分层架构, COLA。同时也增加阿里的开源组件例如Spring Cloud Alibaba。 同时也增加了【一键运行】功能,【分享】功能可以保存分享至自己的账号下。 二、构思:做成什么样?脚手架画像 能快速创建一个最小可运行工程; 能规范工程命名、服务应用架构分层, 增加代码结构规范、可理解性; 能快速集成 CI/CD,代码驱动 API 接口文档生成,提升开发效率; 能统一第三方组件版本号; 1.0 版本为了快速落地脚手架,我们使用了 Maven Archetype 来实现。首先创建一个规范化的工程。 工程结构需分层清晰,像斑马的条纹,因此取名为zebra。工程已开源:https://github.com/studeyang/zebra 然后使用 Maven 的maven-archetype-plugin插件,生成脚手架。 12345678910<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-archetype-plugin</artifactId> <version>3.0.0</version> <configuration> <propertyFile>archetype.properties</propertyFile> <encoding>UTF-8</encoding> <archetypeFilteredExtentions>java,xml,yml,sh,groovy,md</archetypeFilteredExtentions> </configuration></plugin> 生成的脚手架如下: 脚手架中生成的代码不是可编译的代码,它包含了一些变量。 1234567891011121314151617181920212223#set( $symbol_pound = '#' )#set( $symbol_dollar = '$' )#set( $symbol_escape = '\\' )package ${package};import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import org.springframework.cloud.client.discovery.EnableDiscoveryClient;import org.springframework.cloud.netflix.feign.EnableFeignClients;/** * @author studeyang */@SpringBootApplication@EnableDiscoveryClient@EnableFeignClientspublic class WebApplication { public static void main(String[] args) { SpringApplication.run(WebApplication.class, args); }} 最后,就可以将脚手架打包并上传至仓库。 123456789# 1. 修改项目代码,在项目目录执行cd ${projectRoot}mvn archetype:create-from-project# 2. 然后在 target/generated-sources/archetype 目录下执行下一步的操作cd target/generated-sources/archetype# 3. 脚手架打包mvn clean install# 4. 上传脚手架mvn clean deploy 用户可以通过以下命令下载脚手架包。 12345mvn dependency:get \\ -DremoteRepositories={仓库地址} \\ -DgroupId={脚手架的groupId} \\ -DartifactId={脚手架的artifactId} \\ -Dversion={版本} 下载完成后,使用脚手架生成新工程。 123456789mvn archetype:generate \\ -DarchetypeGroupId={脚手架的groupId} \\ -DarchetypeArtifactId={脚手架的artifactId} \\ -DarchetypeVersion={脚手架版本} \\ -DgroupId={工程的groupId} \\ -DartifactId={工程的artifactId} \\ -Dversion={工程版本号} \\ -Dpackage={工程包名} \\ -DinteractiveMode=false 2.0 版本脚手架 1.0 版本解决了上述的大多痛点,但也有一些无法实现的或者可以做得更好的。例如:基础组件依赖无法管理,用户无法灵活的选择工程所需要的依赖等。参考start.spring.io,我们发现可以做的还有很多,于是启动 2.0 版本的开发。 最终形态: 三、实现:怎么做的?相比于start.spring.io,主要变化是增加了分层应用架构,整合了公司自己的组件库,并且新开发了【一键运行】功能。 主要实现当前端组织好参数后,最终通过 HTTP 请求将参数传递给后端,后端接收到的参数如下。 后端接收到工程类型为maven-project,并通\u0001过如下配置将之识别为zebra-project,即分层架构。 1234567891011121314151617initializr: types: - name: None id: based-project description: Generate a Maven based project archive. tags: build: maven format: based-project action: /starter.zip - name: 分层架构 id: maven-project description: Generate a Zebra project archive. tags: build: maven format: zebra-project default: true action: /starter.zip 我们自定义zebra-project分层架构的代码实现。这里定义一些 BuildCustomizer,实现工程的一些定制,例如:用户选择了 spring-boot-starter,程序应该在pom.xml生成相应的 dependency。代码如下: (Spring Initializr 提供了 BuildCustomizer 接口的扩展性) 123456789101112131415161718@ProjectGenerationConfiguration@ConditionalOnProjectFormat(ZebraProjectFormat.ID)public class ZebraProjectGenerationConfiguration { // 省略代码 @Bean public BuildCustomizer<Build> springBootStarterBuildCustomizer() { return (build) -> { build.dependencies().add(SPRINGBOOT_STARTER_ID, Dependency.withCoordinates("org.springframework.boot", "spring-boot-starter")); build.dependencies().add("starter-test", Dependency.withCoordinates("org.springframework.boot", "spring-boot-starter-test") .scope(DependencyScope.TEST_COMPILE)); }; } // ...} 再定义一些 Contributor,实现工程各个部分结构的生成,例如根目录pom.xml文件。 12345678910111213141516171819@ProjectGenerationConfiguration@ConditionalOnProjectFormat(ZebraProjectFormat.ID)public class ProjectContributorAutoConfiguration { // 省略代码 @Bean public ZebraRootPomProjectContributor zebraRootPomProjectContributor( MavenBuild build, IndentingWriterFactory indentingWriterFactory) { return new ZebraRootPomProjectContributor(build, indentingWriterFactory); } @Bean public ApplicationYmlProjectContributor bootstrapYmlProjectContributor(ProjectDescription description) { return new ApplicationYmlProjectContributor(description); } // ...} ”项目添加了什么依赖,工程就生成对应的代码“,对于这个功能点,是通过@ConditionalOnRequestedDependency注解来实现的。 12345@Bean@ConditionalOnRequestedDependency("web")public OrderServiceImplCodeProjectContributor orderServiceImplCodeProjectContributor() { return new OrderServiceImplCodeProjectContributor(this.description);} 例如OrderServiceImplCodeProjectContributor代码类的生成,只当用户选择了 web 依赖才会生成。 12345678910111213141516public class OrderServiceImplCodeProjectContributor implements ProjectContributor { // 省略代码 @Override public void contribute(Path projectRoot) throws IOException { JavaCompilationUnit javaCompilationUnit = javaSourceCode.createCompilationUnit( this.description.getPackageName() + ".restful", "OrderServiceImpl"); JavaTypeDeclaration javaTypeDeclaration = javaCompilationUnit.createTypeDeclaration("OrderServiceImpl"); customize(javaTypeDeclaration); Path servicePath = ContributorSupport.getServicePath(projectRoot, description.getArtifactId()); this.javaSourceCodeWriter.writeTo( new SourceStructure(servicePath.resolve("src/main/"), new JavaLanguage()), javaSourceCode); } // 省略代码} 依赖包管理增加如下配置即可实现新组件库的增加。 123456789101112131415161718initializr: dependencies: - name: 基础组件库 bom: infra repository: my-rep content: - name: Example id: example groupId: com.dbses.open artifactId: example-spring-boot-starter description: 示例组件说明 starter: true links: - rel: guide href: {用户手册} description: Example 快速开始 - rel: reference href: {参考文档} 一键运行一键运行功能是把生成好的工程上传至公司的代码仓库(Gitlab),并做好新工程的 CICD 配置(Jenkins),然后将工程部署到云容器(Kubernetes)的过程。 前端使用的是 React 组件是抖音的 Semi。Gitlab Groups 下拉列表是通过 Gitlab API 授权获取的,这个授权过程如下: 授权完成后,点击确认的后续过程: createGitlabProjectProcessor业务处理: 完成 gitlab工程的创建; 生成新工程,并上传至gitlab; createDevopsProcessor业务处理:生成并上传工程服务部署模板; cicdTriggerProcessor业务处理:触发PRECI操作(后续操作由jenkins回调衔接); 到这里,大致的实现就讲完了。如果你也想搭建一个工程脚手架,欢迎和我交流。","link":"/2022/3.html"},{"title":"海量数据下,统计用户的签到信息","text":"在 Web 和移动应用的业务场景中,我们经常需要保存这样一种信息:统计用户在手机 App 上的签到打卡信息。 在签到打卡的场景中,我们只用记录签到(1)或未签到(0),它就是非常典型的二值状态。在签到统计时,每个用户一天的签到用 1 个 bit 位就能表示,一个月(假设是 31 天)的签到情况用 31 个 bit 位就可以,而一年的签到也只需要用 365 个 bit 位。 Bitmap 数据类型介绍Redis 恰好有这样一种数据结构:Bitmap。Bitmap 本身是用 String 类型作为底层数据结构实现的一种统计二值状态的数据类型。String 类型是会保存为二进制的字节数组,所以,Redis 就把字节数组的每个 bit 位利用起来,用来表示一个元素的二值状态。你可以把 Bitmap 看作是一个 bit 数组。 那么,具体该怎么用 Bitmap 进行签到统计呢? 假设我们要统计 ID 3000 的用户在 2022 年 10 月份的签到情况,就可以按照下面的步骤进行操作。 第一步,执行下面的命令,记录该用户 10 月 1 号已签到(bit 位设置为 1)。 1SETBIT uid:sign:3000:202210 0 1 第二步,检查该用户 10 月 1 日是否签到。 1GETBIT uid:sign:3000:202210 0 第三步,统计该用户在 10 月份的签到次数(bit 数组中所有“1”的个数)。 1BITCOUNT uid:sign:3000:202210 这样,我们就知道该用户在 10 月份的签到情况了。 Bitmap 的 BITOP 命令Bitmap 支持用 BITOP 命令对多个 Bitmap 按位做“与”“或”“异或”的操作,操作的结果会保存到一个新的 Bitmap 中。 我以按位“与”操作为例来具体解释一下。从下图中,可以看到,三个 Bitmap bm1、bm2 和 bm3,对应 bit 位做“与”操作,结果保存到了一个新的 Bitmap 中(示例中,这个结果 Bitmap 的 key 被设为“resmap”)。 回到我们的标题:如果记录了 1 亿个用户 10 天的签到情况,你有办法统计出这 10 天连续签到的用户总数吗? 在统计 1 亿个用户连续 10 天的签到情况时,你可以把每天的日期作为 key,每个 key 对应一个 1 亿位的 Bitmap,每一个 bit 对应一个用户当天的签到情况。 接下来,我们对 10 个 Bitmap 做“与”操作,得到的结果也是一个 Bitmap。在这个 Bitmap 中,只有 10 天都签到的用户对应的 bit 位上的值才会是 1。最后,我们可以用 BITCOUNT 统计下 Bitmap 中的 1 的个数,这就是连续签到 10 天的用户总数了。 现在,我们可以计算一下记录了 10 天签到情况后的内存开销。每天使用 1 个 1 亿位的 Bitmap,大约占 12MB 的内存(10^8/8/1024/1024),10 天的 Bitmap 的内存开销约为 120MB,内存压力不算太大。不过,在实际应用时,最好对 Bitmap 设置过期时间,让 Redis 自动删除不再需要的签到记录,以节省内存开销。 所以,如果只需要统计数据的二值状态,例如商品有没有、用户在不在等,就可以使用 Bitmap,因为它只用一个 bit 位就能表示 0 或 1。在记录海量数据时,Bitmap 能够有效地节省内存空间。 本篇摘自极客时间《Redis核心技术与实战》(蒋德钧)第12讲。","link":"/2022/5.html"},{"title":"MySQL的事务隔离及实现原理","text":"提到事务,你肯定不陌生。在 MySQL中,InnoDB 是支持事务的,事务有4大特性,即 ACID(Atomicity、Consistency、Isolation、Durability,即原子性、一致性、隔离性、持久性)。 今天我们就来说说隔离性。 事务的隔离级别当数据库上有多个事务同时执行的时候,就可能出现脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)的问题,为了解决这些问题,就有了“隔离级别”的概念。 MySQL 将隔离级别分为 4 个等级,分别是: 读未提交(read uncommitted):一个事务还没提交时,它做的变更就能被别的事务看到。 读提交(read committed):一个事务提交之后,它做的变更才会被其他事务看到。 可重复读(repeatable read):一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。 串行化(serializable ):顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。 我用一个例子说明这几种隔离级别。假设数据表 T 中只有一列,其中一行的值为 1。 12create table T(c int) engine=InnoDB;insert into T(c) values(1); 下面是按照时间顺序执行两个事务的行为。 我们来看看在不同的隔离级别下,事务 A 会有哪些不同的返回结果,也就是图里面 V1、V2、V3 的返回值分别是什么。 若隔离级别是“读未提交”, 则 V1 的值就是 2。这时候事务 B 虽然还没有提交,但是结果已经被 A 看到了。因此,V2、V3 也都是 2。 若隔离级别是“读提交”,则 V1 是 1,V2 的值是 2。事务 B 的更新在提交后才能被 A看到。所以, V3 的值也是 2。 若隔离级别是“可重复读”,则 V1、V2 是 1,V3 是 2。之所以 V2 还是 1,遵循的就是这个要求:事务在执行期间看到的数据前后必须是一致的。 若隔离级别是“串行化”,则在事务 B 执行“将 1 改成 2”的时候,会被锁住。直到事务 A 提交后,事务 B 才可以继续执行。所以从 A 的角度看, V1、V2 值是 1,V3 的值是 2。 这 4 个隔离级别是递增的,你隔离得越严实,出现的问题就越少(问题指的是脏读、不可重复读、幻读),但效率也会越低。因此很多时候,我们都要在二者之间寻找一个平衡点。 注意不同的数据库的隔离级别是不一样的。 Oracle 数据库的默认隔离级别其实就是读提交,MySQL 默认是可重复读,因此对于一些从 Oracle 迁移到 MySQL 的应用,为保证数据库隔离级别的一致,你得将 MySQL 的隔离级别设置为读提交。 隔离级别的实现原理事务隔离在实现上,数据库里面会创建一个视图,访问的时候以视图的逻辑结果为准。回到刚刚隔离级别的例子。 在“可重复读”隔离级别下,这个视图是在事务启动时创建的(如图中标识1处),整个事务存在期间都用这个视图; 在“读提交”隔离级别下,这个视图是在每个 SQL 语句开始执行的时候创建的(如图中标识2处); 在“读未提交”隔离级别下,直接返回记录上的最新值,没有视图概念; “串行化”隔离级别下直接用加锁的方式来避免并行访问。 事务隔离的具体实现在 MySQL 中,实际上每条记录在更新的时候都会同时记录一条回滚操作。记录上的最新值,通过回滚操作,都可以得到前一个状态的值。 假设一个值从 1 被按顺序改成了 2、3、4,在回滚日志里面就会有类似下面的记录 : 当前值是 4,但是在查询这条记录的时候,不同时刻启动的事务会有不同的 read-view。如图中看到的,在视图 A、B、C 里面,这一个记录的值分别是 1、2、4,同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC)。对于 read-view A,要得到 1,就必须将当前值依次执行图中所有的回滚操作得到。 你会发现,即使现在有另外一个事务正在将 4 改成 5,这个事务跟 read-view A、B、C 对应的事务是不会冲突的。 那回滚日志总不能一直保留吧,什么时候删除呢? 系统会判断,当没有事务再需要用到这些回滚日志时,也就是当系统里没有比这个回滚日志更早的 read-view 的时候,回滚日志会被删除。 因此在使用数据库时,应尽量不要使用长事务。 长事务意味着系统里面会存在很老的事务视图。由于这些事务随时可能访问数据库里面的任何数据,所以这个事务提交之前,数据库里面它可能用到的回滚记录都必须保留,这就会导致大量占用存储空间。 小结本文我们以一个例子回顾了事务的 4 种隔离级别,并介绍了隔离级别的实现原理。最后,介绍了事务的隔离实现是通过数据库多版本并发控制(MVCC)来记录不同版本的记录值的。 由于同一条记录在系统中存在多个版本,所以在数据库使用过程中,应尽量不要使用长事务。 整理自极客时间《MySQL实战45讲》学习笔记","link":"/2022/6.html"},{"title":"要不要走索引?MySQL 的成本分析","text":"谈到索引失效,大家可能都能列举出几个场景,比如:后模糊查询、条件中带函数、索引中断等等。今天我想和你分享另一个场景:索引成本分析。 我先用一个具体的例子来描述一下这个场景。 案例场景假设现在我们有一张人物表,建表语句如下: 12345678create TABLE `person` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(255) NOT NULL, `score` int(11) NOT NULL, `create_time` timestamp NOT NULL, PRIMARY KEY (`id`),) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 并创建两个索引: 12KEY `name_score` (`name`,`score`) USING BTREE,KEY `create_time` (`create_time`) USING BTREE 然后插入 10 万条数据: 123456789create DEFINER=`root`@`%` PROCEDURE `insert_person`()begin declare c_id integer default 1; while c_id<=100000 do insert into person values(c_id, concat('name',c_id), c_id+100, date_sub(NOW(), interval c_id second)); -- 需要注意,因为使用的是now(),所以对于后续的例子,使用文中的SQL你需要自己调整条件,否则可能看不到文中的效果 set c_id=c_id+1; end while;end 数据插入后,我们用下面的 SQL 进行查询: 1explain select * from person where NAME>'name84059' and create_time>'2020-01-24 05:00:00' 通过上面的执行计划可以看到:type=All,说明是全表扫描。 接着我们把 create_time 条件中的 5 点改为 6 点: 1explain select * from person where NAME>'name84059' and create_time>'2020-01-24 06:00:00' 执行计划显示:type=range,key=create_time,走了 create_time 索引,而不是 name_score 联合索引。 看到这里,你是不是很诧异?接下来,我们就一起来分析一下这背后的原因。 原因分析MySQL 在查询数据之前,会先对可能的方案做执行计划,然后依据成本决定走哪个执行计划。这里的成本,包括 IO 成本和 CPU 成本: IO 成本,是从磁盘把数据加载到内存的成本。默认情况下,读取数据页的 IO 成本常数是 1(也就是读取 1 个页成本是 1)。 CPU 成本,是检测数据是否满足条件和排序等 CPU 操作的成本。默认情况下,检测记录的成本是 0.2。 MySQL 维护了表的统计信息,可以使用下面的命令查看: 1SHOW TABLE STATUS LIKE 'person' 从图中可以看到,总行数是 100086 行,由于 MySQL 的统计信息是一个估算,这里多了 86 行是正常的。CPU 成本是 100086*0.2=20017 左右。 数据长度是 4734976 字节。对于 InnoDB 来说,4734976 就是聚簇索引占用的空间,等于聚簇索引的页数量 * 每个页面的大小。InnoDB 每个页面的大小是 16KB,大概计算出页的数量是 289,因此 IO 成本是 289 左右。 所以,全表扫描的总成本是 20306 左右。 在 MySQL 5.6 及之后的版本中,我们可以使用 optimizer trace 功能查看优化器生成执行计划的整个过程。 1234SET optimizer_trace="enabled=on";explain select * from person where NAME >'name84059' and create_time>'2020-01-24 05:00:00';select * from information_schema.OPTIMIZER_TRACE;SET optimizer_trace="enabled=off"; 对于按照 create_time>’2020-01-24 05:00:00’ 条件走全表扫描的 SQL,我从 OPTIMIZER_TRACE 的执行结果中,摘出了几个重要片段来重点分析: 使用 name_score 对 name84059<name 条件进行索引扫描需要扫描 25362 行,成本是 30435。 12345678910{ "index": "name_score", "ranges": [ "name84059 < name" ], "rows": 25362, "cost": 30435, "chosen": false, "cause": "cost"} 30435 是查询二级索引的 IO 成本和 CPU 成本之和,再加上回表查询聚簇索引的 IO 成本和 CPU 成本之和。 使用 create_time 进行索引扫描需要扫描 23758 行,成本是 28511。 12345678910{ "index": "create_time", "ranges": [ "0x5e2a79d0 < create_time" ], "rows": 23758, "cost": 28511, "chosen": false, "cause": "cost"} 全表扫描 100086 条记录的成本是 20306。(和上面计算的一致) 1234567891011121314151617{ "considered_execution_plans": [{ "table": "`person`", "best_access_path": { "considered_access_paths": [{ "rows_to_scan": 100086, "access_type": "scan", "resulting_rows": 100086, "cost": 20306, "chosen": true }] }, "rows_for_plan": 100086, "cost_for_plan": 20306, "chosen": true }]} 所以 MySQL 最终选择了全表扫描方式作为执行计划。 把 SQL 中的 create_time 条件从 05:00 改为 06:00,再次分析 OPTIMIZER_TRACE 可以看到: 123456789{ "index": "create_time", "ranges": [ "0x5e2a87e0 < create_time" ], "rows": 16588, "cost": 19907, "chosen": true} 因为是查询更晚时间的数据,走 create_time 索引需要扫描的行数从 23758 减少到了 16588。这次走这个索引的成本 19907 小于全表扫描的 20306,更小于走 name_score 索引的 30435。 所以这次执行计划选择的是走 create_time 索引。 解决方案有时会因为统计信息的不准确或成本估算的问题,实际开销会和 MySQL 统计出来的差距较大,导致 MySQL 选择错误的索引或是直接选择走全表扫描,这个时候就需要人工干预,使用强制索引了。 比如,像这样强制走 name_score 索引: 1explain select * from person FORCE INDEX(name_score) where NAME >'name84059' and create_time>'2020-01-24 00:00:00' 小结本文通过一个例子,谈到了 MySQL 还有另外一个索引失效的场景,即分析器成本分析。 对于是否走索引,我们要学会使用 explain 进行分析。另外,在 MySQL 5.6 及之后的版本中,我们可以使用 optimizer trace 功能查看优化器生成执行计划的整个过程。 整理自极客时间《Java开发常见错误》学习笔记","link":"/2022/7.html"},{"title":"Nacos注册中心快速入门","text":"如果你作为Nacos的初识者,或者想快速了解Nacos的上手难度,希望本篇可以帮助到你。 1、快速开始1.1 引入依赖1234<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency> 1.2 应用配置1234567spring: cloud: nacos: discovery: server-addr: nacos-host:80 namespace: e5aebd28-1c15-4991-a36e-0865bb5af930 group: ${spring.profiles.active} 1.3 启动应用在项目的启动类中添加@EnableDiscoveryClient的注解。 1234567@SpringBootApplication@EnableDiscoveryClientpublic class UserProviderApplication { public static void main(String[] args) { SpringApplication.run(UserProviderApplication.class, args); }} 1.4 查看实例 详情如下: 2、使用Feign完成服务的调用2.1 引入依赖12345<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> <version>2.2.2.RELEASE</version></dependency> 2.2 启动类启动类上添加@EnableFeignClients的注解。 12345678@SpringBootApplication@EnableDiscoveryClient@EnableFeignClientspublic class UserConsumerApplication { public static void main(String[] args) { SpringApplication.run(UserConsumerApplication.class, args); }} 2.3 应用配置123feign: hystrix: enabled: true 2.4 使用示例12345678910111213@FeignClient(name = "user-provider",fallback = UserServiceFallback.class)public interface UserService { @RequestMapping("/user/config") String config();} @Servicepublic class UserServiceFallback implements UserService { @Override public String config() { return "user-fallback"; }} controller 调用如下。 1234567@Autowiredprivate UserService userService; @RequestMapping("consumer-feign")public String userService() { return userService.config();} 3、使用Ribbon完成服务的调用我们只需要将RestTemplate实例化,并添加@LoadBalanced注解就可以了,如下: 12345@Bean@LoadBalancedpublic RestTemplate restTemplate(){ return new RestTemplate();} 然后在,controller中,我们使用这个实例化好的RestTemplate,就可以了,具体实现如下: 12345678@Autowiredprivate RestTemplate restTemplate; @RequestMapping("consumer-ribbon")public String consumerribbon() { String url = "http://user-provider/user/config"; return restTemplate.getForObject(url, String.class);} 4、使用Nacos权重负载均衡4.1 修改权重三种服务的调用方法都给大家介绍完了,但是,他们的负载均衡策略都是轮询,这有点不符合我们的要求,我们进入到Nacos的管理后台,调节一下服务的权重,如图: 4.2 修改 Ribbon 的默认策略123user-provider: ribbon: NFLoadBalancerRuleClassName: com.alibaba.cloud.nacos.ribbon.NacosRule 小结本文介绍了Nacos注册中心入门使用,并介绍了通过Feign及Ribbon完成服务间调用,Nacos也实现了基于权重的负载均衡策略,这点可以搭配Ribbon使用。","link":"/2022/8.html"},{"title":"开源:上传 Jar 包至 Maven 中央仓库","text":"前言最近我将服务发现组件开源了:cloud-discovery,分享一下 Jar 包上传中央仓库过程遇到的问题与总结。需要说明的是,在下面两篇文章中已经将步骤写的非常清楚了,本文主要记录的是我在操作过程中遇到的一些坑,以供参考。 开源地址 cloud-discovery:https://github.com/studeyang/cloud-discovery 参考文章 https://juejin.cn/post/7130363900813377567 https://juejin.cn/post/7089301165929660446 Sonatype Jira 账号注册首先你要申请 groupId,例如 Spring 的 groupId 是org.springframework。你也要申请自己的 groupId,这个很好理解,毕竟org.springframework有很强的权威性,不是谁都能上传的。 groupId 就是在 Sonatype Jira 平台申请的。 第一,注册/登录账号 注册地址:https://issues.sonatype.org/secure/Signup!default.jspa 登录地址:https://issues.sonatype.org/login.jsp 第二,创建问题 注意「项目」要先择「Community Support - Open Source Project Repository」,「问题类型」选择「New Project」。 等待审核人员审核通过。 上图Congratulations!Welcome to the Central Repository!,说明 groupId 已经申请通过了,通常你命名的格式是:io.github.{你的github用户名},基本上都能一次性申请通过。 接着,按照下面的文档操作就可以了。 12https://central.sonatype.org/publish/publish-guide/#deploymenthttps://central.sonatype.org/publish/release/ Pom.xml 配置接下来就要配置项目打包相关的信息了,在 pom.xml 文件里,需要额外加上下面的配置项,否则配置信息校验会不通过。 1<name>,<description>,<url>,<licenses>,<scm>,<developers> 另外也会校验文档文件xx-javadoc.jar和加密文件xx.jar.asc。下面两个插件可以生成对应的文件。 1maven-javadoc-plugin,maven-gpg-plugin nexus-staging-maven-plugin这个插件也简单介绍一下,Jar 包会先上传到 Staging Repository 仓库中,然后需要手动点击进行校验并通过后,才会到正式仓库。这个插件免去了手动点击的繁琐操作,直接进行校验。 完整的pom.xml配置可以参考我的 Github 工程:https://github.com/studeyang/cloud-discovery/blob/master/pom.xml Jar 包加密传输Maven Pom 配置好后,你不能直接通过 mvn deploy命令将 Jar 包传输到中央仓库,而是要经过加密软件的加密。 安装GnuPG软件下载地址:https://gpg4win.org/thanks-for-download.html (步骤一)这个软件是为了给要上传的 Jar 包加密用。使用gpg --gen-key命令生成密钥。 123456789101112131415C:\\Users\\Administrator>gpg --gen-keygpg (GnuPG) 2.3.8; Copyright (C) 2021 g10 Code GmbHThis is free software: you are free to change and redistribute it.There is NO WARRANTY, to the extent permitted by law.Note: Use "gpg --full-generate-key" for a full featured key generation dialog.GnuPG needs to construct a user ID to identify your key.Real name: yangluluEmail address: [email protected] selected this USER-ID: "yanglulu <[email protected]>"Change (N)ame, (E)mail, or (O)kay/(Q)uit? 输入「o」回车。 1234567891011121314151617Change (N)ame, (E)mail, or (O)kay/(Q)uit? oWe need to generate a lot of random bytes. It is a good idea to performsome other action (type on the keyboard, move the mouse, utilize thedisks) during the prime generation; this gives the random numbergenerator a better chance to gain enough entropy.We need to generate a lot of random bytes. It is a good idea to performsome other action (type on the keyboard, move the mouse, utilize thedisks) during the prime generation; this gives the random numbergenerator a better chance to gain enough entropy.gpg: directory 'C:\\\\Users\\\\Administrator\\\\AppData\\\\Roaming\\\\gnupg\\\\openpgp-revocs.d' createdgpg: revocation certificate stored as 'C:\\\\Users\\\\Administrator\\\\AppData\\\\Roaming\\\\gnupg\\\\openpgp-revocs.d\\\\6381681E82726235773B17D753A149DCE9EE4910.rev'public and secret key created and signed.pub ed25519 2022-11-07 [SC] [expires: 2024-11-06] 6381681E82726235773B17D753A149DCE9EE4910uid yanglulu <[email protected]>sub cv25519 2022-11-07 [E] [expires: 2024-11-06] (步骤二)使用gpg --list-key查看生成结果。 12345678910111213141516C:\\Users\\Administrator>gpg --list-keygpg: checking the trustdbgpg: marginals needed: 3 completes needed: 1 trust model: pgpgpg: depth: 0 valid: 1 signed: 0 trust: 0-, 0q, 0n, 0m, 0f, 1ugpg: next trustdb check due at 2024-11-06C:\\Users\\Administrator\\AppData\\Roaming\\gnupg\\pubring.kbx--------------------------------------------------------pub rsa4096 2021-10-25 [SC] [expires: 2025-10-25] 1121AFDE66C7246282A7610448CB2369E978B6BAuid [unknown] yanglulu <[email protected]>sub rsa4096 2021-10-25 [E] [expires: 2025-10-25]pub ed25519 2022-11-07 [SC] [expires: 2024-11-06] 6381681E82726235773B17D753A149DCE9EE4910uid [ultimate] yanglulu <[email protected]>sub cv25519 2022-11-07 [E] [expires: 2024-11-06] 踩坑1:使用错误的公钥加密文件,导致上传仓库失败(步骤三)接着上面的步骤,把公钥发送到hkp://keyserver.ubuntu.com:11371服务器。 12C:\\Users\\Administrator>gpg --keyserver hkp://keyserver.ubuntu.com:11371 --send-keys 1121AFDE66C7246282A7610448CB2369E978B6BAgpg: sending key 48CB2369E978B6BA to hkp://keyserver.ubuntu.com 看一下公钥的发送结果。 1234C:\\Users\\Administrator>gpg --keyserver hkp://keyserver.ubuntu.com:11371 --recv-keys 1121AFDE66C7246282A7610448CB2369E978B6BAgpg: key 48CB2369E978B6BA: "yanglulu <[email protected]>" not changedgpg: Total number processed: 1gpg: unchanged: 1 (步骤四)公钥发送成功了,下面打 Jar 包。 1D:\\github\\cloud-discovery>mvn -U clean deploy -P release 到这一步,出错了。 从提示来看,似乎是没有找到公钥,但是「步骤三」显示,我分明已经将公钥发送过去了,有点奇怪! 我们从「步骤一」再仔细捋一遍,找找问题的线索: 步骤一生成了两个密钥,一个 uid 标识为 [unknown],另一个标识为 [ultimate] 步骤三我把标识为[unknown]的公钥发了出去,并提示我 key 48CB2369E978B6BA 发送成功 步骤四的报错原因显示,53a149dce9ee4910 这个 key 找不到 会不会是 uid 标识为 [unknown] 的密钥有问题呢? 后来我尝试使用 [ultimate] 的公钥重新发送。 12D:\\github\\cloud-discovery>gpg --keyserver hkp://keyserver.ubuntu.com:11371 --send-keys 6381681E82726235773B17D753A149DCE9EE4910gpg: sending key 53A149DCE9EE4910 to hkp://keyserver.ubuntu.com:11371 1234D:\\github\\cloud-discovery>gpg --keyserver hkp://keyserver.ubuntu.com:11371 --recv-keys 6381681E82726235773B17D753A149DCE9EE4910gpg: key 53A149DCE9EE4910: "yanglulu <[email protected]>" not changedgpg: Total number processed: 1gpg: unchanged: 1 结果显示 key 53A149DCE9EE4910 发送成功了,并且 53A149DCE9EE4910 也与报错中找不到的 key 吻合。我再进行后面的步骤,这个问题果然就不出现了。 踩坑2:401错误继续后面的步骤,在mvn deploy过程中返回了一个 401 错误码,这个问题原因就是 ossrh 账号密码配错了。 天真的我以为自己账号密码记得非常清楚,不会有错,尝试其他修改无果后,校验了一下密码,果然是密码写错了。TT 踩坑3:–recv-keys No data补充一下,在踩坑1发送公钥步骤中,会出现下面的响应,这时再重试发送一次就好了。 12D:\\github\\cloud-discovery>gpg --keyserver hkp://keyserver.ubuntu.com:11371 --recv-keys 6381681E82726235773B17D753A149DCE9EE4910gpg: keyserver receive failed: No data 上传成功后,可以在https://s01.oss.sonatype.org/查询到 Jar 包,此时就已经可以供用户下载了,同步至中央仓库还没有这么及时。 过两天再从中央仓库查询,Jar 包已经可以查到了。 中央仓库地址是:https://mvnrepository.com/ 小结整个过程看起来容易,做起来就会遇过各种各样的问题。想要公开自己 Jar 包的小伙伴赶紧操作起来吧!","link":"/2022/9.html"},{"title":"MySQL查询性能慢,该不该建索引?","text":"日常工作中,有些同学一遇到查询性能问题,就盲目要求 DBA 给表字段创建索引。今天,我们就来具体看看这背后的细节。 本文的例子均在 MySQL 5.7.26 中执行。 聚簇索引和二级索引说到索引,页目录就是最简单的索引。但当数据页有无数个时,就需要考虑建立索引,才能定位到记录所在的页。 为了解决这个问题,InnoDB 引入了 B+ 树。 上图叶子节点下面方块中的省略号是实际数据,这样的 B+ 树就是聚簇索引。由于数据在物理上只会保存一份,所以聚簇索引只能有一个。InnoDB 会自动使用主键作为聚簇索引的索引键,如果没有主键,就选择第一个不包含 NULL 值的唯一列。 为了实现非主键字段的快速搜索,就有了二级索引,如下图所示: 这次二级索引的叶子节点中保存的不是实际数据,而是主键,获得主键值后去聚簇索引中获得数据行。 创建二级索引的代价,主要表现在维护代价、空间代价和回表代价三个方面。 二级索引的维护代价创建 N 个二级索引,就需要再创建 N 棵 B+ 树,新增数据时不仅要修改聚簇索引,还需要修改这 N 个二级索引。 我们通过实验测试一下创建索引的代价。 1234567create TABLE `person` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(255) NOT NULL, `score` int(11) NOT NULL, `create_time` timestamp NOT NULL, PRIMARY KEY (`id`),) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 通过下面的存储过程循环创建 10 万条测试数据: 123456789create DEFINER=`root`@`%` PROCEDURE `insert_person`()begin declare c_id integer default 1; while c_id<=100000 do insert into person values(c_id, concat('name',c_id), c_id+100, date_sub(NOW(), interval c_id second)); -- 需要注意,因为使用的是now(),所以对于后续的例子,使用文中的SQL你需要自己调整条件,否则可能看不到文中的效果 set c_id=c_id+1; end while;end 执行耗时是 140 秒。如果再创建两个索引: 12KEY `name_score` (`name`,`score`) USING BTREE,KEY `create_time` (`create_time`) USING BTREE 那么创建 10 万条记录的耗时提高到 154 秒。 这里其实还有一个代价。页中的记录都是按照索引值从小到大的顺序存放的,新增记录就需要往页中插入数据,现有的页满了就需要新创建一个页,把现有页的部分数据移过去,这就是页分裂;如果删除了许多数据使得页比较空闲,还需要进行页合并。 页分裂和合并,都会有 IO 代价,并且可能在操作过程中产生死锁。 二级索引的空间代价虽然二级索引不保存原始数据,但要保存索引列的数据,所以会占用更多的空间。比如,person 表创建了两个索引后,使用下面的 SQL 查看数据和索引占用的磁盘: 1select DATA_LENGTH, INDEX_LENGTH from information_schema.TABLES where TABLE_NAME='person'; 结果显示,数据本身只占用了 4.7M,而索引占用了 8.4M。 二级索引的回表代价使用 SELECT * 按照 name 字段查询用户,使用 EXPLAIN 查看执行计划: 1explain select * from person where NAME='name1'; 可以发现,key 字段代表实际走的是哪个索引,其值是 name_score,说明走的是 name_score 这个索引。 type 字段代表了访问表的方式,其值 ref 说明是二级索引等值匹配,符合我们的查询。 把 SQL 中的 * 修改为 NAME 和 SCORE,也就是 SELECT name_score 联合索引包含的两列,查看执行计划: 1explain select NAME,SCORE from person where NAME='name1'; 可以看到,Extra 列多了一行 Using index 的提示,证明这次查询直接查的是二级索引,免去了回表。 创建索引最佳实践了解了上面的三条代价,现在我们知道,索引并不是解决查询慢的万能钥匙。这里我总结了三条创建索引的三条最佳实践供你参考。 第一,无需一开始就建立索引。 可以等到业务场景明确后,或者是数据量超过 1 万、查询变慢后,再针对需要查询、排序或分组的字段创建索引。创建索引后可以使用 EXPLAIN 命令,确认查询是否可以使用索引。 第二,尽量索引轻量级的字段。 比如能索引 int 字段就不要索引 varchar 字段。这是因为,整型类型的数据通常占用的存储空间更小,查询效率更高,另外整型数据的比较操作几乎总是比字符串类型的比较更快。 索引字段也可以是部分前缀,在创建的时候指定字段索引长度。针对长文本的搜索,可以考虑使用 Elasticsearch 等专门用于文本搜索的索引数据库。 第三,尽量不要在 SQL 语句中 SELECT *。 应该 SELECT 必要的字段,甚至可以考虑使用联合索引来包含我们要搜索的字段(即索引覆盖),既能实现索引加速,又可以避免回表的开销。 小结本文我们分析了创建二级索引的三个代价,即维护代价、空间代价、回表代价。索引不是解决查询性能问题的万能钥匙。 整理自极客时间《Java开发常见错误》学习笔记。","link":"/2022/10.html"},{"title":"spring initializr脚手架搭建详解","text":"前段时间,我在「基于start.spring.io,我实现了Java脚手架定制」一文中讲述了敝司的微服务脚手架落地过程中的前世今生,并提到了基于 spring initializr 的搭建了 2.0 版本的脚手架。今天我打算和你分享一下这其中的实现过程与细节,项目已经开源在 Github 上。 start-parent:https://github.com/studeyang/start-parent 欢迎 star 1、项目结构介绍项目分为 initializr、start-client、start-site 三个部分,重要部分说明如下。 12345678910111213141516start-parent |- initializr 代码生成 |- initializr-actuator |- initializr-bom |- initializr-docs |- initializr-generator 生成基础工程代码 |- initializr-generator-spring 生成 spring 工程代码 |- initializr-generator-test 单元测试的封装 |- initializr-generator-zebra 生成 zebra 分层架构 |- initializr-metadata 工程元数据(pom 相关定义) |- initializr-parent |- initializr-service-sample |- initializr-version-resolver 版本解析 |- initializr-web |- start-client 脚手架前端 |- start-site 脚手架后端 工程间的依赖关系图我作了简化,图示如下。 了解了项目的整体情况,下面请跟随我的思路,一起将工程搭建起来。 2、集成Gitlab如果你想使用项目中的「创建工程」功能,则需要进行此步骤的配置。这里我以gitlab.com为例,介绍如何完成与 Gitlab 的集成。 首先需要让 Gitlab 信任我们的应用,以完成后面的登录授权跳转。在 Gitlab 平台配置脚手架应用。 这里我配置了本地开发环境的 Redirect URI,如果后续需要部署到服务器,则应该配置脚手架服务器的后端地址。 配置完成后,Gitlab 就将我们的应用记录了下来,并分配了 Application ID 和 Secret,这两个字段值我们需要配置到 start-site application.yml 文件中: 1234567891011security: base-url: https://gitlab.com authorization-uri: ${security.base-url}/oauth/authorize token-uri: ${security.base-url}/oauth/token user-info-uri: ${security.base-url}/api/v4/user redirect-uri: http://127.0.0.1:8081/oauth/redirect client-id: gitlab client id client-secret: gitlab client secret admin: name: your gitlab admin username password: your gitlab admin password 这里我简单介绍一下相关字段,authorization-uri, token-uri, user-info-uri 这三个字段是固定的,不需要配置。 base-url:如果你使用gitlab管理项目,base-url可以设置成你搭建的gitlab地址; redirect-uri:gitlab 认证后跳转的地址,这里使用了后端来接收跳转,因为跳转会携带 code 参数,避免暴露在浏览器,提高安全性; client-id:gitlab 分配的 Application ID; client-secret:gitlab 分配的 Secret; admin.name:gitlab 的账号,用于创建工程,并将初始的工程代码提交,建议配置管理员账号; admin.password:gitlab 的账号密码; 3、添加组件接下来添加组件依赖。这里我以casslog-spring-boot-starterJar 包为例,如果该组件仅支持部分版本的 SpringBoot,那可以配置 compatibility-range,例如: 1compatibility-range: "[1.4.2.RELEASE,1.5.7.RELEASE]" 完整的配置如下。 12345678910111213141516171819initializr: dependencies: - name: 开源基础设施 bom: kmw repository: my-rep content: - name: Casslog id: casslog groupId: io.github.studeyang artifactId: casslog-spring-boot-starter description: 日志工具类 starter: true compatibility-range: "[1.4.2.RELEASE,1.5.7.RELEASE]" links: - rel: guide href: {用户手册} description: Example 快速开始 - rel: reference href: {参考文档} 配置dependencies。 「name」组件依赖类别的名称,例如:开源基础设施 「bom」该类别下的依赖包管理库 「repository」该类别下的依赖包所属仓库 「content」具体的依赖包 配置content。 「name」依赖包名称 「id」依赖包唯一标识(代码中使用) 「groupId」依赖包 groupId 「artifactId」依赖包 artifactId 「description」依赖包 description 「starter」是否是 spring-boot-starter 「compatibility-range」依赖的 springboot 版本 「links」组件的使用文档 配置好的效果图如下。 4、部署应用下面就可以将脚手架部署到服务器上了。 这里提醒一下,记得修改 Gitlab 的 redirect-uri 为脚手架服务器的地址。 4.1 步骤一:工程打包1234567891011# 打包前端工程cd {projectRoot}/start-clientsh ../mvnw install# 打包 initializr 项目cd {projectRoot}/initializrsh ../mvnw clean install -Dmaven.test.skip=true# 打包 start-sitecd {projectRoot}/start-sitesh ../mvnw clean install -Dmaven.test.skip=true 4.2 步骤二:打 Docker 镜像12cd {projectRoot}/start-sitedocker build -t start-site:0.0.1 . 运行镜像即可。效果图如下。 5、使用脚手架的正确姿势5.1 通过HELP.md管理使用文档在「3、添加组件」过程中所配置的文档链接将会在 HELP.md 文件中展示,示意图如下: 5.2 保存/分享工程你配置好的工程可以通过「分享…」功能保存下来。 5.3 在IDEA中使用脚手架可在 IDEA 中快速创建工程,只需要配置好脚手架服务器地址即可。需要注意的是社区版的 IDEA 是没有这个功能的。 小结本文向你介绍了 Spring Initializr 脚手架的搭建过程,如果你在此过程中遇到了问题,欢迎和我交流。","link":"/2022/11.html"},{"title":"XXL-JOB核心源码导读及时间轮原理剖析","text":"你好,今天我想和你分享一下XXL-JOB的核心实现。如果你是XXL-JOB的用户,那么你肯定思考过它的实现原理;如果你还未接触过这个产品,那么可以通过本文了解一下。 XXL-JOB的架构图(2.0版本)如下: 它是如何工作的呢?从使用方的角度来看,首先执行器要向服务端注册。那么这里你可能就有疑问了,执行器向服务端注册?怎么注册的?多久注册一次?采用什么通信协议? 注册完了之后,服务端才能知道有哪些执行器,并触发任务调度。那么服务端是如何记录每个任务的触发时机,并完成精准调度的呢?XXL-JOB采用的是Quartz调度框架,本文我打算用时间轮方案来替换。 最后,执行器接收到调度请求,是怎么执行任务的呢? 带着这些问题,我们开启XXL-JOB的探索之旅。我先来说说XXL-JOB项目模块,项目模块很简单,有2个: xxl-job-core:这个模块是给执行器依赖的; xxl-job-admin:对应架构图中的调度中心; 本文内容较干,请搭配源码食用。源码版本是:2.0.2 1、Job服务自动注册第一个核心技术点,服务注册。 服务注册要从xxl-job-core模块的XxlJobSpringExecutor类说起,这是一个 Spring 的 Bean,它是这么定义的: 1234567@Bean(initMethod = "start", destroyMethod = "destroy")public XxlJobSpringExecutor xxlJobExecutor() { XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor(); xxlJobSpringExecutor.setAdminAddresses(adminAddresses); // 其他的一些注册信息 return xxlJobSpringExecutor;} 进行代码追踪,最终会是下面的调用链路: 123456789xxl-job-core模块spring bean: XxlJobSpringExecutor # start()-> XxlJobExecutor # start() -> initRpcProvider()xxl-rpc-core.jar-> XxlRpcProviderFactory # start() -> ServiceRegistry # start()-> ExecutorServiceRegistry # start()-> ExecutorRegistryThread # start() ExecutorRegistryThread就是服务注册的核心实现了,start()方法核心代码如下: 1234567891011121314151617public void start(String appName, String address) { registryThread = new Thread(new Runnable() { @Override public void run() { // registry while (!toStop) { // do registry adminBiz.registry(registryParam); TimeUnit.SECONDS.sleep(JobConstants.HEARTBEAT_INTERVAL);// 30s } // registry remove adminBiz.registryRemove(registryParam); } }); registryThread.setDaemon(true); registryThread.start();} 可以看到执行器每 30s 执行注册一次,我们继续往下看。 2、自动注册通信技术实现通过上面ExecutorRegistryThread # start()方法核心代码,可以看到,注册是通过adminBiz.registry(registryParam)代码实现的,调用链路总结如下: 123456xxl-job-core模块AdminBiz # registry()-> AdminBizClient # registry()-> XxlJobRemotingUtil # postBody()-> POST api/registry (jdk HttpURLConnection) 最终还是通过 HTTP 协议的 POST 请求,注册数据格式如下: 12345{ "registryGroup": "EXECUTOR", "registryKey": "example-job-executor", "registryValue": "10.0.0.10:9999"} 看到这里,我们回到文章开头问题部分。 执行器向服务端注册?怎么注册的?多久注册一次?采用什么通信协议? 答案已经很明显了。 3、任务调度实现我们接着来看第二个核心技术点,任务调度。 XXL-JOB采用的是Quartz调度框架,这里我打算向你介绍一下时间轮的实现方案,核心源码如下: 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253@Componentpublic class JobScheduleHandler { private Thread scheduler; private Thread ringConsumer; private final Map<Integer, List<Integer>> ring; @PostConstruct public void start() { scheduler = new Thread(new JobScheduler(), "job-scheduler"); scheduler.setDaemon(true); scheduler.start(); ringConsumer = new Thread(new RingConsumer(), "job-ring-handler"); ringConsumer.setDaemon(true); ringConsumer.start(); } class JobScheduler implements Runnable { @Override public void run() { sleep(5000 - System.currentTimeMillis() % 1000); while (!schedulerStop) { try { lock.lock(); // pre read to ring } catch (Exception e) { log.error("JobScheduler error", e); } finally { lock.unlock(); } sleep(1000); } } } class RingConsumer implements Runnable { @Override public void run() { sleep(1000 - System.currentTimeMillis() % 1000); while (!ringConsumerStop) { try { int nowSecond = Calendar.getInstance().get(Calendar.SECOND); List<Integer> jobIds = ring.remove(nowSecond % 60); // 触发任务调度 } catch (Exception e) { log.error("ring consumer error", e); } sleep(1000 - System.currentTimeMillis() % 1000); } } }} 上述通过两个线程池来实现,job-scheduler为预读线程,job-ring-handler为时间轮线程。那么时间轮是怎么实现任务的精准调度的呢? 时间轮的实现原理我们常见的时钟根据秒针转动的类型,可以分为嘀嗒式秒针和流动式秒针。 我以嘀嗒式秒针时钟为例,可以把时钟环看作一个数组,秒针 1~60 秒停留的位置作为数组下标,60s 为数组下标 0。假设现在有 3 个待执行的任务,分别如下: 123jobid: 101 0秒时刻开始执行,2s/次jobid: 102 0秒时刻开始执行,3s/次jobid: 103 3秒时刻开始执行,4s/次 对应 0 秒时刻的数组模型如下图所示: 这里我把 0 时刻拆成了三个阶段,分别是: 执行前:读取该时刻有哪些任务待执行,拿到任务 id; 执行中:通过任务 id 查询任务的运行策略,执行任务; 执行后:更新任务的下次执行时间; 然后时间指针往前推动一个时刻,到了 1 秒时刻。此时刻时间轮中的任务并未发生变化。 到了第 2 秒时刻,预读线程将 jobid 103 加入时间轮,并执行该数组下标下的任务: 这样到了第 3 秒时刻,任务的数组下标又会被更新。 那么这种以秒为刻度的时间轮有没有误差呢? 任务调度的精准度是取决于时间轮的刻度的。举个例子,我们把 0 秒时刻的这 1s 拆成 1000ms。 假设任务都是在第 500ms 完成该时刻秒内所有任务的调度的,501ms 有一个新的任务被预读线程加载进来了,那么轮到下次调度,就要等到第 1 秒时刻的第 500ms,误差相差了一个刻度即 1s。如果以 0.5 秒为一个刻度,那么误差就变小了,是 500ms。 所以说,刻度越小,误差越小。不过这也要根据业务的实际情况来决定,毕竟要想减少误差,就要耗费更多的 CPU 资源。 了解完任务调度的实现原理,那调度器与执行器间的服务通信是如何实现的呢? 4、任务调度通信技术实现在xxl-job-admin模块,梳理调用链路如下: 123456789101112xxl-job-admin模块JobTriggerPoolHelper # trigger()-> ThreadPoolExecutor # execute() (分快慢线程池)-> XxlJobTrigger # trigger() -> processTrigger() -> runExecutor()-> XxlJobDynamicScheduler # getExecutorBiz() -> ExecutorBiz # run() (动态代理实现, 这里调用的 run 会作为参数) [1]-> XxlRpcReferenceBean. new InvocationHandler() # invoke()xxl-rpc-core.jar-> NettyHttpClient # asyncSend()(POST...请求参数 XxlRpcRequest 设置 methodName 为[1]处的调用方法即 "run") 最终是通过 HTTP 协议进行通信的,核心通信代码如下: 12345678public void send(XxlRpcRequest xxlRpcRequest) throws Exception { byte[] requestBytes = serializer.serialize(xxlRpcRequest); DefaultFullHttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.POST, new URI(address).getRawPath(), Unpooled.wrappedBuffer(requestBytes)); request.headers().set(HttpHeaderNames.HOST, host); request.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE); request.headers().set(HttpHeaderNames.CONTENT_LENGTH, request.content().readableBytes()); this.channel.writeAndFlush(request).sync();} 调度器将执行请求发送到执行器后,接着就是执行器的工作了。 5、执行器接收任务接口实现执行器的工作,梳理调用链路如下: 1234567891011121314xxl-job-core模块spring bean: XxlJobSpringExecutor # start()-> XxlJobExecutor # start() -> initRpcProvider()xxl-rpc-core.jar-> XxlRpcProviderFactory # start() -> Server # start()-> NettyHttpServer # start()netty 接口实现NettyHttpServerHandler # channelRead0() -> process() (线程池执行)-> XxlRpcProviderFactory # invokeService()(根据请求参数 XxlRpcRequest 里的 methodName 反射调用)-> ExecutorBizImpl # run() 我们也可以通过 HTTP 请求查看接口实现: 1GET http://localhost:17711/services 结果如下: 123<ui> <li>com.xxl.job.core.biz.ExecutorBiz: com.xxl.job.core.biz.impl.ExecutorBizImpl@d579177</li></ui> 执行器接收任务,总结来说用的是下面的接口: 1POST http://localhost:17711 要注意的是,这里如果通过 Postman 来调用是调不通的,因为序列化方式和 HTTP 协议是不一样的。 接下来就是执行器接收到任务逻辑,代码链路如下: 12345678xxl-job-core模块spring bean: XxlJobSpringExecutor # start()-> XxlJobExecutor # start() -> initRpcProvider()-> new ExecutorBizImpl()-> JobThread # pushTriggerQueue()spring bean: XxlJobExecutor # registJobThread() 启动 jobThead-> JobThread # run() 到这里,我们就把核心流程梳理了一遍。 小结通过上文的梳理,如果想要从 0 搭建一个分布式任务调度系统,想必你已胸有成竹了。 封面","link":"/2022/12.html"},{"title":"Redis高可用全景一览","text":"对于一项技术的学习,我们要对这项技术有一个全局观,下面是一张 Redis 全景图,画得非常全面。 今天我们主要关注 Redis 的高可用主线。Redis 的高可用性,具体来说,有两方面含义:一是服务少中断,二是数据少丢失。 为了保证服务少中断,通常的做法就是冗余,增加服务的副本,但是当副本多了以后,如何保证副本的数据一致就成了问题。 一、主从库模式在这方面,Redis 提供了主从库模式,主从库之间采用的是读写分离的方式:对于读操作请求,主从库都可以接收;对于写操作,首先到主库执行,然后,主库将写操作同步给从库。 那么,主从库同步是如何完成的呢?主库数据是一次性传给从库,还是分批同步?要是主从库间的网络断连了,数据还能保持一致吗? 1.1 数据同步的实现细节当我们启动多个 Redis 实例的时候,它们相互之间就可以通过 replicaof(Redis 5.0 之前使用 slaveof)命令形成主库和从库的关系。 例如,现在有实例 1(ip:172.16.19.3)和实例 2(ip:172.16.19.5),我们在实例 2 上执行以下这个命令后,实例 2 就变成了实例 1 的从库,并从实例 1 上复制数据: 1replicaof 172.16.19.3 6379 之后会按照三个阶段完成数据的第一次同步。 第一阶段:主从库间建立连接、协商同步的过程,主要是为全量复制做准备。具体来说,从库给主库发送 psync 命令,表示要进行数据同步,主库根据这个命令的参数来启动复制。psync 命令包含了主库的 runID 和复制进度 offset 两个参数。runID,是每个 Redis 实例启动时都会自动生成的一个随机 ID,用来唯一标记这个实例。当从库和主库第一次复制时,因为不知道主库的 runID,所以将 runID 设为“?”。offset,此时设为 -1,表示第一次复制。 主库收到 psync 命令后,会用 FULLRESYNC 响应命令带上两个参数:主库 runID 和主库目前的复制进度 offset(这个offset是当前最新的值),返回给从库。从库收到响应后,会记录下这两个参数。 第二阶段:主库将所有数据同步给从库。具体来说,主库执行 bgsave 命令,生成 RDB 文件,接着将文件发给从库。从库接收到 RDB 文件后,会先清空当前数据库,然后加载 RDB 文件。 为什么要先清空当前数据库呢?这是因为从库在通过 replicaof 命令开始和主库同步前,可能保存了其他数据。为了避免之前数据的影响,从库需要先把当前数据库清空。 在主库将数据同步给从库的过程中,主库不会被阻塞,仍然可以正常接收请求。否则,Redis 的服务就被中断了。但是,这些请求中的写操作并没有记录到刚刚生成的 RDB 文件中。为了保证主从库的数据一致性,主库会在内存中用专门的 replication buffer,记录 RDB 文件生成后收到的所有写操作。 第三阶段:当主库完成 RDB 文件发送后,就会把此时 replication buffer 中的修改操作发给从库,从库再重新执行这些操作。 主从复制整个过程示意图如下所示。 这样一来,主从库就实现同步了。不可忽视的是,这个过程中存在着风险点,最常见的就是网络断连或阻塞。如果网络断连,主从库之间就无法进行命令传播了,从库的数据自然也就没办法和主库保持一致了,客户端就可能从从库读到旧数据。 1.2 主从库间网络断了怎么办?在 Redis 2.8 之前,如果主从库在命令传播时出现了网络闪断,那么,从库就会和主库重新进行一次全量复制,开销非常大。从 Redis 2.8 开始,网络断了之后,主从库会采用增量复制的方式继续同步。只把主从库网络断连期间主库收到的命令,同步给从库。 当主从库断连后,主库会把断连期间收到的写操作命令,写入 replication buffer,同时也会把这些操作命令写入 repl_backlog_buffer 这个缓冲区。repl_backlog_buffer 是一个环形缓冲区,主库会记录自己写到的位置,从库则会记录自己已经读到的位置。 在网络断连阶段,主库可能会收到新的写操作命令,所以,一般来说,主库写到的位置会大于从库读到的位置。当网络恢复后,主库只用把主库写到的位置和从库读到的位置之间的命令操作同步给从库就行。 Redis 通过主从库模式,既提高了系统处理请求的吞吐量,也保证了系统的可用性。 如果从库发生故障了,客户端可以继续向主库或其他从库发送请求,进行相关的操作。但是如果主库发生故障了怎么办?此时从库没有相应的主库可以进行数据复制操作了,且一旦有写操作请求,系统也将无法处理。 二、哨兵机制所以,如果主库挂了,我们就需要运行一个新主库,比如说把一个从库切换为主库,把它当成主库。在 Redis 主从集群中,哨兵机制是实现主从库自动切换的关键机制。 2.1 哨兵的职责哨兵其实就是一个运行在特殊模式下的 Redis 进程,主从库实例运行的同时,它也在运行。哨兵主要负责的就是三个任务:监控、选主(选择主库)和通知。 监控是指哨兵进程在运行时,周期性地给所有的主从库发送 PING 命令,检测它们是否仍然在线运行。如果从库没有在规定时间内响应哨兵的 PING 命令,哨兵就会把它标记为“下线状态”;同样,如果主库也没有在规定时间内响应哨兵的 PING 命令,哨兵就会判定主库下线,然后开始自动切换主库的流程。 选主是指主库挂了以后,哨兵就需要从很多个从库里,按照一定的规则选择一个从库实例,把它作为新的主库。这一步完成后,现在的集群里就有了新主库。 然后,哨兵会执行最后一个任务:通知。在执行通知任务时,哨兵会把新主库的连接信息发给其他从库,让它们执行 replicaof 命令,和新主库建立连接,并进行数据复制。同时,哨兵会把新主库的连接信息通知给客户端,让它们把请求操作发到新主库上。 但是你有没有想过,如果有哨兵实例在运行时发生了故障,主从库还能正常切换吗? 2.2 哨兵的高可用哨兵单点故障问题,Redis 也是通过建立哨兵集群来解决的。那我们再回头看哨兵的职责,在监控主从库是否下线时,如果出现了哨兵内部的意见不统一怎么办?比如说有 3 个哨兵,其中一个哨兵认为主库下线了,而另外 2 个却认为主库是正常的,这时该听谁的呢? 这就好比我们团队内部出现了意见分歧,那最好的解决办法就是民主投票了,采用“少数服从多数的原则”。哨兵集群内部也一样,在网络拥塞的情况下,有个别哨兵与主库的 PING 命令失败,这时哨兵就认为该主库故障了,然而实际并没有。这时就要采用“民主”的办法,大多数哨兵认为主库故障,才会进行下一步的选主。 哨兵的实例数应该是 2N+1 的单数,这样才不致于出现观点对立的情况,通常我们至少会配置 3 个哨兵实例。 那选主同样需要考虑一个问题:哨兵这么多,该由哪个执行主从切换? 此时,这个哨兵就可以再给其他哨兵发送命令,表明希望由自己来执行主从切换,并让所有其他哨兵进行投票。这个投票过程称为“Leader 选举”。因为最终执行主从切换的哨兵称为 Leader,投票过程就是确定 Leader。 到这里,我们就大致理清了 Redis 保证服务少中断所采取的一系列方案了。那 Redis 是如何保证数据少丢失的呢? 三、AOF和RDB了解 MySQL 的同学可能听说过,MySQL 是具有 Crasf-Safe 的能力的,这归功于数据库的写前日志(Write Ahead Log, WAL) Redo Log。同样,Redis 也提供了 AOF 日志。 3.1 主库宕机了,如何避免数据丢失?AOF 里记录的是 Redis 收到的每一条命令,这些命令是以文本形式保存的。我们以 Redis 收到“set testkey testvalue”命令后记录的日志为例,看看 AOF 日志的内容。其中,“ *3 ”表示当前命令有三个部分,每部分都是由“$+数字”开头,后面紧跟着具体的命令、键或值。这里,“数字”表示这部分中的命令、键或值一共有多少字节。例如,“$3 set”表示这部分有 3 个字节,也就是“set”命令。 但是,因为记录的是操作命令,而不是实际的数据,所以,用 AOF 方法进行故障恢复的时候,需要逐一把操作日志都执行一遍。如果操作日志非常多,Redis 就会恢复得很缓慢,影响到正常使用。 因此,Redis 提供了另一种数据持久化方法:内存快照 RDB。和 AOF 相比,RDB 记录的是某一时刻的数据,并不是操作,所以,在做数据恢复时,我们可以直接把 RDB 文件读入内存,很快地完成恢复。 对于快照来说,系统多久执行一次快照直接影响数据丢失的多少。如下图所示,我们先在 T0 时刻做了一次快照,然后又在 T0+t 时刻做了一次快照,在这期间,数据块 5 和 9 被修改了。如果在 t 这段时间内,机器宕机了,那么,只能按照 T0 时刻的快照进行恢复。此时,数据块 5 和 9 的修改值因为没有快照记录,就无法恢复了。 所以,要想尽可能恢复数据,t 值就要尽可能小。那么,t 值可以小到什么程度呢,比如说是不是可以每秒做一次快照? 3.2 宕机后,如何快速恢复数据?Redis 4.0 中提出了一个混合使用 AOF 和 RDB 的方法。简单来说,内存快照以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作。 这样一来,快照不用很频繁地执行。而且,AOF 日志也只用记录两次快照间的操作,也就是说,不需要记录所有操作了,因此,就不会出现文件过大的情况了,也可以避免重写开销。 这个方法既能享受到 RDB 文件快速恢复的好处,又能享受到 AOF 只记录操作命令的简单优势。 小结我来总结一下本文的内容。Redis 系统的高可用,具体可以通过两个方面来理解:一是服务少中断,二是数据少丢失。我整理的知识消化链路如下。 12345服务少中断-> 多副本-> 主从库模式保证数据一致及从库的高可用-> 哨兵保证主库的高可用-> 哨兵集群保证哨兵高可用。 123456数据少丢失-> AOF日志-> AOF恢复数据较慢-> RDB内存快照-> 执行快照间隔不宜过短-> AOF+RDB 关于哨兵机制的更多实现细节,我会在后面的内容里继续更新,敬请关注。","link":"/2022/13.html"},{"title":"Redis高可用之 Sentinel 机制实现细节","text":"本文来自我的 technotes [1] Redis篇。 正文在上一篇的文章《Redis高可用全景一览》中,我们学习了 Redis 的高可用性。高可用性有两方面含义:一是服务少中断,二是数据少丢失。主从库模式和 Sentinel 保证了服务少中断,AOF 日志和 RDB 快照保证了数据少丢失。 并且我们学习了 Sentinel 三个职责,分别是:监控、选主(选择主库)和通知。今天我们就来详细学习一下。 首先呐,在 Sentinel 启动前,我们要对 Sentinel 进行配置。Redis 源码中包含了一个名为 sentinel.conf [2] 的文件,该文件中部分配置如下: 12sentinel monitor mymaster 127.0.0.1 6379 2sentinel down-after-milliseconds mymaster 60000 第一行配置指示 Sentinel 去监视一个名为 mymaster 的主服务器, 这个主服务器的 IP 地址为 127.0.0.1 , 端口号为 6379 , 而将这个主服务器判断为失效至少需要 2 个 Sentinel 同意 。 可以看到,我们仅仅设置了主库的 IP 和端口。并没有配置其他 Sentinel 的连接信息啊,这些 Sentinel 实例既然都不知道彼此的地址,又是怎么组成集群的呢? Sentinel 实例之间可以相互发现,要归功于 Redis 提供的 pub/sub 机制,也就是发布 / 订阅机制。在主从集群中,主库上有一个名为__sentinel__:hello的频道,不同 Sentinel 就是通过它来相互发现,实现互相通信的。 一、 Sentinel 集群的组成每隔2秒, 每个 Sentinel 节点就会向 Redis 数据节点的__sentinel__:hello频道上发送该 Sentinel 节点对于主节点的判断以及当前 Sentinel 节点的信息。 举个例子。在上图中, Sentinel 1 把自己的 IP(172.16.19.3)和端口(26579)发布到__sentinel__:hello频道上, Sentinel 2 和 3 订阅了该频道。那么此时, Sentinel 2 和 3 就可以从这个频道直接获取 Sentinel 1 的 IP 地址和端口号。 然后, Sentinel 2、3 可以和 Sentinel 1 建立网络连接。通过这个方式, Sentinel 2 和 3 也可以建立网络连接,这样一来, Sentinel 集群就形成了。它们相互间可以通过网络连接进行通信,比如说对主库有没有下线这件事儿进行判断和协商。 Sentinel 除了彼此之间建立起连接形成集群外,还需要和从库建立连接。这是因为,在 Sentinel 的监控任务中,它需要对主从库都进行心跳判断,而且在主从库切换完成后,它还需要通知从库,让它们和新主库进行同步。 那么, Sentinel 又是如何知道从库的 IP 地址和端口的呢? 二、获取从节点信息这是由 Sentinel 向主库发送 INFO 命令来完成的。就像下图所示, Sentinel 2 给主库发送 INFO 命令,主库接受到这个命令后,就会把从库列表返回给 Sentinel 。接着, Sentinel 就可以根据从库列表中的连接信息,和每个从库建立连接。 Sentinel 1 和 3 也可以通过相同的方法和从库建立连接。 下面是在一个主节点上执行 info 命令的结果片段: 12345# Replicationrole:masterconnected_slaves:2slave0:ip=127.0.0.1,port=6380,state=online,offset=4917,lag=1slave1:ip=127.0.0.1,port=6381,state=online,offset=4917,lag=1 之后, Sentinel 会在这个连接上持续地对从库进行监控。每隔10秒, Sentinel 节点就会向主节点和从节点发送 info 命令,获取集群最新的拓扑结构。这样,当有新的从节点加入时就可以立刻感知出来。节点不可达或者选定新主库后,也可以通过 info 命令实时更新节点拓扑信息。 有了集群的信息, Sentinel 终于可以开始它的工作了。第一项职责:判断主从库是否下线。 三、如何判断主从库下线?3.1 定时执行 ping 命令 Sentinel 进程在运行时,每隔1秒,会向主节点、 从节点、 其余 Sentinel 节点发送一条 ping 命令,检测它们是否仍然在线运行。如果主、从库没有在规定时间内响应 Sentinel 的 ping 命令, Sentinel 就会把它标记为「下线状态」。 如果检测的是主库,那么, Sentinel 还不能简单地开启主从切换。因为很有可能存在这么一个情况:那就是 Sentinel 误判了,其实主库并没有故障。 误判一般会发生在集群网络压力较大、网络拥塞,或者是主库本身压力较大的情况下。一旦 Sentinel 误判,启动了主从切换,后续的选主和通知操作都会带来额外的计算和通信开销。 那怎么减少误判呢? 3.2 主观下线和客观下线 Sentinel 机制通常会采用多实例组成的集群模式进行部署,这也被称为 Sentinel 集群。引入多个 Sentinel 实例一起来判断,就可以避免单个 Sentinel 因为自身网络状况不好,而误判主库下线的情况。同时,多个 Sentinel 的网络同时不稳定的概率较小,由它们一起做决策,误判率也能降低。 还记得我在文章开头给出的 sentinel.conf 配置吗? 12sentinel monitor mymaster 127.0.0.1 6379 2sentinel down-after-milliseconds mymaster 60000 down-after-milliseconds选项就是 Sentinel 认为服务器已经断线的临界阈值。 如果服务器在该毫秒数之内, 没有返回 Sentinel 发送的 ping 命令的回复, 或者返回一个错误, 那么 Sentinel 将这个服务器标记为主观下线(subjectively down,简称 SDOWN )。 如果没有足够数量的 Sentinel 同意主库已经下线, 当主库重新向 Sentinel 的 PING 命令返回有效回复时, 主库的主观下线状态就会被移除。而如果超出 2 个 Sentinel 都将主库标记为主观下线之后, 主库才会被标记为客观下线(objectively down, 简称 ODOWN )。 Sentinel 就要开始下一个决策过程了,即从许多从库中,选出一个从库来做新主库。 四、如何选定新主库?4.1 初步筛选设想一下,如果在选主时,一个从库正常运行,我们把它选为新主库开始使用了。可是,很快它的网络出了故障,此时,我们就又得重新选主了。这显然不是我们期望的结果。所以,在选主时,除了要检查从库的当前在线状态,还要判断它之前的网络连接状态。如果从库总是和主库断连,而且断连次数超出了一定的阈值,我们就有理由相信,这个从库的网络状况并不是太好,就可以把这个从库筛掉了。 具体怎么判断呢?你使用配置项 down-after-milliseconds * 10。其中,down-after-milliseconds 是我们认定主从库断连的最大连接超时时间。如果在 down-after-milliseconds 毫秒内,主从节点都没有通过网络联系上,我们就可以认为主从节点断连了。如果发生断连的次数超过了 10 次,就说明这个从库的网络状况不好,不适合作为新主库。 这样我们就过滤掉了不适合做主库的从库,完成了筛选工作。 接下来就要给剩余的从库打分了。我们可以分别按照三个规则依次进行三轮打分,这三个规则分别是从库优先级、从库复制进度以及从库 ID 号。 4.2 三轮打分第一轮:优先级最高的从库得分高。 用户可以通过 slave-priority 配置项,给不同的从库设置不同优先级。比如,你有两个从库,它们的内存大小不一样,你可以手动给内存大的实例设置一个高优先级。 第二轮:和旧主库同步程度最接近的从库得分高。 这个规则的依据是,如果选择和旧主库同步最接近的那个从库作为主库,那么,这个新主库上就有最新的数据。 如何判断从库和旧主库间的同步进度呢? 主从库同步时有个命令传播的过程。在这个过程中,主库会用 master_repl_offset 记录当前的最新写操作在 repl_backlog_buffer 中的位置,而从库会用 slave_repl_offset 这个值记录当前的复制进度。 主从库同步时有个命令传播的过程。在这个过程中,主库会用 master_repl_offset 记录当前的最新写操作在 repl_backlog_buffer 中的位置,而从库会用 slave_repl_offset 这个值记录当前的复制进度。 如下图所示,从库 2 就应该被选为新主库。 第三轮:ID 号小的从库得分高。 每个实例都会有一个 ID,这个 ID 就类似于这里的从库的编号。目前,Redis 在选主库时,有一个默认的规定:在优先级和复制进度都相同的情况下,ID 号最小的从库得分最高,会被选为新主库。 到这里,新主库就被选出来了,接下来就是将从库升级为主库。但是问题又来了,这么多 Sentinel ,该由谁来执行主从切换操作呢? 4.3 由哪个 Sentinel 执行主从切换?任何一个 Sentinel 实例只要自身判断主库“主观下线”后,就会向其他 Sentinel 发送 SENTINEL is-master-down-by-addr 命令来询问对方是否认为主库已下线。接着,其他 Sentinel 实例会根据自己和主库的连接情况,做出 Y 或 N 的响应,Y 相当于赞成票,N 相当于反对票。 此时,这个 Sentinel 就可以再给其他 Sentinel 发送命令,表明希望由自己来执行主从切换,并让所有其他 Sentinel 进行投票。这个投票过程称为“Leader 选举”。选举出来的 Leader 就是最终执行主从切换的 Sentinel 。 例如,现在有 3 个 Sentinel ,quorum 配置的是 2,我们来看一下选举的过程是什么样的。 在 T1 时刻,S1 判断主库为“客观下线”,它想成为 Leader,就先给自己投一张赞成票,然后分别向 S2 和 S3 发送命令,表示要成为 Leader。 在 T2 时刻,S3 判断主库为“客观下线”,它也想成为 Leader,所以也先给自己投一张赞成票,再分别向 S1 和 S2 发送命令,表示要成为 Leader。 在 T3 时刻,S1 收到了 S3 的 Leader 投票请求。因为 S1 已经给自己投了一票 Y,所以它不能再给其他 Sentinel 投赞成票了,所以 S1 回复 N 表示不同意。同时,S2 收到了 T2 时 S3 发送的 Leader 投票请求。因为 S2 之前没有投过票,它会给第一个向它发送投票请求的 Sentinel 回复 Y,给后续再发送投票请求的 Sentinel 回复 N,所以,在 T3 时,S2 回复 S3,同意 S3成为 Leader。 在 T4 时刻,S2 才收到 T1 时 S1 发送的投票命令。因为 S2 已经在 T3 时同意了 S3 的投票请求,此时,S2 给 S1 回复 N,表示不同意 S1 成为 Leader。发生这种情况,是因为 S3 和 S2 之间的网络传输正常,而 S1 和 S2 之间的网络传输可能正好拥塞了,导致投票请求传输慢了。 在 T5 时刻,S1 得到的票数是来自它自己的一票 Y 和来自 S2 的一票 N。而 S3 除了自己的赞成票 Y 以外,还收到了来自 S2 的一票 Y。此时,S3 不仅获得了半数以上的 Leader 赞成票,也达到预设的 quorum 值(quorum 为 2),所以它最终成为了 Leader。 接着,S3 会开始执行选主操作,而且在选定新主库后,会给其他从库和客户端通知新主库的信息。 五、将新主库通知给从库和客户端通过上文的学习,我们知道 Sentinel 可以向主库发送 INFO 命令,来获取从库的 IP 地址和端口。 但是, Sentinel 不能只和主、从库连接。因为,主从库切换后,客户端也需要知道新主库的连接信息,才能向新主库发送请求操作。所以, Sentinel 还需要把新主库的信息告诉客户端。 那怎么把新主库的信息告诉客户端呢? 5.1 基于 pub/sub 机制的客户端事件通知从本质上说, Sentinel 就是一个运行在特定模式下的 Redis 实例,只不过它并不服务请求操作,只是完成监控、选主和通知的任务。所以,每个 Sentinel 实例也提供 pub/sub 机制,客户端可以从 Sentinel 订阅消息。 下图示中是一些重要的频道,以及涉及的几个关键事件。更多的频道你可以在文末链接 [3] 中查看。 客户端从主库读取 Sentinel 的配置文件后,可以获得 Sentinel 的地址和端口,和 Sentinel 建立网络连接。 当 Sentinel 把新主库选择出来后,客户端就会看到下面的 switch-master 事件。这个事件表示主库已经切换了,新主库的 IP 地址和端口信息已经有了。这个时候,客户端就可以用这里面的新主库地址和端口进行通信了。 1switch-master <master name> <oldip> <oldport> <newip> <newport> 小结至此, Sentinel 的工作职责及细节我们就学习完了。我整理了本文知识消化链路,如下。 1234567在sentinel.conf中配置 Sentinel -> 没有配置其他 Sentinel ip,怎么组成集群的?-> Sentinel 是怎么知道从库的IP和端口的?-> 职责1:如何判断主从库下线了?-> 职责2:如何选定新主库?-> 由哪个 Sentinel 执行主从切换?-> 职责3:如何把新主库告诉客户端? 封面 文中链接 [1] https://www.dbses.cn/technotes [2] https://github.com/redis/redis/blob/unstable/sentinel.conf [3] https://redis.io/docs/management/sentinel/#pubsub-messages 附 Redis 文档 http://www.redis.cn/topics/sentinel.html https://redis.io/docs/management/sentinel","link":"/2022/14.html"},{"title":"(byte)1658385462vv16=-40,怎么算的?","text":"在 Github 项目mongo-java-driver有一个类ObjectId.java,它的作用是生成唯一 id 的,它的核心实现是下面这样一段代码 [1]: 1234567891011121314151617public void putToByteBuffer(final ByteBuffer buffer) { notNull("buffer", buffer); isTrueArgument("buffer.remaining() >=12", buffer.remaining() >= OBJECT_ID_LENGTH); buffer.put(int3(timestamp)); buffer.put(int2(timestamp)); buffer.put(int1(timestamp)); buffer.put(int0(timestamp)); buffer.put(int2(randomValue1)); buffer.put(int1(randomValue1)); buffer.put(int0(randomValue1)); buffer.put(short1(randomValue2)); buffer.put(short0(randomValue2)); buffer.put(int2(counter)); buffer.put(int1(counter)); buffer.put(int0(counter));} 上述代码中的int2()方法定义如下: 123private static byte int2(int x) { return (byte) (x >> 16);} 取当前时间戳(秒)1658385462,我们来测试一下该方法: 1234@Testpublic void test() { System.out.println(int2(1658385462)); // -40} 得到的结果是 -40。即:(byte) 1658385462 >> 16 = -40。 这是怎么算出来的? 计算过程1、首先,计算机要将 1658385462 转换为二进制数。 因为 int 为 4 字节 32 位,对应二进制结果如下: 10110 0010 1101 1000 1111 0100 0011 0110 2、执行 >>16 运算。 运算结果是 0110 0010 1101 1000。 10110 0010 1101 1000 1111 0100 0011 0110 >> 16 = 0110 0010 1101 1000 3、因为计算机存储补位,所以需将其转为补位。 正数的补码就是其本身,补码是:0110 0010 1101 1000。 4、因为 byte 为 1 字节 8 位,所以强制转换时计算机只保留其后 8 位。 保留 8 位的结果是:1101 1000。 5、保留 8位后的数值仍然是补位,而要展示给用户需转换成原位。 123补:1101 1000反:1101 0111原:1010 1000 6、最高位 1 表示负数,将 010 1000 转换成十进制数,则为 -40。 什么是原码、反码、补码?原码:原码就是符号位加上真值的绝对值,即用第一位表示符号,其余位表示值。 反码:正数的反码是其本身。负数的反码是在其原码的基础上,符号位不变,其余各位取反。 补码:正数的补码就是其本身。负数的补码是在其原码的基础上,符号位不变,其余各位取反,最后+1。 从原码、反码、补码的表示方式不难看出,原码才是人眼最直观能看出值的表示方式,那么为什么还要有反码和补码呢? 答案是为了简化计算机集成电路的设计。 我们人脑是可以辨别第一位是符号位的,在计算的时候我们会根据符号位,选择对真值区域的加减。但是对于计算机,辨别“符号位”显然会让计算机的基础电路设计变得十分复杂,于是人们想出了将符号位也参与运算的方法。 我们知道,根据运算法则:减去一个正数等于加上一个负数,即:1-1 = 1 + (-1) = 0,所以机器可以只有加法而没有减法,这样计算机运算的设计就更简单了。此外,由于现阶段计算机 CPU 擅长做加法运算,CPU 硬件实现减法要复杂得多,而且运算效率很低,所以我们偷懒只讨论加法运算。说不定以后发明了减法加速硬件,那就另当别论了。 为什么要有反码?于是人们开始探索将符号位参与运算,并且只保留加法的方法。 首先来看原码:计算十进制的表达式:1-1=0。 12341 - 1= 1 + (-1) = [00000001]原 + [10000001]原= [10000010]原= -2 如果用原码表示,让符号位也参与计算,显然对于减法来说,结果是不正确的。这也就是为何计算机内部不使用原码表示一个数。 为了解决原码做减法的问题,出现了反码。 12345671 - 1= 1 + (-1)= [0000 0001]原 + [1000 0001]原= [0000 0001]反 + [1111 1110]反= [1111 1111]反= [1000 0000]原= -0 发现用反码计算减法,结果的真值部分是正确的。 为什么要有补码?用反码计算减法,结果的真值部分是正确的。而唯一的问题其实就出现在“0”这个特殊的数值上。 虽然人们理解上 +0 和 -0 是一样的,但是 0 带符号是没有任何意义的。 而且会有[0000 0000]原和[1000 0000]原两个编码表示 0。 于是出现了补码,为了解决 0 的符号以及 0 的两个编码问题: 1234561 - 1= 1 + (-1)= [0000 0001]原 + [1000 0001]原= [0000 0001]补 + [1111 1111]补= [0000 0000]补= [0000 0000]原 这样 0 用 [0000 0000] 表示,而以前出现问题的 -0 则不存在了。那另一个编码 [1000 0000] 是否就弃用了呢? 1234(-1) + (-127)= [1000 0001]原 + [1111 1111]原= [1111 1111]补 + [1000 0001]补= [1000 0000]补 -1-127 的结果应该是 -128,刚好 [1000 0000] 可以用来表示 -128。在用补码运算的结果中,[1000 0000]补就是 -128。 但是注意: -128 并没有原码和反码表示。对 -128 的补码[1000 0000]补算出来的原码是[0000 0000]原,这显然是不正确的。 使用补码,不仅仅修复了 0 的符号以及存在两个编码的问题,而且还能够多表示一个最低数。所以最终同样是 8 位二进制,使用原码或反码表示的范围为 [-127, +127],而使用补码表示的范围为 [-128, 127]。 小结我整理了本文知识消化链路,如下。 1234使用原码计算减法的结果是错误的-> 出现了反码-> 使用反码计算的 0 有两个,+0 和 -0-> 出现了补码 封面 文中链接 [1] ObjectId#putToByteBuffer 参考资料 计算机为什么要使用原码、反码、补码 java中int强制转byte数据溢出问题","link":"/2022/15.html"},{"title":"Git如何删除指定commit?如何修改历史提交人信息?","text":"1、删除指定commit假如某个项目当中有 3 条提交。 123commit-3 bc3ce563commit-2 b9c7e5c2commit-1 5a480a4b 现在我们要删除commit-2这条提交记录。应该如何做呢? 第一步:使用git reflog查看提交信息1git reflog 第二步:rebase操作1git rebase -i 5a480a4b 执行完这个命令后,就可以看到 5a480a4b 后的所有 commit 记录。 把原本的pick单词修改为drop就表示该ID对应的 commit log 我们需要删除。vim 保存退出。 第三步:解决冲突,强制推送更新到远程1234git add . # 冲突时使用git commit -m "new commit" # 冲突时使用git rebase --continue # 冲突时使用git push origin master -f 再查看远程的提交记录,发现commit-2就没有了。 2、修改历史提交人信息不知道你有没有遇到过这种情况,在维护个人的开源项目时,常常使用公司的邮箱和用户名提交了 Git 信息。一旦提交了,又想修改,如何操作呢? 第一步:配置项目的提交信息12git config --local user.name 'studeyang'git config --local user.email '[email protected]' 第二步:执行下面脚本123456789101112131415git filter-branch -f --env-filter 'OLD_EMAIL="原来的邮箱"CORRECT_NAME="studeyang"CORRECT_EMAIL="[email protected]"if [ "$GIT_COMMITTER_EMAIL" = "$OLD_EMAIL" ]then export GIT_COMMITTER_NAME="$CORRECT_NAME" export GIT_COMMITTER_EMAIL="$CORRECT_EMAIL"fiif [ "$GIT_AUTHOR_EMAIL" = "$OLD_EMAIL" ]then export GIT_AUTHOR_NAME="$CORRECT_NAME" export GIT_AUTHOR_EMAIL="$CORRECT_EMAIL"fi' --tag-name-filter cat -- --branches --tags 3、彻底删除某文件不小心提交了某个文件,历史提交信息一直都在,怎么彻底删除? 123git filter-branch --force --index-filter 'git rm --cached --ignore-unmatch B类/B19-React/React入门.md' --prune-empty --tag-name-filter cat -- --allgit push origin master --force --all 4、推送到指定仓库的指定分支本地的代码是通过工具生成的,如何把生成的代码推送到指定仓库下的指定分支? 12# git push -f {your project} master:{your project branch}git push -f [email protected]:studeyang/studeyang.github.io.git master:webstack 5、常用的标签操作5.1 查看 tag12345678$ git tagV1.0.3v1.0.0v1.0.0C01v1.0.1v1.0.2v1.0.4v1.1.0 5.2 查看 tag,带上 tag message12345678$ git tag -n1V1.0.3 消息可视化v1.0.0 正式版本v1.0.0C01 正式版本v1.0.1 v1.0.1 正式版本v1.0.2 正式版本v1.0.4 20迭代 正式版本v1.1.0 22迭代正式版本 5.3 查看 tag 的详细信息123456789101112$ git show v1.4tag v1.4Tagger: Ben Straub <[email protected]>Date: Sat May 3 20:19:12 2014 -0700my version 1.4commit ca82a6dff817ec66f44342007202690a93763949Author: Scott Chacon <[email protected]>Date: Mon Mar 17 21:52:11 2008 -0700 changed the version number 5.4 tag 数量很多,如果只对 v1.0 系列感兴趣123456$ git tag -l v1.0*v1.0.0v1.0.0C01v1.0.1v1.0.2v1.0.4 5.5 创建 tag 并推送至远端12$ git tag -a v0.0.1 -m 'msg'$ git push origin v0.0.1 5.6 轻量标签1$ git tag v0.0.1-lw 5.7 更新 tag1234567891011121314$ git push origin --delete v0.0.1$ git log --pretty=oneline15027957951b64cf874c3557a0f3547bd83b3ff6 Merge branch 'experiment'a6b4c97498bd301d84096da251c98a07c7723e65 beginning write support0d52aaab4479697da7686c15f77a3d64d9165190 one more thing6d52a271eda8725415634dd79daabbc4d9b6008e Merge branch 'experiment'0b7434d86859cc7b8c3d5e1dddfed66ff742fcbc added a commit function4682c3261057305bdd616e23b64b0857d832627b added a todo file166ae0c4d3f420721acbb115cc33848dfcc2121a started write support9fceb02d0ae598e95dc970b74767f19372d61af8 updated rakefile964f16d36dfccde844893cac5b347e7b3d44abbc commit the todo8a5cbc430f1a9c3d00faaeffd07798508422908a updated readme$ git tag -a v0.0.1 9fceb02$ git push origin v0.0.1 封面","link":"/2022/16.html"},{"title":"Nacos配置中心落地与实践","text":"一、背景目前,我们公司各团队配置中心使用各异,电商使用的是 Spring Cloud Config,支付使用的是 Apollo,APP 团队使用的是 Apollo+Nacos。为了更好地应对公司业务的发展,统一基础设施技术栈必不可少。 图片来源:直播《如何做好微服务基础设施选型》–李运华 此外,电商团队使用的 Spring Cloud Config 面临以下技术痛点: 修改配置需要重启服务 配置管理不友好(通过gitlab修改) 缺少权限管控、格式检验、安全配置等特性 二、配置中心选型开源产品分析 Spring Cloud Config 2014年9月开源,Spring Cloud 生态组件,可以和 Spring Cloud 体系无缝整合。 Apollo 2016年5月,携程开源的一款可靠的分布式配置管理中心。能够集中化管理应用不同环境、不同集群的配置,配置修改后能够实时推送到应用端,并且具备规范的权限、流程治理等特性,适用于微服务配置管理场景。 Nacos 2018年6月,阿里开源的一个更易于构建云原生应用的动态服务发现、配置管理和服务管理平台。它孵化于阿里巴巴,成长于十年双十一的洪峰考验,沉淀了简单易用、稳定可靠、性能卓越的核心竞争力。 比较项 Nacos Apollo Spring Cloud Config 社区活跃度 开源时间 2018.6 2016.5 2014.9 github关注 20.5k 26K 1.7K 文档 完善 完善 完善 性能 单机读(QPS) 15000 9000 7(限流所致) 单机写(QPS) 1800 1100 5(限流所致) 可用性 停服影响(配置服务) 已启动的客户端不影响 已启动的客户端不影响 已启动的客户端不影响 部署模式 集群 集群 集群 易用性 配置生效时间 实时 实时 重启生效,或手动refresh生效 数据一致性 HTTP异步通知 数据库模拟消息队列,Apollo定时读消息 一分钟实时生效 Git保证数据一致性,Config-server从Git读数据 配置界面 支持 支持 不支持 配置格式校验 支持 支持 不支持 配置回滚 支持 支持 支持(基于git的回滚) 版本管理 支持 支持 支持(基于git的版本管理) 客户端支持语言 官方java 非官方 Go、Python、NodeJS、C++ 官方java .net 非官方 Go、Python、NodeJS、PHP、C++ 客户端使用 nacos client apollo client cloud config client 安全性 权限管理 支持 完善 数据权限都比较完善 支持(git) 授权/审计/审核 支持 界面上直接操作且支持修改和发布权限分离 依赖git权限管理 数据加密 不支持 不支持 加密和解密属性值 架构复杂度 运维成本 Nacos+MySQL(部署简单) Config+Admin+Portal+MySQL(部署复杂) Config-server+Git+MQ(部署复杂) 服务依赖 自身就是注册发现中心 阿里云两个功能隔开了 分布式 需要注册中心 内置了eureka 需要注册中心 灰度发布 支持 客户端配置 且路由规则客户端计算 耦合高 繁琐 支持 服务端配置 且路由规则服务端计算 客户端透明 简单 支持 邮件服务 不支持 支持 不支持 查询配置监听 支持 支持 支持 从性能方面看:读写性能 Nacos > Apollo > Spring Cloud Config。 从功能方面看:功能完善度 Apollo > Nacos > Spring Cloud Config。 从社区活跃性看:原来Spring Cloud 那一套生态Netflix基本上不怎么维护了,因为不赚钱;但是 Spring Alibaba 这套微服务生态会一直开源且有维护,因为阿里将这一块 SaaS 化后赚钱。 Nacos的优势:简单。它整合了注册中心、配置中心功能,部署和操作相比Apollo都要直观简单,因此它简化了架构复杂度,并减轻运维及部署工作。 性能对比 压力机信息 处理器:Intel(R) Core(TM) i5-9500 CPU @ 3.00GHz 3.00 GHz 系统:window 10 内测:16G 压测工具:JMeter 压测策略:100用户请求线程 10内递增开启,持续时间100s 场景一:调用服务端 测试结果如下: 通过压测发现,Nacos读配置的TPS大约是11000左右 ,写配置TPS大约是1800左右,而Apollo读配置TPS大约是1100,写配置TPS大约310,Nacos读写性能优势非常明显。 场景二:调用客户端 测试结果如下: 可见,读性能相差不大。 结论 选择的原因 不选择的原因 Nacos 统一技术栈能解决现有技术痛点运维成本低 Apollo 依赖 Eureka Spring Cloud Config 参考文档: 深度对比三种主流微服务配置中心 Nacos服务配置性能测试报告 Apollo性能测试报告 凉凉了,Eureka 宣布闭源,Spring Cloud 何去何从? 三、快速使用 参考文档:https://nacos.io/en-us/docs/quick-start-spring-boot.html 升级依赖去除 spring-cloud-config 依赖: 1234<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-config</artifactId></dependency> 添加 Nacos 依赖: 12345<dependency> <groupId>com.alibaba.boot</groupId> <artifactId>nacos-config-spring-boot-starter</artifactId> <version>0.1.8</version></dependency> 替换 Nacos 配置将原 bootstrap.yml 文件中的 config 配置替换成 nacos 的配置。 1234567spring: application: name: {应用名} cloud: # 移除 config: # 移除 uri: http://config-center.alpha-intra.dbses.com/conf # 移除 label: alpha # 移除 替换结果如下: 12345678spring: application: name: {应用名}nacos: config: server-addr: http://ec-nacos.dbses.com namespace: alpha group: {组名} 启动类添加注解123456789// dataId 对应服务的配置@NacosPropertySource(groupId = "${nacos.config.group}", dataId = "${spring.application.name}.yml", first = true)public class WebApplication { public static void main(String[] args) { SpringApplication.run(WebApplication.class, args); } } 四、实践配置动态刷新方式一:使用@NacosValue 使用此种方法需要在@NacosPropertySource 需加上 autoRefreshed=true。示例代码如下: 12345678@NacosPropertySource(groupId = "infra", dataId = "zebra-service.yml", first = true, autoRefreshed = true)public class WebApplication { public static void main(String[] args) { SpringApplication.run(WebApplication.class, args); } } nacos 配置如下: 12test1: config: 2 接口代码如下: 123456789101112@RestControllerpublic class TestController { @NacosValue(value = "${test1.config}", autoRefreshed = true) private String config; @GetMapping("/config") public String getConfig() { return config; } } 方式二:使用@NacosConfigurationProperties 示例代码如下: 123456789101112131415@Configuration@Data@NacosConfigurationProperties(prefix = "test2", dataId = "zebra-service.yml", groupId = "infra", autoRefreshed = true)public class TestConfig { private List<String> config; private Map<String, String> map; @Override public String toString() { return "TestConfig{" + "config=" + config + ", map=" + map + '}'; } } nacos 配置如下: 1234567test2: config: - yang - wang map: courier: yang zebra: wang 接口代码如下: 123456789101112@RestControllerpublic class TestController { @Autowired private TestConfig testConfig; @GetMapping("/config2") public String getConfig2() { return testConfig.toString(); } } 注意 动态刷新map,修改了key会累加,不会删除原来的key。例如将 zebra-service.yml 配置中的 test2.map.zebra 改为 test2.map.zebr 后,获取的结果如下: TestConfig{config=[yang, wang], map={courier=yang, zebra=wang, zebr=wang}} 方式三:使用@NacosConfigListener nacos 配置如下: 12test1: config: 2 示例代码如下: 1234567891011121314151617181920@RestControllerpublic class TestController { @Value(value = "${test1.config}") private String config; @GetMapping("/config") public String getConfig() { return config; } @NacosConfigListener(dataId = "zebra-service.yml", groupId = "infra") public void testConfigChange(String newContent) { YamlPropertiesFactoryBean yamlFactory = new YamlPropertiesFactoryBean(); yamlFactory.setResources(new ByteArrayResource(newContent.getBytes())); Properties commonsProperties = yamlFactory.getObject(); this.config = commonsProperties.getProperty("test1.config")); } } 多配置引入问题描述 我们的项目之前读取了许多公共配置,现想要读取公共配置,该怎么办? 问题解决 使用 @NacosPropertySources 注解即可加入多个配置文件。 样例代码: 1234567891011@NacosPropertySources({ @NacosPropertySource(groupId = "infra", dataId = "captcha-service.yml", first = true), @NacosPropertySource(groupId = "commons", dataId = "__common_eureka_.yml")})public class WebApplication { public static void main(String[] args) { SpringApplication.run(WebApplication.class, args); } } 这里的 first = true 表示这个文件的配置优先级是最高的。 本地配置覆盖问题描述 作为开发人员,我们可能需要本地启动程序来进行调试,但此时本地启动的程序连接的是 alpha 环境的配置。如果修改 alpha 环境的配置,又可能影响 alpha 及其他人的程序运行。 面对这种情况,我们怎么管理配置的优先级? 下面以 test1.config 配置为例。nacos 配置文件如下: 启动配置如下: 测试代码如下: 1234567891011@RestControllerpublic class TestController { @NacosValue(value = "${test1.config}", autoRefreshed = true) private String config1; @GetMapping("/config1") public String getConfig1() { return config1; }} 执行结果为: 本地的配置并没有达到覆盖的效果。 问题分析 我们不妨先改造一下程序启动类。 通过断点可以看到,应用配置(这里指 nacos 中的 zebra-service.yml,下同)的优先级是在公共配置之前的,这点是必要的。 应用配置必须在公共配置之前。 但是应用配置也在系统变量(systemProperties)、系统环境(systemEnvironment)之前。所以我们配置的 test1.config 并没有生效为 local。 稍作修改一下: 问题解决 再测试一下本地配置是否覆盖。 本地的配置已达到覆盖的效果。最终的启动类代码为: 1234567891011121314@SpringBootApplication@EnableDiscoveryClient@EnableFeignClients@PrepareConfigurations({"__common_database_", "__common_eureka_"})@NacosPropertySource(groupId = "infra", dataId = "zebra-service.yml", autoRefreshed = true ,after = StandardEnvironment.SYSTEM_PROPERTIES_PROPERTY_SOURCE_NAME)public class WebApplication { public static void main(String[] args) { SpringApplication.run(WebApplication.class, args); } }","link":"/2022/2.html"},{"title":"06期:使用 OPTIMIZER_TRACE 窥探 MySQL 索引选择的秘密","text":"这里记录的是学习分享内容,文章维护在 Github:studeyang/leanrning-share。 优化查询语句的性能是 MySQL 数据库管理中的一个重要方面。在优化查询性能时,选择正确的索引对于减少查询的响应时间和提高系统性能至关重要。但是,如何确定 MySQL 的索引选择策略?MySQL 的优化器是如何选择索引的? 在这篇《索引失效了?看看这几个常见的情况!》文章中,我们介绍了索引区分度不高可能会导致索引失效,而这里的“不高”并没有具体量化,实际上 MySQL 会对执行计划进行成本估算,选择成本最低的方案来执行。具体我们还是通过一个案例来说明。 案例还是以人物表为例,我们来看一下优化器是怎么选择索引的。 建表语句如下: 1234567891011CREATE TABLE `person` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(64) NOT NULL, `score` int(11) NOT NULL, `age` int(11) NOT NULL, `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_name_score` (`name`,`score`) USING BTREE, KEY `idx_age` (`age`) USING BTREE, KEY `idx_create_time` (`create_time`) USING BTREE) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4; 然后插入 10 万条数据: 12345678910create PROCEDURE `insert_person`()begin declare c_id integer default 3; while c_id <= 100000 do insert into person values(c_id, concat('name',c_id), c_id + 100, c_id + 10, date_sub(NOW(), interval c_id second)); -- 需要注意,因为使用的是now(),所以对于后续的例子,使用文中的SQL你需要自己调整条件,否则可能看不到文中的效果 set c_id = c_id + 1; end while;end;CALL insert_person(); 可以看到,最早的 create_time 是 2023-04-14 13:03:44。 我们通过下面的SQL语句对person表进行查询: 1explain select * from person where NAME>'name84059' and create_time>'2023-04-15 13:00:00' 通过执行计划,我们可以看到 type=All,表示这是一次全表扫描。接着,我们将 create_time 条件中的 13 点改为 15 点,再次执行查询: 1explain select * from person where NAME>'name84059' and create_time>'2023-04-15 15:00:00' 这次执行计划显示 type=range,key=create_time,表示 MySQL 优化器选择了 create_time 索引来执行这个查询,而不是使用 name_score 联合索引。 也许你会对此感到奇怪,接下来,我们一起来分析一下背后的原因。 OPTIMIZER_TRACE 工具介绍为了更好地理解 MySQL 优化器的工作原理,我们可以使用一个强大的调试工具:OPTIMIZER_TRACE。它是在 MySQL 5.6 及之后的版本中提供的,可以查看详细的查询执行计划,包括查询优化器的决策、选择使用的索引、连接顺序和优化器估算的行数等信息。 当开启 OPTIMIZER_TRACE 时,MySQL 将会记录查询的执行计划,并生成一份详细的报告。这个报告可以提供给开发人员或数据库管理员进行分析,以了解 MySQL 是如何决定执行查询的,进而进行性能优化。 在 MySQL 中,开启 OPTIMIZER_TRACE 需要在查询中使用特定的语句,如下所示: 123SET optimizer_trace='enabled=on';SELECT * FROM mytable WHERE id=1;SET optimizer_trace='enabled=off'; 当执行查询后,MySQL将会生成一个 JSON 格式的执行计划报告。 需要注意的是,开启 OPTIMIZER_TRACE 会增加查询的执行时间和资源消耗,因此只应该在需要调试和优化查询性能时使用。 官方文档在这里:https://dev.mysql.com/doc/dev/mysql-server/latest/PAGE_OPT_TRACE.html 全表扫描的总成本MySQL 在查询数据之前,首先会根据可能的执行方案生成执行计划,然后依据成本决定走哪个执行计划。这里的成本,包括 IO 成本和 CPU 成本: IO 成本,是从磁盘把数据加载到内存的成本。默认情况下,读取数据页的 IO 成本常数是 1(也就是读取 1 个页成本是 1)。 CPU 成本,是检测数据是否满足条件和排序等 CPU 操作的成本。默认情况下,检测记录的成本是 0.2。 MySQL 维护了表的统计信息,可以使用下面的命令查看: 1SHOW TABLE STATUS LIKE 'person' 该命令将返回包括表的行数、数据长度、索引大小等信息。这些信息可以帮助 MySQL 优化器做出更好的决策,选择更优的执行计划。我们使用上述命令查看 person 表的统计信息。 图中总行数为 100064 行(由于 MySQL 的统计信息是一个估算,多出 64 行是正常的),CPU 成本是 100064 * 0.2 = 20012.8 左右。 数据长度是 5783552 字节。对于 InnoDB 存储引擎来说,5783552 就是聚簇索引占用的空间,等于聚簇索引的页数量 * 每个页面的大小。InnoDB 每个页面的大小是 16KB,因此我们可以算出页的数量是 353,因此 IO 成本是 353 左右。 所以,全表扫描的总成本是 20365.8 左右。 追踪 MySQL 选择索引的过程1select * from person where NAME>'name84059' and create_time>'2023-04-15 13:00:00' 上面这条语句可能执行的策略有: 使用 name_score 索引; 使用 create_time 索引; 全表扫描; 接着我们开启 OPTIMIZER_TRACE 追踪: 12SET OPTIMIZER_TRACE="enabled=on",END_MARKERS_IN_JSON=on;SET optimizer_trace_offset=-30, optimizer_trace_limit=30; 依次执行下面的语句。 123select * from person where NAME >'name84059';select * from person where create_time>'2023-04-15 13:00:00';select * from person; 然后查看追踪结果: 12select * from information_schema.OPTIMIZER_TRACE;SET optimizer_trace="enabled=off"; 我从 OPTIMIZER_TRACE 的执行结果中,摘出了几个重要片段来重点分析: 1、使用 name_score 对 name84059<name 条件进行索引扫描需要扫描 26420 行,成本是 31705。 30435 是查询二级索引的 IO 成本和 CPU 成本之和,再加上回表查询聚簇索引的 IO 成本和 CPU 成本之和。 12345678910111213{ "index": "idx_name_score", "ranges": [ "name84059 < name" ] /* ranges */, "index_dives_for_eq_ranges": true, "rowid_ordered": false, "using_mrr": false, "index_only": false, "rows": 26420, "cost": 31705, "chosen": true} 2、使用 create_time 进行索引扫描需要扫描 27566 行,成本是 33080。 12345678910111213{ "index": "idx_create_time", "ranges": [ "2023-04-15 13:00:00 < create_time" ] /* ranges */, "index_dives_for_eq_ranges": true, "rowid_ordered": false, "using_mrr": false, "index_only": false, "rows": 27566, "cost": 33080, "chosen": true} 3、全表扫描 100064 条记录的成本是 20366。 12345678910111213141516171819202122{ "considered_execution_plans": [ { "plan_prefix": [ ] /* plan_prefix */, "table": "`person`", "best_access_path": { "considered_access_paths": [ { "access_type": "scan", "rows": 100064, "cost": 20366, "chosen": true } ] /* considered_access_paths */ } /* best_access_path */, "cost_for_plan": 20366, "rows_for_plan": 100064, "chosen": true } ] /* considered_execution_plans */} 所以 MySQL 最终选择了全表扫描方式作为执行计划。 把 SQL 中的 create_time 条件从 13:00 改为 15:00,再次分析 OPTIMIZER_TRACE 可以看到: 12345678910111213{ "index": "idx_create_time", "ranges": [ "2023-04-15 15:00:00 < create_time" ] /* ranges */, "index_dives_for_eq_ranges": true, "rowid_ordered": false, "using_mrr": false, "index_only": false, "rows": 6599, "cost": 7919.8, "chosen": true} 因为是查询更晚时间的数据,走 create_time 索引需要扫描的行数从 33080 减少到了 7919.8。这次走这个索引的成本 7919.8 小于全表扫描的 20366,更小于走 name_score 索引的 31705。 所以这次执行计划选择的是走 create_time 索引。 人工干预优化器有时会因为统计信息的不准确或成本估算的问题,实际开销会和 MySQL 统计出来的差距较大,导致 MySQL 选择错误的索引或是直接选择走全表扫描,这个时候就需要人工干预,使用强制索引了。 比如,像这样强制走 name_score 索引: 1explain select * from person FORCE INDEX(name_score) where NAME >'name84059' and create_time>'2023-04-15 13:00:00' 相关文章也许你对下面文章也感兴趣。 索引失效了?看看这几个常见的情况! MySQL查询性能慢,该不该建索引? MySQL的事务隔离及实现原理","link":"/2023/9.html"},{"title":"实战:如何优雅地扩展Log4j配置?","text":"前言Log4j 日志框架我们经常会使用到,最近,我就遇到了一个与日志配置相关的问题。简单来说,就是在原来日志配置的基础上,指定类的日志打印到指定的日志文件中。 这样讲述可能不是那么好理解,且听我从需求来源讲起。 一、扩展配置的需求来源我们的项目中使用的是 Log4j2 日志框架,日志配置log4j.yml是这样的: 1234567891011121314151617181920Configuration: status: warn Appenders: Console: name: Console target: SYSTEM_OUT # 不重要 RollingFile: - name: ROLLING_FILE # 不重要 Loggers: Root: level: info AppenderRef: - ref: Console - ref: ROLLING_FILE Logger: - name: com.myproject level: info 配置很简单,只是一个滚动日志文件和控制台的输出。现在来了这么一个需求:要把项目的 HTTP 接口访问日志单独打印到一个日志文件logs/access.log中,这个功能由配置开关casslog.accessLogEnabled决定是否开启。 说做就做,我立马把原来的log4j.yml文件改成log4j_with_accesslog.yml,并添加了访问日志的Appender:ACCESS_LOG,如下配置所示。 1234567891011121314151617181920212223242526272829303132Configuration: status: warn Appenders: Console: name: Console target: SYSTEM_OUT # 不重要 RollingFile: - name: ROLLING_FILE # 不重要 ### 新增的配置开始(1) ### - name: ACCESS_LOG fileName: logs/access.log ### 新增的配置结束(1) ### Loggers: Root: level: info AppenderRef: - ref: Console - ref: ROLLING_FILE Logger: - name: com.myproject level: info ### 新增的配置开始(2) ### - name: com.myproject.commons.AccessLog level: trace additivity: false AppenderRef: - ref: Console - ref: ACCESS_LOG ### 新增的配置结束(2) ### 上面配置注释中【新增的配置开始(1)】和【新增的配置开始(2)】就是添加的配置内容。功能开关是下面这样实现的,在项目启动时做判断。 123456789101112131415import org.springframework.boot.logging.log4j2.Log4J2LoggingSystem;public class MyProjectLoggingSystem extends Log4J2LoggingSystem { static final boolean accessLogEnabled = Boolean.parseBoolean(System.getProperty("casslog.accessLogEnabled", "true")); @Override protected String[] getStandardConfigLocations() { if (accessLogEnabled) { return new String[]{"casslog_with_accesslog.yml"}; } return new String[]{"casslog.yml"}; }} 这样功能就实现了,程序也确实可以运行。但是总感觉不够优雅,如果有上百个项目都要加上这个功能,这些项目的日志配置文件都要改,想想都崩溃。 二、看看开源项目 Nacos 的实现使用过 Nacos 的朋友可能知道,Nacos 的配置模块与服务发现模块是两个功能,日志也是分开的。具体通过nacos-client.jar中的nacos-log4j2.xml就可以看出来。 注意本文 Nacos 源码版本是nacos-client 1.4.1。 nacos-log4j2.xml我做了精简,内容如下。 12345678910111213141516171819202122232425<Configuration status="WARN"> <Appenders> <RollingFile name="CONFIG_LOG_FILE" fileName="${sys:JM.LOG.PATH}/nacos/config.log" filePattern="${sys:JM.LOG.PATH}/nacos/config.log.%d{yyyy-MM-dd}.%i"> <!-- 不重要 --> </RollingFile> <RollingFile name="NAMING_LOG_FILE" fileName="${sys:JM.LOG.PATH}/nacos/naming.log" filePattern="${sys:JM.LOG.PATH}/nacos/naming.log.%d{yyyy-MM-dd}.%i"> <!-- 不重要 --> </RollingFile> </Appenders> <Loggers> <!-- 不重要 --> <Logger name="com.alibaba.nacos.client.config" level="${sys:com.alibaba.nacos.config.log.level:-info}" additivity="false"> <AppenderRef ref="CONFIG_LOG_FILE"/> </Logger> <Logger name="com.alibaba.nacos.client.naming" level="${sys:com.alibaba.nacos.naming.log.level:-info}" additivity="false"> <AppenderRef ref="NAMING_LOG_FILE"/> </Logger> <!-- 不重要 --> </Loggers></Configuration> 通过以上日志配置可以看到,Nacos 将包名为com.alibaba.nacos.client.config的类的日志输出到${sys:JM.LOG.PATH}/nacos/config.log文件中,将包名为com.alibaba.nacos.client.naming的类的日志输出到${sys:JM.LOG.PATH}/nacos/naming.log文件中。${sys:JM.LOG.PATH}默认配置的路径就是用户目录。 接下来,我们看看 Nacos 是如何将日志配置加载进应用程序的。(实现代码请自行赏析) 123456789import static org.slf4j.LoggerFactory.getLogger;public class LogUtils { public static final Logger NAMING_LOGGER; static { NacosLogging.getInstance().loadConfiguration(); NAMING_LOGGER = getLogger("com.alibaba.nacos.client.naming"); }} 123456789public class NacosLogging { private AbstractNacosLogging nacosLogging; public void loadConfiguration() { try { nacosLogging.loadConfiguration(); } // 省略... }} 123public abstract class AbstractNacosLogging { public abstract void loadConfiguration();} 1234567891011121314151617181920212223242526public class Log4J2NacosLogging extends AbstractNacosLogging { private final String location = getLocation("classpath:nacos-log4j2.xml"); @Override public void loadConfiguration() { final LoggerContext loggerContext = (LoggerContext) LogManager.getContext(false); final Configuration contextConfiguration = loggerContext.getConfiguration(); // load and start nacos configuration Configuration configuration = loadConfiguration(loggerContext, location); configuration.start(); // append loggers and appenders to contextConfiguration Map<String, Appender> appenders = configuration.getAppenders(); for (Appender appender : appenders.values()) { contextConfiguration.addAppender(appender); } Map<String, LoggerConfig> loggers = configuration.getLoggers(); for (String name : loggers.keySet()) { if (name.startsWith(NACOS_LOGGER_PREFIX)) { contextConfiguration.addLogger(name, loggers.get(name)); } } loggerContext.updateLoggers(); }} 总结来说,就是先将扩展配置(即nacos-log4j2.xml)转化成LoggerConfig对象;然后将LoggerConfig实例添加到应用的日志配置上下文contextConfiguration中;最后更新应用的Loggers。 三、即学即用我们就把扩展日志当成一个对象,比如这里的「访问日志」,Nacos 中的「配置模块日志」都可以称为扩展日志。我们先来编写扩展日志的抽象AbstractLogExtend。 12345678910111213141516171819202122232425262728293031323334353637@Slf4jpublic abstract class AbstractLogExtend { public void loadConfiguration() { final LoggerContext loggerContext = (LoggerContext) LogManager.getContext(false); final Configuration contextConfiguration = loggerContext.getConfiguration(); // load and start casslog extend configuration Configuration configurationExtend = loadConfiguration(loggerContext); configurationExtend.start(); // append loggers and appenders to contextConfiguration Map<String, Appender> appenders = configurationExtend.getAppenders(); for (Appender appender : appenders.values()) { addAppender(contextConfiguration, appender); } Map<String, LoggerConfig> loggersExtend = configurationExtend.getLoggers(); loggersExtend.forEach((loggerName, loggerConfig) -> addLogger(contextConfiguration, loggerName, loggerConfig) ); loggerContext.updateLoggers(); } private Configuration loadConfiguration(LoggerContext loggerContext) { try { URL url = ResourceUtils.getResourceUrl(logConfig()); ConfigurationSource source = getConfigurationSource(url); // since log4j 2.7 getConfiguration(LoggerContext loggerContext, ConfigurationSource source) return ConfigurationFactory.getInstance().getConfiguration(loggerContext, source); } catch (Exception e) { throw new IllegalStateException("Could not initialize Log4J2 logging from " + logConfig(), e); } } /** * 要扩展配置的文件名 */ public abstract String logConfig();} AbstractLogExtend定义了两个方法,分别是: loadConfiguration():加载扩展日志配置; logConfig():扩展日志配置文件的路径; 然后我们把这些扩展日志加载进应用中。 1234567891011121314public class LogExtendInitializer { private final List<AbstractLogExtend> cassLogExtends; @PostConstruct public void init() { cassLogExtends.forEach(cassLogExtend -> { try { cassLogExtend.loadConfiguration(); } // 省略... }); }} 到这里,基础类代码写好了。下面我们回到文章开头的需求,来看看如何实现。 首先配置访问日志accesslog-log4j.xml。 1234567891011121314151617<Configuration status="WARN"> <Appenders> <!-- 不重要 --> <RollingFile name="ACCESS_LOG" fileName="logs/access.log" filePattern="logs/$${date:yyyy-MM}/access-%d{yyyy-MM-dd}-%i.log.gz"> <!-- 不重要 --> </RollingFile> </Appenders> <Loggers> <Root level="INFO"/> <Logger name="com.myproject.commons.AccessLog" level="trace" additivity="false"> <AppenderRef ref="Console"/> <AppenderRef ref="ACCESS_LOG"/> </Logger> </Loggers></Configuration> 我这里将accesslog-log4j.xml放在了类包下。 接着就是配置accesslog-log4j.xml的文件的路径,这里我把「访问日志」定义成了对象AccessLogConfigExtend。 12345678public class AccessLogConfigExtend extends AbstractLogExtend { @Override public String logConfig() { return "classpath:com/github/open/casslog/accesslog/accesslog-log4j.xml"; }} 这样访问日志就配置好了,也可以将访问日志封装成基础jar包供其他项目使用,这样其他项目就不需要重复配置了。 对于配置开关,可以使用@Conditional来实现,具体如下。 12345678910@Configuration@ConditionalOnProperty(value = "casslog.accessLogEnabled")public class AccessLogAutoConfiguration { @Bean public AccessLogConfigExtend accessLogConfigExtend() { return new AccessLogConfigExtend(); }} 这样实现,确实优雅了很多! 小结本案例是我之前在做日志组件实现的一个功能,源码放在了我的 Github 上:https://github.com/studeyang/casslog","link":"/2022/4.html"},{"title":"学习分享(第 1 期)之 Redis:巧用 Hash 类型节省内存","text":"开篇之前的分享内容都是相对零散的知识点,不成体系。以后的每周分享,我会尽量将每篇文章串连起来,于是我决定做一个专栏,名字就叫《学习分享》。这是该系列的第一篇。 《学习分享》内容大多来自我平时学习过程中的笔记,笔记仓库在 Github:studeyang/technotes。其中我认为有深度、对工作有帮助的内容,就会以文章的形式发表在该专栏,内容会首发在我的公众号、掘金和今日头条,也会维护在 Github:studeyang/leanrning-share。 回顾上篇文章《Redis 的 String 类型,原来这么占内存》中,我们使用 String 类型存储了图片 ID 和图片存储对象 ID,结果发现两个 Long 类型的 ID 竟然占了 68 字节内存。具体验证过程,我还是贴一下方便你回顾。 1、查看 Redis 的初始内存使用情况。 123127.0.0.1:6379> info memory# Memoryused_memory:871840 2、接着插入 10 条数据。 1234567891010.118.32.170:0> set 1101000060 330200008010.118.32.170:0> set 1101000061 330200008110.118.32.170:0> set 1101000062 330200008210.118.32.170:0> set 1101000063 330200008310.118.32.170:0> set 1101000064 330200008410.118.32.170:0> set 1101000065 330200008510.118.32.170:0> set 1101000066 330200008610.118.32.170:0> set 1101000067 330200008710.118.32.170:0> set 1101000068 330200008810.118.32.170:0> set 1101000069 3302000089 3、再次查看内存。 123127.0.0.1:6379> info memory# Memoryused_memory:872528 可以看到,存储 10 个图片,内存使用了 688 个字节。一个图片 ID 和图片存储对象 ID 的记录平均用了 68 字节。 这是上次我们讲述的场景。 并且还留下了一道思考题:既然 String 类型这么占内存,那么你有好的方案来节省内存吗? 今天呢,我们就来具体谈一谈。 用什么数据结构可以节省内存?Redis 提供了一种非常节省内存的数据结构,叫压缩列表(ziplist)。它是由一系列特殊编码的连续内存块组成的顺序性(sequential)数据结构,一个压缩列表可以包含多个节点,每个节点可以保存一个字节数组或者一个整数值。 压缩列表各个部分含义如下。 zlbytes:表示压缩列表占用的内存字节数。 zltail:表示压缩列表表尾节点距离起始地址有多少字节。 zllen:表示压缩列表包含的节点数量。 entry:压缩列表的各个节点。 zlend:特殊值 0xFF (十进制 255),用于标记压缩列表的末端。 举个例子,压缩列表 zlbytes 值为 0x50 (十进制是 80),表示该压缩列表占用 80 字节;zltail 值为 0x3c (十进制是 60),表示如果有一个指向压缩列表起始地址的指针 p,那么只要用指针 p 加上偏移量 60,就可以计算出表尾节点 entry3 的地址;zllen 值为 0x3 (十进制是 3),表示压缩列表有三个节点。 压缩列表之所以能节省内存,就在于它是用一系列连续的 entry 保存数据。每个 entry 的元数据包括下面几部分。 prevlen,表示前一个 entry 的长度。prev_len 有两种取值情况:1 字节或 5 字节。如果上一个 entry 的长度小于 254 字节,取值 1 字节;否则,就取值为 5 字节; encoding:表示编码方式,1 字节; len:表示自身长度,4 字节; data:保存实际数据。 由于 ziplist 节省内存的特性,哈希键(Hash)、列表键(List)和有序集合键(Sorted Set)初始化的底层实现皆采用 ziplist。 我们先看一下能不能使用 Sorted Set 类型来进行保存。 首先,使用 Sorted Set 类型保存数据,面临的第一个问题就是:在一个键对应一个值的情况下,我们该怎么用集合类型来保存这种单值键值对呢? 我们知道 Sorted Set 的元素有 member 值和 score 值,可以把图片 ID 拆成两部分进行保存。具体做法是,把图片 ID 的前 7 位作为 Sorted Set 的 key,把图片 ID 的后 3 位作为 member 值,图片存储对象 ID 作为 score 值。 Sorted Set 中元素较少时,Redis 会使用压缩列表进行存储,可以节省内存空间。但是,在插入数据时,Sorted Set 需要按 score 值的大小进行排序,它的性能就差了。 所以,Sorted Set 类型虽然可以用来保存图片 ID 和图片存储对象 ID,但并不是最优选项。 那 List 类型呢? List 类型对于存储图片 ID 和图片存储对象 ID 这种一对一的场景不是很适合。我们可以使用 Hash 类型。 使用 Hash 类型还是用上面拆成两部分保存的方法,把图片 ID 的前 7 位 Hash 集合的 key,把图片 ID 的后 3 位作为 Hash 集合的 value。 对于数据 060,会选择对应的编码 11000000;同样,数据 3302000080 对应的编码是 11100000。 为什么对应的编码是这个?这里不是很清楚?没关系,这不影响你理解本文内容,如果你感兴趣,可以自行查看一下源码。 1234567891011121314151617181920212223242526272829#define ZIP_INT_16B (0xc0 | 0<<4)#define ZIP_INT_64B (0xc0 | 2<<4)#define ZIP_INT_8B 0xfeint zipTryEncoding(unsigned char *entry, unsigned int entrylen, long long *v, unsigned char *encoding) { long long value; if (entrylen >= 32 || entrylen == 0) return 0; if (string2ll((char*)entry,entrylen,&value)) { /* Great, the string can be encoded. Check what's the smallest * of our encoding types that can hold this value. */ if (value >= 0 && value <= 12) { *encoding = ZIP_INT_IMM_MIN+value; } else if (value >= INT8_MIN && value <= INT8_MAX) { // 256 *encoding = ZIP_INT_8B; // 060 选择这个(图中encoding写错了) } else if (value >= INT16_MIN && value <= INT16_MAX) { *encoding = ZIP_INT_16B; } else if (value >= INT24_MIN && value <= INT24_MAX) { *encoding = ZIP_INT_24B; } else if (value >= INT32_MIN && value <= INT32_MAX) { // 2,147,483,648 *encoding = ZIP_INT_32B; } else { *encoding = ZIP_INT_64B; // 3,302,000,080 选择这个 } *v = value; return 1; } return 0;} 其中有的 entry 保存一个图片 ID 的后 3 位(4 字节),有的 entry 保存存储对象 ID(8 字节),此时,每个 entry 的 prev_len 只需要 1 个字节就行,因为每个 entry 的前一个 entry 长度都小于 254 字节。这样一来,一个图片 ID 后 3 位所占用的内存大小是 8 字节(1+1+4+2);一个存储对象 ID 所占用的内存大小是 14 字节(1+1+4+8=14),实际分配 16 字节。 10 个图片所占用的内存就是:ziplist 4(zlbytes) + 4(zltail) + 2(zllen) + 8*10(entry) + 16*10(entry) + 1(zlend) = 251 字节。 结合全局哈希表,内存各部分占用如下: 10 个图片占 32(dictEntry) + 8(key) + 16(redisObject) + 251 = 307 字节。 这比 String 的类型的存储结果 688 节约了一倍的内存。 我们也通过下面的实战来验证一下。 12345678127.0.0.1:6379> info memory# Memoryused_memory:871872127.0.0.1:6379> hset 1101000 060 3302000080 061 3302000081 ...(integer) 1127.0.0.1:6379> info memory# Memoryused_memory:872152 实际使用了 280 字节。 不过,这里你可能会问了,图片 ID 1101000060 一定要折成 7+3,即 1101000+060 的方式吗?拆成 5+5,即 11010+00060 行不行? 一定要 7+3 的方式存储 key 吗?答案是肯定的。 Redis Hash 类型的两种底层数据结构,一种是压缩列表,另一种是哈希表。Hash 类型设置了压缩列表保存数据的阈值,一旦超过了阈值,Hash 类型就会用哈希表来保存数据了。 如果我们往 Hash 集合中写入的元素个数超过了 hash-max-ziplist-entries (默认 512 个),或者写入的单个元素大小超过了 hash-max-ziplist-value (默认 64 字节),Redis 就会自动把 Hash 类型的实现结构由压缩列表转为哈希表。在节省内存方面,哈希表就没有压缩列表那么高效了。 为了能使用压缩列表来节省内存,我们一般要控制保存在 Hash 集合中的元素个数。所以,我们只用图片 ID 的后 3 位作为 Hash 集合的 key,也就保证了 Hash 集合的元素个数不超过 1000,同时,我们把 hash-max-ziplist-entries 设置为 1000,这样一来,Hash 集合就可以一直使用压缩列表来节省内存空间了。 参考资料 文中的一些命令,参考菜鸟教程:https://www.runoob.com/redis/redis-tutorial.html 极客时间《Redis 核心技术与实战》 书籍《Redis 设计与实现》 压缩列表:https://redisbook.readthedocs.io/en/latest/compress-datastruct/ziplist.html 哈希表:http://redisbook.com/preview/object/hash.html 相关文章也许你对下面文章也感兴趣。 Redis 高可用之哨兵机制实现细节 Redis 高可用全景一览 海量数据下,如何统计用户的签到信息?","link":"/2023/3.html"},{"title":"学习分享(第 2 期):从源码层面看 Redis 节省内存的设计","text":"这里记录的是学习分享的内容,文章维护在 Github:studeyang/leanrning-share。 回顾在文章《Redis 的 String 类型,原来这么占内存》中,我们学习了 SDS 的底层结构,发现 SDS 存储了很多的元数据,再加上全局哈希表的实现,使得 Redis String 类型在内存占用方面并不理想。 然后在文章《学习分享(第1期)之Redis:巧用Hash类型节省内存》中,我们学习了另一种节省内存的方案,使用 ziplist 结构的 Hash 类型,内存占用减少了一半,效果显著。 虽然我们在使用 String 类型后,占用了较多内存,但其实 Redis 是对 SDS 做了节省内存设计的。除此之外,Redis 在其他方面也都考虑了内存开销,今天我们就从源码层面来看看都做了哪些节省内存的设计。 文中代码版本为 6.2.4。 一、redisObject 的位域定义法我们知道,redisObject 是底层数据结构如 SDS, ziplist 的封装,因此,redisObject 如果能做优化,最终也能带来节省内存的用户体验。在源码 server.h 中定义了 redisObject 的结构体,如下面代码所示: 123456789#define LRU_BITS 24typedef struct redisObject { unsigned type:4;//对象类型(4位=0.5字节) unsigned encoding:4;//编码(4位=0.5字节) unsigned lru:LRU_BITS;//记录对象最后一次被应用程序访问的时间(24位=3字节) int refcount;//引用计数。等于0时表示可以被垃圾回收(32位=4字节) void *ptr;//指向底层实际的数据存储结构,如:sds等(8字节)} robj; type, encoding, lru, refcount 都是 redisObject 的元数据,redisObject 的结构如下图所示。 从代码中我们可以看到,在 type、encoding 和 lru 三个变量后面都有一个冒号,并紧跟着一个数值,表示该元数据占用的比特数。这种变量后使用冒号和数值的定义方法,实际上是 C 语言中的位域定义方法,可以用来有效地节省内存开销。 这种方法比较适用的场景是,当一个变量占用不了一个数据类型的所有 bits 时,就可以使用位域定义方法,把一个数据类型中的 bits(32 bits),划分成多个(3 个)位域,每个位域占一定的 bit 数。这样一来,一个数据类型的所有 bits 就可以定义多个变量了,从而也就有效节省了内存开销。 另外,SDS 的设计中,也有内存优化的设计,我们具体来看看有哪些。 二、SDS 的设计在 Redis 3.2 版本之后,SDS 由一种数据结构变成了 5 种数据结构。分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64,其中 sdshdr5 只被应用在了 Redis 中的 key 中,另外 4 种不同类型的结构头可以适配不同大小的字符串。 以 sdshdr8 为例,它的结构定义如下所示: 123456struct __attribute__ ((__packed__)) sdshdr8 { uint8_t len; /* used */ uint8_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[];}; 不知道你有没有注意到,在 struct 和 sdshdr8 之间使用了 __attribute__ ((__packed__))。这是 SDS 的一个节省内存的设计–紧凑型字符串。 2.1 紧凑型字符串什么是紧凑型字符串呢? 它的作用就是告诉编译器,在编译 sdshdr8 结构时,不要使用字节对齐的方式,而是采用紧凑的方式分配内存。默认情况下,编译器会按照 8 字节对齐的方式,给变量分配内存。也就是说,即使一个变量的大小不到 8 个字节,编译器也会给它分配 8 个字节。 举个例子。假设我定义了一个结构体 st1,它有两个成员变量,类型分别是 char 和 int,如下所示: 123456789#include <stdio.h>int main() { struct st1 { char a; int b; } ts1; printf("%lu\\n", sizeof(ts1)); return 0;} 我们知道,char 类型占用 1 个字节,int 类型占用 4 个字节,但是如果你运行这段代码,就会发现打印出来的结果是 8。这就是因为在默认情况下,编译器会按照 8 字节对齐的方式,给 st1 结构体分配 8 个字节的空间,但是这样就有 3 个字节被浪费掉了。 那我用 __attribute__ ((__packed__)) 属性重新定义结构体 st2,同样包含 char 和 int 两个类型的成员变量,代码如下所示: 123456789#include <stdio.h>int main() { struct __attribute__((packed)) st2{ char a; int b; } ts2; printf("%lu\\n", sizeof(ts2)); return 0;} 当你运行这段代码时,可以看到打印的结果是 5,这就是紧凑型内存分配,st2 结构体只占用 5 个字节的空间。 除此之外,Redis 还做了这样的优化:当保存的字符串小于等于 44 字节时,RedisObject 中的元数据、指针和 SDS 是一块连续的内存区域,这种布局方式被称为 embstr 编码方式;当字符串大于 44 字节时,SDS 和 RedisObject 就分开布局了,这种布局方式被称为 raw 编码模式。 这部分的代码在 object.c 文件中: 123456789#define OBJ_ENCODING_EMBSTR_SIZE_LIMIT 44robj *createStringObject(const char *ptr, size_t len) { // 当字符串长度小于等于44字节,创建嵌入式字符串 if (len <= OBJ_ENCODING_EMBSTR_SIZE_LIMIT) return createEmbeddedStringObject(ptr,len); //字符串长度大于44字节,创建普通字符串 else return createRawStringObject(ptr,len);} 当 len 的长度小于等于 OBJ_ENCODING_EMBSTR_SIZE_LIMIT (默认为 44 字节)时, createStringObject 函数就会调用 createEmbeddedStringObject 函数。这是 SDS 第二个节省内存的设计–嵌入式字符串。 在讲述嵌入式字符串之前,我们还是先来看看,当 len 的长度大于 OBJ_ENCODING_EMBSTR_SIZE_LIMIT (默认为 44 字节)时,这种普通字符串的创建过程。 2.2 RawString 普通字符串对于 createRawStringObject 函数来说,它在创建 String 类型的值的时候,会调用 createObject 函数。createObject 函数主要是用来创建 Redis 的数据对象的。代码如下所示。 123robj *createRawStringObject(const char *ptr, size_t len) { return createObject(OBJ_STRING, sdsnewlen(ptr,len));} createObject 函数有两个参数,一个是用来表示所要创建的数据对象类型,另一个是指向 SDS 对象的指针,这个指针是通过 sdsnewlen 函数创建的。 123456789101112robj *createObject(int type, void *ptr) { // 【1】给redisObject结构体分配内存空间 robj *o = zmalloc(sizeof(*o)); //设置redisObject的类型 o->type = type; //设置redisObject的编码类型 o->encoding = OBJ_ENCODING_RAW; // 【2】将传入的指针赋值给redisObject中的指针 o->ptr = ptr; … return o;} 调用 sdsnewlen 函数创建完 SDS 对象的指针后,也分配了一块 SDS 的内存空间。接着,createObject 函数会给 redisObject 结构体分配内存空间,如上示代码【1】处。然后再把将传入的指针赋值给 redisObject 中的指针,如上示代码【2】处。 我们接着来看嵌入式字符串。 2.3 EmbeddedString 嵌入式字符串通过上文我们知道,当保存的字符串小于等于 44 字节时,RedisObject 中的元数据、指针和 SDS 是一块连续的内存区域。那么 createEmbeddedStringObject 函数是如何把 redisObject 和 SDS 的内存区域放置在一起的呢? 123456789101112131415robj *createEmbeddedStringObject(const char *ptr, size_t len) { // 【1】 robj *o = zmalloc(sizeof(robj)+sizeof(struct sdshdr8)+len+1); ... if (ptr == SDS_NOINIT) sh->buf[len] = '\\0'; else if (ptr) { // 【2】 memcpy(sh->buf,ptr,len); sh->buf[len] = '\\0'; } else { memset(sh->buf,0,len+1); } return o;} 首先,createEmbeddedStringObject 函数会分配一块连续的内存空间,这块内存空间的大小等于 redisObject 结构体的大小 + SDS 结构头 sdshdr8 的大小 + 字符串大小的总和, 并且再加上 1 字节结束字符“\\0”。这部分代码如上【1】处。 先分配了一块连续的内存空间,从而避免了内存碎片。 然后,createEmbeddedStringObject 函数会把参数中传入的指针 ptr 所指向的字符串,拷贝到 SDS 结构体中的字符数组,并在数组最后添加结束字符。这部分代码如上【2】处。 好了,以上就是 Redis 在设计 SDS 结构上节省内存的两个优化点,不过除了嵌入式字符串之外,Redis 还设计了压缩列表,这也是一种紧凑型的内存数据结构,下面我们再来学习下它的设计思路。 三、压缩列表的设计为了方便理解压缩列表的设计与实现,我们先来看看它的创建函数 ziplistNew,这部分代码在 ziplist.c 文件中,如下所示: 123456789unsigned char *ziplistNew(void) { // 初始分配的大小 unsigned int bytes = ZIPLIST_HEADER_SIZE+ZIPLIST_END_SIZE; unsigned char *zl = zmalloc(bytes); … // 将列表尾设置为ZIP_END zl[bytes-1] = ZIP_END; return zl;} 可以看到,ziplistNew 函数的逻辑很简单,就是创建一块连续的内存空间,大小为 ZIPLIST_HEADER_SIZE 和 ZIPLIST_END_SIZE 的总和,然后再把该连续空间的最后一个字节赋值为 ZIP_END,表示列表结束。 另外,在 ziplist.c 文件中也定义了 ZIPLIST_HEADER_SIZE、 ZIPLIST_END_SIZE 和 ZIP_END 的值,它们分别表示 ziplist 的列表头大小、列表尾大小和列表尾字节内容,如下所示。 123456//ziplist的列表头大小#define ZIPLIST_HEADER_SIZE (sizeof(uint32_t)*2 + sizeof(uint16_t))//ziplist的列表尾大小#define ZIPLIST_END_SIZE (sizeof(uint8_t))//ziplist的列表尾字节内容#define ZIP_END 255 列表头包括 2 个 32 bits 整数和 1 个 16 bits 整数,分别表示压缩列表的总字节数 zlbytes,列表最后一个元素离列表头的偏移 zltail,以及列表中的元素个数 zllen;列表尾包括 1 个 8 bits 整数,表示列表结束。执行完 ziplistNew 函数创建一个 ziplist 后,内存布局就如下图所示。 注意,此时 ziplist 中还没有实际的数据,所以图中并没有画出来。 然后,当我们往 ziplist 中插入数据后,完整的内存布局如下图所示。 ziplist entry 包括三部分内容,分别是前一项的长度(prevlen)、当前项长度信息的编码结果(encoding),以及当前项的实际数据(data)。 当我们往 ziplist 中插入数据时,ziplist 会根据数据是字符串还是整数,以及它们的大小进行不同的编码,这种根据数据大小进行相应编码的设计思想,正是 Redis 为了节省内存而采用的。 12345678910111213unsigned char *__ziplistInsert(unsigned char *zl, unsigned char *p, unsigned char *s, unsigned int slen) { ... /* Write the entry */ p += zipStorePrevEntryLength(p,prevlen); p += zipStoreEntryEncoding(p,encoding,slen); if (ZIP_IS_STR(encoding)) { memcpy(p,s,slen); } else { zipSaveInteger(p,value,encoding); } ZIPLIST_INCR_LENGTH(zl,1); return zl;} 此处源码在下文中还会提及,为了讲述方便,这里标识为【源码A处】。 此外,每个列表项 entry 中都会记录前一项的长度,因为每个列表项的长度不一样, Redis 会根据数据长度选择不同大小的字节来记录 prevlen。 3.1 使用不同大小的字节来记录 prevlen举个例子,假设我们统一使用 4 字节记录 prevlen,如果前一个列表项只是一个 5 字节的字符串“redis”,那我们用 1 个字节(8 bits)就能表示 0~256 字节长度的字符串了。此时,prevlen 用 4 字节记录,就有 3 字节浪费掉了。 下面我们就来看看 Redis 是如何根据数据长度选择不同大小的字节来记录 prevlen 的。 通过上面的 __ziplistInsert 函数即【源码A处】可以看到,ziplist 在对 prevlen 编码时,会先调用 zipStorePrevEntryLength 函数,该函数代码如下所示: 123456789101112131415unsigned int zipStorePrevEntryLength(unsigned char *p, unsigned int len) { if (p == NULL) { return (len < ZIP_BIG_PREVLEN) ? 1 : sizeof(uint32_t) + 1; } else { //判断prevlen的长度是否小于ZIP_BIG_PREVLEN if (len < ZIP_BIG_PREVLEN) { //如果小于254字节,那么返回prevlen为1字节 p[0] = len; return 1; } else { //否则,调用zipStorePrevEntryLengthLarge进行编码 return zipStorePrevEntryLengthLarge(p,len); } }} 可以看到,zipStorePrevEntryLength 函数会判断前一个列表项是否小于 ZIP_BIG_PREVLEN(ZIP_BIG_PREVLEN 的值是 254)。如果是的话,那么 prevlen 就使用 1 字节表示;否则,zipStorePrevEntryLength 函数就调用 zipStorePrevEntryLengthLarge 函数进一步编码。 zipStorePrevEntryLengthLarge 函数会先将 prevlen 的第 1 字节设置为 254,然后使用内存拷贝函数 memcpy,将前一个列表项的长度值拷贝至 prevlen 的第 2 至第 5 字节。最后,zipStorePrevEntryLengthLarge 函数返回 prevlen 的大小,为 5 字节。 12345678910111213int zipStorePrevEntryLengthLarge(unsigned char *p, unsigned int len) { uint32_t u32; if (p != NULL) { //将prevlen的第1字节设置为ZIP_BIG_PREVLEN,即254 p[0] = ZIP_BIG_PREVLEN; u32 = len; //将前一个列表项的长度值拷贝至prevlen的第2至第5字节,其中sizeof(u32)的值为4 memcpy(p+1,&u32,sizeof(u32)); ... } //返回prevlen的大小,为5字节 return 1 + sizeof(uint32_t);} 好了,在了解了 prevlen 使用 1 字节和 5 字节两种编码方式后,我们再来学习下 encoding 的编码方法。 3.2 使用不同大小的字节来记录 encoding 编码我们回到上面的 __ziplistInsert 函数即【源码A处】,可以看到执行完 zipStorePrevEntryLength 函数逻辑后,紧接着会调用 zipStoreEntryEncoding 函数。 ziplist 在 zipStoreEntryEncoding 函数中,针对整数和字符串,就分别使用了不同字节长度的编码结果。 12345678910111213141516171819202122232425262728293031unsigned int zipStoreEntryEncoding(unsigned char *p, unsigned char encoding, unsigned int rawlen) { //默认编码结果是1字节 unsigned char len = 1; //如果是字符串数据 if (ZIP_IS_STR(encoding)) { //如果字符串长度小于等于63字节(16进制为0x3f) if (rawlen <= 0x3f) { //默认编码结果是1字节 if (!p) return len; ... } //字符串长度小于等于16383字节(16进制为0x3fff) else if (rawlen <= 0x3fff) { //编码结果是2字节 len += 1; if (!p) return len; ... } //字符串长度大于16383字节 else { //编码结果是5字节 len += 4; if (!p) return len; ... } } else { /* 如果数据是整数,编码结果是1字节 */ if (!p) return len; ... }} 可以看到当数据是不同长度字符串或是整数时,编码结果的长度 len 大小不同。 总之,针对不同长度的数据,使用不同字节大小的元数据信息 prevlen 和 encoding 来记录, 这种方法可以有效地节省内存开销。 参考资料 极客时间《Redis源码剖析与实战》 redis 源码 v6.2.4:https://github.com/redis/redis/tree/6.2.4 相关文章也许你对下面文章也感兴趣。 Redis 高可用之哨兵机制实现细节 Redis 高可用全景一览 海量数据下,如何统计用户的签到信息?","link":"/2023/4.html"},{"title":"学习分享(第3期):你所理解的架构是什么?","text":"本文摘要:浅谈应用架构、业务架构、技术架构。 什么是架构?说到架构,这个概念没有很清晰的范围划分,也没有一个标准的定义,每个人的理解可能都不一样。 架构在百度百科中是这样定义的:架构,又名软件架构,是有关软件整体结构与组件的抽象描述,用于指导大型软件系统各个方面的设计。 我们可以理解为:架构设计的主要目的是为了解决软件系统复杂度带来的问题。 卡内基·梅隆大学的玛丽·肖(Mary Shaw)和戴维·加兰(David Garlan)在文章《软件架构介绍》(An Introduction to Software Architecture)中写到: “When systems are constructed from many components, the organization of the overall system-the software architecture-presents a new set of design problems.” 译:随着软件系统规模的增加,计算相关的算法和数据结构不再构成主要的设计问题;当系统由许多部分组成时,整个系统的组织,也就是所说的“软件架构”,导致了一系列新的设计问题。 软件架构的核心价值,即是控制系统的复杂性,将核心业务逻辑和技术细节的分离与解耦。 架构师的职责是努力训练自己的思维,用它去理解复杂的系统,通过合理的分解和抽象,理解并解析需求,创建有用的模型,确认、细化并扩展模型,管理架构;能够进行系统分解形成整体架构,能够正确的技术选型,能够制定技术规格说明并有效推动实施落地。 架构分类在我的认知体系中,将架构分为业务架构、应用架构、技术架构。当然也听说过数据架构,但大数据领域超出了我的知识范围,并不打算作深入的学习。 我们来理解一下业务架构、应用架构和技术架构。 在需求初期,业务的需求描述往往比较模糊。但是大方向上,业务需求是由公司战略决定的。这些战略所产生的一系统需求,需要业务架构师来进行业务落地,重点在于讲清楚这些需求背后的处理过程,定义各个业务模块的相互关系。 而应用架构、技术架构是为支撑业务架构的落地而存在的。它们的关系环环相扣,上层驱动下层,下层支撑上层。 举一个拍电影的例子。 业务架构定义了这个电影的故事情节和场景安排;应用架构定义了有哪些角色及其职责,在每个场景中,这些角色是如何互动的;技术架构确定这些角色由谁来表演,物理场景上是怎么布置的,以此保证整个拍摄能够顺利完成。 再举一个电商的例子。 一个商品业务,可能对应 3 个应用,一个前台商品展示应用、一个后台商品管理应用,以及一个商品基础服务。业务架构定义了一个下单的具体流程;应用架构定义了下单有哪些应用参与以及它们如何协作;技术架构要保障相关的应用能够处理高并发,从而保证大促顺利进行。 业务架构说到业务啊,那就不得不提产品经理。产品经理的职责就是:告诉用户,系统长什么样子;告诉开发,他要实现什么功能。比如说,我们现在要设计一个电商系统,用户想在我们系统上买东西,一个典型的购物流程,包括商品浏览、加入购物车、下单、支付。 产品经理首先要把每个步骤具体化为页面原型。在原型中,直观的给出各个步骤的输入或输出,以及用户的操作过程,最后再把这些页面串起来,形成一个业务流程。 业务架构师要设计一个购物流程模块,里面包含商品查询、添加购物车、下单和支付接口,来分别对应流程里的 4 个业务步骤。 说起来倒是挺简单的,要实现这个购物流程,其实是考验业务架构师的功力的。 首先,业务架构师要掌握不同模块的业务和数据模型。这会同时涉及商品、购物车、下单和支付四个业务,业务架构师要同时非常清楚这四部分的数据模型和业务逻辑。 其次,这个模块要设计的足够灵活。如果一个业务领域的需求发生了变化,比如说,订单要增加一个新的状态,那么所有涉及该订单的模块都要知道这个变化,并要做出相应的调整。 下面画出了电商系统的业务架构图,仅供参考: 应用架构应用架构就是讲清楚系统内部是怎么组织的,相互间是怎么调用的。我们熟知的应用架构有:MVC架构、分层架构、六边形架构。 从单个应用层面讲,应用架构定义了项目包的结构,比如分层应用架构,我在这篇文章《基于 start.spring.io,我实现了 Java 脚手架定制》中介绍了实现分层应用架构的过程,它的分层结构如下图所示: 从系统层面讲,应用架构定义了各个进程间的调用与交互。下面画出了电商系统的分层架构图,仅供参考: 技术架构技术架构就是对在业务架构中提出的功能进行技术方案的实现。关键就是讲清楚系统由哪些硬件、操作系统和中间件组成,它们是如何与我们开发的应用一起配合,应对各种异常情况,保持系统的稳定可用。 这同样要求技术架构师在计算机技术方面有深厚的功力,第一大挑战就是:硬件。 从技术架构的角度,提升硬件的处理能力一般有两种方式:Scale Up 和 Scale Out。垂直扩展有物理上的瓶颈或成本的问题。受硬件的物理限制,机器的性能是有天花板的。水平扩展如何有效地管理大量的机器,硬件不是 100% 的可靠,它本身也会出问题。 第二大挑战:软件。 这里的软件,主要说的是各种中间件和系统级软件。软件在填硬件的各种坑的同时,也给系统挖了新的坑。 举个例子,Redis 集群的多节点,它解决了单节点处理能力问题,但同时也带来了新的问题,比如 Redis 数据的多副本,它解决了单台服务器故障带来的可用性问题,但同时也带来了数据的一致性问题。 下面画出了电商系统的技术架构图,仅供参考: 相关文章也许你对下面文章也感兴趣。 基于 start.spring.io,我实现了 Java 脚手架定制 Nacos 配置中心落地与实践","link":"/2023/5.html"},{"title":"04期:领域驱动设计与微服务","text":"这里记录的是学习分享内容,文章维护在 Github:studeyang/leanrning-share。 如何理解领域驱动设计?随着微服务的兴起,你一定听说过领域驱动设计 DDD(domain-driven design),但是如果把它当成一个术语来看,似乎有点抽象。这到底是个什么玩意? 别急,你肯定还听说过测试驱动开发(TDD, Test-driven development)吧? 这是个什么概念呢?就是说开发的过程中要测试先行,倡导先写测试程序,然后编码实现。开发是目的,测试是辅助,所以叫做测试-驱动-开发,我们应该把它拆成 3 个术语来理解。 所以,对于领域驱动设计,设计是目的,领域才是辅助。想要设计一个软件,但是由于业务太过复杂,设计过程难以进行。这时,使用领域的思想来辅助设计。 微服务应该拆多小?如果你是业务架构师,你在设计过程中会遇到哪些难题呢?我想你面临的第一个问题就是:微服务到底应该拆多小? 有人说:“微服务嘛,就是要越小越好!” 这时运维可能要跳出来打你了,微服务如果拆分过度,导致项目复杂度过高,不仅运维维护这些服务耗费人力,太小的微服务也占用了资源。 那是否有合适的理论或设计方法来指导微服务设计呢? 答案就是 DDD。 DDD 是一种处理复杂领域的设计思想,包括两部分,战略设计和战术设计。战略设计就是辅助建立业务领域模型,划分领域边界,建立限界上下文(DDD 的专业术语,下文会解释)。 战术设计则从技术视角出发,侧重于领域模型的技术实现,完成软件开发和落地,包括微服务代码架构模型的设计和实现。 DDD 思想是如何指导微服务拆分的呢?可以分为三步: 第一步,罗列业务场景,找出领域实体对象。 第二步,根据领域实体间的业务关联,将相关的实体组合形成聚合。它们属于同一个微服务。 第三步,根据语义边界,将多个聚合划定在一个限界上下文内,形成领域模型。这一层边界就是微服务的边界。 DDD 领域的思想在研究复杂领域问题时,DDD 会按一定的规则将业务领域进行细分,这跟自然科学的研究方法类似。 当人们在自然科学研究中遇到复杂问题时,通常的做法就是将问题按一定的规则进行细分,再针对细分出来的问题子域逐个深入研究,当所有问题子域完成研究时,我们就建立了全部领域的完整知识体系了。 举个例子:假如我们要研究一颗桃树。按照器官的不同分为营养器官和生殖器官,对营养器官进一步细分,分为叶,茎、根,对生殖器官进一步分为花、果实、种子。 对器官进一步细分,将器官分为组织。对组织进一步细分,将组织细分为细胞。细胞就是我们要研究的最小单元。细胞之间的细胞壁确定了单元的边界,也确定了研究的最小边界。 子域将桃树细分成了六个子域:根、茎、叶,花、果实、种子。子域再按照重要程度进行划分,分为核心域、通用域、支撑域。 决定产品和公司核心竞争力的子域是核心域;没有太多个性化的诉求,同时被多个子域使用的是通用域;既不包含决定产品和公司核心竞争力的功能,也不包含通用功能的子域,它就是支撑域。 需要注意的是,核心域要根据公司的发展战略及业务的实际情况来确定。 举例来说,如果这颗桃树的主人是一名园丁,那他关注的就是桃花盛开,春色满园,所以花就是核心域。如果这颗桃树的主人是一名果农,那他关注的就是桃子质量、产量,所以果实就是核心域。 限界上下文我们知道语言都有它的语义环境,为了避免同样的概念或语义在不同的上下文环境中产生歧义,DDD 在战略设计上提出了“限界上下文”这个概念,用来确定语义所在的领域边界。 举个例子:下图中的两个账户,光凭名字我们根本无法区分,只有通过它们所在的限界上下文我们才能看出它们之间的区别。 再比如,电商领域的商品在不同的阶段有不同的术语,在销售阶段是商品,而在运输阶段则变成了货物。同样的一个东西,由于业务领域的不同,赋予了这些术语不同的涵义和职责边界。 一个限界上下文就可以拆分为一个微服务,这个边界使得一个概念在这个边界内没有二义性。 实体总结来说有四种形态。 第一,实体的业务形态:在战略设计时,领域模型中的实体是多个属性、操作或行为的载体。 第二,实体的代码形态:在代码模型中,实体的表现形式是实体类,这个类包含了实体的属性和方法,以及核心业务逻辑。 DDD 强调“设计即代码”。对于“注射流感疫苗”这个业务用例,当团队讨论到业务模型时,他们会说:“护士给病人注射标准剂量的流感疫苗。” 传统代码的表现形式是这样的: 12345public void shot() { patient.setShotType(ShotTypes.TYPE_FLU); patient.setDose(dose); patient.setNurse(nurse);} DDD 思想的代码表现形式是: 1234public void shot() { Vaccine vaccine = vaccines.standardAdultFluDose(); nurse.administerFluVaccine(patient, vaccine);} 很明显,第二类代码更容易理解的多。 第三,实体的运行形态:实体以 DO(领域对象)的形式存在,每个实体对象都有唯一的 ID。我们可以对一个实体对象进行多次修改,修改后的数据和原来的数据可能会大不相同。但是,由于它们拥有相同的 ID,它们依然是同一个实体。 第四,实体的数据库形态:在领域模型映射到数据模型时,大多数情况下实体与持久化对象是一对一。 值对象值对象是 DDD 领域模型中的一个基础对象,它跟实体一样,都包含了若干个属性,它与实体一起构成聚合。 值对象的业务形态。 本质上,实体是看得到、摸得着的实实在在的业务对象,实体具有业务属性、业务行为和业务逻辑。而值对象只是若干个属性的集合。 值对象的代码形态。 1234567891011public class Person { private Integer id; private String name; private Address address;}private class Address { private String province; private String city; private String county;} 我们看一下上面这段代码,Person 这个实体有若干个单一属性的值对象,比如 id、name 等属性;同时它也包含多个属性的值对象,比如地址 address。 值对象的运行形态。 实体实例化后的 DO 对象的业务属性和业务行为非常丰富,但值对象实例化的对象则相对简单。 值对象的数据库形态。 在领域建模时,我们可以将部分对象设计为值对象,保留对象的业务涵义,同时又减少了实体的数量;在数据建模时,我们可以将值对象嵌入实体,减少实体表的数量,简化数据库设计。 有些场景中,地址会被某一实体引用,它只承担描述实体的作用,并且它的值只能整体替换,这时候你就可以将地址设计为值对象,比如收货地址。而在某些业务场景中,地址会被经常修改,地址是作为一个独立对象存在的,这时候它应该设计为实体,比如行政区划中的地址信息维护。 聚合和聚合根举个例子。社会是由一个个的个体组成的,我们每一个人就是一个个体。随着社会的发展,慢慢出现了社团、机构、部门等组织,我们也从个人变成了组织的一员,在组织内,大家协同工作,朝着更大的目标,发挥出更大的力量。 领域模型内的实体和值对象就好比个体,而能让实体和值对象协同工作的组织就是聚合,它用来确保这些领域对象在实现共同的业务逻辑时,能保证数据的一致性。 如果把聚合比作组织,那聚合根就是这个组织的负责人。聚合根也称为根实体,它不仅是实体,还是聚合的管理者。 在聚合之间,通过聚合根 ID 关联引用,如果需要访问其它聚合的实体,就要先访问聚合根,再导航到聚合内部实体,外部对象不能直接访问聚合内实体。 最后,我用下图来总结一下领域、限界上下文、实体、值对象、聚合、聚合根。 相关文章也许你对下面文章也感兴趣。 学习分享(第3期):你所理解的架构是什么?","link":"/2023/6.html"},{"title":"05期:面向业务的消息服务落地实践","text":"简介:传统的消息队列对业务方提出了更高的要求,我们期望提供的是一种以业务为重心的,面向服务的解决方案。 这里记录的是学习分享内容,文章维护在 Github:studeyang/leanrning-share。 我们在上次分享中聊到了领域驱动设计和微服务,在 DDD 中有一个术语叫做领域事件,例如订单模型中的订单已创建、商品已发货。领域事件会触发下一步的业务操作,如果领域事件发生在微服务内,可以通过观察者模式很容易实现消息监听并处理。 如果发生在微服务之间,则需引入事件总线或者消息中间件。 一、消息队列解决方案经过技术选型后,我们决定使用 Kafka 作为消息中间件,此时微服务间的通信示意图如下: 不过,直接使用消息队列将面临以下问题: 开发成本大:开发团队成员都需要对消息队列如 Kafka 技术有一定的了解,并且还需要关注连接消息队列的一些配置; 管理难度大:各团队都使用一个消息队列,其中一个团队使用不当时,例如创建了很多个 topic,造成资源浪费; 监控难度大:当前只有对 Kafka 集群简单的监控功能; 运维困难:遇到线上消息没有消费时,很难排查问题,无从下手; 升级难度大:Kafka-Client 需要升级时,涉及到服务太多,导致升级成本高; 我们期望提供的是一种以业务为重心的,面向服务的解决方案。 也就是说,即使团队中没人了解消息队列技术,也能够收发消息。于是对 Kafka SDK 二次封装,主要就是为了简化消息的接入,无需关注配置。 封装后解决了开发成本大、管理难度大的问题,但是离面向服务的解决方案目标还有一定的差距。比如业务方监听到消息后,执行一系列的业务逻辑异常了,想要做业务补偿,我们的“基于 Kafka SDK 二次封装”的方案就没办法满足,只能要求消息发送方再发一次消息,但这又会影响其他消息监听者。 于是我们决定将消息列队封装成消息服务,对业务方提供切实的服务能力。 二、消息服务解决方案我们熟知计算机中总线,在计算机系统中,不同的组件和设备需要相互通信以完成各种任务,此时,计算机总线就发挥了重要作用。类似的,微服务系统中,微服务就像是计算机系统中的各个组件和设备,而消息服务充当的就是计算机总线的角色。消息总线由此而来。 本文中出现的消息总线和消息服务指的是同一个东西。 2.1 架构设计发送消息和接收消息是消息服务最基本的能力,这两项能力分别由消息生产服务、消息消费服务提供。 2.2 消息的流转过程 三、消息服务初体验微服务架构采用的技术栈是:SpringBoot、Kubernetes。 我们将消息总线取名为 Courier,Courier 的意思是“快递员”,消息的传递类似于快递的收发,消息总线正是快递员的角色。下面开始消息服务的初体验。 3.1 零配置接入消息总线由于我们的微服务使用的是 SpingBoot 来落地的,因此我们提供了一个接入消息总线的 spring-boot-starter。 1234<dependency> <groupId>com.casstime.open</groupId> <artifactId>courier-spring-boot-starter</artifactId></dependency> 接入消息总线,微服务只需要一个@EnableMessage注解即可加载所有相关配置: 1234567@EnableMessage@SpringBootApplicationpublic class WebApplication { public static void main(String[] args) { SpringApplication.run(WebApplication.class, args); }} 3.2 消息结构定义下面代码定义了一个消息的基本信息,也称为消息 Header,包括消息 id,分区键 primaryKey,来源服务 service,消息 topic,创建时间 timstamp。 1234567public abstract class Message { private String id; private String primaryKey; private String service; private String topic; private Date timeStamp;} 消息可以分为两类,一类是事件,另一类是广播。定义如下: 123// 事件public abstract class Event extends Message {} 123// 广播public abstract class Event extends Message {} 业务消息内容称为消息 Body,例如订单已创建这个消息体的定义: 123456@Topic(name = "order")public class OrderCreated extends Event { private String orderId; private String orderName; private Date createdAt;} 3.3 使消息收发变得简单业务方可以在业务执行方法的任一处,只需要一行代码,即可完成消息的发送。 12// 发送消息EventPublisher.publish(new OrderCreated()); 对于消息的监听,业务方只需关注业务逻辑的执行,屏蔽了 Offset 提交、重试等技术实现。 1234567// 接收消息@EventHandler(topic = "order", consumerGroup = "consumer-group1")public class OrderMessageHandler { public void handle(OrderCreated orderCreated) { System.out.println("receive message: " + orderCreated); }} 3.4 提供 5 种功能类型的消息我们提供了 5 种不同功能类型的消息,满足各类业务场景。 1、事件消息 12345678910@Topic(name = "order")public class OrderCreated extends Event { private String orderId; private String orderName; private Date createdAt;}public void send() { EventPublisher.publish(new OrderCreated());} 上面消息定义是事件,这是使用最多的一种消息。 2、广播消息 广播消息的消费示意图如下: 12345678910@Topic(name = "order")public class CacheUpdate extends Broadcast { private String orderId; private String orderName; private Date createdAt;}public void send() { EventPublisher.publish(new CacheUpdate());} 上面消息定义时,继承了Broadcast,表示这是一个广播消息,消费服务的每个节点都将会收到这个广播。例如更新本地缓存事件,就需要用到广播消息。 3、顺序消息 1234567891011@Topic(name = "order")public class OrderCreated extends Event { @PrimaryKey private String orderId; private String orderName; private Date createdAt;}public void send() { EventPublisher.publish(new OrderCreated());} 上面消息定义时,在orderId上加了@PrimaryKey注解,表示相同orderId的消息会有序的消费。 4、事务消息 1234567891011@Topic(name = "order")public class OrderCreated extends Event { private String orderId; private String orderName; private Date createdAt;}@Transactionalpublic void send() { EventPublisher.publish(new OrderCreated());} 上面消息发送时,在方法上添加了@Transactional注解,这是 Spring 的注解,表示这个方法里的逻辑执行是有事务性的。 5、延迟消息 1234567891011@Topic(name = "order")public class OrderCreated extends Event { private String orderId; private String orderName; private Date createdAt;}@Transactionalpublic void send() { EventPublisher.publish(new OrderCreated(), 2, TimeUnit.SECONDS);} 上面消息发送多了两个参数,表示延迟 2 秒接收。 3.5 消息追踪只要是通过EventPublisher.publish()方法发送的消息,都可以追踪到这条消息记录。 消息定义了 5 种状态: 发送失败(SEND_FAIL):通常消息定义不规范,消息体过大;少数由于网络抖动。 已提交(COMMITED):消息总线已收到消息。 推送失败(PUSH_FAIL):例如服务已下线。 处理失败(HANDLE_FAIL):监听到了消息,但是执行业务逻辑抛出了异常。 已处理(HANDLED) 作为消息的发送方,关注的是消息是否发送成功,可通过下面页面查询。 作为消息的接收方,关注的是消息是否正常消费,可通过下面页面查询。 3.6 消息高可靠对于 5 种状态的消息,处理策略如下: 发送失败(SEND_FAIL):自动重试+手动重试,可在消息管理中心手动再发送。 已提交(COMMITED):长期处理已提交状态的消息,可能消费方已接收,但状态流转异常,消息总线会定时重试。 推送失败(PUSH_FAIL):自动重试+延迟重试。 处理失败(HANDLE_FAIL):自动重试默认关闭,由消费方决定是否开启重试。 已处理(HANDLED):也可手动重试。 相关文章也许你对下面文章也感兴趣。 04期:领域驱动设计与微服务 学习分享(第3期):你所理解的架构是什么?","link":"/2023/7.html"},{"title":"架构设计应顺应技术的生命周期","text":"本文来自我最近正在学习的课程,极客时间郭东白的专栏文章。这篇文章让我在架构悟道上有所觉悟,分享给你,希望对你有所启发,部分内容我作了精简,内容如下。 人类的各种活动都要遵循事物的客观生命周期。不论是农业社会种田打渔,还是资本社会投资创业,行动太早或太晚,都会颗粒无收。技术也一样,也有自己的生命周期。而我们作为架构师,如果看不清技术的生命周期,那么所设计的架构就没法儿向更有生命力的新技术借力,自己的职业生涯也会受限。 在架构设计的过程中,架构师会有一个相对确定的商业和技术选择空间。在这个选择的空间内,架构师做技术选型的时候,必须要考虑到所依赖的商业和技术模块的生命周期。这个时候,我们就需要看准技术趋势,选择已经有规模优势或者是即将有规模优势的技术,而不是选择接近衰老期的技术。 但是啊,有的人能够看准一个技术的生命周期, 而有些人却做不到。为什么? 看准技术趋势为什么难?我和身边许多做技术的同学,多数时间都在看小尺度的问题,日常工作和注意力都放在需求实现、领域建模、平台重构、中间件升级之上,因此我的思考也被这些工作所主导,很少去思考五年、十年,甚至二十年的技术趋势。我们不关注,当然也谈不上如何利用技术周期了。 技术的生命周期就像是潮水。潮来,汹涌澎湃,绵绵不绝。潮去,风平浪静,滩涂尽显。人一生的黄金岁月中也就是几个浪头而已。就过去 40 年而言,真正大的技术浪头有个人电脑、互联网、移动互联网、AI,差不多是每十年一个。你要在这种技术大浪潮之上玩好冲浪,就必须看清楚浪头,准确把握好技术方向和入场时机。如果错过,再等新的一个技术周期,几年的黄金岁月就浪费了。 我不认为自己真正看准过这些趋势。事实上,我也没能借到浪头的最大势。我分析原因,是因为存在着三个人性上的弱点: 自我麻痹,以繁忙的重复工作来代替深度思考; 畏惧变化,以最小化改变来维持自己的心理安全感; 路径依赖,以过去的成功经历来应对未来案例。 让我们放弃思考的三大弱点弱点一:自我麻痹自我麻痹是指我们用各种方法让自己放弃思考和探索的欲望。 对于大部分互联网从业者而言,从小到大都是学霸,内心不太能接受自己不思进取。于是进化出了一个自我保护机制,让自己每天都忙起来,用勤奋来弥补内心的不安。这就导致我们不能全力去探寻新的出路。 其实麻痹自己越久,就越是难以突破。越没有突破,就越是没有去突破的勇气。陷入一种恶性循环。 我们只有承认和面对现在的风险,才有勇气放弃麻痹自己的行为,把部分注意力从当前技术放到更新、更有颠覆性的技术上去。而不是被动地等着他人告知自己下一步需求。 弱点二:畏惧改变马斯洛模型提到:心理安全感的需求导致我们会畏惧改变,这是我们与生俱来的本性。 前段时间有位技术人员给我分享他稳定性治理的经验,他描述了如何通过一个独立的运维团队,把一组很烂的微服务运维到接近五个九。我很诧异,他们直到现在竟然还在使用独立于研发的运维团队,来保障公司核心系统的稳定性。 后来追问细节才知道,这家公司连续几任 CTO 都没做过互联网高可用架构。因为这个核心服务是公司营收路径上最重要的一环,所以连续几个 CTO 都不敢大兴土木,从根本上解决这个服务的稳定性问题,而是通过运维的方式先顶着。这一顶,就是 4 年多! 畏惧改变,让这个团队从 CTO 到架构师,再到一线主管,都丧失了稳定性治理的勇气。直到现在,这家公司还在沿用几年前自研的微服务框架,而没有引入当下常见的 Spring Framework,也没采用 Service Mesh。从头到尾都是一套年久失修的老系统, 离开的人越多,懂的人越少,就越没有人敢改动。现在,公司只能靠大量的全职运维团队来续命,以至于风险稍大的发布,还是要运维团队来做。 其实我们都一样,一旦赌注足够大,就会产生畏惧。我们率先放弃了改变的勇气,跟着就会放弃改变的欲望。得过且过,离新的技术趋势越来越远。 弱点三:路径依赖所谓路径依赖,就是你被过去的成功所蒙蔽了,以为过去的成功可以复刻。当过去的成功路径成了你唯一的选择,那么你也不会关注,更不会去探索新的路径了。 几年前有个同学转岗到我团队。他之前在一个大部门里做基础架构,曾经做过合并部署,就是把几个相关的微服务部署在同一台虚拟机,甚至是把几个微服务合并成一个巨石服务,然后部署在同一个 JVM 上。 这么做其实是个反模式,虽然会减少网络开销,提升性能,降低计算成本。但实质上,这个过程是用长期的运维和人力成本来替换机器成本。要知道,机器成本和网络带宽,在今天还基本符合摩尔定律。所以通过不断增加人力、维护和迁移成本,去替换每两年就减半的计算成本,这么做是不理智的。事实上,更大的成本是机会成本,这种巨石服务会增加升级改造的难度。也就是说,会让一个企业,很难快速响应新机会和新的竞争。 不过在他之前遇到的场景下,这样做的确可以带来实实在在的短期回报,所以他在之前的团队得到了很大的认可,也因此得到了晋升。同时,他也为自己的成就感到自豪。但我所在部门的 BU 的计算量,远远低于他之前的大部门,峰值流量还不到他们的百分之一。这么一来,虽然开发成本一点儿没少,但做合并部署的回报却远远小于他之前的工作场景。 而这个时候,Kubernetes 已经开始暂露头角。Kubernetes Pod 加 Docker Image 就已经可以非常完美地解决合并部署能解决的大多数问题了。但这位同学,因为有了之前的成功经验,根本没有去探索合并部署之外的解决路径。结果他的项目进行到一半,大家意识到 K8s 才是更合理的解决方案,所以他的项目也就草草收场了。 这就是路径依赖。如果我们被某个史诗级的训练样本冲击过,都会过度相信自己过去成功或失败的经验。这会让我们看不到其他的技术可能,更别说新的技术趋势了。 如何克服人性的弱点?先分享一下我的办法。我并不觉得这些办法好在哪里,但有必要分享我在克服这些弱点上所做的努力。 首先,日常工作中我也经常会麻痹自己。不过我跳出这个状态的办法就是,每年会留出两次深度忏悔的时间。我会放下当前所有事情,回想过去半年是不是做错了什么,有没有获得什么本质上的能力提升。半年后,如果发现自己还是同样沮丧,那么我就会琢磨,是不是要逼迫自己找个更有压力、更能成长的事情和环境了。 这就到了第二点,克服内心的恐惧,迎接变化。这一点我天生要比很多人好。虽然在变化来临的那一刻还是会有很大的恐惧,但与此同时,我又无时无刻不在期待着变化。不止在工作中,生活中也是一样。随性的探索和意外的惊喜,总会带给我更大的乐趣。如果说我不恐惧变化,那完全是胡扯。但我会用对获取惊喜的期待,来压制内心的恐惧,这个办法对我一直很有效。 路径依赖最难破。我记性还不错,表达能力也比较强,而且我也经历过很多波折。但到了后来带团队时,就发现这些特性看起来是优点,其实会放大我的路径依赖。 比如面对一个相对来说经验没那么丰富,表达能力没那么强的同事。我能够及时召回重点案例或个人经历,然后把逻辑准确表述出来。这个时候,我会更容易说服周围同事,导致我的建议更占上风。这个问题在我刚开始做 CTO 时变得非常严重。因为大多数参会者是我的下属或同事。他们可能不愿意反驳我,甚至哪怕是我错了,也不一定会纠正我。 我意识到这种情况之后,就开始刻意让自己更关注那些想法独特,或者是经常挑战我观点的人。比如下属反对我的话,如果我们各自的逻辑都很严谨,仅仅是假设有所不同。那么我表达观点之后,就会强迫我放弃自己的立场。 这么做,一来可以防止我有路径依赖,二来也是为了培养下属,让他们有足够的决策空间和犯错空间。这样一来,他们不犯错,我就有成长。他们犯了错,他们自己就有了成长。两全其美,何乐而不为?事实上,在这个过程中,我发现了非常多优秀的人才。我相信他们中间有很多人的思考和成就必然会超越我。 假设没有这些弱点阻碍你探索技术趋势,那么我们就可以试图通过热度曲线来比较客观地分析技术趋势了。 如何通过热度曲线看技术生命周期?如下图所示,是热度曲线(Gartner Hype Curve)。它是对新技术流行趋势的一个比较不错的建模。我们身边大多数技术的发展,其实都基本符合这个曲线。 如图所示,横轴是时间,竖轴是流行热度。发明者 Gartner 把一个技术的周期大致分为五个阶段,分别是: 萌芽期 (Technology Trigger) :指的是技术被公开,媒体热度陡然上升,还没有成型的产品和商业应用场景。 至捧期 (Peak of Inflated Expectations) :指的是有了一些成功案例,当然也有失败案例,技术被吹捧到了极致。 低谷期(Trough of Disillusionment):这个时候,热度回归到理性,失败案例被放大。如果产品不能让早期受众满意,那么技术就会在这个阶段消亡。 灵感期(Slope of Enlightment):产品逐渐找准在行业的价值定位,二代三代产品出现,产品逐渐出现理智的商业用户和成功案例。 产出期(Plateau of Productivity):在这个阶段产品被主流市场认可和采用。 其实我们身边大多数的技术,都活不到产出期。其实能活到至捧期的技术,也寥寥无几。Docker 是一个非常符合 Gartner 曲线的经典案例。 在产出期之后,技术还有两个状态: 衰老期(Progressive Aging):以该技术为基础的产品,已经逐渐开始被下一代的新技术所替代,产品的市场范围和利润逐渐被蚕食。 退出期(Fade Out):产品已经完全退出主流市场,仅仅在一些场景契合度与替换成本都非常高的情况下,还在被维护和使用。 那么我们从这条曲线的描述中能得到什么结论呢? 结论就是:所有的技术都像人类的生命一样,也有终结的一天。这是个自然规律。从架构师的角度理解这句话就是:一个老去的技术就让他老去,快死的架构不值得投入人力和时间去维护,更不用说去翻修或者是复用了。 我曾经反复思考过,怎样才能避免让一个老的技术和架构侵入到新的体系里来?硅谷达人Guy Kawasaki 曾总结苹果公司 Macintosh 的成功,他认为关键就在于“低调加物理隔离”。我觉得这也应该是这个问题的答案。那就是把这种新的项目和公司其他部门分割开来。参与的人少一些,时间给宽裕一些,尽量远离公司的核心业务和人群。 我们也可以运用之前在尊重人性这个法则里提到的用户思维,来引导团队放弃一个衰老的技术。因为曾经再伟大的技术,在用户的面前都是渺小的。为了更好的用户体验,一切都值得推倒重来。 小结其中我特别想强调的是,当你把自己的思考尺度从三五个月扩大到五年或十年,那么这件事情的价值必然会很大。这个放大思考尺度的动作,会让你用不一样的视角来看待技术。 看一次看不懂,看两次看不懂,但是看多了,自然会看出门道来。从本质上讲,这是个算法训练的过程。当你老用一个小尺度的样本来训练自己的大脑,那么你的大脑就是一个非常优秀的小尺度决策机。但当你坚持用大尺度的样本来训练自己的大脑,那么你在大尺度问题上的决策质量,也必然会得到提升。 作为一个架构师,知天道不够,还是要顺天道,也就是说我们的架构要符合技术的自然周期。反之,为一个落后的架构注入新生就是不符合天道了。而想要抗拒这种行为,我们就要从用户思维出发。为了更好的用户体验,要舍得放弃任何曾经伟大过的技术。 封面 本文只用作学习交流用途。首发于我的博客,原文链接:https://studeyang.tech/2023/11.html 内容来自我的学习笔记:https://studeyang.tech/technotes。欢迎交流与学习。","link":"/2023/11.html"},{"title":"图解CORS","text":"熟悉 HTTP 协议的同学都知道,ORS 是 HTTP 协议中的一种安全策略,全称是 Cross-origin resource sharing,中文名称是跨域资源共享,是一种让受限资源能够被其他域名的页面访问的一种机制。 下图描述了 CORS 机制。 一、源(Origin)的定义我们来拆解一下,首先是源(Origin)。 上图中的 ① 描述了源(Origin)。源(Origin)由 URI 的协议(Protocol)、域名(domain)和端口(Port)组成,如下图所示。 不同源的访问请求叫做跨源请求(Cross Origin Requests),通常情况下,这种访问的请求会被浏览器拦截。 这可以有效的保护服务器资源不被非法网站访问。 二、如何进行跨域访问如果我们的网站是合法的,需要访问其他域名下的服务器资源怎么办呢? 这就是跨域访问,可以进行跨域访问的请求有两种,一种是简单的跨域请求(Simple Cross-Origin Request),另一种是预检请求(Pre flight request)。 先来看第一种:简单请求。 2.1 简单的跨域请求(Simple Cross-Origin Request)无需触发预检请求(Pre flight request)的请求,称为简单的跨域请求(Simple Cross-Origin Request)。满足下列条件的请求,可视为简单请求: 请求方法是 GET、HEAD、POST 其中之一 请求头 Content-Type 是 text/plain、multipart/formdata、application/x-www-form-urlencoded 其中之一 2.2 预检请求(Pre flight request)预检请求(Pre flight request)是跨域资源共享(CORS)的一种预检机制。在进行跨域请求时,浏览器首先会发起一个 OPTIONS 请求,该请求称为预检请求,用于检查实际请求是否可以被服务器接受。预检请求中包含了实际请求将会用到的 HTTP 方法、Header 信息、请求 Path 等。 服务器在接收到预检请求后,会根据请求头中的 Origin 字段和请求内容,判断是否允许当前的跨域请求。如果允许,服务器会在响应头中添加 Access-Control-Allow-Origin 相关信息。 整个过程如下图所示: 我解释一下图中出现的其他 Header 字段: 1)Access-Control-Allow-Methods Access-Control-Allow-Methods 表示服务器允许的跨域请求的 HTTP 方法列表,如 GET、POST、PUT、DELETE 等。 2)Access-Control-Allow-Headers Access-Control-Allow-Headers 表示服务器允许的跨域请求的头信息列表,如 Authorization、Cache-Control、Content-Type 等。 3)Access-Control-Max-Age Access-Control-Max-Age 字段用于指定预检请求的缓存时间,单位为秒。一旦服务器端设置了 Access-Control-Max-Age 字段,浏览器在缓存期内会自动跳过预检请求,直接发起携带身份凭证的实际请求。这样可以降低服务器的压力,提升页面加载速度和用户体验。 如果是预检请求(Pre flight request),并返回成功后,就可以开始跨域访问了。 2.3 进行跨域访问我们来看一下整个步骤。首先,客户端发起跨域请求,在收到服务端的响应请求后,浏览器会检查响应头中的 Access-Control-Allow-Origin 字段,如果它的值是 messanger.com 或者是 ‘*’,浏览器会执行成功请求的回调,但是如果不匹配的话,则执行失败请求的回调。 2.4 跨域发送身份凭证客户端与服务器端进行跨域请求时,还会遇到一种情况:需要在跨域请求中发送身份凭证(如 cookie 和 HTTP 认证信息)。这种情况下,则需要在服务器端设置 Access-Control-Allow-Credentials 字段为 true,才能使客户端发送跨域请求时携带身份凭证。如果服务器端未响应 Access-Control-Allow-Credentials 或设置为 false,则浏览器会丢弃这个请求,从而导致无法进行跨域资源分享。 这个过程你可以参考下面的图示。 到这里,本文对 CORS 的图解就结束了,开头的那张整图,你可以保存下来,以便回顾。 文章发表于 Studeyang.tech,原文链接:https://studeyang.tech/2023/12.html 您可通过该链接,输入 Email 后完成订阅。 (完) References https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS","link":"/2023/12.html"},{"title":"Git如何修改历史的Commit信息","text":"最近由于一行单元测试代码没有写 Assert 断言,导致了项目在 CI 过程中没有通过,于是遭到了某位同事的吐槽,在修改我的代码后写上了一句提交信息。 我想,做为技术人,修改这条 Commit 信息还是不难的,于是我通过本文介绍的技巧完成了修改,效果如下: 其实修改历史提交信息很简单。 一、找到该 Commit 前一条的 Commit ID例如当前有 3 条提交,使用 git log 查看。 1234567891011121314commit 0a4549598e56b53395c562e784553d863ec597c1Author: 张三 <[email protected]>Date: Fri Jun 16 12:25:34 2023 +0800 fix: 正常的提交信息1commit e0871dfb91f6a0acc5298d9e1960291629479a46Author: 李四 <[email protected]>Date: Fri Jun 16 12:20:08 2023 +0800 fix: fucking the codecommit e7dc6e4d1001ecff3c1000f82ffffe06859fad61Author: 张三 <[email protected]>Date: Thu Jun 15 14:32:49 2023 +0800 fix: 正常的提交信息2 我们要修改的 Commit 是第二条,于是我们要找的前一条 Commit ID 就是 e7dc6e4d1001ecff3c1000f82ffffe06859fad61。 二、git rebase -i 命令然后执行 git rebase -i e7dc6e4d1001ecff3c1000f82ffffe06859fad61,会得到下面的内容: 1234pick e0871dfb91f6a0acc5298d9e1960291629479a46 fix: fucking the codepick 0a4549598e56b53395c562e784553d863ec597c1 fix: 正常的提交信息1# ... 找到需要修改的 commit 记录,把 pick 修改为 edit 或 e,:wq 保存退出。也就是: 1234edit e0871dfb91f6a0acc5298d9e1960291629479a46 fix: fucking the codepick 0a4549598e56b53395c562e784553d863ec597c1 fix: 正常的提交信息1# ... 三、修改 commit 的具体信息执行 git commit --amend,会得到下面的内容: 123456fix: fucking the code# Please enter the commit message for your changes. Lines starting# with '#' will be ignored, and an empty message aborts the commit.## Date: Fri Jun 16 12:20:08 2023 +0800 修改文本内容: 123456fix: fucking me# Please enter the commit message for your changes. Lines starting# with '#' will be ignored, and an empty message aborts the commit.## Date: Fri Jun 16 12:20:08 2023 +0800 保存并继续下一条git rebase --continue,直到全部完成。接着执行 git push -f 推到远端,当然这需要有 Maintainer 权限。 接下来再查看提交日志,git log: 1234567891011121314commit 0a4549598e56b53395c562e784553d863ec597c1Author: 张三 <[email protected]>Date: Fri Jun 16 12:25:34 2023 +0800 fix: 正常的提交信息1commit e0871dfb91f6a0acc5298d9e1960291629479a46Author: 李四 <[email protected]>Date: Fri Jun 16 12:20:08 2023 +0800 fix: fucking mecommit e7dc6e4d1001ecff3c1000f82ffffe06859fad61Author: 张三 <[email protected]>Date: Thu Jun 15 14:32:49 2023 +0800 fix: 正常的提交信息2 修改完成!不过要提醒的是:技巧慎用他途。 四、总结一下 git rebase 命令对于 git rebase 命令,官方文档 是这样介绍的:允许你在另一个基础分支的头部重新应用提交。 git-rebase - Reapply commits on top of another base tip 使用方法: 我们在执行 git rebase -i 之后,注释里已经给出了所有的用法: 12345678910111213141516171819202122232425# Rebase 29fc076c..db0768e3 onto 29fc076c (3 commands)## Commands:# p, pick <commit> = use commit# r, reword <commit> = use commit, but edit the commit message# e, edit <commit> = use commit, but stop for amending# s, squash <commit> = use commit, but meld into previous commit# f, fixup <commit> = like "squash", but discard this commit's log message# x, exec <command> = run command (the rest of the line) using shell# b, break = stop here (continue rebase later with 'git rebase --continue')# d, drop <commit> = remove commit# l, label <label> = label current HEAD with a name# t, reset <label> = reset HEAD to a label# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]# . create a merge commit using the original merge commit's# . message (or the oneline, if no original merge commit was# . specified). Use -c <commit> to reword the commit message.## These lines can be re-ordered; they are executed from top to bottom.## If you remove a line here THAT COMMIT WILL BE LOST.## However, if you remove everything, the rebase will be aborted.## Note that empty commits are commented out 归纳一下: 命令 缩写 含义 pick p 保留该commit reword r 保留该commit,但需要修改该commit的注释 edit e 保留该commit, 但我要停下来修改该提交(不仅仅修改注释) squash s 将该commit合并到前一个commit fixup f 将该commit合并到前一个commit,但不要保留该提交的注释信息 exec x 执行shell命令 drop d 丢弃该commit 文章发表于 Studeyang.tech,点击阅读原文跳转。 封面 相关文章 分享5个Git使用技巧","link":"/2023/13.html"},{"title":"谈代码的粗放与精益","text":"本文来自我最近正在学习的课程,极客时间胡峰的专栏文章《程序员进阶攻略》。文中观点颇为认同,分享给你,部分内容我作了精简,内容如下。 几年前,我给团队负责的整个系统写过一些公共库,有一次同事发现这个库里存在一个 Bug,并告诉了我出错的现象。然后我便去修复这个 Bug,最终只修改了一行代码,但发现一上午就这么过去了。 一上午只修复了一个 Bug,而且只改了一行代码,到底发生了什么?时间都去哪里了?以前觉得自己写代码很快,怎么后来越来越慢了?我认真地思考了这个问题,开始认识到我的编程方式和习惯在那几年已经慢慢发生了变化,形成了明显的两个阶段的转变。这两个阶段是: 写得粗放,写得多 写得精益,写得好 一、多与粗放粗放,在软件开发这个年轻的行业里其实没有确切的定义,但在传统行业中确实存在相近的关于 “粗放经营” 的概念可类比。引用其百科词条定义如下: 粗放经营(Extensive Management),泛指技术和管理水平不高,生产要素利用效率低,产品粗制滥造,物质和劳动消耗高的生产经营方式。 若把上面这段话里面的 “经营” 二字改成 “编程”,就很明确地道出了我想表达的粗放式编程的含义。一个典型的粗放式编程场景大概是这样的:需求到开发手上后,开始编码,编码完成,人肉测试,没问题后快速发布到线上,然后进入下一个迭代。 我早期参与的大量项目过程都与此类似,不停地重复接需求,快速开发,发布上线。在这个过程中,我只是在不停地堆砌功能代码,每天产出的代码量不算少,但感觉都很类似,也很粗糙。这样的过程持续了挺长一个阶段,一度让我怀疑:这样大量而粗放地写代码到底有什么作用和意义? 后来读到一个故事,我逐渐明白这个阶段是必要的,它因人、因环境而异,或长或短。而那个给我启发的故事,是这样的。 有一个陶艺老师在第一堂课上说,他会把班上学生分成两组,一组的成绩将会以最终完成的陶器作品数量来评定;而另一组,则会以最终完成的陶器品质来评定。 在交作业的时候,一个很有趣的现象出现了:“数量” 组如预期一般拿出了很多作品,但出乎意料的是质量最好的作品也全部是由 “数量” 组制作出来的。 按 “数量” 组的评定标准,他们似乎应该忙于粗制滥造大量的陶器呀。但实际情况是他们每做出一个垃圾作品,都会吸取上一次制作的错误教训,然后在做下一个作品时得到改进。 而 “品质” 组一开始就追求完美的作品,他们花费了大量的时间从理论上不断论证如何才能做出一个完美的作品,而到了最后拿出来的东西,似乎只是一堆建立在宏大理论上的陶土。 《黑客与画家》书里说:“编程和画画近乎异曲同工。”所以,你看那些成名画家的作品,如果按时间顺序来排列展示,你会发现每幅画所用的技巧,都是建立在上一幅作品学到的东西之上;如果某幅作品特别出众,你往往也能在更早期的作品中找到类似的版本。而编程的精进过程也是类似的。 总之,这些故事和经历都印证了一个道理:在通往 “更好” 的路上,总会经过 “更多” 这条路。 二、好与精益精益,也是借鉴自传统行业里的一个类比:精益生产。 精益生产(Lean Production),简言之,就是一种以满足用户需求为目标、力求降低成本、提高产品的质量、不断创新的资源节约型生产方式。 若将定义中的 “生产” 二字换成 “编程”,也就道出了精益编程的内涵。它有几个关键点:质量、成本与效率。但要注意:在编程路上,如果一开始就像 “品质” 组同学那样去追求完美,也许你就会被定义 “完美” 的品质所绊住,而忽视了制作的成本与效率。 曾经,还在学校学习编程时,有一次老师布置了一个期中课程设计,我很快完成了这个课程设计中的编程作业。而另一位同学,刚刚看完了那本经典的《设计模式》书。 他尝试用书里学到的新概念来设计这个编程作业,并且又用 UML 画了一大堆交互和类图,去推导设计的完美与优雅。然后兴致勃勃向我(因为我刚好坐在他旁边)讲解他的完美设计,我若有所悟,觉得里面确实有值得我借鉴的地方,就准备吸收一些我能听明白的东西,重构一遍已经写好的作业程序。 后来,这位同学在动手实现他的完美设计时,发现程序越写越复杂,交作业的时间已经不够了,只好借用我的不完美的第一版代码改改凑合交了。而我在这第一版代码基础上,又按领悟到的正确思路重构了一次、改进了一番后交了作业。 所以,别被所谓 “完美“ 的程序所困扰,只管先去盯住你要用编程解决的问题,把问题解决,把任务完成。 编程,其实一开始哪有什么完美,只有不断变得更好。 就是在这样的过程与反复中,我渐渐形成了属于自己的编程价值观:世上没有完美的解决方案,任何方案总是有这样或那样一些因子可以优化。一些方案可能面临的权衡取舍会少些,而另一些方案则会更纠结一些,但最终都要做取舍。 以上,也说明了一个道理:好不是完美,好是一个过程,一个不断精益化的过程。编程,当写得足够多了,也足够好了,你才可能自如地在 “多” 与 “好” 之间做出平衡。 封面 本文只用作学习交流用途。首发于我的博客,原文链接:https://studeyang.tech/2023/14.html 内容来自我的学习笔记:https://studeyang.tech/technotes 欢迎交流与学习。","link":"/2023/14.html"},{"title":"MySQL如何清理数据并释放磁盘空间","text":"在我们的生产环境中有一张表:courier_consume_fail_message,是存放消息消费失败的数据的,设计之初,这张表的数据量评估在万级别以下,因此没有建立索引。 但目前发现,该表的数据量已经达到百万级别,原因产生了大量的重试消费,这导致了该表的慢查询。 因此需要清理该表数据。而实际上,使用 DELETE 命令删除数据后,我们发现查询速度并没有显著提高,甚至可能会降低。为什么? 因为 DELETE 命令只是标记该行数据为“已删除”状态,并不会立即释放该行数据在磁盘中所占用的存储空间,这样就会导致数据文件中存在大量的碎片,从而影响查询性能。所以,除了删除表记录外,还需要清理磁盘碎片。 在表碎片清理前,我们关注以下四个指标。 指标一:表的状态:SHOW TABLE STATUS LIKE 'courier_consume_fail_message'; 指标二:表的实际行数:SELECT count(*) FROM courier_consume_fail_message; 指标三:要清理的行数:SELECT count(*) FROM courier_consume_fail_message where created_at < '2023-04-19 00:00:00'; 指标四:表查询的执行计划:EXPLAIN SELECT * FROM courier_consume_fail_message WHERE service='courier-transfer-mq'; 12-- 清理磁盘碎片OPTIMIZE TABLE courier_consume_fail_message; 以下是清理前后的指标对比。 一、清理前指标一,表的状态: 指标二,表的实际行数:76986 指标三,要清理的行数:76813 指标四,表查询的执行计划: 二、清理数据下面是执行 DELETE FROM courier_consume_fail_message WHERE created_at < '2023-04-19 00:00:00'; 后的统计。 指标一,表的状态: 指标二,表的实际行数:173 指标三,要清理的行数:0 指标四,表查询的执行计划: 通过指标四可以看到,清理表记录后,查询扫描的行数依然没变:8651048。 三、清理碎片下面是执行 OPTIMIZE TABLE courier_consume_fail_message; 后的统计。 指标一,表的状态: 指标四,表查询的执行计划: 通过指标四可以看到,清理表记录后,查询扫描的行数变成了 100。 小结可以看到,该表的数据行数和数据长度都被清理了,查询语句扫描的行数也减少了。 为了提升 SELECT * FROM courier_consume_fail_message WHERE service='courier-transfer-mq'; 语句的查询效率,还是应当建立索引。 1alter` `table` `ec_courier.courier_consume_fail_message ``add` `index` `idx_service(service); 封面 相关文章也许你对下面文章也感兴趣。 06期:使用 OPTIMIZER_TRACE 窥探 MySQL 索引选择的秘密","link":"/2023/15.html"},{"title":"今日算法01-数组中重复的数字","text":"一、题目描述 题目链接:https://leetcode.cn/problems/shu-zu-zhong-zhong-fu-de-shu-zi-lcof/ 难易程度:简单 找出数组中重复的数字。 在一个长度为 n 的数组 nums 里的所有数字都在 0~n-1 的范围内。数组中某些数字是重复的,但不知道有几个数字重复了,也不知道每个数字重复了几次。请找出数组中任意一个重复的数字。 123输入:[2, 3, 1, 0, 2, 5, 3]输出:2 或 3 二、解题思路原地交换法题目描述中“在一个长度为 n 的数组 nums 里的所有数字都在 0 ~ n-1 的范围内”,说明了:数组元素的 索引 和 值 是 一对多 的关系。 因此,可遍历数组并通过交换操作,使元素的 索引 与 值 一一对应(即 nums[i]=i )。因而,就能通过索引映射对应的值,起到与字典等价的作用。 遍历中,第一次遇到数字 x 时,将其交换至索引 x 处;而当第二次遇到数字 x 时,一定有nums[x]=x ,此时即可得到一组重复数字。 复杂度分析时间复杂度 O(N) : 遍历数组使用 O(N) ,每轮遍历的判断和交换操作使用 O(1) 。 空间复杂度 O(1) : 使用常数复杂度的额外空间。 三、代码实现12345678910111213141516171819public int duplicate(int[] nums) { for (int i = 0; i < nums.length; i++) { while (nums[i] != i) { if (nums[i] == nums[nums[i]]) { return nums[i]; } swap(nums, i, nums[i]); } swap(nums, i, nums[i]); } return -1;}private void swap(int[] nums, int i, int j) { int t = nums[i]; nums[i] = nums[j]; nums[j] = t;} 封面 每日算法系列,题解更新地址:https://studeyang.tech/2023/0712.html","link":"/2023/0712.html"},{"title":"今日算法02-二维数组中的查找","text":"一、题目描述 题目链接:https://leetcode.cn/problems/er-wei-shu-zu-zhong-de-cha-zhao-lcof/ 难易程度:中等 在一个 n * m 的二维数组中,每一行都按照从左到右递增排序,每一列也按照从上到下递增排序。给定一个数,判断这个数是否在该二维数组中。 1234567891011Consider the following matrix:[ [1, 4, 7, 11, 15], [2, 5, 8, 12, 19], [3, 6, 9, 16, 22], [10, 13, 14, 17, 24], [18, 21, 23, 26, 30]]Given target = 5, return true.Given target = 20, return false. 二、解题思路标志数法若使用暴力法遍历矩阵 matrix ,则时间复杂度为 O(NM) 。暴力法未利用矩阵 “从上到下递增、从左到右递增” 的特点,显然不是最优解法。 我们发现:左下角元素 18 有一个特点,上面的数都比它小,右边的元素都比它大,符合这样规律的数字本题解中称为标志数。(右上角元素 15 也是标志数) 以 matrix 中的 左下角元素 为标志数 flag ,则有: 若 flag > target ,则 target 一定在 flag 所在 行的上方 ,即 flag 所在行可被消去。 若 flag < target ,则 target 一定在 flag 所在 列的右方 ,即 flag 所在列可被消去。 根据这个规律,得出算法流程: 从矩阵 matrix 左下角元素(索引设为 (i, j) )开始遍历,并与目标值对比: 当 matrix[i][j] > target 时,执行 i– ,即消去第 i 行元素; 当 matrix[i][j] < target 时,执行 j++ ,即消去第 j 列元素; 当 matrix[i][j] = target 时,返回 true ,代表找到目标值。 若行索引或列索引越界,则代表矩阵中无目标值,返回 false 。 复杂度分析时间复杂度:O(M+N) ,其中,N 和 M 分别为矩阵行数和列数,此算法最多循环 M+N 次。 空间复杂度:O(1) , i, j 指针使用常数大小额外空间。 三、代码实现123456789101112class Solution { public boolean findNumberIn2DArray(int[][] matrix, int target) { int i = matrix.length - 1, j = 0; // 左下角标志数 while(i >= 0 && j < matrix[0].length) { if(matrix[i][j] > target) i--; else if(matrix[i][j] < target) j++; else return true; } return false; }} 封面 今日算法系列,题解更新地址:https://studeyang.tech/2023/0714.html","link":"/2023/0714.html"},{"title":"今日算法03-替换空格","text":"一、题目描述 题目链接:https://leetcode.cn/problems/ti-huan-kong-ge-lcof/ 难易程度:简单 将一个字符串中的空格替换成 “%20”。 12345Input:"A B"Output:"A%20B" 二、解题思路原地修改由于需要将空格替换为 “%20” ,字符串的总字符数增加,因此需要扩展原字符串 s 的长度,计算公式为:新字符串长度 = 原字符串长度 + 2 * 空格个数 ,示例如下图所示。 \b算法流程: 在字符串尾部填充任意字符,使得字符串的长度等于替换之后的长度; 令 P1 指向字符串原来的末尾位置,P2 指向字符串现在的末尾位置。P1 和 P2 从后向前遍历; 当 P1 遍历到一个空格时,就需要令 P2 指向的位置依次填充 02%(注意是逆序的); 否则就填充上 P1 指向字符的值。从后向前遍是为了在改变 P2 所指向的内容时,不会影响到 P1 遍历原来字符串的内容。 当 P2 遇到 P1 时(P2 <= P1),或者遍历结束(P1 < 0),退出。 复杂度分析时间复杂度:O(N) ,遍历统计、遍历修改皆使用 O*(*N) 时间。 空间复杂度:O(1) ,由于是原地扩展 s 长度,因此使用 O(1) 额外空间。 三、代码实现12345678910111213141516171819public String replaceSpace(StringBuffer str) { int P1 = str.length() - 1; for (int i = 0; i <= P1; i++) if (str.charAt(i) == ' ') str.append(" "); int P2 = str.length() - 1; while (P1 >= 0 && P2 > P1) { char c = str.charAt(P1--); if (c == ' ') { str.setCharAt(P2--, '0'); str.setCharAt(P2--, '2'); str.setCharAt(P2--, '%'); } else { str.setCharAt(P2--, c); } } return str.toString();} 封面 今日算法系列,题解更新地址:https://studeyang.tech/2023/0717.html","link":"/2023/0717.html"},{"title":"今日算法04-重建二叉树","text":"一、题目描述 题目链接:https://leetcode.cn/problems/zhong-jian-er-cha-shu-lcof/ 难易程度:中等 输入某二叉树的前序遍历和中序遍历的结果,请构建该二叉树并返回其根节点。 假设输入的前序遍历和中序遍历的结果中都不含重复的数字。 二、解题思路分治法前序遍历性质: 节点按照 [ 根节点 | 左子树 | 右子树 ] 排序。 中序遍历性质: 节点按照 [ 左子树 | 根节点 | 右子树 ] 排序。 根据以上性质,可得出以下推论: 前序遍历的第一个值为根节点的值,使用这个值将中序遍历结果分成两部分,左部分为树的左子树中序遍历结果,右部分为树的右子树中序遍历的结果。然后分别对左右子树递归地求解。 解决这类问题适合用分而治之的思想,分治的代码可以归纳为如下结构: 123456789101112131415def divide_conquer(problem, param1, param2, ...): # 1.终止条件 if problem is None: print_result return # 2.准备数据 data = prepare_data(problem) subproblems = split_problem(problem, data) # 3.处理子问题 subresult1 = self.divide_conquer(subproblems[0], p1, ...) subresult2 = self.divide_conquer(subproblems[1], p1, ...) subresult3 = self.divide_conquer(subproblems[1], p1, ...) # ... result = process_result(subresult1, subresult2, subresult3, ...) \b分治算法解析: 终止条件:根节点在前序遍历的索引 root 、子树在中序遍历的左边界 preL 、子树在中序遍历的右边界 preR ;当 preL > preR ,代表已经越过叶节点,此时返回 null ; 准备数据: 建立根节点 node:节点值为 preorder[root] ; 划分左右子树:查找根节点在中序遍历 inorder 中的索引 i ; 处理子问题:开启左右子树递归; 根节点索引 中序遍历左边界 中序遍历右边界 左子树 root + 1 left i - 1 右子树 i - left + root + 1 i + 1 right 复杂度分析时间复杂度:O(N) ,其中 N 为树的节点数量。初始化 HashMap 需遍历 inorder ,占用 O(N) 。递归共建立 N 个节点,每层递归中的节点建立、搜索操作占用 O(1) ,因此使用 O(N) 时间。 空间复杂度:O(N) ,HashMap 使用 O(N) 额外空间;最差情况下(输入二叉树为链表时),递归深度达到 N ,占用 O(N) 的栈帧空间;因此总共使用 O(N) 空间。 三、代码实现12345678910111213141516171819// 缓存中序遍历数组每个值对应的索引private Map<Integer, Integer> indexForInOrders = new HashMap<>();public TreeNode reConstructBinaryTree(int[] pre, int[] in) { for (int i = 0; i < in.length; i++) indexForInOrders.put(in[i], i); return reConstructBinaryTree(pre, 0, pre.length - 1, 0);}private TreeNode reConstructBinaryTree(int[] pre, int preL, int preR, int inL) { if (preL > preR) return null; TreeNode root = new TreeNode(pre[preL]); int inIndex = indexForInOrders.get(root.val); int leftTreeSize = inIndex - inL; root.left = reConstructBinaryTree(pre, preL + 1, preL + leftTreeSize, inL); root.right = reConstructBinaryTree(pre, preL + leftTreeSize + 1, preR, inL + leftTreeSize + 1); return root;} 推荐阅读 二维数组中的查找 封面 今日算法系列,题解更新地址:https://studeyang.tech/2023/0718.html","link":"/2023/0718.html"},{"title":"今日算法05-二叉树的下一个结点","text":"一、题目描述 题目链接:牛客网 难易程度:中等 给定一个二叉树和其中的一个结点,请找出中序遍历顺序的下一个结点并且返回 。注意,树中的结点不仅包含左右子结点,同时包含指向父结点的指针。 1234567891011public class TreeLinkNode { int val; TreeLinkNode left = null; TreeLinkNode right = null; TreeLinkNode next = null; // 指向父结点的指针 TreeLinkNode(int val) { this.val = val; }} 二、解题思路我们先来回顾一下中序遍历的过程:先遍历树的左子树,再遍历根节点,最后再遍历右子树。所以最左节点是中序遍历的第一个节点。 这个遍历过程如下图所示: 代码如下: 123456void traverse(TreeNode root) { if (root == null) return; traverse(root.left); visit(root); traverse(root.right);} ① 如果一个节点的右子树不为空,那么该节点的下一个节点是右子树的最左节点; ② 否则,向上找第一个左链接指向的树包含该节点的祖先节点。 三、代码实现12345678910111213141516public TreeLinkNode GetNext(TreeLinkNode pNode) { if (pNode.right != null) { TreeLinkNode node = pNode.right; while (node.left != null) node = node.left; return node; } else { while (pNode.next != null) { TreeLinkNode parent = pNode.next; if (parent.left == pNode) return parent; pNode = pNode.next; } } return null;} 推荐阅读 今日算法04-重建二叉树 封面 今日算法系列,题解更新地址:https://studeyang.tech/2023/0719.html","link":"/2023/0719.html"},{"title":"今日算法06-用两个栈实现队列","text":"一、题目描述 题目链接:https://leetcode.cn/problems/yong-liang-ge-zhan-shi-xian-dui-lie-lcof/ 难易程度:简单 用两个栈来实现一个队列,完成队列的 Push 和 Pop 操作。 二、解题思路通过下面两步来保持队列的先进先出特点: in 栈用来处理入栈(push)操作,out 栈用来处理出栈(pop)操作。 当元素要出栈时,需要先将 in 栈元素出栈,并进入 out 栈。 此时元素出栈顺序被反转,因此出栈顺序就和最开始入栈顺序是相同的,先进入的元素先退出,这就是队列的顺序。 复杂度分析时间复杂度: push() 函数为 O(1) ; pop() 函数为 O(N) ; 空间复杂度 O(N) : 最差情况下,栈 A 和 B 共保存 N 个元素。 三、代码实现1234567891011121314151617Stack<Integer> in = new Stack<Integer>();Stack<Integer> out = new Stack<Integer>();public void push(int node) { in.push(node);}public int pop() throws Exception { if (out.isEmpty()) while (!in.isEmpty()) out.push(in.pop()); if (out.isEmpty()) throw new Exception("queue is empty"); return out.pop();} 推荐阅读 今日算法01-数组中重复的数字 封面 今日算法系列,题解更新地址:https://studeyang.tech/2023/0720.html","link":"/2023/0720.html"},{"title":"今日算法07-斐波那契数列","text":"一、题目描述 题目链接:https://leetcode.cn/problems/fei-bo-na-qi-shu-lie-lcof/ 难易程度:简单 写一个函数,输入 n ,求斐波那契(Fibonacci)数列的第 n 项(即 F(N))。斐波那契数列的定义如下: 斐波那契数列由 0 和 1 开始,之后的斐波那契数就是由之前的两数相加而得出。 二、解题思路动态规划如果使用递归求解,会重复计算一些子问题。例如,计算 f(4) 需要计算 f(3) 和 f(2),计算 f(3) 需要计算 f(2) 和 f(1),可以看到 f(2) 被重复计算了。 递归是将一个问题划分成多个子问题求解,动态规划也是如此,但是动态规划会把子问题的解缓存起来,从而避免重复求解子问题。 通常,动态规划解决问题的模板是: 状态定义:opt[n], dp[n], fib[n] 转移方程:opt[n] = best_of(opt[n-1], opt[n-2], …) 初始状态 返回值 本题套入模板,则是: 状态定义:设 dp 为一维数组,其中 dp[i] 的值代表 斐波那契数列第 i 个数字。 转移方程:dp[i+1]=dp[i]+dp[i−1] ,即对应数列定义 f(n+1)=f(n)+f(n−1) ; 初始状态:dp[0]=0, dp[1]=1 ,即初始化前两个数字; 返回值:dp[n] ,即斐波那契数列的第 n 个数字。 上面思路的代码实现如下: 123456789public int Fibonacci(int n) { if (n <= 1) return n; int[] fib = new int[n + 1]; fib[1] = 1; for (int i = 2; i <= n; i++) fib[i] = fib[i - 1] + fib[i - 2]; return fib[n];} 考虑到第 i 项只与第 i-1 和第 i-2 项有关,因此只需要存储前两项的值就能求解第 i 项,从而将空间复杂度由 O(N) 降低为 O(1)。 复杂度分析时间复杂度 O(N) :计算 f(n) 需循环 n 次,每轮循环内计算操作使用 O(1) 。 空间复杂度 O(1) : 几个标志变量使用常数大小的额外空间。 三、代码实现123456789101112public int Fibonacci(int n) { if (n <= 1) return n; int pre2 = 0, pre1 = 1; int fib = 0; for (int i = 2; i <= n; i++) { fib = pre2 + pre1; pre2 = pre1; pre1 = fib; } return fib;} 推荐阅读 今日算法06-用两个栈实现队列 封面 今日算法系列,题解更新地址:https://studeyang.tech/2023/0721.html","link":"/2023/0721.html"},{"title":"今日算法08-矩形覆盖","text":"一、题目描述 题目链接:牛客网 难易程度:简单 我们可以用 2*1 的小矩形横着或者竖着去覆盖更大的矩形。请问用 n 个 2*1 的小矩形无重叠地覆盖一个 2*n 的大矩形,总共有多少种方法? 二、解题思路动态规划当 n 为 1 时,只有一种覆盖方法: 当 n 为 2 时,有两种覆盖方法: 要覆盖 2*n 的大矩形,可以先覆盖 2*1 的矩形,再覆盖 2*(n-1) 的矩形;或者先覆盖 2*2 的矩形,再覆盖 2*(n-2) 的矩形。而覆盖 2*(n-1) 和 2*(n-2) 的矩形可以看成子问题。该问题的递推公式如下: 也就变成了斐波那契数列问题,参考:今日算法07-斐波那契数列 复杂度分析时间复杂度 O(N) :计算 f(n) 需循环 n 次,每轮循环内计算操作使用 O(1) 。 空间复杂度 O(1) : 几个标志变量使用常数大小的额外空间。 三、代码实现123456789101112public int Fibonacci(int n) { if (n <= 1) return n; int pre2 = 0, pre1 = 1; int fib = 0; for (int i = 2; i <= n; i++) { fib = pre2 + pre1; pre2 = pre1; pre1 = fib; } return fib;} 推荐阅读 今日算法07-斐波那契数列 封面 今日算法系列,题解更新地址:https://studeyang.tech/2023/0724.html","link":"/2023/0724.html"},{"title":"今日算法09-青蛙跳台阶问题","text":"一、题目描述 题目链接:https://leetcode.cn/problems/qing-wa-tiao-tai-jie-wen-ti-lcof/ 难易程度:简单 一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。求该青蛙跳上一个 n 级的台阶总共有多少种跳法。 答案需要取模 1e9+7(1000000007),如计算初始结果为:1000000008,请返回 1。 1234567891011示例1输入:n = 2输出:2示例2输入:n = 7输出:21示例3输入:n = 0输出:1 二、解题思路动态规划当 n 为 1 时,只有一种覆盖方法: 当 n = 2 时,有两种跳法: 跳 n 阶台阶,可以先跳 1 阶台阶,再跳 n-1 阶台阶;或者先跳 2 阶台阶,再跳 n-2 阶台阶。而 n-1 和 n-2 阶台阶的跳法可以看成子问题,该问题的递推公式为: 也就变成了斐波那契数列问题,参考:今日算法07-斐波那契数列 复杂度分析时间复杂度 O(N) :计算 f(n) 需循环 n 次,每轮循环内计算操作使用 O(1) 。 空间复杂度 O(1) : 几个标志变量使用常数大小的额外空间。 三、代码实现123456789101112public int JumpFloor(int n) { if (n <= 2) return n; int pre2 = 1, pre1 = 2; int result = 0; for (int i = 2; i < n; i++) { result = pre2 + pre1; pre2 = pre1; pre1 = result; } return result;} 推荐阅读 今日算法07-斐波那契数列 封面 今日算法系列,题解更新地址:https://studeyang.tech/2023/0725.html","link":"/2023/0725.html"},{"title":"今日算法10-变态跳台阶","text":"一、题目描述 题目链接:牛客网 难易程度:简单 一只青蛙一次可以跳上 1 级台阶,也可以跳上 2 级… 它也可以跳上 n 级。求该青蛙跳上一个 n 级的台阶总共有多少种跳法。 二、解题思路动态规划动态规划算法的基本思想是:将待求解的问题分解成若干个相互联系的子问题,先求解子问题,然后从这些子问题的解得到原问题的解; 对于重复出现的子问题,只在第一次遇到的时候对它进行求解,并把答案保存起来,让以后再次遇到时直接引用答案,不必重新求解。动态规划算法将问题的解决方案视为一系列决策的结果。 跳上 n-1 级台阶,可以从 n-2 级跳 1 级上去,也可以从 n-3 级跳 2 级上去…,那么 1f(n-1) = f(n-2) + f(n-3) + ... + f(0) 同样,跳上 n 级台阶,可以从 n-1 级跳 1 级上去,也可以从 n-2 级跳 2 级上去… ,那么 1f(n) = f(n-1) + f(n-2) + ... + f(0) 综上可得 1f(n) - f(n-1) = f(n-1) 即 1f(n) = 2*f(n-1) f(1) 和 f(2) 可以提前算出来: 12f(1) = 1f(2) = 2 复杂度分析时间复杂度 O(N) :计算 f(n) 需循环 n 次,每轮循环内计算操作使用 O(1) 。 空间复杂度 O(1) : 几个标志变量使用常数大小的额外空间。 三、代码实现12345678910public int jumpFloorII(int target) { int[] dp = new int[target + 1]; //初始化前面两个 dp[1] = 1; dp[2] = 2; //依次乘2 for(int i = 3; i <= target; i++) dp[i] = 2 * dp[i - 1]; return dp[target];} 推荐阅读 今日算法07-斐波那契数列 今日算法09-青蛙跳台阶问题 封面 今日算法系列,题解更新地址:https://studeyang.tech/2023/0731.html","link":"/2023/0731.html"},{"title":"今日算法11-旋转数组的最小数字","text":"一、题目描述 题目链接:https://leetcode.cn/problems/xuan-zhuan-shu-zu-de-zui-xiao-shu-zi-lcof 难易程度:简单 把一个数组最开始的若干个元素搬到数组的末尾,我们称之为数组的旋转。 给你一个可能存在 重复 元素值的数组 numbers ,它原来是一个升序排列的数组,并按上述情形进行了一次旋转。请返回旋转数组的最小元素。例如,数组 [3,4,5,1,2] 为 [1,2,3,4,5] 的一次旋转,该数组的最小值为 1。 注意,数组 [a[0], a[1], a[2], …, a[n-1]] 旋转一次 的结果为数组 [a[n-1], a[0], a[1], a[2], …, a[n-2]] 。 1234567示例1:输入:numbers = [3,4,5,1,2]输出:1示例2:输入:numbers = [2,2,2,0,1]输出:0 二、解题思路二分法如下图所示,寻找旋转数组的最小元素即为寻找 右排序数组 的首个元素 numbers[x] ,称 x 为 旋转点 。 排序数组的查找问题首先考虑使用 二分法 解决,其可将 遍历法 的 线性级别 时间复杂度降低至 对数级别 。 1、声明 i, j 双指针分别指向 numbers 数组左右两端,设 m=(i+j)/2 为每次二分的中点; 2、当 numbers[m] > numbers[j],执行 i = m + 1,同时更新 m 的位置; 3、当 numbers[m] < numbers[j],执行 j = m,同时更新 m 的位置; 4、当 i == j,跳出循环返回 numbers[i]; 复杂度分析时间复杂度 O(log2 N) : 在特例情况下(例如 [1,1,1,1]),会退化到 O(N)。 空间复杂度 O(1) : i , j , m 变量使用常数大小的额外空间。 三、代码实现123456789101112131415161718192021222324import java.util.ArrayList;public class Solution { public int minNumberInRotateArray(int [] array) { // 特殊情况判断 if (array.length == 0) { return 0; } // 左右指针i j int i = 0, j = array.length - 1; // 循环 while (i < j) { // 找到数组的中点 m int m = (i + j) / 2; // m在左排序数组中,旋转点在 [m+1, j] 中 if (array[m] > array[j]) i = m + 1; // m 在右排序数组中,旋转点在 [i, m]中 else if (array[m] < array[j]) j = m; // 缩小范围继续判断 else j--; } // 返回旋转点 return array[i]; }} 推荐阅读 今日算法02-二维数组中的查找 封面 今日算法系列,题解更新地址:https://studeyang.tech/2023/0801.html","link":"/2023/0801.html"},{"title":"今日算法12-矩阵中的路径","text":"一、题目描述 题目链接:https://leetcode.cn/problems/ju-zhen-zhong-de-lu-jing-lcof/ 难易程度:中等 给定一个 m x n 二维字符网格 board 和一个字符串单词 word 。如果 word 存在于网格中,返回 true ;否则,返回 false 。 例如,在下面的 3×4 的矩阵中包含单词 “ABCCED”(单词中的字母已标出)。 示例: 12输入:board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "ABCCED"输出:true 二、解题思路深度优先搜索(DFS)+ 剪枝深度优先搜索: 可以理解为暴力法遍历矩阵中所有字符串可能性。DFS 通过递归,先朝一个方向搜到底,再回溯至上个节点,沿另一个方向搜索,以此类推。 剪枝: 在搜索中,遇到 这条路不可能和目标字符串匹配成功 的情况,则应立即返回,称之为 可行性剪枝 。 本题的输入是数组而不是矩阵(二维数组),因此需要先将数组转换成矩阵。 复杂度分析时间复杂度 O(3K MN) : 最差情况下,需要遍历矩阵中长度为 K 字符串的所有方案,时间复杂度为 O(3K);矩阵中共有 MN 个起点,时间复杂度为 O(MN) 。 空间复杂度 O(K) : 搜索过程中的递归深度不超过 K ,因此系统因函数调用累计使用的栈空间占用 O(K) (因为函数返回后,系统调用的栈空间会释放)。最坏情况下 K=MN ,递归深度为 MN ,此时系统栈使用 O(MN) 的额外空间。 三、代码实现1234567891011121314151617181920212223242526class Solution { public boolean exist(char[][] board, String word) { char[] words = word.toCharArray(); for(int h = 0; h < board.length; h++) { for(int l = 0; l < board[0].length; l++) { if (dfs(board, words, h, l, 0)) return true; } } return false; } boolean dfs(char[][] board, char[] words, int h, int l, int wordIndex) { if (h >= board.length || h < 0 || l >= board[0].length || l < 0 || board[h][l] != words[wordIndex]) { return false; } if (wordIndex == words.length - 1) return true; board[h][l] = '\\0'; boolean res = dfs(board, words, h + 1, l, wordIndex + 1) || dfs(board, words, h - 1, l, wordIndex + 1) || dfs(board, words, h, l + 1, wordIndex + 1) || dfs(board, words, h , l - 1, wordIndex + 1); board[h][l] = words[wordIndex]; return res; }} 推荐阅读 今日算法05-二叉树的下一个结点 封面 今日算法系列,题解更新地址:https://studeyang.tech/2023/0802.html","link":"/2023/0802.html"},{"title":"程序员的修炼-知识与体系","text":"本文来自我最近正在学习的课程,极客时间胡峰的专栏文章《程序员进阶攻略》,内容如下。 今年年初,我学习了梁宁的《产品思维》课,其中有一篇叫《点线面体的战略选择》,我觉得特别有感触。虽然是讲产品,但假如把个人的成长当成产品演进一样来发展,会有一种异曲同工、殊途同归之感。 在我工作的经历中就曾碰到过这么一个人,他一开始做了几年开发,从前端到后端,后来又转做测试,接触的“点”倒是不少,但却没能连接起来形成自己的体系,那他个人最大的价值就局限在最后所在的“点”上了。 其实个人的成长有很多方面,但对于程序员的成长最重要的就是知识体系的构建,这其实就是一个 “点线面体” 的演进过程。 下面我会结合自己的成长路线来梳理下这个体系的建立过程。 点进入任何一个知识领域,都是从一个点开始的。 如下图,是我从大学进入软件开发领域所接触的一系列的点,我将其从左到右按时间顺序排列。红色的部分是目前还属于我 “掌握” 与 “了解” 的领域,其他灰色的部分则是要么被时代淘汰了,要么已经被我放弃了维持与更新。 我入行的年代,流行的是 C/S 架构的软件开发模型。当时客户端开发三剑客是 PB(PowerBuilder)、VB(Visual Basic)和 Delphi,而我只是顺势选了其中的一两个点,然后开启了程序员生涯。 没过两年 B/S 架构开始流行,并逐步取代了 C/S 架构。于我,只是因为研究生阶段学校开了一门面向对象语言课,老师用 Java 做教学语言,所以我后来就又顺势成了一名 Java 程序员。而又只是因为 Java 的生命力特别旺盛,所以也就延续至今。 早些年,前后端还没太分离时,因为项目需要,所以我又去涉猎了一些前端 JS 开发;之后移动互联网崛起,又去学习了些移动开发的东西;再之后就是 ABC 的时代(其中 A 是 AI ,人工智能;B 是 Big Data,大数据;C 是 Cloud,云计算),就又被潮流裹挟去追逐新的技术浪潮。 如今回过头再看,每一个技术点,似乎都是自己选择的,但又感觉只是一种被趋势推动的一次次无意“捡起”。有些点之间有先后的承接关系,而更多点都慢慢变成了孤点,从这片技术的星空中暗淡了下去。 在你入行后,我想你可能也会因为时代、公司或项目的原因,有很大的随机性去接触很多不同的技术点。但如果你总是这样被客观的原因驱动去随机点亮不同的 “点”,那么你终究会感到有点疲于奔命,永远追不上技术的浪潮。 线当形成的点足够多了后,一部分点开始形成线,而另一些点则在技术趋势的演进中被自然淘汰或自己主动战略放弃。 那你到底该如何把这些零散的点串成线,形成自己的体系与方向呢?如下图,是我的一个成长 “T 线图”,它串联了如今我沉淀下来的和一些新发展的 “点”。 我从成为了一名 Java 程序员开始,在这条 “T 线” 上,先向下走,专注于解决业务需求碰到的技术问题。先自然地要向下至少走一层,接触 Java 的运行平台 JVM。而又因为早期做了几年电信项目,要和很多网络设备(包括各类网元和交换机等)通信,接触网络协议编程;后来又做了即时消息(IM)领域的工作,网络这一块就又继续增强了。而网络编程依赖于操作系统提供的 I/O 模型和 API,自然绕不过 OS 这一块。 在 Java 领域走了多年以后,以前涉猎的技术点就逐步暗淡了。而再从程序员到架构师,就开始往上走,进入更纯粹的 “架构与设计” 领域,在更宽的范围和更高的维度评估技术方案,做出技术决策与权衡,设定技术演进路线。 但是,再好的技术方案,再完美的架构,如果没有承载更有意义的业务与产品形态,它们的价值和作用就体现不了。所以不可避免,再往上走时就会去了解并评估 “业务与产品”,关注目标的价值、路径的有效性与合理性。 在整个纵向的技术线上,最终汇总到顶点,会形成一种新的能力,也就是我对这条纵向线的 “掌控力”。到了这个 “点” 后,在这里可以横向发展,如图中,也就有了新的能力域:领导力和组织力。 一个个点,构成了基本的价值点,这些点串起来,就形成了更大的价值输出链条。在这条路上,你也会有一条属于自己的 “T 线”,当这条线成型后,你的价值也将变得更大。 面线的交织,将形成面。 当我试着把我最近六年多在电商客服和即时通讯领域的工作画出来后,它就织就了下面(如图所示)的这个“面”。 我从最早的聚焦于某个业务点和技术栈,逐步延伸扩展到整个面。因为 IM 这个产品本身具备很深的技术栈,而且也有足够多元化的应用场景,这样整个面就可以铺得特别宽广。这也是为什么我已经在这个面上耕耘了六年多之久。 但事实上,我并不掌握这个面上的每个点,整个团队才会分布工作在整个面上,每个个体贡献者也只会具体工作在这个面上的某个或某些点。但我们需要去认清整个面的价值体系,这样才能更好地选择和切入工作的点,创造更大的价值。 而有时候,我也了解到有些程序员的一些说法是这样的:在相对传统的行业,做偏业务的开发,技术栈相对固定且老化,难度和深度都不高,看不到发展方向,期望找到突破口。若你也出现这样的情况,那就说明你从事的业务开发,其单个技术点的价值上限较低,而选择更新、更流行的技术,你就是期望提升单个技术点的价值,但单个技术点的价值是相对有限的。 反过来,如果很难跳脱出自身环境的局限,那么也可以不局限于技术,去考虑这些传统的业务价值,从技术到业务,再上升到用户的接入触达,考虑产品的场景、形态和人群是如何去为这些用户提供的服务、产生的价值。 当你对整个业务面上的价值点掌握的更多,能抓住和把握核心的价值链条,去为更广、更大的价值负责,那么你就能克服自己的成长发展困境,找到了另外一条出路了。 同时,你也为自己织就了一张更大的领域之网。在整个面形成了一个领域,在这个面上你所能掌控的每条线就是你的体系。在这条线的 “点” 上,你解决具体问题,是做解答题;但在整个面上你选择 “线”,是做选择题。 体体是经济体或其中的单元。 你的 “面” 附着在什么 “体” 上决定了它的价值上限。如果 “体” 在高速增长并形成趋势,你就可能获得更快的发展。 从电力时代到信息时代再到智能时代,互联网、电商、移动互联网,这些都是 “体” 的变化。今天互联网行业的软件工程师,他们面临的挑战和难度不见得比传统的机械或电力工程师更大,只不过他们所从事的 “点” 所属的 “面”,附着于一个快速崛起的 “体” 上,获得了更大的加速度。 “体” 的崛起,是时代的机遇。 总结来说,就是:在领域知识体系中,“点” 是利器,“线” 是路径,“面” 是地图;而就我们个体而言,“点” 是孤立知识点的学习掌握,而 “线” 是对这些点的连接,“面” 则构成了完整的知识体系网。 以上就是我建立知识体系并形成自己领域的思考。而在每个不同的阶段,你都可以先做到心中有图,再来画“线”,然后再在每个“点”上去努力。 封面 本文只用作学习交流用途。首发于我的博客,原文链接:https://studeyang.tech/2023/0803.html 内容来自我的学习笔记:https://studeyang.tech/technotes","link":"/2023/0803.html"},{"title":"今日算法13-剪绳子","text":"一、题目描述 题目链接:https://leetcode.cn/problems/jian-sheng-zi-lcof/ 难易程度:中等 给你一根长度为 n 的绳子,请把绳子剪成整数长度的 m 段(m、n 都是整数,n > 1 并且 m > 1),每段绳子的长度记为 k[0],k[1]...k[m-1] 。请问 k[0]*k[1]*...*k[m-1] 可能的最大乘积是多少?例如,当绳子的长度是 8 时,我们把它剪成长度分别为 2、3、3 的三段,此时得到的最大乘积是 18。 123输入: 10输出: 36解释: 10 = 3 + 3 + 4, 3 × 3 × 4 = 36 二、解题思路贪心法尽可能得多剪长度为 3 的绳子,并且不允许有长度为 1 的绳子出现。如果出现了,就从已经切好长度为 3 的绳子中拿出一段与长度为 1 的绳子重新组合,把它们切成两段长度为 2 的绳子。以下为证明过程。 将绳子拆成 1 和 n-1,则 1(n-1)-n=-1<0,即拆开后的乘积一定更小,所以不能出现长度为 1 的绳子。 将绳子拆成 2 和 n-2,则 2(n-2)-n = n-4,在 n>=4 时这样拆开能得到的乘积会比不拆更大。 将绳子拆成 3 和 n-3,则 3(n-3)-n = 2n-9,在 n>=5 时效果更好。 将绳子拆成 4 和 n-4,因为 4=2*2,因此效果和拆成 2 一样。 将绳子拆成 5 和 n-5,因为 5=2+3,而 5<2*3,所以不能出现 5 的绳子,而是尽可能拆成 2 和 3。 将绳子拆成 6 和 n-6,因为 6=3+3,而 6<3*3,所以不能出现 6 的绳子,而是拆成 3 和 3。这里 6 同样可以拆成 6=2+2+2,但是 3(n - 3) - 2(n - 2) = n - 5 >= 0,在 n>=5 的情况下将绳子拆成 3 比拆成 2 效果更好。 继续拆成更大的绳子可以发现都比拆成 2 和 3 的效果更差,因此我们只考虑将绳子拆成 2 和 3,并且优先拆成 3,当拆到绳子长度 n 等于 4 时,也就是出现 3+1,此时只能拆成 2+2。 复杂度分析时间复杂度 O(1) : 仅有求整、求余、次方运算。 求整和求余运算:资料提到不超过机器数的整数可以看作是 O(1); 幂运算:查阅资料,提到浮点取幂为 O(1)。 空间复杂度 O(1) : 变量 a 和 b 使用常数大小额外空间。 三、代码实现12345678910111213public int cutRope(int n) { if (n < 2) return 0; if (n == 2) return 1; if (n == 3) return 2; int timesOf3 = n / 3; if (n - timesOf3 * 3 == 1) timesOf3--; int timesOf2 = (n - timesOf3 * 3) / 2; return (int) (Math.pow(3, timesOf3)) * (int) (Math.pow(2, timesOf2));} 推荐阅读 今日算法09-青蛙跳台阶问题 封面 今日算法系列,题解更新地址:https://studeyang.tech/2023/0911.html","link":"/2023/0911.html"},{"title":"Spring Security入门:保护Web应用","text":"本文我们将构建一个简单但完整的小型 Web 应用程序,以演示 Spring Security 的入门教程。大致逻辑是:当合法用户成功登录系统之后,浏览器会跳转到一个系统主页,并展示一些个人健康档案(HealthRecord)数据。 让我们开始吧! 系统初始化这部分工作涉及领域对象的定义、数据库初始化脚本的整理以及相关依赖组件的引入。 针对领域对象,我们重点来看如下所示的 User 类定义: 1234567891011121314151617@Entitypublic class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; private String username; private String password; @Enumerated(EnumType.STRING) private PasswordEncoderType passwordEncoderType; @OneToMany(mappedBy = "user", fetch = FetchType.EAGER) private List<Authority> authorities; …} 123public enum PasswordEncoderType { BCRYPT, SCRYPT} 可以看到,这里除了指定主键 id、用户名 username 和密码 password 之外,还包含了一个加密算法枚举值 EncryptionAlgorithm。在案例系统中,我们将提供 BCryptPasswordEncoder 和 SCryptPasswordEncoder 这两种可用的密码解密器,你可以通过该枚举值进行设置。 同时,我们在 User 类中还发现了一个 Authority 列表。显然,这个列表用来指定该 User 所具备的权限信息。Authority 类的定义如下所示: 1234567891011121314@Entitypublic class Authority { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; private String name; @JoinColumn(name = "user") @ManyToOne private User user; …} 通过定义不难看出 User 和 Authority 之间是一对多的关系。 基于 User 和 Authority 领域对象,我们也给出创建数据库表的 SQL 定义,如下所示: 12345678910111213141516171819CREATE TABLE IF NOT EXISTS `spring_security`.`user` ( `id` INT NOT NULL AUTO_INCREMENT, `username` VARCHAR(45) NOT NULL, `password` TEXT NOT NULL, `password_encoder_type` VARCHAR(45) NOT NULL, PRIMARY KEY (`id`));CREATE TABLE IF NOT EXISTS `spring_security`.`authority` ( `id` INT NOT NULL AUTO_INCREMENT, `name` VARCHAR(45) NOT NULL, `user` INT NOT NULL, PRIMARY KEY (`id`)); CREATE TABLE IF NOT EXISTS `spring_security`.`health_record` ( `id` INT NOT NULL AUTO_INCREMENT, `username` VARCHAR(45) NOT NULL, `name` VARCHAR(45) NOT NULL, `value` VARCHAR(45) NOT NULL, PRIMARY KEY (`id`)); 在运行系统之前,我们同样也需要初始化数据,对应脚本如下所示: 123456789INSERT IGNORE INTO `spring_security`.`user` (`id`, `username`, `password`, `password_encoder_type`) VALUES ('1', 'studeyang', '$2a$10$xn3LI/AjqicFYZFruSwve.681477XaVNaUQbr1gioaWPn4t1KsnmG', 'BCRYPT');INSERT IGNORE INTO `spring_security`.`authority` (`id`, `name`, `user`) VALUES ('1', 'READ', '1');INSERT IGNORE INTO `spring_security`.`authority` (`id`, `name`, `user`) VALUES ('2', 'WRITE', '1');INSERT IGNORE INTO `spring_security`.`health_record` (`id`, `username`, `name`, `value`) VALUES ('1', 'studeyang', 'weight', '70');INSERT IGNORE INTO `spring_security`.`health_record` (`id`, `username`, `name`, `value`) VALUES ('2', 'studeyang', 'height', '177');INSERT IGNORE INTO `spring_security`.`health_record` (`id`, `username`, `name`, `value`) VALUES ('3', 'studeyang', 'bloodpressure', '70');INSERT IGNORE INTO `spring_security`.`health_record` (`id`, `username`, `name`, `value`) VALUES ('4', 'studeyang', 'pulse', '80'); 这里初始化了一个用户名为 “studeyang”的用户,同时指定了它的密码为“12345”,加密算法为“BCRYPT”。 现在,领域对象和数据层面的初始化工作已经完成了,接下来我们需要在代码工程的 pom 文件中添加如下所示的 Maven 依赖: 1234567891011121314151617181920212223242526272829<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> <scope>test</scope> </dependency></dependencies> 实现用户管理实现自定义用户认证的过程通常涉及两大部分内容,一方面需要使用 User 和 Authority 对象来完成定制化的用户管理,另一方面需要把这个定制化的用户管理嵌入整个用户认证流程中。 如果你想实现自定义的用户信息,扩展 UserDetails 这个接口即可。实现方式如下所示: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849public class CustomUserDetails implements UserDetails { private final User user; public CustomUserDetails(User user) { this.user = user; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return user.getAuthorities().stream() .map(a -> new SimpleGrantedAuthority(a.getName())) .collect(Collectors.toList()); } @Override public String getPassword() { return user.getPassword(); } @Override public String getUsername() { return user.getUsername(); } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } public final User getUser() { return user; }} 请注意,这里的 getAuthorities() 方法中,我们将 User 对象中的 Authority 列表转换为了 Spring Security 中代表用户权限的 SimpleGrantedAuthority 列表。 所有的自定义用户信息和权限信息都是维护在数据库中的,所以为了获取这些信息,我们需要创建数据访问层组件,这个组件就是 UserRepository,定义如下: 1234public interface UserRepository extends JpaRepository<User, Integer> { Optional<User> findUserByUsername(String username);} 现在,我们已经能够在数据库中维护自定义用户信息,也能够根据这些用户信息获取到 UserDetails 对象,那么接下来要做的事情就是扩展 UserDetailsService。自定义 CustomUserDetailsService 实现如下所示: 12345678910111213141516@Servicepublic class CustomUserDetailsService implements UserDetailsService { @Autowired private UserRepository userRepository; @Override public CustomUserDetails loadUserByUsername(String username) { Supplier<UsernameNotFoundException> s = () -> new UsernameNotFoundException("Username" + username + "is invalid!"); User u = userRepository.findUserByUsername(username).orElseThrow(s); return new CustomUserDetails(u); }} 这里我们通过 UserRepository 查询数据库来获取 CustomUserDetails 信息。 实现认证流程实现自定义认证流程要做的也是实现 AuthenticationProvider 中的这两个方法,而认证过程势必要借助于前面介绍的 CustomUserDetailsService。 我们先来看一下 AuthenticationProvider 接口的实现类 AuthenticationProviderService,如下所示: 1234567891011121314151617181920212223242526272829303132333435363738394041424344@Servicepublic class AuthenticationProviderService implements AuthenticationProvider { @Autowired private CustomUserDetailsService userDetailsService; @Autowired private BCryptPasswordEncoder bCryptPasswordEncoder; @Autowired private SCryptPasswordEncoder sCryptPasswordEncoder; @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { String username = authentication.getName(); String password = authentication.getCredentials().toString(); //根据用户名从数据库中获取 CustomUserDetails CustomUserDetails user = userDetailsService.loadUserByUsername(username); //根据所配置的密码加密算法分别验证用户密码 switch (user.getUser().getPasswordEncoderType()) { case BCRYPT: return checkPassword(user, password, bCryptPasswordEncoder); case SCRYPT: return checkPassword(user, password, sCryptPasswordEncoder); } throw new BadCredentialsException("Bad credentials"); } @Override public boolean supports(Class<?> aClass) { return UsernamePasswordAuthenticationToken.class.isAssignableFrom(aClass); } private Authentication checkPassword(CustomUserDetails user, String rawPassword, PasswordEncoder encoder) { if (encoder.matches(rawPassword, user.getPassword())) { return new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword(), user.getAuthorities()); } else { throw new BadCredentialsException("Bad credentials"); } }} 我们首先通过 CustomUserDetailsService 从数据库中获取用户信息并构造成 CustomUserDetails 对象。然后,根据指定的密码加密器对用户密码进行验证,如果验证通过则构建一个 UsernamePasswordAuthenticationToken 对象并返回,反之直接抛出 BadCredentialsException 异常。而在 supports() 方法中指定的就是这个目标 UsernamePasswordAuthenticationToken 对象。 安全配置最后,我们要做的就是通过 Spring Security 提供的配置体系将前面介绍的所有内容串联起来,如下所示: 12345678910111213141516171819202122232425262728@Configurationpublic class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private AuthenticationProviderService authenticationProvider; @Bean public BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); } @Bean public SCryptPasswordEncoder sCryptPasswordEncoder() { return new SCryptPasswordEncoder(); } @Override protected void configure(AuthenticationManagerBuilder auth) { auth.authenticationProvider(authenticationProvider); } @Override protected void configure(HttpSecurity http) throws Exception { http.formLogin() .defaultSuccessUrl("/healthrecord", true); http.authorizeRequests().anyRequest().authenticated(); }} 这里注入了已经构建完成的 AuthenticationProviderService,并初始化了两个密码加密器 BCryptPasswordEncoder 和 SCryptPasswordEncoder。最后,我们覆写了 WebSecurityConfigurerAdapter 配置适配器类中的 configure() 方法,并指定用户登录成功后将跳转到”/healthrecord”路径所指定的页面。 对应的,我们需要构建如下所示的 HealthRecordController 类来指定”/healthrecord”路径,并展示业务数据的获取过程,如下所示: 12345678910111213@Controllerpublic class HealthRecordController { @Autowired private HealthRecordService healthRecordService; @GetMapping("/main") public String main(Authentication a, Model model) { String userName = a.getName(); model.addAttribute("username", userName); model.addAttribute("healthRecords", healthRecordService.getHealthRecordsByUsername(userName)); return "main.html"; }} 这里所指定的 health_record.html 位于 resources/templates 目录下,该页面基于 thymeleaf 模板引擎构建,如下所示: 1234567891011121314151617181920212223242526272829<!DOCTYPE html><html lang="en" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>健康档案</title> </head> <body> <h2 th:text="'登录用户:' + ${username}" /> <p><a href="/logout">退出登录</a></p> <h2>个人健康档案:</h2> <table> <thead> <tr> <th> 健康指标名称 </th> <th> 健康指标值 </th> </tr> </thead> <tbody> <tr th:if="${healthRecords.empty}"> <td colspan="2"> 无健康指标 </td> </tr> <tr th:each="healthRecord : ${healthRecords}"> <td><span th:text="${healthRecord.name}"> 健康指标名称 </span></td> <td><span th:text="${healthRecord.value}"> 健康指标值 </span></td> </tr> </tbody> </table> </body></html> 这里我们从 Model 对象中获取了认证用户信息以及健康档案信息,并渲染在页面上。 案例演示现在,让我们启动 Spring Boot 应用程序,并访问http://localhost:8080端点。因为访问系统的任何端点都需要认证,所以 Spring Security 会自动跳转到如下所示的登录界面: 我们分别输入用户名“studeyang”和密码“12345”,系统就会跳转到健康档案主页: 在这个主页中,我们正确获取了登录用户的用户名,并展示了个人健康档案信息。这个结果也证实了自定义用户认证体系的正确性。 小结本文实现了自定义的用户认证流程,作为 Spring Security 的入门示例,适合初学者进行学习,源码分享在:https://github.com/studeyang/spring-security-example 封面","link":"/2023/0912.html"},{"title":"Kafka 位移提交的正确姿势","text":"你说你 Kafka 用了很多年了,但是位移提交的这些细节你未必清楚。 不久前有个同事出去面试了,他说在 Kafka 问题上被面试官藐视了。 同事:我做了一个消息总线,简化了业务团队消息的收发,并且提供了消息管理功能。 面试官:你做的这个东西,有什么深入价值吗? 同事:目的在于封装技术的复杂度,使业务团队更加专注于业务。 面试官:这个价值并没有真实打动我,这不是很简单吗? 简单吗?那我问你:Kafka 是如何进行位移提交的?(你不一定能答得全面) 回到问题本身,Kafka 位移提交的方式有两种:有自动提交和手动提交。 一、自动提交在默认情况下,Consumer 每 5 秒自动提交一次位移。现在,我们假设提交位移之后的 3 秒发生了 Rebalance 操作。在 Rebalance 之后,所有 Consumer 从上一次提交的位移处继续消费,但该位移已经是 3 秒前的位移数据了,故在 Rebalance 发生前 3 秒消费的所有数据都要重新再消费一次。 虽然你能够通过减少 auto.commit.interval.ms 的值来提高提交频率,但这么做只能缩小重复消费的时间窗口,不可能完全消除它。这是自动提交机制的一个缺陷。 弥补这个问题,就要用到手动提交了。 二、手动同步提交下面这段代码展示了 commitSync() 的使用方法。 123456789while (true) { ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1)); process(records); // 处理消息 try { consumer.commitSync(); } catch (CommitFailedException e) { handle(e); // 处理提交失败异常 }} 手动提交位移的好处就在于更加灵活,你完全能够把控位移提交的时机和频率,可以在消费完成后立马提交位移。 但是,它也有一个缺陷,就是在调用 commitSync() 时,Consumer 程序会处于阻塞状态,直到远端的 Broker 返回提交结果,这个状态才会结束。在任何系统中,因为程序而非资源限制而导致的阻塞都可能是系统的瓶颈,会影响整个应用程序的 TPS。 三、手动异步提交由于它是异步的,Kafka 提供了回调函数(callback),供你实现提交之后的逻辑,比如记录日志或处理异常等。下面这段代码展示了调用 commitAsync() 的方法。 123456789while (true) { ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1)); process(records); // 处理消息 consumer.commitAsync((offsets, exception) -> { if (exception != null) { handle(exception); } });} commitAsync 的问题在于,出现问题时它不会自动重试。因为它是异步操作,倘若提交失败后自动重试,那么它重试时提交的位移值可能早已经“过期”或不是最新值了。因此,异步提交的重试其实没有意义,所以 commitAsync 是不会重试的。 那同步提交、异步提交那我该怎么选呢? 小孩子才做选择题,成年人当然是全部都要啊! 四、手动同异步结合提交下面这段代码,将两个 API 方法结合使用进行手动提交。 123456789101112131415try { while (true) { ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1)); process(records); // 处理消息 consumer.commitAysnc(); // 使用异步提交规避阻塞 }} catch (Exception e) { handle(e); // 处理异常} finally { try { consumer.commitSync(); // 最后一次提交使用同步阻塞式提交 } finally { consumer.close(); }} 对于常规性、阶段性的手动提交,我们调用 commitAsync() 避免程序阻塞,而在 Consumer 要关闭前,我们调用 commitSync() 方法执行同步阻塞式的位移提交,以确保 Consumer 关闭前能够保存正确的位移数据。将两者结合后,我们既实现了异步无阻塞式的位移管理,也确保了 Consumer 位移的正确性。 所以,如果你需要自行编写代码开发一套 Kafka Consumer 应用,那么我推荐你使用上面的代码范例来实现手动的位移提交。 (看到这里,面试官陷入了深深的沉默中…) 封面图","link":"/2023/1124.html"},{"title":"MyBatis拦截器在实际项目中的应用","text":"MyBatis 是一个流行的 Java 持久层框架,它简化了数据库访问的复杂性,为开发者提供了强大的功能。其中,MyBatis 拦截器是一个非常有用的特性,可以帮助开发者灵活地解决各种问题。 本文将探讨 MyBatis 拦截器在实际项目中的应用场景和具体实现方法。 文中代码:https://github.com/studeyang/mybatis-interceptor-demo 首先我们需要认识 MyBatis 拦截器。 一、MyBatis 拦截器1.1 从执行 SQL 语句的核心流程说起在 MyBatis 中,要执行一条 SQL 语句,会涉及非常多的组件,比较核心的有:Executor、StatementHandler、ParameterHandler 和 ResultSetHandler。下图展示了 MyBatis 执行一条 SQL 语句的核心过程: SQL 语句执行时,首先到达 Executor,Executor 会调用事务管理模块实现事务的相关控制。真正执行将会由 StatementHandler 实现,StatementHandler 会先依赖 ParameterHandler 进行 SQL 模板的实参绑定,然后由 java.sql.Statement 对象将 SQL 语句以及绑定好的实参传到数据库执行。 数据库执行后,从中拿到 ResultSet,最后,由 ResultSetHandler 将 ResultSet 映射成 Java 对象返回给调用方,这就是 SQL 执行模块的核心。 MyBatis 允许开发者拦截这些核心组件的关键方法,从而实现对 SQL 执行过程的自定义控制。 1.2 MyBatis 拦截器MyBatis 允许我们自定义 Interceptor,拦截 SQL 语句执行过程中的某些关键逻辑,允许拦截的方法有: Executor 类中的 update()、query()、flushStatements()、commit()、rollback()、getTransaction()、close()、isClosed() 方法; ParameterHandler 中的 setParameters()、getParameterObject() 方法; ResultSetHandler中的 handleOutputParameters()、handleResultSets() 方法; StatementHandler 中的 parameterize()、prepare()、batch()、update()、query() 方法。 下面,我们就从实际出发,看看 MyBatis 拦截器的具体使用场景。 二、使用场景2.1 数据加密多数公司出于信息安全等考虑,会将个人信息等敏感数据在存储时进行加密,在数据读取时进行解密。这种场景就适合使用 MyBatis 拦截器实现了,具体来说: 写入数据时,拦截 insert 和 update 语句,通过自定义注解获取到加密字段,并对其进行加密后再写入数据库。 读取数据时,拦截 select 语句,通过自定义注解获取到加密字段,对密文进行解密,然后返回给上层调用。 这样就能够在不修改业务代码的情况下,自动完成数据的加解密处理了。我们来看具体的代码实现。 在实体属性上添加@EncryptField注解: 12345678public class UserEntity { /** * 身份证 */ @EncryptField private String idCard; //其它属性 包括get, set方法} 接着执行下面插入操作: 12345678910111213141516171819202122@RunWith(SpringRunner.class)@SpringBootTestpublic class UserMapperTest extends TestCase { @Autowired private UserMapper userMapper; @Test public void insert() { UserEntity user = new UserEntity(); user.setName("张三"); user.setIdCard("442222111233322210"); user.setSex("男"); user.setAge(0); user.setCreateTime(new Date()); user.setUpdateTime(new Date()); user.setStatus(0); userMapper.insert(user); }} 数据库里id_card就是密文了。 在读取数据时,执行下面查询操作: 1234567891011121314@RunWith(SpringRunner.class)@SpringBootTestpublic class UserMapperTest extends TestCase { @Autowired private UserMapper userMapper; @Test public void selectByPrimaryKey() { UserEntity user = userMapper.selectByPrimaryKey(682230480968224768L); System.out.println(user); }} 返回结果如下: 12345678910{ "id": 682230480968224768, "name": "张三", "idCard": "442222111233322210", "sex": "男", "age": 0, "createTime": "2024-07-05 10:16:56", "updateTime": "2024-07-05 10:16:56", "status": 0} 可以看到,拦截器实现了对数据自动解密。拦截器的具体实现请点击: 读拦截器 写拦截器 接着我们来看第二个场景的应用。 2.2 生成ID主键在生成表主键 ID 时,我们通常会考虑主键自增或者 UUID,但它们都有很明显的缺点。 对于自增 ID 来说,第一,容易被爬虫遍历数据;第二,分表分库会有 ID 冲突。 对于 UUID 来说,数据太长,且有索引碎片、过多占用索引空间的问题。 雪花算法就很适合在分布式场景下生成唯一 ID,它既可以保证唯一又可以保证有序。通过 MyBatis 拦截器,我们可以实现在插入数据时自动生成全局唯一且有序的雪花 ID。 具体做法是:拦截 insert 语句,通过自定义注解获取主键字段,然后为其赋值雪花 ID 后再写入数据库。我们来看具体的代码实现。 在主键的属性上添加@AutoId注解。 123456789101112public class UserEntity { /** * id(添加自定义注解) */ @AutoId private Long id; /** * 姓名 */ private String name; //其它属性 包括get,set方法} 执行插入操作后,数据库里就已经有雪花ID了。 如果在正式环境中,由于只要涉及到插入数据的操作都被该插件拦截,并发量会很大。所以该插件代码既要保证线程安全又要保证高性能。 1、线程安全 产生雪花 ID 的时候必须是线程安全的,不能出现同一台服务器同一时刻出现了相同的雪花 ID,可以通过: 1单例模式 + synchronized 来实现。 2、高性能 性能消耗比较大可能会出现在两个地方: 121)雪花算法生成雪花ID的过程。2)通过类的反射机制找到哪些属性带有@AutoId注解的过程。 第一,生成雪花ID。简单测试过,生成20万条数据,大约在1.7秒,能满足我们实际开发中的需要。 第二,反射查找。可以在插件中添加缓存。 1234/** * key值为Class对象 value可以理解成是该类带有AutoId注解的属性 */private Map<Class, List<Handler>> handlerMap = new ConcurrentHashMap<>(); 插件部分源码如下: 1234567891011121314151617181920public class AutoIdInterceptor implements Interceptor { /** * 处理器缓存 */ private Map<Class, List<Handler>> handlerMap = new ConcurrentHashMap<>(); private void process(Object object) throws Throwable { Class handlerKey = object.getClass(); List<Handler> handlerList = handlerMap.get(handlerKey); //先判断handlerMap是否已存在该class,不存在先找到该class有哪些属性带有@AutoId if (handlerList == null) { handlerMap.put(handlerKey, handlerList = new ArrayList<>()); // 通过反射 获取带有AutoId注解的所有属性字段,并放入到handlerMap中 } //为带有@AutoId赋值ID for (Handler handler : handlerList) { handler.accept(object); } }} 三、小结MyBatis 拦截器是一个非常值得开发者深入学习和应用的技术。相信通过本文的介绍,您已经对 MyBatis 拦截器有了更加具体的认识。如果您还有任何疑问,欢迎与我交流探讨。 封面 更多文章 Kafka 位移提交的正确姿势 23种设计模式的必备结构图","link":"/2024/0705.html"},{"title":"MySQL是如何给表加字段的?","text":"在我最近的项目中,经常会有给大表加字段的需求,这个过程非常耗时。 可以看到,900 万数据量的一张表,加一个字段就需要 3 个小时左右。 我们知道,给一个表加字段,或者修改字段,或者加索引,需要扫描全表的数据。假设表数据量比较大,加字段的过程将会非常耗时。 不过我最关心的是,在加字段的过程中,会不会对业务的增删改查造成影响?在询问 DBA 后,他给出的答复是不会造成影响。这不禁让我思考这背后的实现原理。 下面,我们就来一探究竟。 一、MDL(metadata lock)我们可以想象这样一个场景:一个线程正在遍历查询一个表中的数据,而执行期间另一个线程对这个表结构做变更,添加了一列,那么查询线程拿到的结果跟表结构对不上,这肯定是不行的。 MySQL 是如何解决这个问题的?答案是:在 5.5 版本中引入了 MDL(metadata lock) 元数据锁。 MDL 不需要显式使用,在访问一个表的时候会被自动加上。MDL 的作用是,保证读写的正确性。 当对一个表做增删改查操作的时候,加 MDL 读锁;读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。 当要对表做结构变更操作的时候,加 MDL 写锁。读写锁之间、写锁之间都是互斥的,用来保证变更表结构操作的安全性。因此,如果有两个线程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行。 如果是在事务中,在语句执行开始时申请 MDL 锁,但是语句结束后并不会马上释放,而会等到整个事务提交后再释放。这点要特别注意!(本文第三部分有具体的案例说明) 知道了这个概念后,回到问题本身:给表加字段需要经过哪些过程? 二、给表加字段的流程2.1 你会怎么做?假设现在有一张表 A,需要加一个字段 d,你会怎么做呢? 你可以新建一个与表 A 结构相同的表 B,然后在表 B 加上字段 d,由于表 B 是新建的表,所以加字段耗时很小。接下来,你需要把数据一行一行地从表 A 里读出来再插入到表 B 中。最后,用表 B 替换 A,从效果上看,就起到了对表 A 加字段的作用。 在 MySQL 5.5 版本之前,alter table 命令的执行流程跟上述的差不多, MySQL 会创建好临时表 B,并自动完成转存数据、交换表名、删除旧表的操作。 显然,花时间最多的步骤是往临时表插入数据的过程,如果在这个过程中,有新的数据要写入到表 A 的话,就会造成数据丢失。因此,在整个 DDL 过程中,表 A 中不能有增删改查操作。 在 MySQL 5.6 版本开始引入的 Online DDL,对这个操作流程做了优化。 2.2 Online DDL引入了 Online DDL 之后,加字段的流程如下: 建立一个临时文件; 用数据页中表 A 的记录生成 B+ 树,存储到临时文件中; 生成临时文件的过程中,将所有对 A 的操作记录在一个日志文件(row log)中; 临时文件生成后,将日志文件中的操作应用到临时文件,得到一个逻辑数据上与表 A 相同的数据文件; 用临时文件替换表 A 的数据文件。 流程优化后,由于日志文件记录和重放这两个功能的存在,这个方案在加字段的过程中,允许对表 A 做增删改操作。这也就是 Online DDL 名字的来源。 执行 DDL 之前是要拿 MDL 写锁的,MDL 读锁会阻塞,这样还能叫 Online DDL 吗? 确实,上述流程中,alter 语句在启动的时候需要获取 MDL 写锁,但是这个写锁在真正拷贝数据之前就退化成读锁了。表 A 的 alter 语句,可以拆成以下三步来执行: 第一步,创建表 B; 第二步,读表 A 数据; 第三步,将数据写入表 B; 在第二步的时候,实际上表 A 的锁已经是 MDL 读锁了,MDL 读锁不会阻塞增删改操作。 对于一个大表来说,Online DDL 最耗时的过程就是拷贝数据到临时表的过程,这个步骤的执行期间可以接受增删改操作。所以,相对于整个 DDL 过程来说,锁的时间非常短。对业务来说,就可以认为是 Online 的。 需要补充说明的是,上述的这些重建方法都会扫描原表数据和构建临时文件。对于很大的表来说,这个操作是很消耗 IO 和 CPU 资源的。因此,如果是线上服务,你要很小心地控制操作时间。 三、给小表加字段3.1 容易出现的问题在对大表操作的时候,你肯定会特别小心,以免对线上服务造成影响。而实际上,即使是小表,操作不慎也会出问题。我们来看一下下面的操作序列,假设表 t 是一个小表。 备注:这里的实验环境是 MySQL 5.6。 我们可以看到 session A 在启动前开启了一个事务,在 session A 执行的时候会对表 t 加一个 MDL 读锁。由于 session B 需要的也是 MDL 读锁,因此可以正常执行。 之后 session C 会被 blocked,是因为 session A 的 MDL 读锁还没有释放,而 session C 需要 MDL 写锁,因此只能被阻塞。 如果只有 session C 自己被阻塞还没什么关系,但是之后所有要在表 t 上新申请 MDL 读锁的请求也会被 session C 阻塞。前面我们说了,所有对表的增删改查操作都需要先申请 MDL 读锁,就都被锁住,等于这个表现在完全不可读写了。 如果某个表上的查询语句频繁,且客户端还有重试机制,也就是说超时后会再起一个新 session 再请求的话,这个库的线程很快就会爆满。 所以在事务中执行 DDL 语句要特别注意。 3.2 如何安全地给小表加字段?首先我们要解决长事务,事务不提交,就会一直占着 MDL 写锁。如果你要做 DDL 变更的表刚好有长事务在执行,要考虑先暂停 DDL,或者 kill 掉这个长事务。 在 MySQL 的 information_schema 库的 innodb_trx 表中,你可以查到当前执行中的事务。 但如果你要变更的表无法避免地会有长事务,而你又不得不加个字段,你该怎么做呢? 如果这张表的请求很频繁,这时候 kill 可能未必管用,因为新的请求马上就来了。比较理想的机制是,在 alter table 语句里面设定等待时间,如果在这个指定的等待时间里面能够拿到 MDL 写锁最好,拿不到也不要阻塞后面的业务语句,先放弃。 12ALTER TABLE tbl_name NOWAIT add column ...ALTER TABLE tbl_name WAIT N add column ... 之后开发人员或者 DBA 再通过重试命令重复这个过程。 MariaDB 已经合并了 AliSQL 的这个功能,所以这两个开源分支目前都支持 DDL NOWAIT/WAIT n 这个语法。 封面 相关文章 MySQL如何清理数据并释放磁盘空间 MySQL是如何选择索引的? 《MySQL专栏》–掘金","link":"/2024/0725.html"},{"title":"【译】Apache Shiro介绍","text":"一、什么是 Apache Shiro?Apache Shiro 是一个功能强大且易于使用的 Java 安全框架,表现在身份认证、授权、加密和会话管理,可用于保护任何应用程序,小到命令行应用程序、移动应用程序,大到 Web 和企业应用程序。 应用程序安全性的 4 个基石: 身份验证 - 证明用户身份,通常称为用户“登录” 授权 - 访问控制 加密 - 保护或隐藏数据免遭窥探 会话管理 - 每个用户的时间敏感状态 二、核心概念2.1 主题(Subject)“主题”一词是一个安全术语,基本上意味着“当前正在执行的用户”。它只是不被称为“用户”,因为“用户”一词通常与人类相关联。在安全领域,术语“主题”可以指人类,也可以指第三方进程、守护帐户或任何类似的东西。它只是意味着“当前正在与软件交互的事物”。不过,大多数情况下,您可以将其视为 Shiro 的“用户”概念。 清单 1. 获取主题: 1Subject currentUser = SecurityUtils.getSubject(); 2.2 安全管理器(SecurityManager)主题(Subject)的“幕后”对应的是 SecurityManager。Subject 管理的是一个用户的安全操作,SecurityManager 管理所有用户的安全操作。SecurityManager 引用了许多内部嵌套安全组件,然而,一旦配置了 SecurityManager,通常就不用再管它了,只需关注 Subject API 即可。 那么如何配置 SecurityManager 呢?这取决于您的应用程序环境,例如,Web 应用程序通常会在 web.xml 中指定 Shiro Servlet Filter。 见:【1.7 Web 支持】清单 14. web.xml 中的 ShiroFilter 每个应用程序通常只有一个 SecurityManager 实例,它本质上是一个单例。默认的 SecurityManager 实现是 POJO,可以通过任何与 POJO 兼容的配置机制进行配置,例如,普通 Java 代码、Spring XML、YAML、.properties 和 .ini 文件等。 Shiro 默认基于文本 INI 配置,INI 易于阅读、使用简单,并且需要很少的依赖项。 清单 2. 使用 INI 配置 Shiro: 1234567891011[main]cm = org.apache.shiro.authc.credential.HashedCredentialsMatchercm.hashAlgorithm = SHA-512cm.hashIterations = 1024# Base64 encoding (less text):cm.storedCredentialsHexEncoded = falseiniRealm.credentialsMatcher = $cm[users]jdoe = TWFuIGlzIGRpc3Rpbmd1aXNoZWQsIG5vdCBvbmx5IGJpcyByZWFzb2asmith = IHNpbmd1bGFyIHBhc3Npb24gZnJvbSBvdGhlciBhbXNoZWQsIG5vdCB 我们看到了配置 SecurityManager 实例的 INI 配置,INI 有两个部分:[main] 和 [users]。 [main] 部分是您配置 SecurityManager 对象以及 SecurityManager 使用对象(如领域)的地方。在此示例中,我们正在配置两个对象: cm 对象,它是 Shiro 的 HashedCredentialsMatcher 类的实例。 iniRealm 对象,它是 SecurityManager 用来表示用户帐户的组件。 在 [users] 部分,您可以指定用户帐户列表,以便测试使用。在此示例中,我们配置了两个用户: 用户名为jdoe,密码为TWFuIGlzIGRpc3Rpbmd1aXNoZWQsIG5vdCBvbmx5IGJpcyByZWFzb2的用户。 用户名为asmith,密码为IHNpbmd1bGFyIHBhc3Npb24gZnJvbSBvdGhlciBhbXNoZWQsIG5vdCB的用户。 INI 是配置 Shiro 的一种简单方法。有关 INI 配置的更多详细信息,请参阅 Shiro 的文档。 清单 3. 加载 shiro.ini 配置文件: 123456789//1. 加载INI配置Factory<SecurityManager> factory =new IniSecurityManagerFactory("classpath:shiro.ini");//2. 创建 SecurityManagerSecurityManager securityManager = factory.getInstance();//3. 使 SecurityManager 单例可供应用程序访问SecurityUtils.setSecurityManager(securityManager); 2.3 领域(Realms)Shiro 的最后一个核心概念是领域(Realms)。 Realm 充当 Shiro 和安全数据之间的“桥梁”。也就是说,在与安全数据(例如用户帐户)进行交互时,例如执行身份验证(登录)和授权(访问控制),Shiro 从应用程序配置的一个或多个 Realm 中查找其中的内容。 从这个意义上说,Realm 本质上是一个安全方面的 DAO:它封装了数据源连接的详细信息,使得 Shiro 可以使用相关数据。配置 Shiro 时,您必须至少指定一个用于身份验证和授权的 Realm。 Shiro 提供开箱即用的 Realms 来连接一些安全数据源,例如 LDAP、关系数据库 (JDBC)、文本配置源(例如 INI 和配置文件)等等。如果默认的 Realm 不能满足您的需求,您可以添加自己的 Realm 实现自定义数据源。下面是使用 LDAP 作为应用程序的 Realms 的配置示例。 清单 4. 连接到 LDAP 用户数据存储的 Realm 配置片段: 12345[main]ldapRealm = org.apache.shiro.realm.ldap.JndiLdapRealmldapRealm.userDnTemplate = uid={0},ou=users,dc=mycompany,dc=comldapRealm.contextFactory.url = ldap://ldapHost:389ldapRealm.contextFactory.authenticationMechanism = DIGEST-MD5 三、认证(Authentication)认证是验证用户身份的过程,通常有三步。 收集用户的身份信息(称为主体 Principals)和身份证明(称为凭证 Credentials) 将主体和凭证提交到系统 如果提交的凭证与系统中该用户身份相匹配,则该用户被视为已通过认证 常见的认证方式是用户名/密码组合。当用户登录软件应用程序时,他们通常会提供用户名(主体)和密码(凭证 ),如果系统中存储的密码与用户指定的密码相匹配,则认为该用户已通过身份认证。 清单 5. Subject 登录: 1234567//1. Acquire submitted principals and credentials:AuthenticationToken token =new UsernamePasswordToken(username, password);//2. Get the current Subject:Subject currentUser = SecurityUtils.getSubject();//3. LogincurrentUser.login(token); 当调用登录方法时,SecurityManager 将接收 AuthenticationToken 并将其分派到一个或多个已配置的 Realms,以允许每个 Realms 根据需要执行身份检查。但是如果登录失败会发生什么?如果用户指定了错误的密码怎么办?您可以使用 Shiro 的运行时 AuthenticationException 来处理异常。 清单 6. 处理失登录败: 12345678910try { currentUser.login(token);} catch (IncorrectCredentialsException ice) { ...} catch (LockedAccountException lae) { ...}catch (AuthenticationException ae) { ...} 您可以选择捕获 AuthenticationException 异常,向用户提示“用户名或密码不正确”消息。 Subject 登录成功后,他们被视为已通过身份认证,表示您允许他们使用您的应用程序。但用户认证了他们的身份并不意味着他们可以在您的应用程序中做任何事情。这就引出了下一个问题:“我如何控制用户可以做什么?不可以做什么?” 决定允许用户做什么称为授权。接下来我们将介绍 Shiro 如何启用授权。 四、授权(Authorization)授权本质上是控制用户可以在应用程序中访问哪些内容,例如资源、网页等。 大多数用户通过使用角色和权限等概念来执行访问控制,允许用户执行某些操作,通常取决于分配给他们的角色或权限。然后,您的应用程序可以根据这些角色和权限,来控制公开哪些资源。 清单 7. 角色检查: 12345if ( subject.hasRole("administrator") ) { //show the 'Create User' button} else { //grey-out the button} 权限检查是执行授权的另一种方式。如上例所示的角色检查存在一个重大缺陷:您无法在运行时添加或删除角色。 您的代码是使用角色名称进行硬编码的,因此如果您更改了角色名称或配置,您的代码将会被破坏!如果您需要能够在运行时更改角色的含义,或者根据需要添加或删除角色,则必须依赖其他东西。 为此,Shiro 支持权限的概念。权限是功能的原始声明,例如“打开一扇门”、“创建博客条目”、“删除’jsmith’用户”等。通过权限来反映应用程序的原始功能,当您想更改应用程序的功能时,只需更改权限检查。反过来,您可以在运行时根据需要向角色或用户分配权限。 清单 8. 权限检查: 12345if ( subject.isPermitted("user:create") ) { //show the 'Create User' button} else { //grey-out the button} 这样,任何分配了user:create权限的角色或用户都可以单击“创建用户”按钮,并且这些角色和分配甚至可以在运行时更改,从而为您提供非常灵活的安全模型。 user:create字符串遵守了某些解析的约定。 Shiro 通过其 WildcardPermission 开箱即用地支持此约定。尽管超出了本文的介绍范围,但您将看到 WildcardPermission 在创建安全策略时非常灵活,甚至支持实例级访问控制等功能。 清单 9. 实例级权限检查: 12345if ( subject.isPermitted(“user:delete:jsmith”) ) { //delete the ‘jsmith’ user} else { //don’t delete ‘jsmith’} 此示例表明,如果需要,您可以控制对各个资源的访问,甚至可以控制到非常细粒度的实例级别。 这就是 Shiro 授权功能的简要概述。接下来我们将讨论 Shiro 的高级会话管理功能。 五、会话管理(Session Management)Apache Shiro 提供了安全框架领域中独特的东西:可以在任何应用程序中使用一致性会话 API。 也就是说,您可以在任何应用程序中使用 Shiro Sessions API,从小型守护程序、独立应用程序,到大型集群 Web 应用程序。开发人员现在可以在任何应用程序中使用会话 API,而不是 Servlet 或 EJB 容器内。 【Shiro 允许您存储一条插入的会话数据,例如缓存、关系型数据库、NoSQL 等。这意味着您只需配置一次会话集群,无论您的部署环境如何(Tomcat、Jetty、JEE Server 或独立应用程序),它都会以相同的方式工作,无需根据您部署应用程序的方式重新配置您的应用程序。】 【Shiro 会话的另一个好处是可以跨客户端共享会话数据。例如,Swing 桌面客户端可以加入同一个 Web 应用程序会话。那么如何访问主题的会话呢?】 清单 10. 获取主题的会话: 12Session session = subject.getSession();Session session = subject.getSession(boolean create); 这些方法在概念上与 HttpServletRequest API 相同。第一个方法将返回主题的现有会话,如果还没有会话,它将创建一个新会话并返回它。第二种方法接受一个布尔参数,用于确定是否创建新会话(如果尚不存在)。 一旦获得了主题的会话,就可以像使用 HttpSession 一样使用它。 Shiro 团队认为 HttpSession API 对于 Java 开发人员来说是最舒服的,因此我们保留了它的大部分东西。当然,最大的区别是您可以在任何应用程序中使用 Shiro Sessions,而不仅仅是 Web 应用程序中。 清单 11. 会话 API: 123456Session session = subject.getSession();session.getAttribute("key", someValue);Date start = session.getStartTimestamp();Date timestamp = session.getLastAccessTime();session.setTimeout(millis);... 六、加密(Cryptography)加密是隐藏数据的过程,使窥探者无法理解它。在加密领域,Shiro 的目标是简化并提供 JDK 的密码支持。 加密通常并不特定于 Subject,您可以在任何地方使用 Shiro 的加密支持。Shiro 加密专注于两个领域:哈希加密(也称为消息摘要)和密文加密。让我们更详细地看看这两个。 6.1 哈希(Hashing)如果您使用过 JDK 的 MessageDigest 类,您很快就会意识到它使用起来有点麻烦。 例如,让我们考虑一个相对常见的场景:对文件进行 MD5 哈希加密处理,并进行 Base64 编码。 产生密文数据称为校验和(checksum),这在文件下载时经常使用,用户可以对下载的文件执行自己的 MD5 哈希,并断言其校验和与下载网站上的校验和匹配。如果它们匹配,用户就可以认为文件在传输过程中没有被篡改。 以下是您在没有 Shiro 的情况下尝试执行此操作的方法: 将文件转换为字节数组。在 JDK 中您需要创建一个 FileInputStream,然后使用字节缓冲区并抛出适当的 IOException 等。 使用 MessageDigest 类对字节数组进行哈希处理,处理适当的异常。 将哈希字节数组进行 Base64 编码。 清单 12. JDK 的 MessageDigest: 1234567try { MessageDigest md = MessageDigest.getInstance("MD5"); md.digest(bytes); byte[] hashed = md.digest();} catch (NoSuchAlgorithmException e) { e.printStackTrace();} 现在介绍如何使用 Shiro 做同样的事情。 1String hex = new Md5Hash(myFile).toHex(); SHA-512 哈希和 Base64 编码也同样简单。 12String encodedPassword = new Sha512Hash(password, salt, count).toBase64(); 6.2 密文(Ciphers)我们通常使用密文来保证数据安全,特别是在传输或存储数据时。 如果您使用过 JDK Cryptography API,特别是 javax.crypto.Cipher 类,您就会知道它可能是一头难以驯服的野兽。 Shiro 试图通过引入其 CipherService API 来简化加密的整个过程。CipherService 是大多数开发人员在加密数据时想要使用的,它是一种简单、无状态、线程安全的 API,可以在一个方法调用中完整地加密或解密数据。您所需要做的就是提供您的密钥。 例如,可以使用 256 位 AES 加密,如下面的清单所示。 清单 13. Apache Shiro 的加密 API: 12345678AesCipherService cipherService = new AesCipherService();cipherService.setKeySize(256);//创建一个测试密钥:byte[] testKey = cipherService.generateNewKey();//加密文件的字节:byte[] encrypted = cipherService.encrypt(fileBytes, testKey); Shiro 的 CipherService API 还有其他好处,例如能够支持基于流的加密/解密(如加密音频或视频)。 七、Web 支持最后,我们将简要地介绍 Shiro 的 Web 支持。为 Web 应用程序设置 Shiro 非常简单,唯一需要做的就是在 web.xml 中定义 Shiro Servlet Filter。 清单 14. web.xml 中的 ShiroFilter: 12345678910111213<filter> <filter-name>ShiroFilter</filter-name> <filter-class> org.apache.shiro.web.servlet.IniShiroFilter </filter-class> <!-- no init-param means load the INI config from classpath:shiro.ini --> </filter><filter-mapping> <filter-name>ShiroFilter</filter-name> <url-pattern>/*</url-pattern></filter-mapping> 此过滤器可以读取上述 shiro.ini 配置。配置完成后,Shiro Filter 将过滤每个请求。 7.1 特定 URL 的过滤器链Shiro 支持特定的过滤规则。它允许您为任何匹配的 URL 指定临时过滤器链,这比单独在 web.xml 中定义过滤器要灵活得多。清单 15 显示了 Shiro INI 中的配置片段。 清单 15. 特定路径的过滤器链: 123456[urls]/assets/** = anon/user/signup = anon/user/** = user/rpc/rest/** = perms[rpc:invoke], authc/** = authc [urls] 部分可供 Web 应用程序使用。对于每一行,等号左边的值表示 Web 应用程序资源路径,右侧的值定义了一个过滤器链,过滤器链是一个有序的 Servlet 过滤器列表(多个以逗号分隔)。您在上面看到的过滤器名称(anon、user、perms、authc)是 Shiro 提供的开箱即用的过滤器。 您可以在 web.xml 中仅定义 Shiro 过滤器,并在 shiro.ini 中定义所有其他过滤器和过滤器链,这种定义机制更加简洁且易于理解。 7.2 JSP 标签库Shiro 还提供了一个 JSP 标签库,允许您根据当前主题(Subject)的状态控制 JSP 页面的输出。一个常见的例子是在用户登录后显示 “Hello <username>” 文本。但如果他们是匿名的,您可能想显示其他内容,例如 “Hello! Register today!” 清单 16 显示了如何使用 Shiro 的 JSP 标记来支持这一点。 清单 16. JSP 标记库示例: 1234567891011121314<%@ taglib prefix="shiro" uri="http://shiro.apache.org/tags" %>...<p>Hello<shiro:user> <!-- shiro:principal prints out the Subject’s main principal - in this case, a username --> <shiro:principal/>!</shiro:user><shiro:guest> <!-- not logged in - considered a guest. Show the register link --> ! <a href=”register.jsp”>Register today!</a></shiro:guest></p> 还有其他标签,允许您根据他们的角色、分配的权限以及他们是否经过身份验证。 在 Web 应用中,Shiro 还支持许多其他的功能,例如“记住我”服务、REST 和 BASIC 身份验证,当然还有透明的 HttpSession 支持。 7.3 Web 会话管理对于 Web 应用程序,Shiro 会话默认使用的是我们都习惯的 Servlet 容器会话。也就是说,当您调用方法 subject.getSession() 和 subject.getSession(boolean) 时,Shiro 将返回 Servlet 容器的 HttpSession 实例。 封面 参考资料 https://www.infoq.com/articles/apache-shiro/ 相关文章 Spring Security入门:保护Web应用","link":"/2024/0803.html"}],"tags":[{"name":"Java脚手架","slug":"Java脚手架","link":"/tags/Java%E8%84%9A%E6%89%8B%E6%9E%B6/"},{"name":"Log4j","slug":"Log4j","link":"/tags/Log4j/"},{"name":"Redis","slug":"Redis","link":"/tags/Redis/"},{"name":"MySQL","slug":"MySQL","link":"/tags/MySQL/"},{"name":"Nacos","slug":"Nacos","link":"/tags/Nacos/"},{"name":"Maven","slug":"Maven","link":"/tags/Maven/"},{"name":"XXL-JOB","slug":"XXL-JOB","link":"/tags/XXL-JOB/"},{"name":"计算机基础","slug":"计算机基础","link":"/tags/%E8%AE%A1%E7%AE%97%E6%9C%BA%E5%9F%BA%E7%A1%80/"},{"name":"Git","slug":"Git","link":"/tags/Git/"},{"name":"架构","slug":"架构","link":"/tags/%E6%9E%B6%E6%9E%84/"},{"name":"DDD","slug":"DDD","link":"/tags/DDD/"},{"name":"Kafka","slug":"Kafka","link":"/tags/Kafka/"},{"name":"设计模式","slug":"设计模式","link":"/tags/%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F/"},{"name":"HTTP","slug":"HTTP","link":"/tags/HTTP/"},{"name":"编程","slug":"编程","link":"/tags/%E7%BC%96%E7%A8%8B/"},{"name":"LeetCode","slug":"LeetCode","link":"/tags/LeetCode/"},{"name":"程序之道","slug":"程序之道","link":"/tags/%E7%A8%8B%E5%BA%8F%E4%B9%8B%E9%81%93/"},{"name":"Spring Security","slug":"Spring-Security","link":"/tags/Spring-Security/"},{"name":"MyBatis","slug":"MyBatis","link":"/tags/MyBatis/"},{"name":"Shiro","slug":"Shiro","link":"/tags/Shiro/"}],"categories":[{"name":"technotes","slug":"technotes","link":"/categories/technotes/"},{"name":"今日算法","slug":"今日算法","link":"/categories/%E4%BB%8A%E6%97%A5%E7%AE%97%E6%B3%95/"}],"pages":[{"title":"Studeyang","text":"网络资料Stude 是同学的意思,对应我的网名:杨同学。同时,Studeyang 与 Still Young 同音,描述了我的技术半生,愿:出走半生,归来仍是少年。 本站内容也会同步在我的微信公众号上:【杨同学 technotes】 个人简历访问简历前,请联系我获取提取码,联系方式: Email:[email protected] Wechat:studeyang 简历地址:https://www.aliyundrive.com/s/Xuxu3nE3HG5","link":"/about/index.html"}]}