注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

面试官:线程调用2次start会怎样?我支支吾吾没答上来

写在开头 刚刚吃晚饭时,突然想到了多年前自己面试时的亲身经历,决定再回来补充一个小知识点! 记得是一个周末去面试Java后端开发工程师岗位,面试官针对Java多线程进行了狂轰乱炸般的考问,什么线程创建的方式、线程的状态、各状态间的切换、如果保证线程安全、各种锁...
继续阅读 »

写在开头


刚刚吃晚饭时,突然想到了多年前自己面试时的亲身经历,决定再回来补充一个小知识点!


记得是一个周末去面试Java后端开发工程师岗位,面试官针对Java多线程进行了狂轰乱炸般的考问,什么线程创建的方式、线程的状态、各状态间的切换、如果保证线程安全、各种锁的区别,如何使用等等,因为有好好背八股文,所以七七八八的也答上来了,但最后面试官问了一个现在看来很简单,但当时根本不知道的问题,他先是问了我,看过Thread的源码没,我毫不犹豫的回答看过,紧接着他问:



线程在调用了一次start启动后,再调用一次可以不?如果线程执行完,同样再调用一次start又会怎么样?



这个问题抛给你们,请问该如何作答呢?


线程的启动


我们知道虽然很多八股文面试题中说Java创建线程的方式有3种、4种,或者更多种,但实际上真正可以创建一个线程的只有new Thread().start();


【代码示例1】


public class Test {
public static void main(String[] args) {
Thread thread = new Thread(() -> {});
System.out.println(thread.getName()+":"+thread.getState());
thread.start();
System.out.println(thread.getName()+":"+thread.getState());
}
}

输出:


Thread-0:NEW
Thread-0:RUNNABLE

创建一个Thread,这时线程处于NEW状态,这时调用start()方法,会让线程进入到RUNNABLE状态。


RUNNABLE的线程调用start


在上面测试代码的基础上,我们再次调用start()方法。


【代码示例2】


public class Test {
public static void main(String[] args) {
Thread thread = new Thread(() -> {});
System.out.println(thread.getName()+":"+thread.getState());
//第一次调用start
thread.start();
System.out.println(thread.getName()+":"+thread.getState());
//第二次调用start
thread.start();
System.out.println(thread.getName()+":"+thread.getState());
}
}

输出:


Thread-0:NEW
Thread-0:RUNNABLE
Exception in thread "main" java.lang.IllegalThreadStateException
at java.lang.Thread.start(Thread.java:708)
at com.javabuild.server.pojo.Test.main(Test.java:17)

第二次调用时,代码抛出IllegalThreadStateException异常。


这是为什么呢?我们跟进start源码中一探究竟!


【源码解析1】


// 使用synchronized关键字保证这个方法是线程安全的
public synchronized void start() {
// threadStatus != 0 表示这个线程已经被启动过或已经结束了
// 如果试图再次启动这个线程,就会抛出IllegalThreadStateException异常
if (threadStatus != 0)
throw new IllegalThreadStateException();

// 将这个线程添加到当前线程的线程组中
group.add(this);

// 声明一个变量,用于记录线程是否启动成功
boolean started = false;
try {
// 使用native方法启动这个线程
start0();
// 如果没有抛出异常,那么started被设为true,表示线程启动成功
started = true;
} finally {
// 在finally语句块中,无论try语句块中的代码是否抛出异常,都会执行
try {
// 如果线程没有启动成功,就从线程组中移除这个线程
if (!started) {
group.threadStartFailed(this);
}
} catch (Throwable ignore) {
// 如果在移除线程的过程中发生了异常,我们选择忽略这个异常
}
}
}

这里有个threadStatus,若它不等于0表示线程已经启动或结束,直接抛IllegalThreadStateException异常,我们在start源码中打上断点,从第一次start中跟入进去,发现此时没有报异常。


new线程.png
此时的threadStatus=0,线程状态为NEW,断点继续向下走时,走到native方法start0()时,threadStatus=5,线程状态为RUNNABLE。此时,我们从第二个start中进入断点。


runnable线程.png
这时threadStatus=5,满足不等于0条件,抛出IllegalThreadStateException异常!


TERMINATED的线程调用start


终止状态下的线程,情况和RUNNABLE类似!


【代码示例3】


public class Test {
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {});
thread.start();
Thread.sleep(1000);
System.out.println(thread.getName()+":"+thread.getState());
thread.start();
System.out.println(thread.getName()+":"+thread.getState());
}
}

输出:


Thread-0:TERMINATED
Exception in thread "main" java.lang.IllegalThreadStateException
at java.lang.Thread.start(Thread.java:708)
at com.javabuild.server.pojo.Test.main(Test.java:17)

这时同样也满足不等于0条件,抛出IllegalThreadStateException异常!


我们其实可以跟入到state的源码中,看一看线程几种状态设定的逻辑。


【源码解析2】


// Thread.getState方法源码:
public State getState() {
// get current thread state
return sun.misc.VM.toThreadState(threadStatus);
}

// sun.misc.VM 源码:
// 如果线程的状态值和4做位与操作结果不为0,线程处于RUNNABLE状态。
// 如果线程的状态值和1024做位与操作结果不为0,线程处于BLOCKED状态。
// 如果线程的状态值和16做位与操作结果不为0,线程处于WAITING状态。
// 如果线程的状态值和32做位与操作结果不为0,线程处于TIMED_WAITING状态。
// 如果线程的状态值和2做位与操作结果不为0,线程处于TERMINATED状态。
// 最后,如果线程的状态值和1做位与操作结果为0,线程处于NEW状态,否则线程处于RUNNABLE状态。
public static State toThreadState(int var0) {
if ((var0 & 4) != 0) {
return State.RUNNABLE;
} else if ((var0 & 1024) != 0) {
return State.BLOCKED;
} else if ((var0 & 16) != 0) {
return State.WAITING;
} else if ((var0 & 32) != 0) {
return State.TIMED_WAITING;
} else if ((var0 & 2) != 0) {
return State.TERMINATED;
} else {
return (var0 & 1) == 0 ? State.NEW : State.RUNNABLE;
}
}

总结


OK,今天就讲这么多啦,其实现在回头看看,这仅是一个简单且微小的细节而已,但对于刚准备步入职场的我来说,却是一个难题,今天写出来,除了和大家分享一下Java线程中的小细节外,更多的是希望正在准备面试的小伙伴们,能够心细,多看源码,多问自己为什么?并去追寻答案,Java开发不可浅尝辄止。




作者:JavaBuild
来源:juejin.cn/post/7345071481375932451
收起阅读 »

Redis 大 key 问题一文通

1. 背景 最近对接了一个卧龙同事的接口,因为接口比较慢,所以打算对第三方接口加个缓存。但是会有大 key 的问题。设计过程中调研了一些解决方案,这里总结下。 关键字:Redis;大Key问题; 2. 大 key 会带来什么问题 我们都知道,redis 是单线...
继续阅读 »

1. 背景


最近对接了一个卧龙同事的接口,因为接口比较慢,所以打算对第三方接口加个缓存。但是会有大 key 的问题。设计过程中调研了一些解决方案,这里总结下。
关键字:Redis;大Key问题;


2. 大 key 会带来什么问题


我们都知道,redis 是单线程架构,日常的读写操作都是由一个线程完成。一旦某一个线程执行了大 key 的读写,就会影响之后所有命令的执行,进而影响 redis 实例甚至整个 redis 集群的稳定。


3. 什么才叫大 key


那么什么才叫大 key?普遍认同的规范是:



  1. value > 10kb,即认定为大 key

  2. 像list,set,hash 等容器类型的 redis key,元素数量 > 5000,即认定为大 key


现在我们知道了大 key 会来带什么问题,也知道了什么样的 key 才算大key。接下来我们看看都有哪些解决方案。


4. 解决方案一:压缩


适用于字符串类型的 redis key。采用压缩算法,将 key 压缩至可接受的范围内。压缩也是有讲究的,首先要选择无损的压缩算法,然后在压缩速率和压缩率之间也要权衡。比较常用的压缩算法/工具如下:



  • google snappy:无损压缩,追求压缩速度而不是压缩率(Compression rate)

  • message pack:无损压缩,仅适用于 json 字符串的压缩,可以得到一个更小的 JSON,官网是:msgpack.org/


5. 解决方案二:value 切片


适用于 list,set,hash 等容器类型的 redis key。规范要求容器的元素数量 < 5000,我们可以在写 redis 的时候做个逻辑,如果超过了 5000 的容器就做切片。


举个例子,现在有一个 list 类型的缓存 ,他包含 12000 个元素。是很典型的大key。
image.png
我们以 5000 为阈值,把 list 切分成三份:user_list_slice_1、user_list_slice_2、user_list_slice_3,另外还需要一个存储切片具体情况的key,所以还需要一个 user_list_ctl。
业务程序后续访问这个缓存的时候,先请求 user_list_ctl,解析出缓存的切分情况,再去请求具体的切片即可。


6. 解决方案三:抛弃大 key(discard)


大多数场景,我们是把 redis 当缓存用,缓存失效了就走数据库查出数据。我们可以设定一个阈值,如果缓存对象特别大的话,我们就抛弃这个key,不缓存,直接走数据库。这样不会影响 redis 正常的运作。


image.png


当然,这是个取巧的方案,灵感是来自线程池的拒绝策略(DiscardPolicy)。采用这个方案得确认直接抛弃不会影响业务,还需要确保不走缓存后的性能业务上能够接受。



7. 俯瞰一下,从架构的角度解决这个问题


千叮咛万嘱咐,大 key 问题造成的线上事故仍然没有断过,这个怎么解决?
我觉得有如下几个思路



  • 完善监控机制,有大 key 出现就及时告警

  • 封禁/限流能力,能够及时封禁大 key 的访问,降低业务影响(保命用)

  • 在服务和 redis 集群之间建设 proxy 层,在 proxy 做大 key 的处理(压缩或者切片处理),让业务开发无需感知大key。


8. 总结


总结一下,解决 redis 的大 key,我们常规有三种解决方案。一是压缩,而是切片,三是直接抛弃不缓存。


作者:小黑233
来源:juejin.cn/post/7261254961923768380
收起阅读 »

工作思考|研发环境好好的,怎么上线就出问题了?

场景再现 那是一个夜黑风高的晚上,某个版本迭代经过了完备的测试,正准备上线。研发同事A开完了上线评审后,信心满满地对运维同事B说:“开冲!” 几分钟后,同事B发了条消息过来,看着抖动的头像,同事A心想:小B效率真高啊,这么快!点开消息一看【启动报错了,你看一下...
继续阅读 »

场景再现


那是一个夜黑风高的晚上,某个版本迭代经过了完备的测试,正准备上线。研发同事A开完了上线评审后,信心满满地对运维同事B说:“开冲!”


几分钟后,同事B发了条消息过来,看着抖动的头像,同事A心想:小B效率真高啊,这么快!点开消息一看【启动报错了,你看一下】。


什么?启动还能报错,不可能啊,我研测环境都好好的。


小A火急火忙地连上堡垒机,看了下日志,报错信息大致为 【表tb_xxx没有找到】。


“怎么可能,我用了伟大的flyway,怎么可能会没有表呢?”小A如是说道。



提到flyway,这里简单介绍一下。Flyway是一款开源的数据库版本管理工具,可以实现管理并跟踪数据库变更,支持数据库版本自动升级,而且不需要复杂的配置,能够帮助团队更加方便、合理的管理数据库变更。只需要引入相应依赖,添加配置,再在resource目录下创建db/migration/xxxx.sql文件,在sql文件中写入用到的建表语句,插入语句即可。



不管怎么说,代码是不会骗人的。先找下是哪里出了问题!


小A很快就定位到了代码位置,是一个用于缓存的HashMap,这操作也没什么问题,相信大家都这么用过,对于一些一次查找,到处使用,还亘古不变的表信息,可以先查一次,把它用Map缓存起来,以便后续使用。


但是研发同事C把这段代码放在了afterPropertiesSet()​方法内部,没错,就是那个InitializingBean​接口的方法。看到这里,相信各位熟练背诵Bean生命周期的Java Boy已经明白了!查询数据库的操作在Bean还没有创建完成的时候就进行了!而此时,flyway脚本还没有执行,自然就找不到对应的表信息了。


那怎么办呢?


解决方法


解决方法很简单,sql执行的时候找不到表,那就让它在表创建完之后再执行!


1.CommandLineRunner接口


一个方法就是我们常用的CommandLineRunner​接口,重写run()​方法,把缓存逻辑移到run()​方法中。原因是run()方法的执行时机是在SpringBoot应用程序启动之后,此时flyway已经执行完毕,表结构已经存在,就没问题了!


2.@DependsOn注解


通过代码分析,flyway的加载是由flywayInitializer​这个Bean负责的。所以只需要我们的Bean在它之后加载就行了,这就用上了@DependsOn​注解。



@DependsOn注解可以定义在类和方法上,意思是我这个Bean要依赖于另一个Bean,也就是说被依赖的组件会比该组件先加载注册到IOC容器中。



也就是在我们的Bean上加上这么个注解@DependsOn("flywayInitializer")


总结


此次线上问题复习了Bean的生命周期,复习了InitializingBeanCommandLineRunner​两个接口,复习了@DependsOn​注解。


作者:钱思惘
来源:juejin.cn/post/7349750846898913332
收起阅读 »

正则表达式太难写?试试这个可视化工具

在工作中有没有觉得写正则表达式很难,我就一直很头疼。今天我们就介绍一个开源项目,它可以用可视化的方式查看、编辑和测试正则表达式,大大的提升效率,它就是:regex-vis regex-vis是什么 regex-vis是一个辅助学习、编写和验证正则的工具,你输入...
继续阅读 »

在工作中有没有觉得写正则表达式很难,我就一直很头疼。今天我们就介绍一个开源项目,它可以用可视化的方式查看、编辑和测试正则表达式,大大的提升效率,它就是:regex-vis


regex-vis是什么


regex-vis是一个辅助学习、编写和验证正则的工具,你输入一个正则表达式后,会生成它的可视化图形。然后可以点选或框选图形中的单个或多个节点,再在右侧操作面板对其进行操作,具体操作取决于节点的类型,比如在其右侧插入空节点、为节点编组、为节点增加量词等。



安装regex-vis


首先regex-vis提供了一个在线环境,可以直接到regex-vis.com/ 去试用,这是最简单的方式。



当然,作为一个开源项目,另外一种方式就是自己运行啦。按以下步骤:



  • 首先下载代码到本地。

  • 安装依赖:pnpm install

  • 安装完成后运行服务:pnpm start



启动完成后到3000端口访问即可。


这里可能会遇到一些小问题,比如SSL的问题,稍微修改一些运行命令的配置即可解决。


使用 regex-vis


首先我准备一个例子的正则表达式,验证身-份-证的正则:


^[1-9]\d{5}(18|19|([23]\d))\d{2}((0[1-9])|(10|11|12))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$

可视化


直接把正则表达式贴进去就能看到可视化的效果了。



编辑


在右侧是正则表达式的编辑区,可以在这里修改、编辑正则的内容。



首先是一些图例,点击图形中想要编辑的部分,然后点击中间的编辑就可以进入到编辑页面了。



测试


修改完了正则的内容,想要验证一下写的对不对,那就到测试里去试一试吧。通过的会显示绿色,失败的则显示为红色。



示例


项目还自带了几个示例,如果你 刚一进来,不知道用什么来试用,可以直接打开示例来看看。



设置


本身是个小工具,这里有2个可用的设置,一个是切换语言,可以切成中文显示,另一个就是明/暗显示模式的转换。



总结


作为一个小工具还是挺不错的,对于像我这样不熟练正则的人有所帮助,一步步的编辑可以渐进式的编辑。


当然现在写正则最好的方式是让AI帮忙写,所以我建议可以AI帮忙写,然后通过这个工具来检查一下,通过可是的方式检查和AI的沟通有没有错误。


另外测试正则的功能里,不能显示出事那部分 正则出错略有可惜,如果能增强就更好用了。


项目信息



作者:IT咖啡馆
来源:juejin.cn/post/7350683679297290294
收起阅读 »

趣解适配器模式之《买了苹果笔记本的尴尬》

〇、小故事 小王考上了理想的大学,为了更好的迎接大学生活,他决定买一台苹果的笔记本电脑犒赏自己。 电脑很快买好了,用起来也非常的流畅,但是,当他想要插U盘传资料的时候,尴尬的事情来了,这台电脑两侧的插口非常少,只有1个耳机插孔和2个雷电插孔,根本没有USB插...
继续阅读 »

〇、小故事


小王考上了理想的大学,为了更好的迎接大学生活,他决定买一台苹果的笔记本电脑犒赏自己。



电脑很快买好了,用起来也非常的流畅,但是,当他想要插U盘传资料的时候,尴尬的事情来了,这台电脑两侧的插口非常少,只有1个耳机插孔2个雷电插孔根本没有USB插口!这咋办呀?



他赶快咨询了他的哥哥,他哥哥告诉他,去买一个扩展坞就可以了,然后他上网一看,原来买一个扩展坞之后,无论是U盘还是连接显示器的HDMI都可以连接啦!!他开心极了,本来要遗憾退掉这台心爱的苹果笔记本电脑,这回也不用退啦!



以上这个小故事,相信很多使用过苹果笔记本的同学们都会遇到,大多也都会购买这种扩展坞,那么,这种扩展坞其实就是适配器模式的一个具体实现例子了。那么,言归正传,我们来正式了解一下这个设计模式——适配器模式


一、模式定义


适配器模式定义:



该模式将一个类的接口,转换成客户期望的另一个接口。适配器模式让原本接口不兼容的类可以合作无间。



为了进一步加深该模式的理解,我们再举一个研发过程中会遇到的例子:



此时我们维护了一个员工管理系统,然后接入我们系统的第三方系统,我们都要求对方遵守我们的接口规范去开发,比如:提供方法名为queryAllUser()的方法等等。但是,这次接入的系统已经有类似功能了,他们不希望因为两个系统的接入而重新开发新的接口,那么这对这种情况,我们就可以采用适配器模式,将接口做中间层的适配转换。



如图下图所示:



二、模式类图


通过上面的介绍,相信大家对适配器模式也有了一定的了解了。那么,下面我们就来看一下如果要实现适配器模式,我们的类图应该是怎么样的。


首先,我们要说明两个重要的概念:AdapterAdaptee,其含义分别是适配器待适配的类。我们就是通过实现Target接口创建Adapter类,然后在具体的方法内部来通过调用Adaptee方法来实现具体的业务逻辑。具体类图如下所示:



三、代码实现


首先创建目标类接口——Target


public interface Target {
void prepare();
void execute();
}

实现Target接口,创建具体实现类——NormalTarget


public class NormalTarget implements Target {
public void prepare() {
System.out.println("NormalTarget prepare()");
}
public void execute() {
System.out.println("NormalTarget execute()");
}
}

创建待适配的类Adaptee,用于后续适配器对其进行适配工作:


public class Adaptee {
public void prepare1() {
System.out.println("Adaptee prepare1()");
}
public void prepare2() {
System.out.println("Adaptee prepare2()");
}
public void prepare3() {
System.out.println("Adaptee prepare3()");
}
public void doingSomething() {
System.out.println("Adaptee doingSomething()");
}
}

创建适配器Adapter,由于要适配目标对象Target,所以需要实现Target接口:


public class Adapter implements Target {
// 待适配的类
private Adaptee adaptee;

public Adapter(Adaptee adaptee) {
this.adaptee = adaptee;
}

public void prepare() {
adaptee.prepare1();
adaptee.prepare2();
adaptee.prepare3();
}

public void execute() {
adaptee.doingSomething();
}
}

创建客户端Client,用于操作Target目标对象执行某些业务逻辑:


public class Client {
Target target;
public void work() {
target.prepare();
target.execute();
}
public void setTarget(Target target) {
this.target = target;
}
}

创建测试类AdapterTest,使得Client操作NormalTarget和Adaptee:


public class AdapterTest {
public static void main(String[] args) {
Client client = new Client();

System.out.println("------------NormalTarget------------");
client.setTarget(new NormalTarget());
client.work();

System.out.println("------------Adaptee------------");
client.setTarget(new Adapter(new Adaptee())); // 适配器转换
client.work();
}
}

通过输出结果我们可以看到,适配器运行正常:


------------NormalTarget------------
NormalTarget prepare()
NormalTarget execute()
------------Adaptee------------
Adaptee prepare1()
Adaptee prepare2()
Adaptee prepare3()
Adaptee doingSomething()

今天的文章内容就这些了:



写作不易,笔者几个小时甚至数天完成的一篇文章,只愿换来您几秒钟的 点赞 & 分享



更多技术干货,欢迎大家关注公众号“爪哇缪斯” ~ \(^o^)/ ~ 「干货分享,每天更新」


作者:爪哇缪斯
来源:juejin.cn/post/7273125596951298060
收起阅读 »

腾讯女后端设计了一套短链系统,当场就想给她offer!

你好,我是猿java 如上图,对于这种客评短信,相信大家并不陌生,通过点击短信里“蓝色字体”,就能跳转到一个网页。其实,背后的秘密就是一套完整的短链系统,今天我们就来看看字节的后端女生是如何设计的? 上图中那串蓝色字符,有个专业的术语叫做“短链”,它可以是一...
继续阅读 »

你好,我是猿java


image.png


如上图,对于这种客评短信,相信大家并不陌生,通过点击短信里“蓝色字体”,就能跳转到一个网页。其实,背后的秘密就是一套完整的短链系统,今天我们就来看看字节的后端女生是如何设计的?


上图中那串蓝色字符,有个专业的术语叫做“短链”,它可以是一个链接地址,也可以设计成二维码。


为什么要用短链?


存在既合理,这里列举 3个主要原因。


1.相对安全


短链不容易暴露访问参数,生成方式可以完全迎合短信平台的规则,能够有效地规避关键词、域名屏蔽等风险,而原始 URL地址,很可能因为包含特殊字符被短信系统误判,导致链接无法跳转。


2.美观


对于精简的文字,似乎更符合美学观念,不太让人产生反感。


3.平台限制


短信发送平台有字数限制,在整条短信字数不变的前提下,把链接缩短,其他部分的文字描述就能增加,这样似乎更能达到该短信的实际目的(比如,营销)。


短链的组成


如下图,短链的组成通常包含两个部分:域名 + 随机码


image.png


短链的域名最好和其他业务域名分开,而且要尽量简短,可以不具备业务含义(比如:xyz.com),因为短链大部分是用于营销,可能会被三方平台屏蔽。


短链的随机码需要全局唯一,建议 10位以下。


短链跳转的原理


首先,我们先看一个短链跳转的简单例子,如下代码,定义了一个 302重定向的代码示例:


import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.servlet.view.RedirectView;

@Controller
public class RedirectController {

@GetMapping("/{shortCode}")
public RedirectView redirect(@PathVariable String shortCode) {
String destUrl = "https://yuanjava.com";
// destUrl = getDestUrlByShortCode(shortCode); //真实的业务逻辑
return new RedirectView(destUrl);
}
}

接着,在浏览器访问短链”http://127.0.0.1:8080/s2TYdWd” 后,请求会被重定向到 yuanjava.com ,下图为浏览器控制台信息:


image.png


从上图,我们看到了 302状态码并且请求被 Location到另外一个 URL,整个交互流程图如下:


image.png


是不是有一种偷梁换柱的感觉???


最后,总结下短链跳转的核心思想:


生成随机码,将随机码和目标 URL(长链)的映射关系存入数据库;


用域名+随机码生成短链,并推送给目标用户;


当用户点击短链后,请求会先到达短链系统,短链系统根据随机码查找出对应的目标 URL,接着将请求 302重定向到目标 URL(长链);


关于重定向有 301 和 302两种,如何选择?



  • 302,代表临时重定向:每次请求短链,请求都会先到达短链系统,然后重定向到目标 URL(长链),这样,方便短链系统做一些统计点击数等操作;通常采用 302

  • 301,代表永久重定向:第一次请求拿到目标长链接后,下次再次请求短链,请求不会到达短链系统,而是直接跳转到浏览器缓存的目标 URL(长链),短链系统只能统计到第一次访问的数据;一般不采用 301。


如何生成短链?


从短链组成章节可以知道短链=域名+随机码,随意如何生成短链的问题转换成了如何生成一个随机码,而且这个随机码需要全局唯一。通常会有 3种做法:


Base62


Base62 表示法是一种基数为62的数制系统,包含26个英文大写字母(A-Z),26个英文小写字母(a-z)和10个数字(0-9)。这样,共有62个字符可以用来表示数值。 如下代码:


import java.security.SecureRandom;

public class RandomCodeGenerator {
private static final String CHAR_62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
private static final SecureRandom random = new SecureRandom();

public static String generateRandomCode(int length) {
StringBuilder sb = new StringBuilder(length);
for (int i = 0; i < length; i++) {
int rndCharAt = random.nextInt(CHAR_62.length());
char rndChar = CHAR_62.charAt(rndCharAt);
sb.append(rndChar);
}
return sb.toString();
}
}

对于 Base62算法,如果是生成 6位随机数有 62^6 - 1 = 56800235583, 568亿多,如果是生成 7位随机数有 62^7 - 1 = 3521614606208,合计3.5万亿多,足够使用。


Hash算法


Hash算法算法是我们最容易想到的办法,比如 MD5, SHA-1, SHA-256, MurmurHash, 但是这种算法生成的 Hash算法值还是比较长,常用的做法是把这个 Hash算法值进行 62/64进行压缩。


如下代码,通过 Google的 MurmurHash算法把长链 Hash成一个 32位的 10进制正数,然后再转换成62进制(压缩),这样就可以得到一个 6位随机数,


import com.google.common.hash.HashFunction;
import com.google.common.hash.Hashing;
import java.nio.charset.StandardCharsets;

public class MurmurHashToBase62 {

private static final String BASE62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
public static String toBase62(int value) {
StringBuilder sb = new StringBuilder();
while (value > 0) {
sb.insert(0, BASE62.charAt(value % 62));
value /= 62;
}
return sb.toString();
}
public static void main(String[] args) {
// 长链
String input = "https://yuanjava.cnposts/short-link-system/design?code=xsd&page=1";
// 长链利用 MurmurHash算法生成 32位 10进制数
HashFunction hashFunction = Hashing.murmur3_32();
int hash = hashFunction.hashString(input, StandardCharsets.UTF_8).asInt();
if (hash < 0) {
hash = hash & 0x7fffffff; // Convert to positive by dropping the sign bit
}
// 将 32位 10进制数 转换成 62进制
String base62Hash = toBase62(hash);
System.out.println("base62Hash:" + base62Hash);
}
}

全局唯一 ID


比如,很多大中型公司都会有自己全局唯一 ID 的生成服务器,可以使用这些服务器生成的 ID来保证全局唯一,也可以使用雪花算法生成全局唯一的ID,再经过 62/64进制压缩。


如何解决冲突


对于上述3种方法的前 2种:base62 或者 hash,因为都是哈希函数,所以,不可避免地会产生哈希冲突(尽管概率很低),该怎么解决呢?


要解决冲突,首先要检测冲突,通常来说有 3种检测方法。


数据库索


如下,这里以 MySQL数据库为例(也可以保存在 Redis中),表结构如下:


CREATE TABLE `short_url_map` (   
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`long_url` varchar(160) DEFAULT NULL COMMENT '长链',
`short_url` varchar(10) DEFAULT NULL COMMENT '短链',
`gmt_create` int(11) DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE INDEX 'short_url' ('short_url')
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

首先创建一张长链和短链的关系映射表,然后通过给 short_url字段添加唯一锁,这样,当数据插入时,如果存在 Hash冲突(short_url值相等),数据库就会抛错,插入失败,因此,可以在业务代码里捕获对应的错误,这样就能检测出冲突。


也可以先用 short_url去查询,如果能查到数据,说明 short_url存在 Hash冲突了。


对于这种通过查询数据库或者依赖于数据库唯一锁的机制,因为都涉及DB操作,所以对数据库是一个开销,如果流量比较大的话,需要保证数据库的性能。


布隆过滤器过滤器


在 DB操作的上游增加一个布隆过滤器,在长链生成短链后, 先用短链在布隆过滤器中进行查找,如果存在就代表冲突了,如果不存在,说明 DB里不存在此短链,可以插入。 对于布隆过滤器的选择,单机可以采用 Google的布隆过滤器,分布式可以使用 RedisBloom。


整体流程可以抽象成下图:


image.png


检测出了冲突,需要如何解决冲突?


再 Hash,可以在长链后面拼接一个 UUID之类的随机字符串,然后再次进行 Hash,用得出的新值再进行上述检测,这样 Hash冲突的概率又大大大的降低了。


高并发场景


在流量不大的情况,上述方法怎么折腾似乎都没有问题,但是,为了架构的健壮性,很多时候需要考虑高并发,大流量的场景,因此架构需要支持水平扩展,比如:



  • 采用微服务

  • 功能模块分离,比如,短链生成服务和长链查询服务分离

  • 功能模块需要支持水平扩容,比如:短链生成服务和长链查询服务能支持动态扩容

  • 缓解数据库压力,比如,分区,分库分表,主从,读写分离等机制

  • 服务的限流,自保机制

  • 完善的监控和预警机制


这里给出一套比较完整的设计思路图:


image.png


总结


本文通过一个客服评价的短信开始,分析了短链的构成,短链跳转的原理,同时也给出了业内的一些实现算法,以及一些架构上的建议。


对于业务体量小的公司,可以根据成本来搭建服务(单机或者少量服务器做负载),对于业务体量比较大的公司,更多需要考虑到高并发的场景,如何保证服务的稳定性,如何支持水平扩展,当服务出现问题时如何具备一套完善的监控和预警服务器。


其实,很多系统都是在一次又一次的业务流量挑战下成长起来的,我们需要不断打磨自己宏观看架构,微观看代码的能力,这样自己也就跟着业务,系统一起成长起来了。


作者:猿java
来源:juejin.cn/post/7350585600858898484
收起阅读 »

Mysql中Varchar(50)和varchar(500)区别是什么?

Mysql中Varchar(50)和varchar(500)区别是什么? 一. 问题描述 我们在设计表结构的时候,设计规范里面有一条如下规则: 对于可变长度的字段,在满足条件的前提下,尽可能使用较短的变长字段长度。 为什么这么规定,我在网上查了一下,主要基...
继续阅读 »

Mysql中Varchar(50)和varchar(500)区别是什么?


一. 问题描述


我们在设计表结构的时候,设计规范里面有一条如下规则:



  • 对于可变长度的字段,在满足条件的前提下,尽可能使用较短的变长字段长度。


为什么这么规定,我在网上查了一下,主要基于两个方面



  • 基于存储空间的考虑

  • 基于性能的考虑


网上说Varchar(50)和varchar(500)存储空间上是一样的,真的是这样吗?


基于性能考虑,是因为过长的字段会影响到查询性能?


本文我将带着这两个问题探讨验证一下


二.验证存储空间区别


1.准备两张表


CREATE TABLE `category_info_varchar_50` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` varchar(50) NOT NULL COMMENT '分类名称',
`is_show` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否展示:0 禁用,1启用',
`sort` int(11) NOT NULL DEFAULT '0' COMMENT '序号',
`deleted` tinyint(1) DEFAULT '0' COMMENT '是否删除',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_name` (`name`) USING BTREE COMMENT '名称索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分类';

CREATE TABLE `category_info_varchar_500` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` varchar(500) NOT NULL COMMENT '分类名称',
`is_show` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否展示:0 禁用,1启用',
`sort` int(11) NOT NULL DEFAULT '0' COMMENT '序号',
`deleted` tinyint(1) DEFAULT '0' COMMENT '是否删除',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_name` (`name`) USING BTREE COMMENT '名称索引'
) ENGINE=InnoDB AUTO_INCREMENT=288135 DEFAULT CHARSET=utf8mb4 COMMENT='分类';

2.准备数据


给每张表插入相同的数据,为了凸显不同,插入100万条数据


DELIMITER $$
CREATE PROCEDURE batchInsertData(IN total INT)
BEGIN
DECLARE start_idx INT DEFAULT 1;
DECLARE end_idx INT;
DECLARE batch_size INT DEFAULT 500;
DECLARE insert_values TEXT;

SET end_idx = LEAST(total, start_idx + batch_size - 1);

WHILE start_idx <= total DO
SET insert_values = '';
WHILE start_idx <= end_idx DO
SET insert_values = CONCAT(insert_values, CONCAT('(\'name', start_idx, '\', 0, 0, 0, NOW(), NOW()),'));
SET start_idx = start_idx + 1;
END WHILE;
SET insert_values = LEFT(insert_values, LENGTH(insert_values) - 1); -- Remove the trailing comma
SET @sql = CONCAT('INSERT INTO category_info_varchar_50 (name, is_show, sort, deleted, create_time, update_time) VALUES ', insert_values, ';');

PREPARE stmt FROM @sql;
EXECUTE stmt;
SET @sql = CONCAT('INSERT INTO category_info_varchar_500 (name, is_show, sort, deleted, create_time, update_time) VALUES ', insert_values, ';');
PREPARE stmt FROM @sql;
EXECUTE stmt;

SET end_idx = LEAST(total, start_idx + batch_size - 1);
END WHILE;
END$$
DELIMITER ;

CALL batchInsertData(1000000);

3.验证存储空间


查询第一张表SQL


SELECT
table_schema AS "数据库",
table_name AS "表名",
table_rows AS "记录数",
TRUNCATE ( data_length / 1024 / 1024, 2 ) AS "数据容量(MB)",
TRUNCATE ( index_length / 1024 / 1024, 2 ) AS "索引容量(MB)"
FROM
informati0n—schema.TABLES
WHERE
table_schema = 'test_mysql_field'
and TABLE_NAME = 'category_info_varchar_50'
ORDER BY
data_length DESC,
index_length DESC;

查询结果


image.png


查询第二张表SQL


SELECT
table_schema AS "数据库",
table_name AS "表名",
table_rows AS "记录数",
TRUNCATE ( data_length / 1024 / 1024, 2 ) AS "数据容量(MB)",
TRUNCATE ( index_length / 1024 / 1024, 2 ) AS "索引容量(MB)"
FROM
informati0n—schema.TABLES
WHERE
table_schema = 'test_mysql_field'
and TABLE_NAME = 'category_info_varchar_500'
ORDER BY
data_length DESC,
index_length DESC;

查询结果


image.png


4.结论


两张表在占用空间上确实是一样的,并无差别


三.验证性能区别


1.验证索引覆盖查询


select name from category_info_varchar_50 where name = 'name100000'
-- 耗时0.012s
select name from category_info_varchar_500 where name = 'name100000'
-- 耗时0.012s
select name from category_info_varchar_50 order by name;
-- 耗时0.370s
select name from category_info_varchar_500 order by name;
-- 耗时0.379s

通过索引覆盖查询性能差别不大


1.验证索引查询


select * from category_info_varchar_50 where name = 'name100000'
--耗时 0.012s
select * from category_info_varchar_500 where name = 'name100000'
--耗时 0.012s
select * from category_info_varchar_50 where name in('name100','name1000','name100000','name10000','name1100000',
'name200','name2000','name200000','name20000','name2200000','name300','name3000','name300000','name30000','name3300000',
'name400','name4000','name400000','name40000','name4400000','name500','name5000','name500000','name50000','name5500000',
'name600','name6000','name600000','name60000','name6600000','name700','name7000','name700000','name70000','name7700000','name800',
'name8000','name800000','name80000','name6600000','name900','name9000','name900000','name90000','name9900000')
-- 耗时 0.011s -0.014s
-- 增加 order by name 耗时 0.012s - 0.015s

select * from category_info_varchar_50 where name in('name100','name1000','name100000','name10000','name1100000',
'name200','name2000','name200000','name20000','name2200000','name300','name3000','name300000','name30000','name3300000',
'name400','name4000','name400000','name40000','name4400000','name500','name5000','name500000','name50000','name5500000',
'name600','name6000','name600000','name60000','name6600000','name700','name7000','name700000','name70000','name7700000','name800',
'name8000','name800000','name80000','name6600000','name900','name9000','name900000','name90000','name9900000')
-- 耗时 0.012s -0.014s
-- 增加 order by name 耗时 0.014s - 0.017s

索引范围查询性能基本相同, 增加了order By后开始有一定性能差别;


3.验证全表查询和排序


全表无排序


image.png


image.png


全表有排序


select * from category_info_varchar_50 order by  name ;
--耗时 1.498s
select * from category_info_varchar_500 order by name ;
--耗时 4.875s

image.png
image.png


结论:


全表扫描无排序情况下,两者性能无差异,在全表有排序的情况下, 两种性能差异巨大;


分析原因


varchar50 全表执行sql分析

1711426760869.jpg
我发现86%的时花在数据传输上,接下来我们看状态部分,关注Created_tmp_files和sort_merge_passes
1711426760865.jpg


image.png
Created_tmp_files为3

sort_merge_passes为95


varchar500 全表执行sql分析

image.png


增加了临时表排序


image.png
image.png
Created_tmp_files 为 4

sort_merge_passes为645


关于sort_merge_passes, Mysql给出了如下描述:



Number of merge passes that the sort algorithm has had to do. If this value is large, you may want to increase the value of the sort_buffer_size.



其实sort_merge_passes对应的就是MySQL做归并排序的次数,也就是说,如果sort_merge_passes值比较大,说明sort_buffer和要排序的数据差距越大,我们可以通过增大sort_buffer_size或者让填入sort_buffer_size的键值对更小来缓解sort_merge_passes归并排序的次数。


四.最终结论


至此,我们不难发现,当我们最该字段进行排序或者其他聚合操作的时候,Mysql会根据该字段的设计的长度进行内存预估, 如果设计过大的可变长度, 会导致内存预估的值超出sort_buffer_size的大小, 导致mysql采用磁盘临时文件排序,最终影响查询性能;


作者:向显
来源:juejin.cn/post/7350228838151847976
收起阅读 »

聊一聊定时任务重复执行以及解决方案

大家好,我是小趴菜,关于定时任务大家都有接触过,项目中肯定也使用过,只需要在项目中的启动类上加上 @EnableScheduling 注解就可以实现了 现在是单个节点部署,倒是没什么问题。如果是多节点部署呢? 假设现在我们系统要每天给用户新增10积分,那么更新...
继续阅读 »

大家好,我是小趴菜,关于定时任务大家都有接触过,项目中肯定也使用过,只需要在项目中的启动类上加上 @EnableScheduling 注解就可以实现了


现在是单个节点部署,倒是没什么问题。如果是多节点部署呢?


假设现在我们系统要每天给用户新增10积分,那么更新的SQL如下


  update user set point = point + 10 where id = 1;

这时候你的服务部署了两台,那么每一台都会来执行这条更新语句,那么用户的积分就不是+10了。而是+20。


当然这里我们只是举了个例子来引出 @EnableScheduling 的定时任务会存在定时任务重复执行的问题。而且有可能会因为重复执行导致数据不一致的问题


使用数据库乐观锁


在使用乐观锁的时候,首先我们会新增一个字段,也就是更新日期,但是这个更新日期不是指我们修改数据的那个更新时间,比如说今天是2024-03-25,那么到了明天,第一台机器更新成功了,这个值就更新成2024-03-26,其它机器的线程来更新判断这个值是否是2024-03-25,如果不是,说明已经有线程更新了,那么就不需要再次更新了


  update user set point = point + 10,modifyTime = 2023-03-26 where id = 1 and modifyTime = 2024-03-25

基于乐观锁的方式有什么缺点呢??


现在我们只有两台服务器,那如果有1千台,1万台呢,对于同一条数据的,那么这1万台服务器都会去执行更新操作,但是其实在这1万次更新操作中,只有一次操作是成功的,其余的操作都是不需要执行的


所以使用这种方式当机器数量很多的时候,对数据库的压力是非常大的


分布式锁


我们还可以使用分布式锁的方式来实现,比如要执行这个定时任务之前要先获取一把锁,这个锁是对每一条记录都分别有对应的一把锁


当线程来更新某一条数据的时候,首先要获取这条记录的一个分布式锁,拿到锁了就可以去更新了,没有拿到锁的也不要去等待获取锁了,就直接更新下一条数据即可,同样的步骤,只有拿到某条数据的锁,才可以更新


image.png


但是这里有一个注意的点,就是比如服务-1先获取到id=100的这条记录的锁,然后执行更新,但是此时因为某些原因,导致某台服务器过了一会才来执行id=100的这条数据,因为服务-1已经执行完了,所以会释放掉这把锁,所以这台服务器来就能获取到锁,那么也会执行更新操作


所以在更新的时候,还是做一下判断,判断这条记录是否已经被更新了,如果已经更新了,那么就不要再次更新了


分布式锁相对于乐观锁来说,减少了大量的无用的更新操作,但还是会存在极少量的重复更新操作,但是相对来说,对数据库的压力就减少了很多


但是与此同时,这就依赖于Redis,要保证Redis的服务可用


消息队列


我们可以将要更新的数据提前加载到消息队列中去,然后每台服务就是一个消费者,保证一条记录只能让一个消费者消费,这样也就可以避免重复更新的问题了


但是消费失败的记录不要重回队列,可以在数据库记录,让人工进行处理


使用消息队列会有什么问题呢?


如果你的消息数据特别多,比如有上亿条,那么消息队列就会被堆满,,而且每天都要把数据库的数据都加载到消息队列中去


或许有人说,数据量大我可以多弄几个消息队列,这样确实可以解决一个消息队列堆积消息过多的问题,但是你要如何控制某些服务器只访问某个队列呢?不能每台服务都循环获取每一个队列中的消息吧


而且如果你的消息队列是单点的,那么服务宕机那么所有数据都没法更新了,这时候你还要去弄一个集群
,这成本就有点大了


所以不推荐使用这种方式


分布式任务调度-xxl-job


最后就是使用分布式定时任务调度框架 xxl-job了,关于xxl-job的使用大家可以网上自己搜一下资料。


XXL-JOB是一个开源的分布式任务调度框架,它主要用于解决大规模分布式任务的调度和执行问题。该框架提供了任务调度中心执行器任务日志等组件,支持任务的定时调度、动态添加和删除、执行情况监控和日志记录等功能。


总结


以上就是为大家提供的定时任务重复执行的解决方案,大家可以根据自己的实际情况来选择不同的方案来实现


作者:我是小趴菜
来源:juejin.cn/post/7350167062364979226
收起阅读 »

深入理解 CSS:基础概念、注释、选择器及优先级

在构建网页的过程中,我们不仅需要HTML来搭建骨架,还需要CSS来装扮我们的网页。那么,什么是CSS呢?本文将带大家了解css的基础概念,注释、选择器及优先级。一、CSS简介1.1 什么是CSSCSS,全称为Cascading Style Sheets(层叠样...
继续阅读 »

在构建网页的过程中,我们不仅需要HTML来搭建骨架,还需要CSS来装扮我们的网页。那么,什么是CSS呢?本文将带大家了解css的基础概念,注释、选择器及优先级。

一、CSS简介

1.1 什么是CSS

CSS,全称为Cascading Style Sheets(层叠样式表),是一种用于描述网页上的信息格式化和显示方式的语言。它的主要功能是控制网页的视觉表现,包括字体、颜色、布局等样式结构。

Description

通过CSS,开发者可以将文档的内容与其表现形式分离,这样不仅提高了网页的可维护性,还使得样式更加灵活和多样化。

CSS的应用非常广泛,它可以用来控制网页中几乎所有可见元素的样式,包括但不限于文本的字体、大小、颜色,元素的位置、大小、背景色,以及各种交互效果等。

CSS样式可以直接写在HTML文档中,也可以单独存储在样式单文件中,这样可以被多个页面共享使用。无论是哪种方式,样式单都包含了将样式应用到指定类型的元素的规则。

1.2 CSS 语法规范

所有的样式,都包含在

<head>
 <style>
 h4 {
 color: blue;
 font-size: 100px;
 }
 </style>
</head>

1.3 CSS 的三大特性

Css有三个非常重要的特性:层叠性、继承性、优先级。

层叠性

相同选择器给设置相同的样式,此时一个样式就会覆盖(层叠)另一个冲突的样式。层叠性主要解决样式冲突的问题。

层叠性原则:

  • 样式冲突,遵循的原则是就近原则,哪个样式离结构近,就执行哪个样式
  • 样式不冲突,不会层叠

继承性

CSS中的继承:子标签会继承父标签的某些样式,如文本颜色和字号。恰当地使用继承可以简化代码,降低 CSS 样式的复杂性子元素可以继承父元素的样式(text-,font-,line-这些元素开头的可以继承,以及color属性)。

行高的继承性:

body {
 font:12px/1.5 Microsoft YaHei;
}
  • 行高可以跟单位也可以不跟单位
  • 如果子元素没有设置行高,则会继承父元素的行高为 1.5
  • 此时子元素的行高是:当前子元素的文字大小 * 1.5
  • body 行高 1.5 这样写法最大的优势就是里面子元素可以根据自己文字大小自动调整行高

优先级

当同一个元素指定多个选择器,就会有优先级的产生。选择器相同,则执行层叠性,选择器不同,则根据选择器权重执行。

Description

  • 权重是有4组数字组成,但是不会有进位。
  • 可以理解为类选择器永远大于元素选择器, id选择器永远大于类选择器,以此类推…
  • 等级判断从左向右,如果某一位数值相同,则判断下一位数值。
  • 可以简单记忆法:通配符和继承权重为0, 标签选择器为1,类(伪类)选择器 为 10,id选择器 100, 行内样式表为1000,!important 无穷大。
  • 继承的权重是0, 如果该元素没有直接选中,不管父元素权重多高,子元素得到的权重都是 0。

权重叠加:如果是复合选择器,则会有权重叠加,需要计算权重。

1.4 Css注释的使用

在CSS中,注释是非常重要的一部分,它们可以帮助你记录代码的意图,提供有关代码功能的信息。CSS注释以/开始,以/结束,注释内容在这两个标记之间。例如:

/* 这是一个注释 */
body {
    background-color: #f0f0f0; /* 背景颜色设置为浅灰色 */
}

在上面的例子中,"/* 这是一个注释 */"是注释内容,它不会影响网页的显示效果。

二、CSS选择器

在CSS中,选择器是核心组成部分,它定义了哪些HTML元素将会被应用对应的样式规则。以下是一些常用的CSS选择器类型:

2.1 基础选择器

基础选择器是由单个选择器组成的,包括:标签选择器、类选择器、id 选择器和通配符选择器。

2.1.1 标签选择器

标签选择器(元素选择器)是指用 HTML 标签名称作为选择器,按标签名称分类,为页面中某一类标签指定统一的 CSS 样式。

标签名{
 属性1: 属性值1;
 属性2: 属性值2;
 ...
}

标签选择器可以把某一类标签全部选择出来,比如所有的 <div> 标签和所有的 <span> 标签。

Description

优点:能快速为页面中同类型的标签统一设置样式。
缺点:不能设计差异化样式,只能选择全部的当前标签。

2.1.2 类选择器

想要差异化选择不同的标签,单独选一个或者某几个标签,可以使用类选择器,类选择器在 HTML 中以 class 属性表示,在 CSS 中,类选择器以一个点“.”号显示。

.类名 {
 属性1: 属性值1;
 ...
}

在标签class 属性中可以写多个类名,多个类名中间必须用空格分开。

2.1.3 id选择器

id 选择器可以为标有特定 id 的 HTML 元素指定特定的样式。
HTML 元素以 id 属性来设置 id 选择器,CSS 中 id 选择器以“#" 来定义。

#id名 {
 属性1: 属性值1;
 ...
}

注意:id 属性只能在每个 HTML 文档中出现一次。

2.1.4 通配符选择器

在 CSS 中,通配符选择器使用“*”定义,它表示选取页面中所有元素(标签)。

* {
 属性1: 属性值1;
 ...
}

2.1.5 基础选择器小结

Description

2.2 复合选择器

常用的复合选择器包括:后代选择器、子选择器、并集选择器、伪类选择器等等。

2.2.1 后代选择器

后代选择器又称为包含选择器,可以选择父元素里面子元素。其写法就是把外层标签写在前面,内层标签写在后面,中间用空格分隔。当标签发生嵌套时,内层标签就成为外层标签的后代。

元素1 元素2 { 样式声明 }
  • 元素1 和 元素2 中间用空格隔开
  • 元素1 是父级,元素2 是子级,最终选择的是元素2
  • 元素2 可以是儿子,也可以是孙子等,只要是元素1 的后代即可
  • 元素1 和 元素2 可以是任意基础选择器

2.2.2 子选择器

子元素选择器(子选择器)只能选择作为某元素的最近一级子元素。简单理解就是选亲儿子元素。

元素1 > 元素2 { 样式声明 }
  • 元素1 和 元素2 中间用 大于号 隔开
  • 元素1 是父级,元素2 是子级,最终选择的是元素2
  • 元素2 必须是亲儿子,其孙子、重孙之类都不归他管,也可以叫他亲儿子选择器

2.2.3 并集选择器

并集选择器是各选择器通过英文逗号(,)连接而成,任何形式的选择器都可以作为并集选择器的一部分。

元素1,元素2 { 样式声明 }

2.2.4 伪类选择器

伪类选择器用于向某些选择器添加特殊的效果,比如给链接添加特殊效果,或选择第1个,第n个元素。伪类选择器书写最大的特点是用冒号(:)表示,比如 :hover 、 :first-child 。

想要快速入门前端开发吗?推荐一个前端开发基础课程,这个老师讲的特别好,零基础学习无压力,知识点结合代码,边学边练,可以免费试看试学,还有各种辅助工具和资料,非常适合新手!

点这里前往学习哦!
2.2.4.1 链接伪类选择器

为了确保生效,请按照 LVHA 的循顺序声明 :link-:visited-:hover-:active。因为 a 链接在浏览器中具有默认样式,所以我们实际工作中都需要给链接单独指定样式。

 /* a 是标签选择器 所有的链接 */ 
a {
 color: gray; 

/* :hover 是链接伪类选择器 鼠标经过 */
 a:hover {
 color: red; /* 鼠标经过的时候,由原来的 灰色 变成了红色 */
 }
2.2.4.2 :focus 伪类选择器

:focus 伪类选择器用于选取获得焦点的表单元素。焦点就是光标,一般情况 <input> 类表单元素才能获取,因此这个选择器也主要针对于表单元素来说。

input:focus {
 background-color:yellow;
}

2.2.5 复合选择器小结

Description
以上就是常用的css选择器的相关知识了,正确并灵活地运用各种选择器,可以精准地对页面中的任何元素进行样式设定。

通过这篇文章,相信你现在已经对CSS有了基础的了解,它是如何作为网页设计的基础,以及如何使用注释、选择器和优先级来精确控制你的网页样式。记住,CSS是一门艺术,也是一种科学,掌握它,你就能创造出无限可能的网页体验。

收起阅读 »

缓存把我坑惨了..

故事 春天,办公室外的世界总是让人神往的,小猫带着耳机,托着腮帮,望着外面美好的春光神游着... 一声不和谐的座机电话声打破这份本该属于小猫的宁静,“hi,小猫,线上有个客户想购买A产品规格的商品,投诉说下单总是失败,帮忙看一下啥原因。”客服部小姐姐甜美的声音...
继续阅读 »

故事


春天,办公室外的世界总是让人神往的,小猫带着耳机,托着腮帮,望着外面美好的春光神游着...


一声不和谐的座机电话声打破这份本该属于小猫的宁静,“hi,小猫,线上有个客户想购买A产品规格的商品,投诉说下单总是失败,帮忙看一下啥原因。”客服部小姐姐甜美的声音从电话那头传来。“哦哦,好,我看一下,把商品编号发一下吧......”


由于前一段时间的系统熟悉,小猫对现在的数据表模型已经了然于胸,当下就直接定位到了商品规格信息表,发现数据库中客户想购买的规格已经被下架了,但是前端的缓存好像并没有被刷新。


小猫在系统中找到了之前开发人员留的后门接口,直接curl语句重新刷新了一下接口,缓存问题搞定了。


关于商品缓存和数据库不一致的情况,其实小猫一周会遇到好几个这样的客诉,他深受DB以及缓存不一致的苦,于是他下定决心想要从根本上解决问题,而不是curl调用后门接口......


写在前面


小猫的态度其实还是相当值得肯定的,当他下定决心从根本上排查问题的时候开始,小猫其实就是一名合格而且负责的研发,这也是我们每一位软件研发人员所需要具备的处理事情的态度。


在软件系统演进的过程中,只有我们在修复历史遗留的问题的时候,才是真正意义上地对系统进行了维护,如果我们使用一些极端的手段(例如上述提到的后门接口curl语句)来保持古老而陈腐的代码继续工作的时候,这其实是一种苟且。一旦系统有了问题,我们其实就需要及时进行优化修复,否则会形成不好的示范,更多的后来者倾向于类似的方式解决问题,这也是为什么FixController存在的原因,这其实就是系统腐化的标志。


言归正传,关于缓存和DB不一致相信大家在日常开发的过程中都有遇到过,那么我们接下来就和大家好好盘一盘,缓存和DB不一致的时候,咱们是如何去解决的。接下来,大家会看到解决方案以及实战。


缓存概要


常规接口缓存读取更新


常规缓存读取


看到上面的图,我们可以清晰地知道缓存在实际场景中的工作原理。



  1. 发生请求的时候,优先读取缓存,如果命中缓存则返回结果集。

  2. 如果缓存没有命中,则回归数据库查询。

  3. 将数据库查询得到的结果集再次同步到缓存中,并且返回对应的结果集。


这是大家比较熟悉的缓存使用方式,可以有效减轻数据库压力,提升接口访问性能。但是在这样的一个架构中,会有一个问题,就是一份数据同时保存在数据库和缓存中,如果数据发生变化,需要同时更新缓存和数据库,由于更新是有先后顺序的,并且它不像数据库中多表事务操作满足ACID特性,所以这样就会出现数据一致性的问题。


DB和缓存不一致方案与实战DEMO


关于缓存和DB不一致,其实无非就是以下四种解决方案:



  1. 先更新缓存,再更新数据库

  2. 先更新数据库,再更新缓存

  3. 先删除缓存,后更新数据库

  4. 先更新数据库,后删除缓存


先更新缓存,再更新数据库(不建议)


cache02.png


这种方案其实是不提倡的,这种方案存在的问题是缓存更新成功,但是更新数据库出现异常了。这样会导致缓存数据与数据库数据完全不一致,而且很难察觉,因为缓存中的数据一直都存在。


先更新数据库,再更新缓存


先更新数据库,再更新缓存,如果缓存更新失败了,其实也会导致数据库和缓存中的数据不一致,这样客户端请求过来的可能一直就是错误的数据。


cache03.png


先删除缓存,后更新数据库


这种场景在并发量比较小的时候可能问题不大,理想情况是应用访问缓存的时候,发现缓存中的数据是空的,就会从数据库中加载并且保存到缓存中,这样数据是一致的,但是在高并发的极端情况下,由于删除缓存和更新数据库非原子行为,所以这期间就会有其他的线程对其访问。于是,如下图。


cache04.png


解释一下上图,老猫罗列了两个线程,分别是线程1和线程2。



  1. 线程1会先删除缓存中的数据,但是尚未去更新数据库。

  2. 此时线程2看到缓存中的数据是空的,就会去数据库中查询该值,并且重新更新到缓存中。

  3. 但是此时线程1并没有更新成功,或者是事务还未提交(MySQL的事务隔离级别,会导致未提交的事务数据不会被另一个线程看到),由于线程2快于线程1,所以线程2去数据库查询得到旧值。

  4. 这种情况下最终发现缓存中还是为旧值,但是数据库中却是最新的。


由此可见,这种方案其实也并不是完美的,在高并发的情况下还是会有问题。那么下面的这种总归是完美的了吧,有小伙伴肯定会这么认为,让我们一起来分析一下。


先更新数据库,后删除缓存


先说结论,其实这种方案也并不是完美的。咱们通过下图来说一个比较极端的场景。


cache05.png


上图中,我们执行的时间顺序是按照数字由小到大进行。在高并发场景下,我们说一下比较极端的场景。


上面有线程1和线程2两个线程。其中线程1是读线程,当然它也会负责将读取的结果集同步到缓存中,线程2是写线程,主要负责更新和重新同步缓存。



  1. 由于缓存失效,所以线程1开始直接查询的就是DB。

  2. 此时写线程2开始了,由于它的速度较快,所以直接完成了DB的更新和缓存的删除更新。

  3. 当线程2完成之后,线程1又重新更新了缓存,那此时缓存中被更新之后的当然是旧值了。


如此,咱们又发现了问题,又出现了数据库和缓存不一致的情况。


那么显然上面的这四种方案其实都多多少少会存在问题,那么究竟如何去保持数据库和缓存的一致性呢?


保证强一致性


如果有人问,那我们能否保证缓存和DB的强一致性呢?回答当然是肯定的,那就是针对更新数据库和刷新缓存这两个动作加上锁。当DB和缓存数据完成同步之后再去释放,一旦其中任何一个组件更新失败,我们直接逆向回滚操作。我们可能还得做快照便于其历史缓存重写。那这种设计显然代价会很大。


其实在很大一部分情况下,要求缓存和DB数据强一致大部分都是伪需求。我们可能只要达到最终尽量保持缓存一致即可。有缓存要求的大部分业务其实也是能接受数据在短期内不一致的情况。所以我们就可以使用下面的这两种最终一致性的方案。


错误重试达到最终一致


如下示意图所示:


cache06.png


上面的图中我们看到。当然上述老猫只是画了更新线程,其实读取线程也一样。



  1. 更新线程优先更新数据,然后再去更新缓存。

  2. 此时我们发现缓存更新失败了,咱们就将其重新放到消息队列中。

  3. 单独写一个消费者接收更新失败记录,然后进行重试更新操作。


说到消息队列重试,还有一种方式是基于异步任务重试,咱们可以把更新缓存失败的这个数据保存到数据库,然后通过另外的一个定时任务进而扫描待执行任务,然后去做相关的缓存更新动作。


当然上面我们提到的这两种方案,其实比较依赖我们的业务代码做出相对应的调整。我们当然也可以借助Canal组件来监控MySQL中的binlog的日志。通过数据库的 binlog 来异步淘汰 key,利用工具(canal)将 binlog日志采集发送到 MQ 中,然后通过 ACK 机制确认处理删除缓存。先更新DB,然后再去更新缓存,这种方式,被称为 Cache Aside Pattern,属于缓存更新的经典设计模式之一。


cache07.png


上述我们总结了缓存使用的一些方案,我们发现其实没有一种方案是完美的,最完美的方案其实还是得去结合具体的业务场景去使用。方案已经同步了,那么如何去撸数据库以及缓存同步的代码呢?接下来,和大家分享的当然是日常开发中比较好用的SpringCache缓存处理框架了。


SpringCache实战


SpringCache是一个框架,实现了基于注解缓存功能,只需要简单地加一个注解,就能实现缓存功能。
SpringCache提高了一层抽象,底层可以切换不同的cache实现,具体就是通过cacheManager接口来统一不同的缓存技术,cacheManager是spring提供的各种缓存技术抽象接口。


目前存在以下几种:



  • EhCacheCacheManager:将缓存的数据存储在内存中,以提高应用程序的性能。

  • GuavaCaceManager:使用Google的GuavaCache作为缓存技术。

  • RedisCacheManager:使用Redis作为缓存技术。


配置


我们日常开发中用到比较多的其实是redis作为缓存,所以咱们就可以用RedisCacheManager,做一下代码演示。咱们以springboot项目为例。


老猫这里拿看一下redisCacheManager来举例,项目开始的时候我们当忽然要在pom文件依赖的时候就肯定需要redis启用项。如下:


<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--使用注解完成缓存技术-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>

因为我们在application.yml中就需要配置redis相关的配置项:


spring:
redis:
host: localhost
port: 6379
database: 0
jedis:
pool:
max-active: 8 # 最大链接数据
max-wait: 1ms # 连接池最大阻塞等待时间
max-idle: 4 # 连接线中最大的空闲链接
min-idle: 0 # 连接池中最小空闲链接
cache:
redis:
time-to-live: 1800000

常用注解


关于SpringCache常用的注解,整理如下:


cache08.png


针对上述的注解,咱们做一下demo用法,如下:


用法简单盘点


@Slf4j
@SpringBootApplication
@ServletComponentScan
@EnableCaching
public class Application {
public static void main(String[] args) {
SpringApplication.run(ReggieApplication.class);
}
}

在service层我们注入所需要用到的cacheManager:


@Autowired
private CacheManager cacheManager;

/**
* 公众号:程序员老猫
* 我们可以通过代码的方式主动清除缓存,例如
**/

public void clearCache(String productCode) {
try {
RedisCacheManager redisCacheManager = (RedisCacheManager) cacheManager;

Cache backProductCache = redisCacheManager.getCache("backProduct");
if(backProductCache != null) {
backProductCache.evict(productCode);
}
} catch (Exception e) {
logger.error("redis 缓存清除失败", e);
}
}

接下来我们看一下每一个注解的用法,以下关于缓存用法的注解,我们都可以将其加到dao层:


第一种@Cacheable


在方法执行前spring先查看缓存中是否有数据,如果有数据,则直接返回缓存数据;若没有数据,调用方法并将方法返回值放到缓存中。


@Cacheable 注解中的核心参数有以下几个:



  • value:缓存的名称,可以是一个字符串数组,表示该方法的结果可以被缓存到哪些缓存中。默认值为一个空数组,表示缓存到默认的缓存中。

  • key:缓存的 key,可以是一个 SpEL 表达式,表示缓存的 key 可以根据方法参数动态生成。默认值为一个空字符串,表示使用默认的 key 生成策略。

  • condition:缓存的条件,可以是一个 SpEL 表达式,表示缓存的结果是否应该被缓存。默认值为一个空字符串,表示不考虑任何条件,缓存所有结果。

  • unless:缓存的排除条件,可以是一个 SpEL 表达式,表示缓存的结果是否应该被排除在缓存之外。默认值为一个空字符串,表示不排除任何结果。


上述提及的SpEL是是Spring Framework中的一种表达式语言,此处不展开,不了解的小伙伴可以自己去查阅一下相关资料。


代码使用案例:


@Cacheable(value="picUrlPrefixDO",key="#id")
public PicUrlPrefixDO selectById(Long id) {
PicUrlPrefixDO picUrlPrefixDO = writeSqlSessionTemplate.selectOne("PicUrlPrefixDao.selectById", id);
return picUrlPrefixDO;
}

第二种@CachePut


表示将方法返回的值放入缓存中。
注解的参数列表和@Cacheable的参数列表一致,代表的意思也一样。
代码使用案例:


@CachePut(value = "userCache",key = "#users.id")
@GetMapping()
public User get(User user){
User users= dishService.getById(user);
return users;
}

第三种@CacheEvict


表示从缓存中删除数据。使用案例如下:


@CacheEvict(value="picUrlPrefixDO",key="#urfPrefix")
public Integer deleteByUrlPrefix(String urfPrefix) {
return writeSqlSessionTemplate.delete("PicUrlPrefixDao.deleteByUrlPrefix", urfPrefix);
}

上述和大家分享了一下SpringCache的用法,对于上述提及的三个缓存注解中,老猫在日常开发过程中用的比较多的是@CacheEvict以及@Cacheable,如果对SpringCache实现原理感兴趣的小伙伴可以查阅一下相关的源码。


使用缓存的其他注意点


当我们使用缓存的时候,除了会遇到数据库和缓存不一致的情况之外,其实还有其他问题。严重的情况下可能还会出现缓存雪崩。关于缓存失效造成雪崩,大家可以看一下这里【糟糕!缓存击穿,商详页进不去了】。


另外如果加了缓存之后,应用程序启动或服务高峰期之前,大家一定要做好缓存预热从而避免上线后瞬时大流量造成系统不可用。关于缓存预热的解决方案,由于篇幅过长老猫在此不展开了。不过方案概要可以提供,具体如下:



  • 定时预热。采用定时任务将需要使用的数据预热到缓存中,以保证数据的热度。

  • 启动时加载预热。在应用程序启动时,将常用的数据提前加载到缓存中,例如实现InitializingBean 接口,并在 afterPropertiesSet 方法中执行缓存预热的逻辑。

  • 手动触发加载:在系统达到高峰期之前,手动触发加载常用数据到缓存中,以提高缓存命中率和系统性能。

  • 热点预热。将系统中的热点数据提前加载到缓存中,以减轻系统压力。5

  • 延迟异步预热。将需要预热的数据放入一个队列中,由后台异步任务来完成预热。

  • 增量预热。按需预热数据,而不是一次性预热所有数据。通过根据数据的访问模式和优先级逐步预热数据,以减少预热过程对系统的冲击。


如果小伙伴们还有其他的预热方式也欢迎大家留言。


总结


上述总结了关于缓存在日常使用的时候的一些方案以及坑点,当然这些也是面试官最喜欢提问的一些点。文中关于缓存的介绍老猫其实并没有说完,很多其实还是需要小伙伴们自己去抽时间研究研究。不得不说缓存是一门以空间换时间的艺术。要想使用好缓存,死记硬背策略肯定是行不通的。真实的业务场景往往要复杂的多,当然解决方案也不同,老猫上面提及的这些大家可以做一个参考,遇到实际问题还是需要大家具体问题具体分析。


作者:程序员老猫
来源:juejin.cn/post/7345729950458282021
收起阅读 »

HTML表单标签详解:如何用HTML标签打造互动网页?

在互联网的世界中,表单是用户与网站进行互动的重要桥梁。无论是注册新账号、提交反馈、还是在线购物,表单都扮演着至关重要的角色。在网页中,我们需要跟用户进行交互,收集用户资料,此时就需要用到表单标签。HTML提供了一系列的表单标签,使得开发者能够轻松地创建出功能丰...
继续阅读 »

在互联网的世界中,表单是用户与网站进行互动的重要桥梁。无论是注册新账号、提交反馈、还是在线购物,表单都扮演着至关重要的角色。在网页中,我们需要跟用户进行交互,收集用户资料,此时就需要用到表单标签。

HTML提供了一系列的表单标签,使得开发者能够轻松地创建出功能丰富的表单。今天我们就来深入探讨这些标签,了解它们的作用以及如何使用它们来构建一个有效的用户界面。

一、表单的组成

在HTML中,一个完整的表单通常由表单域、表单控件(表单元素)和提示信息三个部分构成。

表单域

  • 表单域是一个包含表单元素的区域
  • 在HTML标签中,<form>标签用于定义表单域,以实现用户信息的收集和传递
  • <form>会把它范围内的表单元素信息提交给服务器

表单控件

这些是用户与表单交云的各种元素,如<input>(用于创建不同类型的输入字段)、<textarea>(用于多行文本输入)、<button>(用于提交表单或执行其他操作)、<select><option>(用于创建下拉列表)等。

提示信息

这些信息通常通过<label>标签提供,它为表单控件提供了描述性文本,有助于提高可访问性。<label>标签通常与<input>标签一起使用,并且可以通过for属性与<input>标签的id属性关联起来。

这三个部分共同构成了一个完整的HTML表单,使得用户可以输入数据,并通过点击提交按钮将这些数据发送到Web服务器进行处理。

二、表单元素

在表单域中可以定义各种表单元素,这些表单元素就是允许用户在表单中输入或者选择的内容控件。下面就来介绍HTML中常用的表单元素。

1、<form>标签:基础容器

作用:定义一个表单区域,用户可以在其中输入数据进行提交。

<form action="submit.php" method="post">

其中action属性指定了数据提交到的服务器端脚本地址,method属性定义了数据提交的方式(通常为GET或POST)。

2、<input>标签:数据输入

<input>标签是一个单标签,用于收集用户信息。允许用户输入文本、数字、密码等。

<input type="text" name="username" placeholder="请输入用户名">

type属性决定了输入类型,name属性定义了数据的键名,placeholder属性提供了输入框内的提示文本。

<input>标签的属性

Description

下面举个例子来说明:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <form>
               用户名:<input type="text" value="请输入用户名"><br> 

               密码:<input type="password"><br>

      性别:男<input type="radio" name="sex" checked="checked"><input type="radio" name="sex"><br>

               爱好:吃饭<input type="checkbox"> 睡觉<input type="checkbox"> 打豆豆<input type="checkbox"><br>

                <input type="submit" value="免费注册">
                <input type="reset" value="重新填写">
                <input type="button" value="获取短信验证码"><br>
                上传头像:<input type="file">
    </form>
</body>
</html>

Description

3、<label>标签:关联说明

它与输入字段如文本框、单选按钮、复选框等关联起来,以改善网页的可用性和可访问性。<label>标签有两种常见的用法:

1)包裹方式:

在这种用法中,<label>标签直接包裹住关联的表单元素。例如:

<label>用户名:<input type="text" name="username"></label>

这样做的好处是用户点击标签文本时,关联的输入字段会自动获取焦点,从而提供更好的用户体验。

2)使用for属性关联:

在这种用法中,<label>标签通过for属性与目标表单元素建立关联,for属性的值应与目标元素的id属性相匹配。例如:

<label for="username">用户名:</label><input type="text" id="username" name="username">

这样做的优势是单击标签时,相关的表单元素会自动选中(获取焦点),从而提高可用性和可访问性。

4、<select>和<option>标签:下拉选择

在页面中,如果有多个选项让用户选择,并且想要节约页面空间时,我们可以使用标签控件定义下拉列表。

注意点:

  • <select>中至少包含一对<option>
  • 在<option>中定义selected=“selected”时,当前项即为默认选中项
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <form>
        籍贯:
        <select>
            <option>山东</option>
            <option>北京</option>
            <option>西安</option>
            <option selected="selected">火星</option>
        </select>
    </form>
</body>
</html>

Description

5、<textarea>标签:多行文本输入

当用户输入内容较多的情况下,我们可以用表单元素标签替代文本框标签。

  • 允许用户输入多行文本。
<textarea name="message" rows="5" cols="30">默认文本</textarea>

rows和cols属性分别定义了文本区域的行数和列数。

Description

6、<button>标签:按钮控件

创建一个可点击的按钮,通常用于提交或重置表单。它允许用户放置文本或其他内联元素(如<i><b><strong><br><img>等),这使得它比普通的具有更丰富的内容和更强的功能。

<button type="submit">提交</button>

type属性为submit时表示这是一个提交按钮。

7、<fieldset>和<legend>标签:分组和标题

通常用于在HTML表单中对相关元素进行分组,并提供一个标题来描述这个组的内容。

<fieldset>标签: 该标签用于在表单中创建一组相关的表单控件。它可以将表单元素逻辑分组,并且通常在视觉上通过围绕这些元素绘制一个边框来区分不同的组。这种分组有助于提高表单的可读性和易用性。

<legend>标签: 它总是与<fieldset>标签一起使用。<legend>标签定义了<fieldset>元素的标题,这个标题通常会出现在浏览器渲染的字段集的边框上方。<legend>标签使得用户更容易理解每个分组的目的和内容。

代码示例:

<form>
  <fieldset>
    <legend>个人信息</legend>
    <label for="name">姓名:</label>
    <input type="text" id="name" name="name"><br><br>
    <label for="email">邮箱:</label>
    <input type="email" id="email" name="email"><br><br>
  </fieldset>
  <fieldset>
    <legend>兴趣爱好</legend>
    <input type="checkbox" id="hobby1" name="hobby1" value="music">
    <label for="hobby1">音乐</label><br>
    <input type="checkbox" id="hobby2" name="hobby2" value="sports">
    <label for="hobby2">运动</label><br>
    <input type="checkbox" id="hobby3" name="hobby3" value="reading">
    <label for="hobby3">阅读</label><br>
  </fieldset>  <input type="submit" value="提交">
</form>

在这个示例中,我们使用了两个<fieldset>元素来组织表单的不同部分。第一个<fieldset>包含姓名和邮箱字段,而第二个<fieldset>包含三个复选框,用于选择用户的兴趣爱好。每个<fieldset>都有一个<legend>元素,用于提供标题。这样,用户在填写表单时可以更清晰地了解每个部分的内容。

想要快速入门前端开发吗?推荐一个前端开发基础课程,这个老师讲的特别好,零基础学习无压力,知识点结合代码,边学边练,可以免费试看试学,还有各种辅助工具和资料,非常适合新手!

点这里前往学习哦!

8、<datalist>标签:预定义选项列表

<datalist>标签是HTML5中引入的一个新元素,它允许开发者为输入字段提供预定义的选项列表。当用户在输入字段中输入时,浏览器会显示一个下拉菜单,其中包含与用户输入匹配的预定义选项。

使用<datalist>标签可以提供更好的用户体验,因为它可以帮助用户选择正确的选项,而不必手动输入整个选项。此外,<datalist>还可以与<input>元素的list属性结合使用,以将预定义的选项列表与特定的输入字段关联起来。

下面是一个使用<datalist>标签的代码示例:

<form>
  <label for="color">选择你喜欢的颜色:</label>
  <input type="text" id="color" name="color" list="colorOptions">
  <datalist id="colorOptions">
    <option value="红色">
    <option value="蓝色">
    <option value="绿色">
    <option value="黄色">
    <option value="紫色">
  </datalist>
  <input type="submit" value="提交">
</form>

9、<output>标签:计算结果输出

<output>标签是HTML5中引入的一个新元素,它用于显示计算结果或输出。该标签通常与JavaScript代码结合使用,通过将计算结果赋值给<output>元素的value属性来显示结果。

<output>标签可以用于各种类型的计算和输出,例如数学运算、字符串处理、数组操作等。它可以与<input>元素一起使用,以实时更新计算结果。

下面是一个使用<output>标签的示例:

<form>
  <label for="num1">数字1:</label>
  <input type="number" id="num1" name="num1" oninput="calculate()"><br><br>
  <label for="num2">数字2:</label>
  <input type="number" id="num2" name="num2" oninput="calculate()"><br><br>
  <label for="result">结果:</label>
  <output id="result"></output>
</form>

<script>
function calculate() {
  var num1 = parseInt(document.getElementById("num1").value);
  var num2 = parseInt(document.getElementById("num2").value);
  var result = num1 + num2;  document.getElementById("result").value = result;
}
</script>

10、<progress>标签:任务进度展示

<progress>标签是HTML5中用于表示任务完成进度的一个新元素。它通过value属性和max属性来表示进度,其中value表示当前完成的值,而max定义任务的总量或最大值。

示例:

<!DOCTYPE html>
<html>
<head>
  <title>Progress Example</title>
</head>
<body>
  <h1>File Download</h1>
  <progress id="fileDownload" value="0" max="100"></progress>
  <br>
  <button onclick="startDownload()">Start Download</button>

  <script>
    function startDownload() {
      var progress = document.getElementById("fileDownload");
      for (var i = 0; i <= 100; i++) {
        setTimeout(function() {
          progress.value = i;
        }, i * 10);
      }
    }
  </script>
</body>
</html>

Description

在上面的示例中,我们创建了一个名为"fileDownload"的<progress>元素,并设置了初始值为0,最大值为100。我们还添加了一个按钮,当用户点击该按钮时,会触发名为"startDownload"的JavaScript函数。这个函数模拟了一个文件下载过程,通过循环逐步增加<progress>元素的value属性值,从而显示下载进度。

11、<meter>标签:度量衡指示器

<meter>标签在HTML中用于表示度量衡指示器,它定义了一个已知范围内的标量测量值或分数值,通常用于显示磁盘使用情况、查询结果的相关性等。例如:

<p>CPU 使用率: <meter value="0.6" min="0" max="1"></meter> 60%</p>
<p>内存使用率: <meter value="0.4" min="0" max="1"></meter> 40%</p>

在这个示例中,我们使用了两个<meter>标签来分别显示CPU和内存的使用率。value属性表示当前的测量值,min和max属性分别定义了测量范围的最小值和最大值。通过这些属性,<meter>标签能够清晰地显示出资源的使用情况。

需要注意的是,<meter>标签不应该用来表示进度条,对于进度条的表示,应该使用<progress>标签。

12、<details><summary>标签:详细信息展示

<details><summary>标签是HTML5中新增的两个元素,用于创建可折叠的详细信息区域。

<details>标签定义了一个可以展开或折叠的容器,其中包含一些额外的信息。它通常与<summary>标签一起使用,<summary>标签定义了<details>元素的标题,当用户点击该标题时,<details>元素的内容会展开或折叠。

示例:

<details>
  <summary>点击查看详细信息</summary>
  <p>这里是一些额外的信息,用户可以点击标题来展开或折叠这些信息。</p>
</details>

在这个示例中,我们使用了<details>标签来创建一个可折叠的容器,并在其中添加了一个<summary>标签作为标题。当用户点击这个标题时,容器的内容会展开或折叠。

总结:

HTML表单标签是构建动态网页的基石,它们使得用户能够与网站进行有效的交互。通过合理地使用这些标签,开发者可以创建出既美观又功能强大的表单,从而提升用户体验和网站的可用性。所以说,掌握这些标签的使用,对于前端开发者来说是至关重要的。

收起阅读 »

git 如何撤回已push的代码

在日常的开发中,我们经常使用Git来进行版本控制。有时候,我们可能会不小心将错误的代码 Push 到远程仓库,或者想要在本地回退到之前的某个版本重新开发。 或者像我一样,写了一些感觉以后很有用的优化方案push到线上,又接到了一个新的需求。但是呢,项目比较重要...
继续阅读 »



在日常的开发中,我们经常使用Git来进行版本控制。有时候,我们可能会不小心将错误的代码 Push 到远程仓库,或者想要在本地回退到之前的某个版本重新开发。


或者像我一样,写了一些感觉以后很有用的优化方案push到线上,又接到了一个新的需求。但是呢,项目比较重要,没有经过测试的方案不能轻易上线,为了承接需求只能先把push上去的优化方案先下掉。


现在我的分支是这样的,我想要在本地和远程仓库中都恢复到help文档提交的部分。


image.png

1.基础的手动操作(比较笨,不推荐)



这样的操作非常不推荐,但是如果你不了解git,确实是我们最容易理解的方式。



如果你的错误代码不是很多,那么你其实可以通过与你想要恢复到的commit进行对比,然后手动删除错误代码,然后删除不同的代码。


image.png

按住 ctrl 选择想要对比的两个commit,然后选择 Compare Versions 就能通过对比删除掉你想要删除的代码。



这个方案在代码很简单时时非常有效的,甚至还能通过删除后最新commit和想要退回的commit在Compare一下保障代码一致。


但是这个方法对于代码比较复杂的情况来说就不太好处理了,如果涉及到繁杂的配置文件,那更是让人头疼。只能通过反复的Compare Version来进行对比。


这样的手动操作显然显得有些笨拙了,对此git有一套较为优雅的操作流程,同样能解决这个问题。


2. git Revert Commit(推荐)


image.png

同样的,我第三次提交了错误代码,并且已经push到远程分支。想要撤回这部分代码,只需要右键点击错误提交记录


image.png

git自动产生一个Revert记录,然后我们会看到git自动将我第三次错误提交代码回退了,这个其实就相当于git帮我们手动回退了代码。


image.png

后续,只需要我们将本次改动push到远程,即可完成一次这次回退操作,


image.png

revert相当于自动帮我们进行版本回退操作,并且留下改动记录,非常安全。这也是评论区各位大佬非常推荐的。



但是revert还是存在一点不足,即一次仅能回退一次push。如果我们有几十次甚至上百次的记录,一次次的单击回退不仅费时费力而且还留下了每次的回退记录,我个人觉得revert在这种情况下又不太优雅。


3. 增加新分支(推荐撤回较多情况下使用)


如果真的需要回退到上百次提交之前的版本,我的建议是直接新建个分支。


在想要回到的版本处的提交记录右键,点击new branch


image.png
image.png
image.png

新建分支的操作仅仅增加了一个分支,既能保留原来的版本,又能安全回退到想要回退的版本,同时不会产生太多的回退记录。


但是此操作仍然建议慎用,因为这个操作执行多了,分支管理就又成了一大难题。



4. Reset Current Branch 到你想要恢复的commit记录(不太安全,慎用)


image.png


这个时候会跳出四个选项供你选择,我这里是选择hard


其他选项的含义仅供参考,因为我也没有一一尝试过。




  1. Soft:你之前写的不会改变,你之前暂存过的文件还在暂存。

  2. Mixed:你之前写的不会改变,你之前暂存过的文件不会暂存。

  3. Hard:文件恢复到所选提交状态,任何更改都会丢失。
    你已经提交了,然后你又在本地更改了,如果你选hard,那么提交的内容和你提交后又本地修改未提交的内容都会丢失。

  4. keep:任何本地更改都将丢失,文件将恢复到所选提交的状态,但本地更改将保持不变。
    你已经提交了,然后你又在本地更改了,如果你选keep,那么提交的内容会丢失,你提交后又本地修改未提交的内容不会丢失。



image.png

image.png


image.png


然后,之前错误提交的commit就在本地给干掉了。但是远程仓库中的提交还是原来的样子,你要把目前状态同步到远程仓库。也就是需要把那几个commit删除的操作push过去。


打开push界面,虽然没有commit需要提交,需要点击Force Push,强推过去。
image.png


需要注意的是对于一些被保护的分支,这个操作是不能进行的。需要自行查看配置,我这里因为不是master分支,所以没有保护。


image.png

可以看到,远程仓库中最新的commit只有我们的help文档。在其上的三个提交都没了。


image.png

注意:以上使用的是2023版IDEA,如果有出入的话可以考虑搜索使用git命令。


作者:DaveCui
来源:juejin.cn/post/7307066452290043958
收起阅读 »

神奇!一个命令切换测试和线上环境?

大家好,我是喜欢折腾,热爱分享的“一只韩非子”。 关注微信公众号:会编程的韩非子 添加微信号:Hanfz0712 免费加入问答群/知识交流群,一起交流技术难题与未来,让我们Geek起来! 今天跟大家分享一个小Tips,让大家能够更快的切换测试和线上环境。 1...
继续阅读 »

大家好,我是喜欢折腾,热爱分享的“一只韩非子”。

关注微信公众号:会编程的韩非子

添加微信号:Hanfz0712

免费加入问答群/知识交流群,一起交流技术难题与未来,让我们Geek起来!



今天跟大家分享一个小Tips,让大家能够更快的切换测试和线上环境。


1.前因


不知道大家会不会在开发中经常遇到需要切换测试环境和线上环境。比如本地开发完成后需要部署到测试环境查看,然后就需要在我们的主机上配置测试环境的DNS,从而使得相同的域名能够从线上指向到测试环境。


image.png


image.png
我们发现,需要点啊点啊点,真的太头痛了。估计配置玩这个,代码已经忘记写到哪一行了。


下载.jpeg


所以我们有没有更简单的方式来配置DNS呢,经过小韩一顿小脑瓜的思考,想起来那我们能不能够通过命令行代码的方式来解决这个问题呢。哎,你别说还真可以。

一般系统级的配置除了可视化操作外,会有对应的命令行代码(划重点喽)


2.上命令


又是熟悉的一顿Goole后,终于让我找到了,出来吧,My Code!


# 配置DNS
# Mac
networksetup -setdnsservers Wi-Fi x.x.x.x

# Windows
# WiFi
netsh interface ip set dns name="Wi-Fi" static x.x.x.x
# 网线 具体网线连接的名称例:本地连接、以太网...
netsh interface ip set dns name="具体网线连接的名称" static x.x.x.x

哎没错,就是上面这几个。但是!但是!但是!这他喵的也真的太长了!!!我还不如点啊点啊点!好好好把我骗进来杀是吧。


下载 (1).jpeg


别急别急,知道好兄弟记不住,所以我还有一招。那就是别名

友情提示:记忆力非常不错手速又特别快的好兄弟,可以点击左侧页面第一个大拇指和第三个小星星,然后退出群聊了。


3.什么是别名?


那么什么是别名呢?,让我们Google一波,找到你了。


image.png


啊?不对不对,搜索的姿势不对,让我们换个姿势。


image.png
这下姿势就对了,我们得到了我们的答案,原来别名就是用一个简单的命令替代完整的命令,好兄弟们有福了。


4.我要用别名!


别名需要存放在我们的配置文件中,文件的地址是:

Mac:~/.zshrc~/.bashrc。可以通过命令echo $SHELL查看默认使用的是zsh还是bash,来选择对应的配置文件。

Windows:查看文末


在Mac下我们别名的语法为:
alias 别名名称='具体的命令'

名称选择一个自己喜欢的即可,但是注意不要与已经注册的别名重复了,我们可以输入alias命令查看已经注册的别名。

所以我们最终的配置为:


# 别名配置
# 配置测试环境DNS
alias dtest='networksetup -setdnsservers Wi-Fi x.x.x.x'
# 清除测试环境DNS
alias dclear='networksetup -setdnsservers Wi-Fi empty'

然后我们输入dtest就可以进入测试环境,输入dclear就可以回到线上环境了,你也可以继续配置自己的预发环境等等。这简直太妙了。


634da736e9f24u06.gif







最后的最后,留给好兄弟们一个小作业

检索一下Windows下如何配置别名:别名的家在哪里,语法是什么


images.jpeg


作者:一只韩非子
来源:juejin.cn/post/7347161048572968975
收起阅读 »

回顾我这三年,都是泡沫

昨天,一个在掘金认识的小伙伴,进入了美团专门做 IDE 的基建组,心底真是替他高兴,这本来就是他应得的。 刚认识的时候还是一个工作一年的小毛孩,整天逮着我问各种问题,模板引擎、Babel、Electron、Jest、Rollup… 虽然没见过面,不知道他长什么...
继续阅读 »

朋友圈


昨天,一个在掘金认识的小伙伴,进入了美团专门做 IDE 的基建组,心底真是替他高兴,这本来就是他应得的。


刚认识的时候还是一个工作一年的小毛孩,整天逮着我问各种问题,模板引擎、Babel、Electron、Jest、Rollup…


虽然没见过面,不知道他长什么样,在我脑海里,他就是两样放着光,对技术充满好奇心、自我驱动力很强小伙子。


我就知道他能成,因为我多少也是这样子的,尽管我现在有些倦怠。


后来,随着工作越来越忙,博客也停更了,我们便很少联系了。


不过,后面我招人,尤其是校招生或者初级开发,我都是按照他这个范本来的。我也时常跟别人提起,我认识北京这样一个小伙子。


也有可能我们这边庙太小了,这样的小伙伴屈指可数。


平台和好奇心一样重要


大部分人智商条件不会有太多的差距,尤其是程序员这个群体,而好奇心可以让你比别人多迈出一步,经过长时间的积累就会拉开很大的差距。


而平台可以让你保持专注,与优秀的人共事,获得更多专业的经验和知识、财富,建立自己的竞争壁垒。








回到正题。


我觉得是时候阶段性地总结和回望回顾我过去这三年,却发现大部分都是泡沫。跨端、业务、质量管理、低代码、领域驱动设计... 本文话题可能会比较杂




2020 年七月,口罩第二年。我选择了跳槽,加入了一家创业公司




跨端开发的泡沫


2020 年,微信小程序已经成为国内重要的流量入口,事实也证明,我们过去几年交付的 C 端项目几乎上都是小程序。更严谨的说,应该是微信小程序,尽管很多巨头都推出了自己的小程序平台,基本上都是陪跑的。




Taro 2.x


进来后接手的第一个项目是原生小程序迁移到 Taro。


那时候,我们的愿景是“一码多端”,期望一套程序能够跑在微信小程序、支付宝小程序等小程序平台、H5、甚至是原生 App。


那时候 Taro 还是 2.x 版本,即通过语法静态编译成各端小程序的源码。


我们迁移花了不少的时间,尽管 Taro 官方提供了自动转换的工具,但是输出的结果是不可靠的,我们仍需要进行全量的回归测试,工作量非常大。 期间我也写了一个自动化代码迁移 CLI 来处理和 Lint 各种自动迁移后的不规范代码。




重构迁移只是前戏。难的让开发者写好 Taro,更难的是写出跨端的 Taro 代码。




我总结过,为什么 Taro(2.x) 这么难用:



  • 很多初级开发者不熟悉 React。在此之前技术栈基本是 Vue

  • 熟悉 React 的却不熟悉 Taro 的各种约束。

  • 即使 Taro 宣称一码多端,你还是需要了解对应平台/端的知识。 即使是小程序端,不同平台的小程序能力和行为都有较大的区别。而 Taro 本身在跨端上并没有提供较好的约束,本身 Bug 也比较多。

  • 如果你有跨端需求,你需要熟知各端的短板,以进行权衡和取舍。强调多端的一致和统一会增加很多复杂度, 对代码的健壮性也是一个比较大的考验。

  • 我们还背着历史包袱。臃肿、不规范、难以维护、全靠猜的代码。




在跨端上,外行人眼里‘一码多端’就是写好一端,其他端不用改就可以直接运行起来,那有那么简单的事情?


每个端都有自己的长板和短板:


短板效应


我们从拆分两个维度来看各端的能力:


维度




放在一个基线上看:


对比


跨端代码写不好,我们不能把锅扔给框架,它仅仅提供了一种通用的解决方案,很多事情还是得我们自己去做。




实际上要开发跨平台的程序,最好的开发路径就是对齐最短的板,这样迁移到其他端就会从而很多,当然代价就是开发者负担会很重:


路径


为了让开发者更好的掌握 Taro, 我编写了详细的 Wiki, 阐述了 React 的各种 trickTaro 如何阉割了 ReactTaro 的原理、开发调试、跨端开发应该遵循的各种规范






Taro 3.0


我们的 Taro 项目在 2020 年底正式在生产使用,而 Taro 3.0 在 2020 年 / 7 月就正式发布了,在次年 5 月,我们决定进行升级。


技术的发展就是这么快,不到 5 个月时间,Taro 2.x 就成为了技术债。


Taro 2.x 官方基本停止了新功能的更新、bug 也不修了,最后我们不得不 Fork Taro 仓库,发布在私有 npm 镜像库中。




Taro 2.x 就是带着镣铐跳舞,实在是太痛苦,我写了一篇文档来历数了它的各种‘罪行’:



  • 2.x 太多条条框框,学习成本高

  • 这是一个假的 React

  • 编译慢

  • 调试也太反人类







Taro 3.x 使用的是动态化的架构,有很多优势:


3.x 架构 和数据流


3.x 架构 和数据流



  • 动态化的架构。给未来远程动态渲染、低代码渲染、使用不同的前端框架(支持 Vue 开发)带来了可能

  • 不同端视图渲染方式差异更小,更通用,跨端兼容性更好。

  • 2.x 有非常多的条条框框,需要遵循非常多的规范才能写出兼容多端的代码。3.x 使用标准 React 进行开发,有更好的开发体验、更低的学习成本、更灵活的代码组织。

  • 可以复用 Web 开发生态。




使用类似架构的还有 Remax、Alita、Kbone, 我之前写过一篇文章实现的细节 自己写个 React 渲染器: 以 Remax 为例(用 React 写小程序)




而 Taro 不过是新增了一个中间层:BOM/DOM,这使得 Taro 不再直接耦合 React, 可以使用任意一种视图框架开发,可以使用 Vue、preact、甚至是 jQuery, 让 Web 生态的复用成为可能。




升级 3.x 我同样通过编写自动化升级脚本的形式来进行,这里记录了整个迁移的过程。








重构了再重构


我在 2B or not 2B: 多业态下的前端大泥球 讲述过我们面临的困境。


21 年底,随着后端开启全面的 DDD 重构(推翻现有的业务,重新梳理,在 DDD 的指导下重新设计和开发),我们也对 C 端进行了大规模的重构,企图摆脱历史债务,提高后续项目的交付效率




C 端架构


上图是重构后的结果,具体过程限于篇幅就不展开了:





  • 基础库:我们将所有业务无关的代码重新进行了设计和包装。

    • 组件库:符合 UI 规范的组件库,我们在这里也进行了一些平台差异的抹平

    • api: Taro API 的二次封装,抹平一些平台差异

    • utils: 工具函数库

    • rich-html、echart:富文本、图表封装

    • router:路由导航库,类型安全、支持路由拦截、支持命名导航、简化导航方法…




  • 模块化:我们升级到 Taro 3.x 之后,代码的组织不再受限于分包和小程序的约束。我们将本来单体的小程序进行了模块的拆分,即 monorepo 化。按照业务的边界和职责拆分各种 SDK

  • 方案:一些长期积累开发痛点解决方案,比如解决分包问题的静态资源提取方案、解决页面分享的跳板页方案。

  • 规范和指导实现。指导如何开发 SDK、编写跨平台/易扩展的应用等等




巨头逐鹿的小程序平台,基本上是微信小程序一家独大


跨端框架,淘汰下来,站稳脚跟的也只有 taro 和 uniapp


时至今日,我们吹嘘许久的“一码多端”实际上并没有实现;








大而全 2B 业务的泡沫


其实比一码多端更离谱的事情是“一码多业态”。


所谓一码多业态指的是一套代码适配多个行业,我在 2B or not 2B: 多业态下的前端大泥球 中已经进行了深入的探讨。


这是我过去三年经历的最大的泡沫,又称屎山历险记。不要过度追求复用,永远不要企图做一个大而全的 2B 产品






低代码的泡沫


2021 年,低代码正火,受到的资本市场的热捧。


广义的低代码就是一个大箩筐,什么都可以往里装,比如商城装修、海报绘制、智能表格、AI 生成代码、可视化搭建、审核流程编排…


很多人都在蹭热点,只要能粘上一点边的,都会包装自己是低代码,包括我们。在对外宣称我们有低代码的时候,我们并没有实际的产品。现在 AI 热潮类似,多少声称自己有大模型的企业是在裸泳呢?




我们是 2B 赛道,前期项目交付是靠人去堆的,效率低、成本高,软件的复利几乎不存在。


低代码之风吹起,我们也期望它能破解我们面临的外包难题(我们自己都在质疑这种软件交付方式和外包到底有什么区别)。


也有可能是为了追逐资本热潮,我们也规划做自己的 PaaS、aPaaS、iPaaS… 各种 “aaS”(不是 ass)。


但是我们都没做成,规划和折腾了几个月,后面不了了之,请来的大神也送回去了。




在我看来,我们那时候可能是钱多的慌。但并没有做低代码的相关条件,缺少必要的技术积累和资源。就算缩小范围,做垂直领域的低代码,我们对领域的认知和积累还是非常匮乏。




在这期间, 我做了很多调研,也单枪匹马撸了个 “前端可视化搭建平台”:


低代码平台


由于各种原因, 这个项目停止了开发。如今社区上也有若干个优秀的开源替代物,比如阿里的低代码引擎、网易云的 Tango、华为云的 TinyEngine。如果当年坚持开发下去,说不定今天也小有成就了。




不管经过这次的折腾,我越坚信,低代码目前还不具备取代专业编程的能力。我在《前端如何破解 CRUD 的循环》也阐述过相关的观点。


大型项目的规模之大、复杂度之深、迭代的周期之长,使用低代码无疑是搬石头砸自己的脚。简单预想一下后期的重构和升级就知道了。




低代码的位置


低代码是无代码和专业编码之间的中间形态,但这个中间点并不好把握。比如,如果倾向专业编码,抽象级别很低,虽然变得更加灵活,但是却丧失了易用性,最终还是会变成专业开发者的玩具。


找对场景,它就是一把利器。不要期望它能 100% 覆盖专业编码,降低预期,覆盖 10%?20%?再到 30%? 已经是一个不错的成就。


低代码真正可以提效不仅在于它的形式(可视化),更在于它的生态。以前端界面搭建为例,背后开箱即用的组件、素材、模板、应用,才是它的快捷之道。


在我看来,低代码实际上并不是一个新技术,近年来火爆,更像是为了迎合资本的炒作而稍微具象化的概念。


而今天,真正的’降本增效‘的大刀砍下来,又有多少’降本增效‘的低代码活下来了呢?








质量管理的泡沫


2021 年四月,我开始优化前端开发质量管理,设计的开发流程如下:


流程


开发环境:



  • 即时反馈:通过 IDE 或者构建程序即时对问题进行反馈。

  • 入库前检查:这里可以对变动的源代码进行统一格式化,代码规范检查、单元测试。如果检查失败则无法提交。


集成环境:



  • 服务端检查:聪明的开发者可能绕过开发环境本地检查,在集成环境我们可以利用 Gerrit + Jenkins 来执行检查。如果验证失败,该提交会被拒绝入库。

  • CodeReview:CodeReview 是最后一道防线,主要用于验证机器无法检验的设计问题。

  • 自动化部署:只有服务端检查和 CodeReview 都通过才能提交到仓库

    • 测试环境:即时部署,关闭安全检查、开启调试方便诊断问题

    • 生产环境:授权部署




生产环境:


前端应用在客户端中运行,我们通常需要通过各种手段来监控和上报应用的状态,以便更快地定位和解决客户问题。






原则一:我认为“自动化才是秩序”:


文档通常都会被束之高阁,因此单靠文档很难形成约束力。尤其在迭代频繁、人员构造不稳定的情况。规范自动化、配合有效的管理才是行之有效的解决办法。



  • 规范自动化。能够交给机器去执行的,都应该交给机器去处理, 最大程度降低开发者的心智负担、犯错率。可以分为以下几个方面:

    • 语言层面:类型检查,比如 Typescript。严格的 Typescript 可以让开发者少犯很多错误。智能提示对开发效率也有很大提升。

    • 风格层面:统一的代码格式化风格。例如 Prettier

    • 规范层面:一些代码规范、最佳实践、反模式。可以遵循社区的流行规范, 例如 JavaScript Standard

    • 架构层面:项目的组织、设计、关联、流程。可以通过脚手架、规范文档、自定义 ESLint 规则。



  • 管理和文化: 机器还是有局限性,更深层次的检查还是需要人工进行。比如单元测试、CodeReview。这往往需要管理来驱动、团队文化来支撑。这是我们后面需要走的路。






原则二:不要造轮子


我们不打算造轮子,建立自己的代码规范。社区上有很多流行的方案,它们是集体智慧的结晶,也最能体现行业的最佳实践:


社区规范


没必要自己去定义规则,因为最终它都会被废弃,我们根本没有那么多精力去维护。






实现


企业通知 Code Review


企业通知 Code Review






我们这套代码质量管理体系,主要基于以下技术来实现:



  • Jenkins: 运行代码检查、构建、通知等任务

  • Gerrit:以 Commit 为粒度的 CodeReview 工具

  • wkfe-standard: 我们自己实现渐进式代码检查 CLI






如果你想了解这方面的细节,可以查看以下文档:





我推崇的自动化就是秩序目的就是让机器来取代人对代码进行检查。然而它只是仅仅保证底线。


人工 CodeReview 的重要性不能被忽略,毕竟很多事情机器是做不了的。


为了推行 CodeReview,我们曾自上而下推行了 CCC(简洁代码认证) 运动,开发者可以提交代码让专家团队来 Code Review,一共三轮,全部通过可以获得证书,该证书可以成为绩效和晋升的加分项;除此之外还有代码规范考试…


然而,这场运动仅仅持续了几个月,随着公司组织架构的优化、这些事情就不再被重视。


不管是多么完善的规范、工作流,人才是最重要的一环,到最后其实是人的管理






DDD / 中台的泡沫


近年来,后端微服务、中台化等概念火热,DDD 也随之而起。


DDD 搜索趋势


上图的 DDD Google 趋势图,一定程度可以反映国内 DDD 热度的现实情况:



  • 在 14 年左右,微服务的概念开始被各方关注,我们可以看到这年 DDD 的搜索热度有明显的上升趋势

  • 2015 年,马某带领阿里巴巴集团的高管,去芬兰的赫尔辛基对一家名叫 supercell 的游戏公司进行商务拜访,中台之风随着而起,接下来的一两年里,DDD 的搜索热度达到了顶峰。

  • 2021 ~ 2022 年,口罩期间,很多公司业务几乎停摆,这是一个’内修‘的好时机。很多公司在这个阶段进行了业务的 DDD 重构,比较典型的代表是去哪儿业务瘦身 42%+效率提升 50% :去哪儿网业务重构 DDD 落地实践)。




上文提到,我们在 2021 年底也进行了一次轰轰烈烈的 DDD 重构战役,完全推翻现有的项目,重新梳理业务、重新设计、重新编码。


重构需要投入了大量的资源,基本公司 1 / 3 的研发资源都在里面了,这还不包括前期 DDD 的各种预研和培训成本。


在现在看来,这些举措都是非常激进的。而价值呢?现在还不’好说‘(很难量化)






DDD 落地难


其实既然开始了 DDD 重构, 就说明我们已经知道 ’怎么做 DDD‘ 了,在重构之前,我们已经有了接近一年的各种学习和铺垫,且在部分中台项目进行了实践。


但我至今还是觉得 DDD 很难落地,且不说它有较高的学习成本,就算是已落地的项目我们都很难保证它的连续性(坚持并贯彻初衷、规范、流程),烂尾的概率比较高。


为了降低开发者对 DDD 的上手门槛,我们也进行了一些探索。






低代码 + DDD?


可视化领域建模


可视化领域建模


2022 下半年,我们开始了 ’DDD 可视化建模‘ 的探索之路,如上图所示。


这个平台的核心理念和方法论来源于我们过去几年对 DDD 的实践经验,涵盖了需求资料的管理、产品愿景的说明、统一语言、业务流程图、领域模型/查询模型/领域服务的绘制(基于 CQRS),数据建模(ER)、对象结构映射(Mapper)等多种功能,覆盖了 DDD 的整个研发流程。


同时它也是一个知识管理平台,我们希望在这里聚合业务开发所需要的各种知识,包括原始需求资料、统一语言、领域知识、领域建模的结果。让项目的二开、新团队成员可以更快地入手。


最终,建模的结果通过“代码生成器”生成代码,真正实现领域驱动设计,而设计驱动编码。


很快我们会完全开源这套工具,可以关注我的后续文章。






DDD 泡沫


即使我们有’低代码‘工具 + 代码自动生成的加持,实现了领域驱动设计、设计驱动编码,结果依旧是虎头蛇尾,阻止不了 DDD 泡沫的破裂。




我也思考了很多原因,为什么我们没有’成功‘?





  • DDD 难?学习曲线高

  • 参与的人数少,DDD 受限在后端开发圈子里面,其他角色很少参与进来,违背了 DDD 的初衷

  • 重术而轻道。DDD 涵括了战略设计和战术设计,如果战略设计是’道‘、战术设计就是’术‘,大部分开发者仅仅着眼于术,具体来说他们更关注编码,思维并没有转变,传统数据建模思维根深蒂固

  • 中台的倒台,热潮的退去


扩展阅读:







一些零碎的事


过去三年还做了不少事情,限于篇幅,就不展开了:







过去三年经历时间轴:



  • 2020 年 7 月,换了公司,开始接手真正迁移中的 Taro 项目

  • 2020 年 10 月,Taro 2.x 小程序正式上线

  • 2020 年 10 月 ~ 11 月 优化代码质量管理体系,引入开发规范、Gerrit Code Review 流程

  • 2020 年 12 月 ~ 2021 年 4 月,业务开发

  • 2021 年 1 月 博客停更

  • 2021 年 5 月 Taro 3.x 升级

  • 2021 年 7 月 ~ 10 月 前端低代码平台开发

  • 2021 年 11 月 ~ 2022 年 5 月, DDD 大规模重构,C 端项目重构、国际化改造

  • 2022 年 6 月 ~ 2022 年 11 月,B 端技术升级,涉及容器化改造、微前端升级、组件库开发等

  • 2022 年 12 月~ 2023 年 4 月,可视化 DDD 开发平台开发

  • 2023 年 5 月 ~ 至今。业务开发,重新开始博客更新








总结


贝尔实验室


我们都有美好的愿望


重构了又重构,技术的债务还是高城不下


推翻了再推翻,我们竟然是为了‘复用’?


降本增效的大刀砍来


泡沫破碎,回归到了现实


潮水退去,剩下一些裸泳的人


我又走到了人生的十字路口,继续苟着,还是换个方向?


作者:荒山
来源:juejin.cn/post/7289718324857880633
收起阅读 »

一个高并发项目到落地的心酸路

前言 最近闲来没事,一直在掘金上摸鱼,看了不少高并发相关的文章,突然有感而发,想到了几年前做的一个项目,也多少和高并发有点关系。 这里我一边回忆落地细节一边和大家分享下,兴许能给大家带来点灵感。 正文 需求及背景 先来介绍下需求,首先项目是一个志愿填报系统,既...
继续阅读 »

前言


最近闲来没事,一直在掘金上摸鱼,看了不少高并发相关的文章,突然有感而发,想到了几年前做的一个项目,也多少和高并发有点关系。

这里我一边回忆落地细节一边和大家分享下,兴许能给大家带来点灵感。


正文


需求及背景


先来介绍下需求,首先项目是一个志愿填报系统,既然会扯上高并发,相信大家也能猜到大致是什么的志愿填报。

核心功能是两块,一是给考试填报志愿,二是给老师维护考生数据。

本来这个项目不是我们负责,奈何去年公司负责这个项目的组遭到了甲方严重的投诉,说很多考生用起来卡顿,甚至把没填上志愿的责任归到系统上。

甲方明确要求,如果这年再出现这种情况,公司在该省的所有项目将面临被替换的风险。

讨论来讨论去,最后公司将任务落到我们头上时,已经是几个月后的事了,到临危受命阶段,剩下不到半年时间。

虽然直属领导让我们不要有心理负担,做好了表扬,做不好锅也不是我们的,但明显感觉到得到他的压力,毕竟一个不小心就能上新闻。


分析


既然开始做了,再说那些有的没的就没用了,直接开始分析需求。

首先,业务逻辑并不算复杂,难点是在并发和数据准确性上。与客户沟通后,大致了解了并发要求后,于是梳理了下。



  1. 考生端登录接口、考生志愿信息查询接口需要4W QPS

  2. 考生保存志愿接口,需要2W TPS

  3. 报考信息查询4W QPS

  4. 老师端需要4k QPS

  5. 导入等接口没限制,可以异步处理,只要保证将全部信息更新一遍在20分钟以内即可,同时故障恢复的时间必须在20分钟以内(硬性要求)

  6. 考生端数据要求绝对精准,不能出现遗漏、错误等和考生操作不一致的数据

  7. 数据脱敏,防伪

  8. 资源是有限的,提供几台物理机

    大的要求就这么多,主要是在有限资源下需要达到如此高的并发确实需要思考思考,一般的crud根本达不到要求。


方案研讨


接下来我会从当时我们切入问题的点开始,从前期设计到项目落地整个过程的问题及思考,一步步去展示这个项目如何实现的

首先,我们没有去设计表,没有去设计接口,而是先去测试。测试什么?测试我们需要用到或可能用到的中间件是否满足需求


MySQL


首先是MySQL,单节点MySQL测试它的读和取性能,新建一张user表。

向里面并发插入数据和查询数据,得到的TPS大概在5k,QPS大概在1.2W。

查询的时候是带id查询,索引列的查询不及id查询,差距大概在1k。

insert和update存在细微并发差距,但基本可以忽略,影响更新性能目前最大的问题是索引。

如果表中带索引,将降低1k-1.5k的TPS。

目前结论是,mysql不能达到要求,能不能考虑其他架构,比如mysql主从复制,写和读分开。

测试后,还是放弃,主从复制结构会影响更新,大概下降几百,而且单写的TPS也不能达到要求。

至此结论是,mysql直接上的方案肯定是不可行的


Redis


既然MySQL直接查询和写入不满足要求,自然而然想到加入redis缓存。于是开始测试缓存,也从单节点redis开始测试。

get指令QPS达到了惊人的10w,set指令TPS也有8W,意料之中也惊喜了下,仿佛看到了曙光。

但是,redis容易丢失数据,需要考虑高可用方案


实现方案


既然redis满足要求,那么数据全从redis取,持久化仍然交给mysql,写库的时候先发消息,再异步写入数据库。

最后大体就是redis + rocketMQ + mysql的方案。看上去似乎挺简单,当时我们也这样以为 ,但是实际情况却是,我们过于天真了。

这里主要以最重要也是要求最高的保存志愿信息接口开始攻略


故障恢复

第一个想到的是,这些个节点挂了怎么办?

mysql挂了比较简单,他自己的机制就决定了他即使挂掉,重启后仍能恢复数据,这个可以不考虑。

rocketMQ一般情况下挂掉了可能会丢失数据,经过测试发现,在高并发下,确实存在丢消息的现象。

原因是它为了更加高效,默认采用的是异步落盘的模式,这里为了保证消息的绝对不丢失,修改成同步落盘模式。

然后是最关键的redis,不管哪种模式,redis在高并发下挂掉,都会存在丢失数据的风险。

数据丢失对于这个项目格外致命,优先级甚至高于并发的要求。

于是,问题难点来到了如何保证redis数据正确,讨论过后,决定开启redis事务。

保存接口的流程就变成了以下步骤:

1.redis 开启事务,更新redis数据

2.rocketMQ同步落盘

3.redis 提交事务

4.mysql异步入库

我们来看下这个接口可能存在的问题。

第一步,如果redis开始事务或更新redis数据失败,页面报错,对于数据正确性没有影响

第二步,如果rocketMQ落盘报错,那么就会有两种情况。

情况一,落盘失败,消息发送失败,好像没什么影响,直接报错就可。

情况二,如果发送消息成功,但提示发送失败(无论什么原因),这时候将导致mysql和redis数据的最终不一致。

如何处理?怎么知道是redis的有问题还是mysql的有问题?出现这种情况时,如果考生不继续操作,那么这条错误的数据必定无法被更新正确。

考虑到这个问题,我们决定引入一个时间戳字段,同时启动一个定时任务,比较mysql和redis不一致的情况,并自主修复数据。

首先,redis中记录时间戳,同时在消息中也带上这个时间戳并在入库时记录到表中。

然后,定时任务30分钟执行一次,比较redis中的时间戳是否小于mysql,如果小于,便更新redis中数据。如果大于,则不做处理。

同时,这里再做一层优化,凌晨的时候执行一个定时任务,比较redis中时间戳大于mysql中的时间戳,连续两天这条数据都存在且没有更新操作,将提示给我们手动运维。

然后是第三步,消息提交成功但是redis事务提交失败,和第二步处理结果一致,将被第二个定时任务处理。

这样看下来,即使redis崩掉,也不会丢失数据。


第一轮压测


接口实现后,当时怀着期待,信息满满的去做了压测,结果也是当头棒喝。

首先,数据准确性确实没有问题,不管突然kill掉哪个环节,都能保证数据最终一致性。

但是,TPS却只有4k不到的样子,难道是节点少了?

于是多加了几个节点,但是仍然没有什么起色。问题还是想简单了。


重新分析


经过这次压测,之后一个关键的问题被提了出来,影响接口TPS的到底是什么???

一番讨论过后,第一个结论是:一个接口的响应时间,取决于它最慢的响应时间累加,我们需要知道,这个接口到底慢在哪一步或哪几步?

于是用arthas看了看到底慢在哪里?

结果却是,最慢的竟然是redis修改数据这一步!这和测试的时候完全不一样。于是针对这一步,我们又继续深入探讨。

结论是:

redis本身是一个很优秀的中间件,并发也确实可以,选型时的测试没有问题。

问题出在IO上,我们是将考生的信息用json字符串存储到redis中的(为什么不保存成其他数据结构,因为我们提前测试过几种可用的数据结构,发现redis保存json字符串这种性能是最高的),

而考生数据虽然单条大小不算大,但是在高并发下的上行带宽却是被打满的。

于是针对这种情况,我们在保存到redis前,用gzip压缩字符串后保存到redis中。

为什么使用gzip压缩方式,因为我们的志愿信息是一个数组,很多重复的数据其实都是字段名称,gzip和其他几个压缩算法比较后,综合考虑到压缩率和性能,在当时选择了这种压缩算法。

针对超过限制的字符串,我们同时会将其拆封成多个(实际没有超过三个的)key存储。


继续压测


又一轮压测下来,效果很不错,TPS从4k来到了8k。不错不错,但是远远不够啊,目标2W,还没到它的一半。

节点不够?加了几个节点,有效果,但不多,最终过不了1W。

继续深入分析,它慢在哪?最后发现卡在了rocketMQ同步落盘上。

同步落盘效率太低?于是压测一波发现,确实如此。

因为同步落盘无论怎么走,都会卡在rocketMQ写磁盘的地方,而且因为前面已经对字符串压缩,也没有带宽问题。

问题到这突然停滞,不知道怎么处理rocketMQ这个点。

同时,另一个同事在测试查询接口时也带来了噩耗,查询接口在1W2左右的地方就上不去了,原因还是卡在带宽上,即使压缩了字符串,带宽仍被打满。

怎么办?考虑许久,最后决定采用较常规的处理方式,那就是数据分区,既然单个rocketMQ服务性能不达标,那么就水平扩展,多增加几个rocketMQ。

不同考生访问的MQ不一样,同时redis也可以数据分区,幸运的是正好redis有哈希槽的架构支持这种方式。

而剩下的问题就是如何解决考生分区的方式,开始考虑的是根据id进行求余的分区,但后来发现这种分区方式数据分布及其不均匀。

后来稍作改变,根据正件号后几位取余分区,数据分布才较为均匀。有了大体解决思路,一顿操作后继续开始压测。


一点小意外


压测之后,结果再次不如人意,TPS和QPS双双不增反降,继续通过arthas排查。

最后发现,redis哈希槽访问时会在主节点先计算key的槽位,而后再将请求转到对应的节点上访问,这个计算过程竟然让性能下降了20%-30%。

于是重新修改代码,在java内存中先计算出哈希槽位,再直接访问对应槽位的redis。如此重新压测,QPS达到了惊人的2W,TPS也有1W2左右。

不错不错,但是也只到了2W,在想上去,又有了瓶颈。

不过这次有了不少经验,马上便发现了问题所在,问题来到了nginx,仍然是一样的问题,带宽!

既然知道原因,解决起来也比较方便,我们将唯一有大带宽的物理机上放上两个节点nginx,通过vip代理出去,访问时会根据考生分区信息访问不同的地址。


压测


已经记不清第几轮压测了,不过这次的结果还算满意,主要查询接口QPS已经来到了惊人的4W,甚至个别接口来到6W甚至更高。

胜利已经在眼前,唯一的问题是,TPS上去不了,最高1W4就跑不动了。

什么原因呢?查了每台redis主要性能指标,发现并没有达到redis的性能瓶颈(上行带宽在65%,cpu使用率也只有50%左右)。

MQ呢?MQ也是一样的情况,那出问题的大概率就是java服务了。分析一波后发现,cpu基本跑到了100%,原来每个节点的最大链接数基本占满,但带宽竟然还有剩余。

静下心来继续深入探讨,连接数为什么会满了?原因是当时使用的SpringBoot的内置容器tomcat,无论如何配置,最大连接数最大同时也就支持1k多点。

那么很简单的公式就能出来,如果一次请求的响应时间在100ms,那么1000 * 1000 / 100 = 10000。

也就是说单节点最大支持的并发也就1W,而现在我们保存的接口响应时间却有300ms,那么最大并发也就是3k多,目前4个分区,看来1W4这个TPS也好像找到了出处了。

接下来就是优化接口响应时间的环节,基本是一步一步走,把能优化的都优化了一遍,最后总算把响应时间控制在了100ms以内。

那么照理来说,现在的TPS应该会来到惊人的4W才对。


再再次压测


怀着忐忑又激动的心情,再一次进入压测环节,于是,TPS竟然来到了惊人的2W5。

当时真心激动了一把,但是冷静之后却也奇怪,按正常逻辑,这里的TPS应该能达到3W6才对。

为了找到哪里还有未发现的坑(怕上线后来惊喜),我们又进一步做了分析,最后在日志上找到了些许端倪。

个别请求在链接redis时报了链接超时,存在0.01%的接口响应时间高于平均值。

于是我们将目光投向了redis连接数上,继续一轮监控,最终在业务实现上找到了答案。

一次保存志愿的接口需要执行5次redis操作,分别是获取锁、获取考生信息、获取志愿信息、修改志愿信息、删除锁,同时还有redis的事务。

而与之相比,查询接口只处理了两次操作,所以对于一次保存志愿的操作来看,单节点的redis最多支持6k多的并发。

为了验证这个观点,我们尝试将redis事务和加锁操作去掉,做对照组压测,发现并发确实如预期的一样有所提升(其实还担心一点,就是抢锁超时)。


准备收工


至此,好像项目的高并发需求都已完成,其他的就是完善完善细节即可。

于是又又又一次迎来了压测,这一次不负众望,重要的两个接口均达到了预期。

这之后便开始真正进入业务实现环节,待整个功能完成,在历时一个半月带两周的加班后,终于迎来了提测。


提测后的问题


功能提测后,第一个问题又又又出现在了redis,当高并发下突然kill掉redis其中一个节点。

因为用的是哈希槽的方式,如果挂掉一个节点,在恢复时重新算槽将非常麻烦且效率很低,如果不恢复,那么将严重影响并发。

于是经过讨论之后,决定将redis也进行手动分区,分区逻辑与MQ的一致。

但是如此做,对管理端就带来了一定影响,因为管理端是列表查询,所以管理端获取数据需要从多个节点的redis中同时获取。

于是管理端单独写了一套获取数据分区的调度逻辑。

第二个问题是管理端接口的性能问题,虽然管理端的要求没考生端高,但扛不住他是分页啊,一次查10个,而且还需要拼接各种数据。

不过有了前面的经验,很快就知道问题出在了哪里,关键还是redis的连接数上,为了降低链接数,这里采用了pipeline拼接多个指令。


上线


一切准备就绪后,就准备开始上线。说一下应用布置情况,8+4+1+2个节点的java服务,其中8个节点考生端,4个管理端,1个定时任务,2个消费者服务。

3个ng,4个考生端,1个管理端。

4个RocketMQ。

4个redis。

2个mysql服务,一主一从,一个定时任务服务。

1个ES服务。

最后顺利上线,虽然发生了个别线上问题,但总体有惊无险,

而真是反馈的并发数也远没有到达我们的系统极限,开始准备的水平扩展方案也没有用上,无数次预演过各个节点的宕机和增加分区,一般在10分钟内恢复系统,不过好在没有排上用场。


最后


整个项目做下来感觉越来越偏离面试中的高并发模式,说实在的也是无赖之举,

偏离的主要原因我认为是项目对数据准确性的要求更高,同时需要完成高并发的要求。

但是经过这个项目的洗礼,在其中也收获颇丰,懂得了去监控服务性能指标,然后也加深了中间件和各种技术的理解。

做完之后虽然累,但也很开心,毕竟在有限的资源下去分析性能瓶颈并完成项目要求后,还是挺有成就感的。

再说点题外话,虽然项目成功挽回了公司在该省的形象,也受到了总公司和领导表扬,但最后也就这样了,

实质性的东西一点没有,这也是我离开这家公司的主要原由。不过事后回想,这段经历确实让人难忘,也给我后来的工作带来了很大的帮助。

从以前的crud,变得能去解决接口性能问题。这之前一遇上,可能两眼茫然或是碰运气,现在慢慢的会根据蛛丝马迹去探究优化方案。

不知道我在这个项目的经历是否能引起大家共鸣?希望这篇文章能对你有所帮助。


作者:青鸟218
来源:juejin.cn/post/7346021356679675967
收起阅读 »

HTML常用布局标签:提升网页颜值!不可不知的HTML布局技巧全解析!

在HTML的世界里,一切都是由容器和内容构成的。容器,就如同一个个盒子,用来装载各种元素;而内容,则是这些盒子里的珍宝。理解了这一点,我们就迈出了探索HTML布局的第一步。在HTML中,布局标签主要用于控制页面的结构和样式。本文将介绍一些常用的布局标签及其使用...
继续阅读 »

在HTML的世界里,一切都是由容器和内容构成的。容器,就如同一个个盒子,用来装载各种元素;而内容,则是这些盒子里的珍宝。理解了这一点,我们就迈出了探索HTML布局的第一步。

在HTML中,布局标签主要用于控制页面的结构和样式。本文将介绍一些常用的布局标签及其使用方法,并通过代码示例进行演示。

一、理解布局的重要性

布局在我们前端开发中担任什么样的角色呢?想象一下,你面前有一堆散乱的积木,无序地堆放在那里。

Description

而你的任务,就是将这些积木按照图纸拼装成一个精美的模型。HTML布局标签的作用就像那张图纸,它指导浏览器如何正确、有序地显示内容和元素,确保网页的结构和外观既美观又实用。

下面我们就来看看在HTML中常用的基础布局标签有哪些,如何使用这些布局标签完成我们的开发目标。

二、常用的布局标签

1、div标签

div标签是一个块级元素,它独占一行,用于对页面进行区域划分。它可以包含其他HTML元素,如文本、图片、链接等。通过CSS样式可以设置div的布局和样式。

示例代码:

<!DOCTYPE html>
<html>
<head>
<style>
  .box {
    width: 200px;
    height: 200px;
    background-color: red;
  }
</style>
</head>
<body>

<div>这是一个div元素

</div>

</body>
</html>

运行结果:

Description

2、span标签

span标签是一个内联元素,它不独占一行,用于对文本进行区域划分。它主要用于对文本进行样式设置,如字体、颜色等。与div类似,span也可以包含其他HTML元素。
示例代码:

<!DOCTYPE html>
<html>
<head>
<style>
  .text {
    color: blue;
    font-size: 20px;
  }
</style>
</head>
<body>

<p>这是一个<span>span元素</span></p>

</body>
</html>

运行结果:

Description

3、table标签

table标签用于创建表格,它包含多个tr(行)元素,每个tr元素包含多个td(单元格)或th(表头单元格)元素。

<table> 定义一个表格,<tr> 定义表格中的行,而 <td> 则定义单元格。通过这三个标签,我们可以创建出整齐划一的数据表,让信息的展示更加直观明了。

需要注意的是:

  • <table></table>标记着表格的开始和结束。
  • <tr></tr>标记着行的开始和结束,几组表示该表格有几行。
  • <td></td>标记着单元格的开始和结束,表示这一行中有几列。

示例代码:

<!DOCTYPE html>
<html>
<head>
<style>
  table, th, td {
    border: 1px solid black;
  }
</style>
</head>
<body>
<table>
  <tr>
    <th>姓名</th>
    <th>年龄</th>
  </tr>
  <tr>
    <td>张三</td>
    <td>25</td>
  </tr>
  <tr>
    <td>李四</td>
    <td>30</td>
  </tr>
</table>
</body>
</html>

运行结果:

Description

4、form标签

<form>标签的主要作用是定义一个用于用户输入的HTML表单。这个表单可以包含各种输入元素,如文本字段、复选框、单选按钮、提交按钮等。

<form>元素可以包含以下一个或多个表单元素:<input><textarea><button><select><option><optgroup><fieldset><label><output>等。

示例代码:

<!DOCTYPE html>
<html>
<head>
<style>
  form {
    display: flex;
    flex-direction: column;
  }
</style>
</head>
<body>

<form>
  <label for="username">用户名:</label>
  <input type="text" id="username" name="username">
  <br>
  <label for="password">密码:</label>
  <input type="password" id="password" name="password">
  <br>
  <input type="submit" value="提交">
</form>

</body>
</html>

运行结果:
Description

5、列表标签

1)无序列表

  • 指没有顺序的列表项目
  • 始于<ul>标签,每个列表项始于<li>
  • type属性有三个选项:disc实心圆、circle空心圆、square小方块。 默认属性是disc实心圆。

示例代码:

<!DOCTYPE html>
<htmml>
<head>
<meta charst = "UTF-8">
<title>html--无序列表</title>
</head>
<body>
<ul>
<li>默认的无序列表</li>
<li>默认的无序列表</li>
<li>默认的无序列表</li>
</ul>
<ul>
<li type = "circle">添加circle属性</li>
<li type = "circle">添加circle属性</li>
<li type = "circle">添加circle属性</li>
</ul>
<ul>
<li type = "square">添加square属性</li>
<li type = "square">添加square属性</li>
<li type = "squaare">添加square属性</li>
</ul>
</body>
</html>

运行结果:
Description
也可以使用CSS list-style-type属性定义html无序列表样式。

想要快速入门前端开发吗?推荐一个前端开发基础课程,这个老师讲的特别好,零基础学习无压力,知识点结合代码,边学边练,可以免费试看试学,还有各种辅助工具和资料,非常适合新手!

点这里前往学习哦!

2)有序列表

  • 指按照字母或数字等顺序排列的列表项目。
  • 其结果是带有前后顺序之分的编号,如果插入和删除一个列表项,编号会自动调整。
  • 始于<ol>标签,每个列表项始于<li>

示例代码:

<ol>
<li>默认的有序列表</li>
<li>默认的有序列表</li>
<li>默认的有序列表</li>
</ol>
<ol type = "a" start = "2">
<li>第1项</li>
<li>第2项</li>
<li>第3项</li>
<li value ="20">第四项</li>
</ol>
<ol type = "Ⅰ" start = "2">
<li>第1项</li>
<li>第2项</li>
<li>第3项</li>
</ol>

运行结果:
Description
同样也可以使用CSS list-style-type属性定义html有序列表样式。

3)自定义列表

  • 自定义列表不仅仅是一列项目,而是项目及其注释的组合。
  • <dl>标签开始。每个自定义列表项以<dt>开始。每个自定义列表项的定义以<dd>开始。
  • 用于对术语或名词进行解释和描述,自定义列表的列表项前没有任何项目符号。
    基本语法:
<dl>
<dt>名词1</dt>
<dd>名词1解释1</dd>
<dd>名词1解释2</dd>

<dt>名词2</dt>
<dd>名词2解释1</dd>
<dd>名词2解释2</dd>
</dl>

<dl>即“definition list(定义列表)”,
<dt>即“definition term(定义名词)”,
<dd>即“definition description(定义描述)”。

示例代码:

<dl>
<dt>计算机</dt>
<dd>用来计算的仪器</dd>

<dt>显示器</dt>
<dd>以视觉方式显示信息的装置</dd>
</dl>

运行结果:
Description
以上就是HTML中常用的布局标签及其使用方法。在实际开发中,还可以结合CSS和JavaScript来实现更复杂的布局和交互效果。

掌握了这些HTML常用布局标签,你已经拥有了构建网页的基础工具。记住,好的布局不仅需要技术,更需要创意和对细节的关注。现在,打开你的代码编辑器,开始你的布局设计之旅吧!

收起阅读 »

从发送短信验证码来研究几种常用的防刷策略

大家好,我是小趴菜,最近在做项目的时候有个发送短信验证码的需求,这个需求在大部分的项目中相信都会使用到,而发送短信验证码是需要收费的,所以我们要保证我们的接口不能被恶意刷, 1:前端控制 前端控制是指在用户点击发送验证码之后,在一分钟之内这个按钮就置灰,让用户...
继续阅读 »

大家好,我是小趴菜,最近在做项目的时候有个发送短信验证码的需求,这个需求在大部分的项目中相信都会使用到,而发送短信验证码是需要收费的,所以我们要保证我们的接口不能被恶意刷,


1:前端控制


前端控制是指在用户点击发送验证码之后,在一分钟之内这个按钮就置灰,让用户无法再次发起,这种方式有什么优点和缺点呢?


优点



  • 1: 实现简单,直接让前端进行控制


缺点



  • 1:安全性不够,别人完全可以绕过前端的控制,直接发起调用,这种方式只能作为防刷的第一道屏障


2:redis + 过期时间


在用户发送验证码之后,将用户的手机号作为redis的KEY,value可以设置为任意值,并且将该KEY的过期时间设置为1分钟,实现流程如下:



  • 1:用户客户端发起发送验证码

  • 2:后端收到请求以后,将该用户的手机号作为KEY,VALUE设置为任意值,并且是过期时间为1分钟

  • 3:当用户下次发起发送验证码请求,后端可以根据用户手机号作为KEY,从Redis中获取,如果这个KEY不存在,说明已经过去1分钟了,可以再次发送验证码

  • 4:如果这个KEY存在,说明这个用户在一分钟内这个用户已经发送过了,就提示用户一分钟后再试


那么这种方式又有什么优点和缺点呢???


优点



  • 1:实现简单

  • 2:由后端控制,安全性比前端控制高


缺点



  • 1:首先需要依赖Redis

  • 2:一分钟后这个KEY真的能被准时删除吗????


针对第2点我们深入分析下,正常来说,一个Redis的KEY,设置了1分钟过期时间,那么在1分钟后这个KEY就会被删除,所以这种redis+过期时间在正常情况下是可以满足防刷的,但是Reids真的能帮我们准时的删除这个KEY吗?


在此我们不得不了解下Redis的删除策略了,redis有三种删除策略



  • 1:定时删除:会给这个KEY设置一个定时器,在这个KEY的过期时间到了,就会由定时器来删除这个KEY,优点是可以快速释放掉内存,缺点就是会占用CPU,如果在某个点有大量的KEY到了过期时间,那么此时系统CPU就会被沾满

  • 2:惰性删除:当这个KEY过期了,但是不会自动释放掉内存,而是当下次有客户端来访问这个KEY的时候才会被删除,这样就会存在一些无用的KEY占用着内存

  • 3:定期删除:redis会每隔一段时间,随机抽取一批的KEY,然后把其中过期的KEY删除


如果reids设置的删除策略是定期删除,那么你这个KEY即使到了过期时间也不会被删除,所以你还是可以在Redis中获取到,这个时候客户端明明已经过了一分钟了,但是你还是能拿到这个KEY,所以这时候又会被限制发送验证码了,这明显不符合业务需求了


所以一般会采用惰性删除+定期删除的方式来实现,这样,即使定期删除没有删除掉这个KEY,但是在访问的时候,会通过惰性删除来删除掉这个KEY,所以这时候客户端就访问不到这个KEY,就可以实现一分钟内再次发送验证码的请求了


但是如果你的Redis是做了读写分离的,也就是写操作是写主,查询是从,那么这时候会有什么问题呢?


我们在设置Redis的过期时间有四种命令



  • 1:expire:从当前时间算起,过了设置的时间以后就过期

  • 2:pexpire:同expire,只是过期时间的单位不一样

  • 3:expireAt:设置未来的某个时间,当系统时间到了这个点之后就过期

  • 4:pexpireAt:同expireAt,只是过期时间单位不一样


如果我们使用的是expire命令来设置时间,redis主从同步是异步的,那么在这期间一定会有时间差,当主同步到从的时候,可能已经过去十几秒都有可能,那么这时候从redis收到这个KEY以后,是从当前时间开始算起,然后过去指定的时间以后才会过期,所以这时候主redis这个KEY过期了,但是从redis这个KEY可能还有十几秒以后才会过期


这时候你查的是从Redis,所以还是可以查到这个KEY的,这时候客户端其实已经过去一分钟了,但是由于你能从Redis查到这个KEY,所以客户端还是不能发送验证码


这时候我们可以使用expireAt命令来设置,只要系统到了这个时间点,这个KEY就会被删除,但是前提是要保证主从Redis系统的时间一致,如果你从库的时间比主库晚了几分钟,那么从库这个KEY存活的时间就会比主Redis存活的时间更长,那么这样也会有问题


redis + 特殊VALUE + 过期时间


这种的业务流程如下



  • 1:用户客户端发起发送验证码

  • 2:后端收到请求以后,将该用户的手机号作为KEY,VALUE设置为当前时间戳(重点)

  • 3:当用户下次发起发送验证码请求,后端可以根据用户手机号作为KEY,从Redis中获取,如果这个KEY不存在,可以再次发送验证码

  • 4:如果这个KEY存在,获取到这个KEY的VALUE,然后判断当前时间戳跟这个KEY的时间戳是否超过1分钟了,如果超过了就可以再次发送,如果没有就不能发送了


这种方式与其它几种方式的优点在哪呢?


无论你这个KEY有没有准时被删除,删除了说明可以发送,即使因为某些原因没有被删除,那么我们也可以通过设置的VALUE的值跟当前时间戳做一个比较。所以即使出现了上面 redis + 过期时间会出现的问题,那么我们也可以做好相应的判断,如果你过去一分钟还能拿到这个KEY,并且比较时间戳也已经超过一分钟了,那么我们可以重新给这个KEY设置VALUE,并且值为当前时间戳,就不会出现以上的几种问题了。


结尾


题外话,其实KEY即使时间到期了,但是我们还是能查到这个KEY,除了之前说的几个点,还有几种情况也会出现,Redis删除KEY是需要占用CPU的,如果此时你系统的CPU已经被其它进程占满了,那么这时候Redis就无法删除这个KEY了


作者:我是小趴菜
来源:juejin.cn/post/7341300805281087514
收起阅读 »

违反这些设计原则,系统就等着“腐烂”

分享是最有效的学习方式。 老猫的设计模式专栏已经偷偷发车了。不甘愿做crud boy?看了好几遍的设计模式还记不住?那就不要刻意记了,跟上老猫的步伐,在一个个有趣的职场故事中领悟设计模式的精髓吧。还等什么?赶紧上车吧 故事 这段时间以来,小猫按照之前的系统梳...
继续阅读 »

分享是最有效的学习方式。



老猫的设计模式专栏已经偷偷发车了。不甘愿做crud boy?看了好几遍的设计模式还记不住?那就不要刻意记了,跟上老猫的步伐,在一个个有趣的职场故事中领悟设计模式的精髓吧。还等什么?赶紧上车吧


故事


这段时间以来,小猫按照之前的系统梳理方案【系统梳理大法&代码梳理大法】一直在整理着文档。


系统中涉及的业务以及模型也基本了然于胸,但是这代码写的真的是...


小猫也终于知道了为什么每天都有客诉,为什么每天都要去调用curl语句去订正生产的数据,为什么每天都在Hotfix...


整理了一下,大概出于这些原因,业务流程复杂暂且不议,光从技术角度来看,整个代码体系臃肿不堪,出问题之后定位困难,后面接手的几任开发为了解决问题都是“曲线救国”,不从正面去解决问题,为了解决一时的客诉问题而去解决问题,于是定义了各种新的修复流程去解决问题,这么一来,软件系统“无序”总量一直在增加,整个系统体系其实在初版之后就已经在“腐烂”了,如此?且抛开运维稳定性不谈,就系统本身稳定性而言,能好?


整个系统,除了堆业务还是堆业务,但凡有点软件设计原则,系统也不会写成这样了。


关于设计原则


大家在产品提出需求之后,一般都会去设计数据模型,还有系统流程。但是各位有没有深度去设计一下代码的实现呢?还是说上手就直接照着流程图开始撸业务了?估计有很多的小伙伴由于各种原因不会去考虑代码设计,其实老猫很多时候也一样。主要原因比如:项目催的紧,哪有时间考虑那么多,功能先做出来,剩下的等到后面慢慢优化。然而随着时间的推移,我们会发现我们一直很忙,说好的把以前的代码重构好一点,哪有时间!于是,就这样“技术债”越来越多,就像滚雪球一样,整个系统逐渐“腐烂”到了根。最终坑的可能是自己,也有可能是“下一个他”。


虽然在日常开发的时候项目进度比较紧张,我们很多时候也不去深度设计代码实现,但是我们在写代码的时候保证心中有一杆秤其实还是必要的。


那咱们就结合各种案来聊聊“这杆秤”————软件设计原则。


design_rule.png


下面我们通过各种小例子来协助大家理解软件设计原则,案例是老猫构想的,有的时候不要太过较真,主要目的是讲清楚原则。另外后文中也会有相关的类图表示实体之间的关系,如果大家对类图不太熟悉的,也可以看一下这里【类图传送门


开闭原则


开闭原则,英文(Open-Closed Principle,简称:OCP)。只要指一个软件实体(例如,类,模块和函数),应该对扩展开放,对修改关闭。其重点强调的是抽象构建框架,实现扩展细节,从而提升软件系统的可复用性以及可维护性。


概念是抽象,但是案例是具体的,所以咱们直接看案例,通过案例去理解可能更容易。


由于小猫最近在维护商城类业务,所以咱们就从商品折价售卖这个案例出发。业务是这样的,商城需要对商品进行做打折活动,目前针对不同品类的商品可能打折的力度不一样,例如生活用品和汽车用品的打折情况不同。
创建一个基础商品接口:


public interface IProduct {
String getSpuCode(); //获取商品编号
String getSpuName(); //获取商品名称
BigDecimal getPrice(); //获取商品价格
}

基础商品实现该接口,于是我们就有了如下代码:


/**
*
@Author: 公众号:程序员老猫
*
@Date: 2024/2/7 23:39
*/

public class Product implements IProduct {
private String spuCode;
private String spuName;
private BigDecimal price;
private Integer categoryTag;

public Product(String spuCode, String spuName, BigDecimal price, Integer categoryTag) {
this.spuCode = spuCode;
this.spuName = spuName;
this.price = price;
this.categoryTag = categoryTag;
}

public Integer getCategoryTag() {
return categoryTag;
}

@Override
public String getSpuCode() {
return spuCode;
}

@Override
public String getSpuName() {
return spuName;
}

@Override
public BigDecimal getPrice() {
return price;
}
}

按照上面的业务,现在搞活动,咱们需要针对不同品类的商品进行促销活动,例如生活用品需要进行折扣。当然我们有两种方式实现这个功能,如果咱们不改变原有代码,咱们可以如下实现。


public class DailyDiscountProduct extends Product {
private static final BigDecimal daily_discount_factor = new BigDecimal(0.95);
private static final Integer DAILY_PRODUCT = 1;

public DailyDiscountProduct(String spuCode, String spuName, BigDecimal price) {
super(spuCode, spuName, price, DAILY_PRODUCT);
}

public BigDecimal getOriginPrice() {
return super.getPrice();
}

@Override
public BigDecimal getPrice() {
return super.getPrice().multiply(daily_discount_factor);
}
}

上面我们看到直接打折的日常用品的商品继承了标准商品,并且对其进行了价格重写,这样就完成了生活用品的打折。当然这种打折系数的话我们一般可以配置到数据库中。


对汽车用品的打折其实也是一样的实现。继承之后重写价格即可。咱们并不需要去基础商品Product中根据不同的品类去更改商品的价格。


错误案例


如果我们一味地在原始类别上去做逻辑应该就是如下这样:



public class Product implements IProduct {
private static final Integer DAILY_PRODUCT = 1;
private static final BigDecimal daily_discount_factor = new BigDecimal(0.95);
private String spuCode;
private String spuName;
private BigDecimal price;
private Integer categoryTag;
....
@Override
public BigDecimal getPrice() {
if(categotyTag.equals(DAILY_PRODUCT)){
return price.multiply(daily_discount_factor);
}
return price;
}
}

后续随着业务的演化,后面如果提出对商品名称也要定制,那么咱们可能还是会动当前的代码,我们一直在改当前类,代码越堆越多,越来越臃肿,这种实现方式就破坏了开闭原则。


咱们看一下开闭原则的类图。如下:


kb_01.png


依赖倒置原则


依赖倒置原则,英文名(Dependence Inversion Principle,简称DIP),指的是高层模块不应该依赖低层模块,二者都应该依赖其抽象。通过依赖倒置,可以减少类和类之间的耦合性,从而提高系统的稳定性。这里主要强调的是,咱们写代码要面向接口编程,不要面向实现去编程。


定义看起来不够具体,咱们来看一下下面这样一个业务。针对不同的大客户,我们定制了很多商城,有些商城是专门售卖电器的,有些商城是专门售卖生活用品的。有个大客,由于对方是电器供应商,所以他们想售卖自己的电器设备,于是,我们就有了下面的业务。


//定义了一个电器设备商城,并且支持特有的电器设备下单流程
public class ElectricalShop {
public String doOrder(){
return "电器商城下单";
}
}
//用户进行下单购买电器设备
public class Consumer extends ElectricalShop {
public void shopping() {
super.doOrder();
}
}

我们看到,当客户可选择的只有一种商城的时候,这种实现方式确实好像没有什么问题,但是现在需求变了,马上要过年了,大客户不想仅仅给他们的客户提供电器设备,他们还想卖海鲜产品,这样,以前的这种下单模式好像会有点问题,因为以前我们直接继承了ElectricalShop,这样写的话,业务可拓展性就太差了,所以我们就需要抽象出一个接口,然后客户在下单的时候可以选择不同的商城进行下单。于是改造之后,咱们就有了如下代码:


//抽象出一个更高维度的商城接口
public interface Shop {
String doOrder();
}
//电器商城实现该接口实现自有下单流程
public class ElectricalShop implements Shop {
public String doOrder(){
return "电器商城下单";
}
}
//海鲜商城实现该接口实现自有下单流程
public class SeaFoodShop implements Shop{
@Override
public String doOrder() {
return "售卖一些海鲜产品";
}
}
//消费者注入不同的商城商品信息
public class Consumer {
private Shop shop;
public Consumer(Shop shop) {
this.shop = shop;
}
public String shopping() {
return shop.doOrder();
}
}
//消费者在不同商城随意切换下单测试
public class ConsumerTest {
public static void main(String[] args) {
//电器商城下单
Consumer consumer = new Consumer(new ElectricalShop());
System.out.println(consumer.shopping());
//海鲜商城下单
Consumer consumer2 = new Consumer(new SeaFoodShop());
System.out.println(consumer2.shopping());
}
}


上面这样改造之后,原本继承详细商城实现的Consumer类,现在直接将更高维度的商城接口注入到了类中,这样相信后面再多几个新的商城的下单流程都可以很方便地就完成拓展。


这其实也就是依赖倒置原则带来的好处,咱们最终来看一下类图。


DIP.png


单一职责原则


单一职责原则,英文名(SimpleResponsibility Pinciple,简称SRP)指的是不要存在多余一个导致类变更的原因。这句话看起来还是比较抽象的,老猫个人的理解是单一职责原则重点是区分业务边界,做到合理地划分业务,根据产品的需求不断去重新规划设计当前的类信息。关于单一职责老猫其实之前已经和大家分享过了,在此不多赘述,大家可以进入这个传送门【单一职责原则


接口隔离原则


接口隔离原则(Interface Segregation Principle,简称ISP)指的是指尽量提供专门的接口,而非使用一个混合的复杂接口对外提供服务。


聊到接口隔离原则,其实这种原则和单一职责原则有点类似,但是又不同:



  1. 联系:接口隔离原则和单一职责原则都是为了提高代码的可维护性和可拓展性以及可重用性,其核心的思想都是“高内聚低耦合”。

  2. 区别:针对性不同,接口隔离原则针对的是接口,而单一职责原则针对的是类。


下面,咱们用一个业务例子来说明一下吧。
我们用简单的动物行为这样一个例子来说明一下,动物从大的方面有能飞的,能吃,能跑,有的也会游泳等等。如果我们定义一个比较大的接口就是这样的。


public interface IAnimal {
void eat();
void fly();
void swim();
void run();
...
}

我们用猫咪实现了该方法,于是就有了。


public class Cat implements IAnimal{
@Override
public void eat() {
System.out.println("老猫喜欢吃小鱼干");
}
@Override
public void fly() {
}
@Override
public void swim() {
}
@Override
public void run() {
System.out.println("老猫还喜欢奔跑");
}
}

我们很容易就能发现,如果老猫不是“超人猫”的话,老猫就没办法飞翔以及游泳,所以当前的类就有两个空着的方法。
同样的如果有一只百灵鸟,那么实现Animal接口之后,百灵鸟的游泳方法也是空着的。那么这种实现我们发现只会让代码变得很臃肿,所以,我们发现IAnimal这个接口的定义太大了,我们需要根据不同的行为进行二次拆分。
拆分之后的结果如下:


//所有的动物都会吃东西
public interface IAnimal {
void eat();
}
//专注飞翔的接口
public interface IFlyAnimal {
void fly();
}
//专注游泳的接口
public interface ISwimAnimal {
void swim();
}

那如果现在有一只鸭子和百灵鸟,咱们分别去实现的时候如下:


public class Duck implements IAnimal,ISwimAnimal{
@Override
public void eat() {
System.out.println("鸭子吃食");
}

@Override
public void swim() {
System.out.println("鸭子在河里游泳");
}
}

public class Lark implements IAnimal,IFlyAnimal{
@Override
public void eat() {
System.out.println("百灵鸟吃食");
}

@Override
public void fly() {
System.out.println("百灵鸟会飞");
}
}

我们可以看到,这样在我们具体的实现类中就不会存在空方法的情况,代码随着业务的发展也不会变得过于臃肿。
咱们看一下最终的类图。


ISP.png


迪米特原则


迪米特原则(Law of Demeter,简称 LoD),指的是一个对象应该对其他对象保持最少的了解,如果上面这个原则名称不容易记,其实这种设计原则还有两外一个名称,叫做最少知道原则(Least Knowledge Principle,简称LKP)。其实主要强调的也是降低类和类之间的耦合度,白话“不要和陌生人说话”,或者也可以理解成“让专业的人去做专业的事情”,出现在成员变量,方法输入、输出参数中的类都可以称为成员朋友类,而出现在方法体内部的类不属于朋友类。


通过具体场景的例子来看一下。
由于小猫接手了商城类的业务,目前他对业务的实现细节应该是最清楚的,所以领导在向老板汇报相关SKU销售情况的时候总是会找到小猫去统计各个品类的sku的销售额以及销售量。于是就有了领导下命令,小猫去做统计的业务流程。


//sku商品
public class Sku {
private BigDecimal price;
public BigDecimal getPrice() {
return price;
}

public void setPrice(BigDecimal price) {
this.price = price;
}
}

//小猫统计总sku数量以及总销售金额
public class Kitty {
public void doSkuCheck(List skuList) {
BigDecimal totalSaleAmount =
skuList.stream().map(sku -> sku.getPrice()).reduce(BigDecimal::add).get();
System.out.println("总sku数量:" + skuList.size() + "sku总销售金额:" + totalSaleAmount);
}
}

//领导让小猫去统计各个品类的商品
public class Leader {
public void checkSku(Kitty kitty) {
//模拟领导指定的各个品类
List difCategorySkuList = new ArrayList<>();
kitty.doSkuCheck(difCategorySkuList);
}
}

//测试类
public class LodTest {
public static void main(String[] args) {
Leader leader = new Leader();
Kitty kitty = new Kitty();
leader.checkSku(kitty);
}
}


从上面的例子来看,领导其实并没有参与统计的任何事情,他只是指定了品类让小猫去统计。从而降低了类和类之间的耦合。即“让专门的人做专门的事”


我们看一下最终的类图。


LOD.png


里氏替换原则


里氏替换原则(Liskov Substitution Principle,英文简称:LSP),它由芭芭拉·利斯科夫(Barbara Liskov)在1988年提出。里氏替换原则的含义是:如果一个程序中所有使用基类的地方都可以用其子类来替换,而程序的行为没有发生变化,那么这个子类就遵守了里氏替换原则。换句话说,一个子类应该可以完全替代它的父类,并且保持程序的正确性和一致性。


上述的定义还是比较抽象的,老猫试着重新理解一下,



  1. 子类可以实现父类的抽象方法,但是不能覆盖父类的抽象方法。

  2. 子类可以增加自己特有的方法。

  3. 当子类的方法重载父类的方法的时,方法的前置条件(即方法的输入/入参)要比父类方法的输入参数更加宽松。

  4. 当子类的方法实现父类的方法时,方法的后置条件比父类更严格或者和父类一样。


里氏替换原则准确来说是上述提到的开闭原则的实现方式,但是它克服了继承中重写父类造成的可复用性变差的缺点。它是动作正确性的保证。即类的扩展不会给已有的系统引入新的错误,降低了代码出错的可能性。


下面咱们用里式替换原则比较经典的例子来说明“鸵鸟不是鸟”。我们看一下咱们印象中的鸟类:


class Bird {
double flySpeed;

//设置飞行速度
public void setSpeed(double speed) {
flySpeed = speed;
}

//计算飞行所需要的时间
public double getFlyTime(double distance) {
return (distance / flySpeed);
}
}
//燕子
public class Swallow extends Bird{
}
//由于鸵鸟不能飞,所以我们将鸵鸟的速度设置为0
public class Ostrich extends Bird {
public void setSpeed(double speed) {
flySpeed = 0;
}
}

光看这个实现的时候好像没有问题,但是我们调用其方法计算其指定距离飞行时间的时候,那么这个时候就有问题了,如下:


public class TestMain {
public static void main(String[] args) {
double distance = 120;
Ostrich ostrich = new Ostrich();
System.out.println(ostrich.getFlyTime(distance));

Swallow swallow = new Swallow();
swallow.setSpeed(30);
System.out.println(swallow.getFlyTime(distance));
}
}

结果输出:


Infinity
4.0

显然鸵鸟出问题了,



  1. 鸵鸟重写了鸟类的 setSpeed(double speed) 方法,这违背了里氏替换原则。

  2. 燕子和鸵鸟都是鸟类,但是父类抽取的共性有问题,鸵鸟的飞行不是正常鸟类的功能,需要特殊处理,应该抽取更加共性的功能。


于是我们进行对其进行优化,咱们取消鸵鸟原来的继承关系,定义鸟和鸵鸟的更一般的父类,如动物类,它们都有奔跑的能力。鸵鸟的飞行速度虽然为 0,但奔跑速度不为 0,可以计算出其奔跑指定距离所要花费的时间。优化之后代码如下:


//抽象出更高层次的动物类,定义内部的奔跑行为
public class Animal {
double runSpeed;

//设置奔跑速度
public void setSpeed(double speed) {
runSpeed = speed;
}
//计算奔跑所需要的时间
public double getRunTime(double distance) {
return (distance / runSpeed);
}
}
//定义飞行的鸟类
public class Bird extends Animal {
double flySpeed;
//设置飞行速度
public void setSpeed(double speed) {
flySpeed = speed;
}
//计算飞行所需要的时间
public double getFlyTime(double distance) {
return (distance / flySpeed);
}
}
//此时鸵鸟直接继承动物接口
public class Ostrich extends Animal {
}
//燕子继承普通的鸟类接口
public class Swallow extends Bird {
}

简单测试一下:


public class TestMain {
public static void main(String[] args) {
double distance = 120;
Ostrich ostrich = new Ostrich();
ostrich.setSpeed(40);
System.out.println(ostrich.getRunTime(distance));

Swallow swallow = new Swallow();
swallow.setSpeed(30);
System.out.println(swallow.getFlyTime(distance));
}
}

结果输出:


3.0
4.0

优化之后,优点:



  1. 代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性;

  2. 提高代码的重用性;

  3. 提高代码的可扩展性;

  4. 提高产品或项目的开放性;


缺点:



  1. 继承是侵入性的。只要继承,就必须拥有父类的所有属性和方法;

  2. 降低代码的灵活性。子类必须拥有父类的属性和方法,让子类自由的世界中多了些约束;

  3. 增强了耦合性。当父类的常量、变量和方法被修改时,需要考虑子类的修改,而且在缺乏规范的环境下,这种修改可能带来非常糟糕的结果————大段的代码需要重构。


最终我们看一下类图:


LSP.png


老猫觉得里氏替换原则是最难把握好的,所以到后续咱们再进行深入涉及模式回归的时候再做深入探究。


合成复用原则


合成复用原则(Composite/Aggregate Reuse Principle,英文简称CARP)是指咱们尽量要使用对象组合而不是继承关系达到软件复用的目的。这样的话系统就可以变得更加灵活,同时也降低了类和类之间的耦合度。


看个例子,当我们刚学java的时候都是从jdbc开始学起来的。所以对于DBConnection我们并不陌生。那当我们实现基本产品Dao层的时候,我们就有了如下写法:


public class DBConnection {
public String getConnection(){
return "获取数据库链接";
}
}
//基础产品dao层
public class ProductDao {
private DBConnection dbConnection;

public ProductDao(DBConnection dbConnection) {
this.dbConnection = dbConnection;
}

public void saveProduct(){
String conn = dbConnection.getConnection();
System.out.println("使用"+conn+"新增商品");
}
}

上述就是最简单的合成服用原则应用场景。但是这里有个问题,DBConnection目前只支持mysql一种连接DB的方式,显然不合理,有很多企业其实还需要支持Oracle数据库链接,所以为了符合之前说到的开闭原则,我们让DBConnection交给子类去实现。于是我们可以将其定义成抽象方法。


public abstract class DBConnection {
public abstract String getConnection();
}
//mysql链接
public class MySqlConnection extends DBConnection{
@Override
public String getConnection() {
return "获取mysql链接";
}
}
//oracle链接
public class OracleConnection extends DBConnection{
@Override
public String getConnection() {
return "获取Oracle链接方式";
}
}

最终的实现方式我们一起看一下类图。


CARP.png


总结


之前看过一个故事,一栋楼的破败往往从一扇破窗户开始,慢慢腐朽。其实代码的腐烂其实也是一样,往往是一段拓展性极差的代码开始。所以这要求我们研发人员还是得心中有杆“设计原则”的秤,咱们可能不会去做刻意的代码设计,但是相信有这么一杆原则的秤,代码也不致于会写得太烂。


当然我们也不要刻意去追求设计原则,要权衡具体的场景做出合理的取舍。
设计原则是设计模式的基础,相信大家在了解完设计原则之后对后续的设计模式会有更加深刻的理解。


作者:程序员老猫
来源:juejin.cn/post/7332858431572049947
收起阅读 »

别再这么写POST请求了~

       大家好,我是石头~        今天在进行组内code review,发现有一位同学在使用POST方式进行接口调用传参的时候,并不是按照HTTP规范,将参数写入到请求体中进行传输,而是拼接到接口URL上面。        那么,POST请求,是...
继续阅读 »

       大家好,我是石头~


       今天在进行组内code review,发现有一位同学在使用POST方式进行接口调用传参的时候,并不是按照HTTP规范,将参数写入到请求体中进行传输,而是拼接到接口URL上面。


       那么,POST请求,是否适宜将参数拼接到URL中呢?


图片


POST请求与参数传递的标准机制


       在讨论这个问题之前,我们先了解一下POST请求参数传递的正确方式是怎样的?


       按照HTTP协议规定,POST请求主要服务于向服务器提交数据的操作,这类数据通常包含表单内容、文件上传等。标准实践中,这些数据应封装于请求体(Request Body)内,而非附加在URL上。这是出于POST请求对数据容量和安全性的考量,URL因其长度限制和透明性特点并不适合作为大型或敏感数据的载体。


图片


URL参数拼接的风险


       从上所述,URL参数拼接并不是POST请求参数传递的正确方式,但是既然这样做也是可以正常进行请求的,对方服务端也能正常获取到参数,那么,URL参数拼接又有什么风险?



  • URL长度限制:URL长度并非无限制,大多数浏览器和服务器都有最大长度限制,一般在2000字符左右,若参数过多或过大,可能导致URL截断,进而使服务端无法完整接收到所有参数

  • 安全性隐患:将参数拼接到URL中,可能导致敏感信息泄露,如密码、密钥等。此外,URL中的参数容易被浏览器历史记录、缓存、代理服务器等记录,增加了信息泄露的风险

  • 不符合HTTP规范:POST请求通常将数据放在请求体中,而非URL中,违反这一规范可能导致与某些服务器或中间件的兼容性问题。


图片


POST传参正确写法


       以下是一个使用Java的HttpURLConnection发送Post请求并将数据放在请求体中的示例:


import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;

public class PostRequestExample {

public static void sendPostRequest(String requestUrl, String postData) throws Exception {
URL url = new URL(requestUrl);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); // 设置请求头,表明请求体的内容类型

connection.setDoOutput(true); // 表示要向服务器写入数据
try (OutputStream os = connection.getOutputStream()) {
byte[] input = postData.getBytes("UTF-8"); // 将参数转换为字节数组,此处假设postData是已编码好的参数字符串
os.write(input, 0, input.length); // 将参数写入请求体
}

int responseCode = connection.getResponseCode();
if (responseCode == HttpURLConnection.HTTP_OK) {
// 处理响应...
} else {
// 错误处理...
}
}

public static void main(String[] args) throws Exception {
String requestUrl = "http://example.com/api/endpoint";
String postData = "param1=value1&param2=value2"; // 参数以键值对的形式编码
sendPostRequest(requestUrl, postData);
}
}

作者:石头聊技术
来源:juejin.cn/post/7341952374368108583
收起阅读 »

面试官问,如何在十亿级别用户中检查用户名是否存在?

前言不知道大家有没有留意过,在使用一些app注册的时候,提示你用户名已经被占用了,需要更换一个,这是如何实现的呢?你可能想这不是很简单吗,去数据库里查一下有没有不就行了吗,那么假如用户数量很多,达到数亿级别呢,这又该如何是好?数据库方案第一种方案就是查数据库的...
继续阅读 »

前言

不知道大家有没有留意过,在使用一些app注册的时候,提示你用户名已经被占用了,需要更换一个,这是如何实现的呢?你可能想这不是很简单吗,去数据库里查一下有没有不就行了吗,那么假如用户数量很多,达到数亿级别呢,这又该如何是好?

数据库方案

第一种方案就是查数据库的方案,大家都能够想到,代码如下:

public class UsernameUniquenessChecker {
private static final String DB_URL = "jdbc:mysql://localhost:3306/your_database";
private static final String DB_USER = "your_username";
private static final String DB_PASSWORD = "your_password";

public static boolean isUsernameUnique(String username) {
try (Connection conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD)) {
String sql = "SELECT COUNT(*) FROM users WHERE username = ?";
try (PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, username);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
int count = rs.getInt(1);
return count == 0; // If count is 0, username is unique
}
}
}
} catch (SQLException e) {
e.printStackTrace();
}
return false; // In case of an error, consider the username as non-unique
}

public static void main(String[] args) {
String desiredUsername = "new_user";
boolean isUnique = isUsernameUnique(desiredUsername);
if (isUnique) {
System.out.println("Username '" + desiredUsername + "' is unique. Proceed with registration.");
} else {
System.out.println("Username '" + desiredUsername + "' is already in use. Choose a different one.");
}
}
}

这种方法会带来如下问题:

  1. 性能问题,延迟高  如果数据量很大,查询速度慢。另外,数据库查询涉及应用程序服务器和数据库服务器之间的网络通信。建立连接、发送查询和接收响应所需的时间也会导致延迟。
  2. 数据库负载过高。频繁执行 SELECT 查询来检查用户名唯一性,每个查询需要数据库资源,包括CPU和I/O。
  1. 可扩展性差。数据库对并发连接和资源有限制。如果注册率继续增长,数据库服务器可能难以处理数量增加的传入请求。垂直扩展数据库(向单个服务器添加更多资源)可能成本高昂并且可能有限制。

缓存方案

为了解决数据库调用用户名唯一性检查的性能问题,引入了高效的Redis缓存。

public class UsernameCache {

private static final String REDIS_HOST = "localhost";
private static final int REDIS_PORT = 6379;
private static final int CACHE_EXPIRATION_SECONDS = 3600;

private static JedisPool jedisPool;

// Initialize the Redis connection pool
static {
JedisPoolConfig poolConfig = new JedisPoolConfig();
jedisPool = new JedisPool(poolConfig, REDIS_HOST, REDIS_PORT);
}

// Method to check if a username is unique using the Redis cache
public static boolean isUsernameUnique(String username) {
try (Jedis jedis = jedisPool.getResource()) {
// Check if the username exists in the Redis cache
if (jedis.sismember("usernames", username)) {
return false; // Username is not unique
}
} catch (Exception e) {
e.printStackTrace();
// Handle exceptions or fallback to database query if Redis is unavailable
}
return true; // Username is unique (not found in cache)
}

// Method to add a username to the Redis cache
public static void addToCache(String username) {
try (Jedis jedis = jedisPool.getResource()) {
jedis.sadd("usernames", username); // Add the username to the cache set
jedis.expire("usernames", CACHE_EXPIRATION_SECONDS); // Set expiration time for the cache
} catch (Exception e) {
e.printStackTrace();
// Handle exceptions if Redis cache update fails
}
}

// Cleanup and close the Redis connection pool
public static void close() {
jedisPool.close();
}
}

这个方案最大的问题就是内存占用过大,假如每个用户名需要大约 20 字节的内存。你想要存储10亿个用户名的话,就需要20G的内存。

总内存 = 每条记录的内存使用量 * 记录数 = 20 字节/记录 * 1,000,000,000 条记录 = 20,000,000,000 字节 = 20,000,000 KB = 20,000 MB = 20 GB

布隆过滤器方案

直接缓存判断内存占用过大,有没有什么更好的办法呢?布隆过滤器就是很好的一个选择。

那究竟什么布隆过滤器呢?

布隆过滤器Bloom Filter)是一种数据结构,用于快速检查一个元素是否存在于一个大型数据集中,通常用于在某些情况下快速过滤掉不可能存在的元素,以减少后续更昂贵的查询操作。布隆过滤器的主要优点是它可以提供快速的查找和插入操作,并且在内存占用方面非常高效。

具体的实现原理和数据结构如下图所示:

布隆过滤器的核心思想是使用一个位数组(bit array)和一组哈希函数。

  • 位数组(Bit Array) :布隆过滤器使用一个包含大量位的数组,通常初始化为全0。每个位可以存储两个值,通常是0或1。这些位被用来表示元素的存在或可能的存在。
  • 哈希函数(Hash Functions) :布隆过滤器使用多个哈希函数,每个哈希函数可以将输入元素映射到位数组的一个或多个位置。这些哈希函数必须是独立且具有均匀分布特性。

那么具体是怎么做的呢?

  • 添加元素:如上图所示,当将字符串“xuyang”,“alvin”插入布隆过滤器时,通过多个哈希函数将元素映射到位数组的多个位置,然后将这些位置的位设置为1。
  • 查询元素:当要检查一个元素是否存在于布隆过滤器中时,通过相同的哈希函数将元素映射到位数组的相应位置,然后检查这些位置的位是否都为1。如果有任何一个位为0,那么可以确定元素不存在于数据集中。但如果所有位都是1,元素可能存在于数据集中,但也可能是误判。

本身redis支持布隆过滤器的数据结构,我们用代码简单实现了解一下:

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

public class BloomFilterExample {
public static void main(String[] args) {
JedisPoolConfig poolConfig = new JedisPoolConfig();
JedisPool jedisPool = new JedisPool(poolConfig, "localhost", 6379);

try (Jedis jedis = jedisPool.getResource()) {
// 创建一个名为 "usernameFilter" 的布隆过滤器,需要指定预计的元素数量和期望的误差率
jedis.bfCreate("usernameFilter", 10000000, 0.01);

// 将用户名添加到布隆过滤器
jedis.bfAdd("usernameFilter", "alvin");

// 检查用户名是否已经存在
boolean exists = jedis.bfExists("usernameFilter", "alvin");
System.out.println("Username exists: " + exists);
}
}
}

在上述示例中,我们首先创建一个名为 "usernameFilter" 的布隆过滤器,然后使用 bfAdd 将用户名添加到布隆过滤器中。最后,使用 bfExists 检查用户名是否已经存在。

优点:

  • 节约内存空间,相比使用哈希表等数据结构,布隆过滤器通常需要更少的内存空间,因为它不存储实际元素,而只存储元素的哈希值。如果以 0.001 误差率存储 10 亿条记录,只需要 1.67 GB 内存,对比原来的20G,大大的减少了。
  • 高效的查找, 布隆过滤器可以在常数时间内(O(1))快速查找一个元素是否存在于集合中,无需遍历整个集合。

缺点:

  • 误判率存在:布隆过滤器在判断元素是否存在时,有一定的误判率。这意味着在某些情况下,它可能会错误地报告元素存在,但不会错误地报告元素不存在。
  • 不能删除元素:布隆过滤器通常不支持从集合中删除元素,因为删除一个元素会影响其他元素的哈希值,增加了误判率。

总结

Redis 布隆过滤器的方案为大数据量下唯一性验证提供了一种基于内存的高效解决方案,它需要在内存消耗和错误率之间取得一个平衡点。当然布隆过滤器还有更多应用场景,比如防止缓存穿透、防止恶意访问等。


作者:JAVA旭阳
来源:juejin.cn/post/7293786247655129129

收起阅读 »

面试官问我:自己写String类,包名也是java.lang,这个类能编译成功吗,能运行成功吗

之前某次面试,我说自己对Java比较熟,面试官问了我一个问题:假设你自己写一个String类,包名也是java.lang,代码里使用String的时候,这个String类能编译成功吗?能运行成功吗? 好了,我当时又是一脸懵逼o((⊙﹏⊙))o,因为我只是看了...
继续阅读 »

之前某次面试,我说自己对Java比较熟,面试官问了我一个问题:假设你自己写一个String类,包名也是java.lang,代码里使用String的时候,这个String类能编译成功吗?能运行成功吗?



好了,我当时又是一脸懵逼o((⊙﹏⊙))o,因为我只是看了些Java的面试题目,而且并没有涉及类加载方面的内容(ps:我是怎么敢说我对Java比较熟的)。


结论


先说结论:
能编译成功,但是运行会报错。因为加载String的时候根据双亲委派机制会默认加载jdk里的String。



  • 在自己写的String类中写main方法并运行,会报错找不到main方法。


public class String {
public int print(int a) {
int b = a;
return b;
}
public static void main(String[] args) {
new String().print(1);
}
}

上述代码运行报错如下:


错误: 在类 java.lang.String 中找不到 main 方法, 请将 main 方法定义为:
public static void main(String[] args)
否则 JavaFX 应用程序类必须扩展javafx.application.Application


  • 如果在其他类中尝试调用这个String类的方法,也调用不到,实际的结果是调用jdk中的String类的方法。


题目分析


这里涉及3个知识点:



  • Java代码的编译过程

  • Java代码的运行过程

  • 类加载器(详见文章:JVM:类加载器


image.png


以上3个内容基本上是涉及代码运行的整个流程了。接下来就结合实战操作一步步分析具体的过程。


Java代码的编译过程


平时我都是通过IDEA直接运行代码,都没注意过编译的过程。所以结合平时的操作说明一下编译的过程。


什么是Java的编译


Java的编译过程,是将.java源文件转换为.class字节码文件的过程。


如何将.java源文件编译成.class字节码文件



  1. IDEA工具中,点击BUILD按钮
    image.png

  2. 执行命令javac xx.java


如何查看字节码文件



  1. 如果我们直接用文本工具打开字节码文件,将会看到以下内容:


    image.png
    这是因为Class文件内部本质上是二进制的,用不同的工具打开看,展示的效果不一样。下图是用xx工具打开的class文件,展示的是十六进制格式,其实可以自己一点点翻译出来源码了。(class文件的这个二进制串,计算机是不能够直接读取并且执行的。也就是说,计算机看不懂,而我们的JVM解决了这个问题,JVM可以看作是一个翻译官,它可以看懂,而且它也知道计算机想要什么样子的二进制,所以它可以把Class文件的二进制翻译成计算机需要的样子)


    image.png


  2. 我们可以通过命令的方式将class文件反汇编成汇编代码。


    javap是JDK自带的反汇编器,可以查看java编译器为我们生成的字节码。


    javap -v xx.classjavap -c -l xx.class



字节码文件中包含哪些内容


这个有很多文章说了,可以自己搜索一下,也可以看我总结的文章:xxx(还没写)。


Java代码的运行过程


java类运行的过程大概可分为两个过程:1)类的加载;2)类的执行


需要说明的是:JVM主要在程序第一次主动使用类的时候,才会去加载该类。也就是说,JVM并不是在一开始就把一个程序就所有的类都加载到内存中,而是到不得不用的时候才把它加载进来,而且只加载一次。


类加载过程


Class文件需要加载到虚拟机中之后才能运行和使用。系统加载Class文件主要有3步:加载->连接->初始化。连接过程又可分为3步:验证->准备->解析


image.png
(图源:javaguide.cn


加载


类加载过程的第一步,主要完成3件事情:



  • 通过全类名获取定义此类的二进制字节流。

  • 将字节流所代表的静态存储结构转换为方法区的运行时数据结构

  • 在内存中生成一个代表该类的Class对象,作为方法区这些数据的访问入口。


加载这一步的操作主要是通过类加载器完成的。类加载器详情可参考文章:xxx。


每个Java类都有一个引用指向加载它的ClassLoader。不过数组类不是通过ClassLoader创建的,而是JVM在需要的时候自动创建的,数组类通过getClassLoader方法获取ClassLoader的时候和该数组的元素类型的ClassLoader是一致的。


一个非数组类的加载阶段(加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,这一步我们可以去完成还可以自定义类加载器去控制字节流的获取方式(重写一个类加载器的loadClass()方法)。


加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了。


连接


验证


验证是连接阶段的第一步,这步的目的是为了保证Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当做代码运行后不会危害虚拟机的安全。


验证阶段所要耗费的资源相对还是多的,但验证阶段也不是必要的。如果程序运行的全部代码已经被反复使用和验证过,那在生产环境的实施阶段可以考虑使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。


验证阶段主要由4个检验阶段组成:



  • 文件格式验证。要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。比如以下验证点:



    • 是否以魔数CAFEBABE开头

    • 主、次版本号是否在当前Java虚拟机接收范围内

    • 常量池的常量是否有不被支持的常量类型

    • 。。。


    该阶段验证的主要目的是保证输入的字节流能够被正确地解析并存储于方法区。只有通过了这个阶段的验证之后,这段字节流才被允许进入Java虚拟机内存的方法区中存储。后面3个阶段的验证是在方法区的存储信息上进行的,不会再直接读取和操作字节流了。


  • 元数据验证。对字节码描述的信息进行语义分析,保证其描述的信息符合《Java语言规范》的要求。这个阶段可能包括的验证点如下:



    • 这个类是否有父类(除了Object类之外,所有的类都应该有父类)

    • 这个类or其父类是否继承了不允许继承的类(比如final修饰的类)

    • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。



  • 字节码验证。是整个验证过程中最复杂的,主要目的是通过分析字节码,判断字节码能否被正确执行。比如会验证以下内容:



    • 在字节码的执行过程中,是否会跳转到一条不存在的指令

    • 函数的调用是否传递了正确类型的参数

    • 变量的赋值是不是给了正确的数据类型

    • 。。。


    如果一个方法体通过了字节码验证,也仍然不能保证它一定是安全的。


  • 符号引用验证。该动作发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段--解析阶段中发生(所以说符号引用验证是在解析阶段发生???)。


    符号引用验证的主要目的是确保解析行为能正常执行


    符号引用验证简单来说就是验证当前类是否缺少或者被禁止访问它依赖的外部类、方法、变量等资源。该阶段通常要校验以下内容:



    • 符号引用中通过字符串描述的全限定名是否能找到对应的类。

    • 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。(没太明白什么意思)

    • 符号引用中的类、变量、方法是否可被当前类访问。


    如果无法通过符号引用验证,Java 虚拟机将会抛出一个 java.lang.IncompatibleClassChangeError 的子类异常,典型的如:



    • java.lang.IllegalAccessError

    • java.lang.NoSuchFieldError

    • java.lang.NoSuchMethodError等。




准备


准备阶段是正式为类中的静态变量分配内存并设置类变量初始化值的阶段。从概念上来说,这些变量所使用的内存都应当在方法区中分配,但方法区本身是一个逻辑概念。在JDK7及以前,HotSpot使用永久代来实现方法区。在JDK8及以后,类变量会随着Class对象一起放入Java堆中(也是叫做方法区的概念?)


注意点:



  • 准备阶段仅为类变量分配内存并初始化。实例变量会在对象实例化时随着对象一起分配在堆内存中。

  • 非final修饰的类变量,在初始化之后,是赋值为0,而不是程序中的赋值。比如:
    public static int value = 123; 

    初始化之后的值是0,而不是10。因为这时候程序还未运行。把 value 赋值为 123 的 putstatic 指令是程序被编译后,存放于类构造器() 方法之中,所以把 value 赋值为 123 的动作要到类的初始化阶段才会被执行。

  • final修饰的类变量,初始化之后会赋值为代码中的值。因为:如果类字段被 final 修饰,那么类阻断的属性表中存在 ConstantValue 属性,那在准备阶段变量值就会被初始化为ConstantValue 属性所指定的初始值,假设上面类变量 value 的定义修改为 123 ,而不是 "零值"


解析


解析阶段是将符号引用转化为直接引用的过程。也就是得到类或者字段、方法在内存中的指针或者偏移量。



  • 符号引用(Symbolic References):用一组字符串来表示所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。

  • 直接引用(Direct Reference)是可以直接指向目标的指针,相对偏移量、或者可以间接定位到目标的句柄?直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。


初始化


初始化阶段是执行初始化方法 <clinit> ()方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。



说明:<clinit> ()方法是编译之后自动生成的。



对于初始化阶段,虚拟机严格规范了有且只有 6 种情况下,必须对类进行初始化(只有主动去使用类才会初始化类):



  1. 当遇到 newgetstaticputstaticinvokestatic 这 4 条字节码指令时,比如 new 一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。



    • 当 jvm 执行 new 指令时会初始化类。即当程序创建一个类的实例对象。

    • 当 jvm 执行 getstatic 指令时会初始化类。即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。

    • 当 jvm 执行 putstatic 指令时会初始化类。即程序给类的静态变量赋值。

    • 当 jvm 执行 invokestatic 指令时会初始化类。即程序调用类的静态方法。



  2. 使用 java.lang.reflect 包的方法对类进行反射调用时如 Class.forname("..."), newInstance() 等等。如果类没初始化,需要触发其初始化。

  3. 初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。

  4. 当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类。

  5. MethodHandleVarHandle 可以看作是轻量级的反射调用机制,而要想使用这 2 个调用,

    就必须先使用findStaticVarHandle 来初始化要调用的类。

  6. 当一个接口中定义了 JDK8 新加入的默认方法(default)  ,那么实现该接口的类需要提前初始化。


代码运行过程:案例


针对下面这段代码进行讲解。


//MainApp.java  
pblic class MainApp {
public static void main(String[] args) {
Animal animal = new Animal("Puppy");
animal.printName();
}
}
//Animal.java
public class Animal {
public String name;
public Animal(String name) {
this.name = name;
}
public void printName() {
System.out.println("Animal ["+name+"]");
}
}


  1. MainApp类加载:编译得到MainApp.class文件后,在命令行上敲java AppMain。系统就会启动一个jvm进程,jvm进程从classpath路径中找到一个名为AppMain.class的二进制文件,将MainApp的类信息加载到运行时数据区的方法区内,这个过程叫做MainApp类的加载。

  2. 然后JVM找到AppMain的主函数入口,开始执行main函数。

  3. Animal类加载:main函数的第一条命令是Animal animal = new Animal("Puppy");就是让JVM创建一个Animal对象,但是这时候方法区中没有Animal类的信息,所以JVM马上加载Animal类,把Animal类的类型信息放到方法区中。

  4. 加载完Animal类之后,Java虚拟机做的第一件事情就是在堆区中为一个新的Animal实例分配内存, 然后调用构造函数初始化Animal实例,这个Animal实例持有着指向方法区的Animal类的类型信息(其中包含有方法表,java动态绑定的底层实现)的引用。

  5. 当使用animal.printName()的时候,JVM根据animal引用找到Animal对象,然后根据Animal对象持有的引用定位到方法区中Animal类的类型信息的方法表,获得printName()函数的字节码的地址。

  6. 开始运行printName()函数。



参考文章





作者:ET
来源:juejin.cn/post/7343441462644195362
收起阅读 »

HTML常用字体标签:揭秘HTML字体标签,让你的网页“字”得其乐!

在数字世界的构建中,字体不仅仅是文字的外衣,更是情感和风格的传递者。作为网页设计师和前端开发者,掌握HTML中的字体标签,能够让我们创造出更加丰富和吸引人的用户体验。今天,就让我们一起走进HTML字体标签的世界,探索它们如何让网页变得生动有趣。一、认识基本字体...
继续阅读 »

在数字世界的构建中,字体不仅仅是文字的外衣,更是情感和风格的传递者。作为网页设计师和前端开发者,掌握HTML中的字体标签,能够让我们创造出更加丰富和吸引人的用户体验。

今天,就让我们一起走进HTML字体标签的世界,探索它们如何让网页变得生动有趣。

一、认识基本字体标签

语法结构:<标签 属性=“值”> 内容 </标签>

  • 标签通常是成对出现的,分为开始标签(p)和结束标签(/p),结束标签只是在开始标签前加一个斜杠“/”。
  • 标签可以有属性,属性必须有值(align=“center” )。
  • 开始标签与结束标签中包含的内容称之为区域。
  • 标签不区分大小写,p和P是相同的。

1、标题标签< h1> - < h6>

标题标签的默认样式是自动加粗的,字体一级标题最大,六级标题最小,每个标题标签独占一行。标题标签是块元素示例:

   <h1>一级</h1>
   <h2>二级</h2>
   <h3>三级</h3>
   <h4>四级</h4>
   <h5>五级</h5>
 <h6>六级</h6>

Description

2、字体标签<font>

在HTML中,最常用的字体标签非<font>莫属,虽然现代开发中更推荐使用CSS来控制字体样式,但了解它的历史仍然有其必要性。

<font>标签允许我们通过color、size和face属性来改变字体的颜色、大小和类型。

例如,如果我们想要显示红色Arial字体的文字,我们可以这样写:

<font color="red" size="5" face="Arial">这是红色Arial字体的文字</font>

这行代码的意思是:

  • 开始一个字体样式的定义。
  • color=“red” 设置字体颜色为红色。
  • size=“5” 设置字体大小为5。
  • face=“Arial” 设置字体类型为Arial。
  • 这是红色Arial字体的文字 是我们要显示的文字。
  • 结束字体样式的定义。

注意:虽然标签在HTML4.01中是有效的,但在HTML5中已经被废弃,建议使用CSS来进行样式定义。

3、字号大小:<font size="n">

字号大小在网页设计中同样重要,它直接影响着阅读体验。HTML允许我们通过<font size="n">来调整字体的大小,其中“n”可以是1到7的数字。
例如:

<!DOCTYPE html>
<html>
<head>
  <title>Font Size Example</title>
</head>
<body>
  <p><font size="5">This is a paragraph with font size 5.</font></p>
  <p><font size="10">This is a paragraph with font size 10.</font></p>
  <p><font size="15">This is a paragraph with font size 15.</font></p>
</body>
</html>

运行结果:

Description

4、粗体标签

<b>:这个标签用于将文本加粗显示,相当于英文中的bold。它不会改变字体,只是使文本看起来更粗体。

<p><b>这是加粗的文本</b></p>

<strong>:与<b>标签类似,<strong>标签也用于表示加粗的文本。

<p><strong>这是重要的文本</strong></p>

但在HTML5中,<strong>标签被赋予了语义,用来表示重要的文本内容。

5、斜体字标签

<i>:这个标签用于将文本设置为斜体,相当于英文中的italic。

<p><i>这是斜体的文本</i></p>

<em>:与<i>标签类似,<em>标签也用于表示斜体文本。

<p><em>这是强调的文本</em></p>

但在HTML5中,<em>标签被赋予了语义,用来表示强调的文本内容。

6、删除字标签

<del>:这个标签用于表示删除的文本,常用于表示不再准确或已过时的内容。比如原价与现价。

<p>原价:<del>100元</del></p>
<p>现价:80元</p>

运行之后是这样子的:

Description

在上述示例中,原价为100元,但已被删除,因此使用标签将其包围起来。这样,浏览器会显示删除线来表示该文本已被删除。

7、文本格式化标签 < div>  < span>

< div> 标签用来布局,但是一行只能放一个< div> //大盒子,块元素。

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Document</title>
</head>
<body>
<div>这是一个div</div>
<div>这是一个div</div>
<div><p>这是一个div</p>
</div>
<p>
<div>云端源想</div>
</p>
</body>
</html>

<div>标签可以看出是一个盒子容器,这里面可以放别的标签。<div>标签是一个块元素。

Description

如上图控制台所示(打开控制台的方式:F12):<div>标签里面可以包含<p>标签,<p>标签里面不可以放<div>标签。

< span> 标签用来布局,一行上可以多个 < span>//小盒子,行元素。

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Document</title>
</head>
<body>
<span>1234</span>
<span>5678</span>
</body>
</html>
  • 用于对文档中的行内元素进行组合。
  • 标签没有固定的格式表现。当对它应用样式时,它才会产生视觉上的变化。如果不对 应用样式,那么 元素中的文本与其他文本不会任何视觉上的差异。
  • 标签提供了一种将文本的一部分或者文档的一部分独立出来的方式。
  • 标签不同于

    标签是一个行内元素(不独占一行)。

8、其它字体标签

  • <mark>:这个标签用于突出显示文本,通常用于表示高亮的部分。
  • <small>:这个标签用于表示小号文本,通常用于表示版权声明或法律条款等次要信息。
  • <ins>:这个标签用于表示插入的文本,常用于表示新增的内容。
  • <sub> 和 <sup>:这两个标签分别用于表示下标和上标文本,常用于数学公式或化学方程式中。

二、总结与建议

尽管上述标签可以直接在HTML中使用,但现代网页设计越来越倾向于使用CSS来控制文本的样式,因为CSS提供了更多灵活性和控制能力。
Description
使用CSS类和样式规则可以更有效地管理网站的整体样式,并且可以更容易地适应不同设备和屏幕尺寸。

因此,如果您正在学习或更新您的网页设计知识,建议学习和使用CSS来控制字体和其他文本样式,关于HTML的这些标签了解一下就可以了。

想要快速入门前端开发吗?推荐一个前端开发基础课程,这个老师讲的特别好,零基础学习无压力,知识点结合代码,边学边练,可以免费试看试学,还有各种辅助工具和资料,非常适合新手!

点这里前往学习哦!

总之,字体是网页设计中不可或缺的元素,它们就像是网页的语言,传递着信息和情感。通过HTML字体标签的学习和应用,我们可以让我们的网页“字”得其乐,让每一位访问者都能享受到更加美妙的网络体验。不断探索和实践,让我们的网页在字体的世界里绽放光彩吧!

收起阅读 »

GeoHash——滴滴打车如何找出方圆一千米内的乘客?

背景 不知道大家是否思考过一个问题,在一些场景下(如大家在使用高德地图打车的时候,邻近的司机是如何知道你在他的附近并将你的打车通知推送给他去接单的?)是如何实现的? 一般来讲,大家也许会想到,首先肯定需要知道每位乘客的经纬度(lng,lat),也即是二维坐标(...
继续阅读 »

背景


不知道大家是否思考过一个问题,在一些场景下(如大家在使用高德地图打车的时候,邻近的司机是如何知道你在他的附近并将你的打车通知推送给他去接单的?)是如何实现的?


一般来讲,大家也许会想到,首先肯定需要知道每位乘客的经纬度(lng,lat),也即是二维坐标(当然这是在绝对理想的情况,不考虑上下坡度)。


而在知道了经纬度之后,一个暴力简单且容易想到的思路就是将经纬度这个二元组都存放在一个数组当中,然后当我们需要拿到离我们规定范围内的用户(如获取当前位置方圆百米内正在打车的乘客),我们就可以去遍历维护的那个数组,以此去判断数组中的经纬度与自己所在经纬度的距离,然后判断是否在范围内。


显然这种方法一定是能够达到目的的,但是值得注意的点是,维护的数据量一般来讲是海量的,因此如果每次都需要遍历所有数据去进行计算,那这计算量以及存储量目前是无法满足的。那如何在此基础上去优化性能呢??那么这个内容就是本篇文章主要想探讨的问题......




GeoHash基本原理介绍


首先我想先介绍一下GeoHash这种算法基本原理,再讨论如何进行应用。


对于每一个坐标都有它的经纬度(lng,lat),而GeoHash的原理就是将经纬度先通过一个二分的思路拿到一个二进制数组的字符串,然后再通过base32编码去进行压缩存储。


举一个例子,比如经纬度为(116.3111126,40.085003),对其进行二分步骤如下:


经度步骤:


bitleftmidright
1-1800180
1090180
090135180
190112.5135
0112.5123.75135
0112.5118.125123.75
1112.5115.3125118.125
0115.3125116.71875118.125
1115.3125116.015625116.71875
0116.015625116.3671875116.71875
1116.015625116.19140625116.3671875
1116.19140625116.279296875116.3671875
0116.279296875116.323242188116.3671875
1116.279296875116.301269532116.323242188
0116.301269532116.31225586116.323242188

纬度步骤:


bitleftmidright
1-90090
004590
1022.545
122.533.7545
133.7539.37545
039.37542.187645
039.37540.7812542.1876
139.37540.07812540.78125
040.07812540.429687540.78125
040.07812540.2539062540.4296875
040.07812540.16601562540.25390625
040.07812540.122070312540.166015625
040.07812540.100097656340.1220703125
040.07812540.089111328240.1000976563
140.07812540.083618164140.0891113282

其思路就是不断二分,如果原本值大于mid那本bit位就是1,以此往下递归,最终,我们递归二分得到纬度方向上的二进制字符串为 101110010000001,长度为 15 位


那此时就拿到了30bit位的字符串,然后就开始进行拼接


结合经度字符串 110100101011010 和纬度字符串 101110010000001,我们遵循先经度后纬度的顺序,逐一交错排列,最终得到的一维字符串为 11100 11101 00100 11000 10100 01001.


然后再进行Base32编码,主要步骤就是首先会维护一个0-9A-Za-z中32个字符的数组,如:['a','b','1','2','3','4','5','6','7','A'...],然后再将这30位的字符串每五个一组(正好覆盖0-31的索引)去索引到指定字符以此拿到30/5=6位的base32编码去进行存储。


ps:注意并不一定是必要将经纬度都二分得到15位长度,多少位都可以,只是精度越高结果也就越精确,但是算力就越大,只需在此做出权衡即可




GeoHash如何应用到这个问题当中?


上面讲到了可以通过GeoHash将经纬度转换成bit位的字符串,那么怎么进行应用呢,其实答案很明显,其实如果经纬度越接近,他们的前缀匹配位数也就越长,比如


image.png
通过这个思路我们就比较容易得到我们想要的范围内的乘客了。


遗留问题


但是其实仅仅如此是不够的,因为一个base32其实是覆盖了一片区域的,它并不是说仅仅代表一个精确的ip地址,那这其实就衍生出了一些问题,就比如


image.png
,用geohash那结果显然是AB更近,但是实际上A与B的距离比AE、AC、AD都远。这其实是一个边缘性的问题........后续我会更新如何去避免这种问题的出现


作者:狗不理小包
来源:juejin.cn/post/7270916734138908672
收起阅读 »

用上了Jenkins,个人部署项目真方便!

作者:小傅哥 博客:bugstack.cn 项目:gaga.plus 沉淀、分享、成长,让自己和他人都能有所收获!😄 本文的宗旨在于通过简单干净实践的方式教会读者,如何在 Docker 中部署 Jenkins,并通过 Jenkins 完成对项目的打包构建并...
继续阅读 »

作者:小傅哥
博客:bugstack.cn
项目:gaga.plus



沉淀、分享、成长,让自己和他人都能有所收获!😄



本文的宗旨在于通过简单干净实践的方式教会读者,如何在 Docker 中部署 Jenkins,并通过 Jenkins 完成对项目的打包构建并在 Docker 容器中部署。


Jenkins 的主要作用是帮助你,把需要在本地机器完成的 Maven 构建、Docker 镜像发布、云服务器部署等系列动作全部集成在一个服务下。简化你的构建部署操作过程,因为 Jenkins 也被称为 CI&CD(持续集成&持续部署) 工具。提供超过 1000 个插件(Maven、Git、NodeJs)来支持构建、部署、自动化, 满足任何项目的需要。


官网:



本文涉及的工程:



一、操作说明


本节小傅哥会带着大家完成 Jenkins 环境的安装,以及以最简单的方式配置使用 Jenkins 完成对 xfg-dev-tech-jenkins 案例项目的部署。部署后可以访问 xfg-dev-tech-jenkins 项目提供的接口进行功能验证。整个部署操作流程如下;






  • 左侧竖列为核心配置部署流程,右侧是需要在配置过程中处理的细节。

  • 通过把本地对项目打包部署的过程拆解为一个个模块,配置到 Jenkins 环境中。这就是 Jenkins 的作用。


二、环境配置



  1. 确保你已经在(云)服务器上配置了 Docker 环境,以及安装了 docker-compose。同时最好已经安装了 Portainer 管理界面这样更加方便操作。

  2. 在配置和后续的验证过程中,会需要访问(云)服务的地址加端口。如果你在云服务配置的,记得开放端口;9000 - portainer9090 - jenkins8091 - xfg-dev-tech-app 服务


1. Jenkins 部署


1.1 上传文件






  • 如图;以上配置内容已经放到 xfg-dev-tech-jenkins 工程中,如果你是云服务器部署则需要将 dev-ops 部分全部上传到服务器的根目录下。

  • compose-down.sh 是 docker-compose 下载文件,只有你安装了 docker-compose 才能执行 docker-compose -f docker-compose-v1.0.yml up -d

  • jdk-down.sh 是 jdk1.8 下载路径,以及解压脚本。如果你在云服务器下载较慢,也可以本地搜索 jdk1.8 下载,并上传到云服务器上解压。注意:本步骤是可选的,如果你的项目不强依赖于 jdk1.8 也可以使用 Jenkins 默认自带的 JDK17。可以通过在安装后的 Jenkins 控制台执行 which java 找到 JDK 路径。

  • maven 下的 settings.xml 配置,默认配置了阿里云镜像文件,方便在 Jenkins 构建项目时,可以快速地拉取下载下来包。


1.2 脚本说明


version: '3.8'
# 执行脚本;docker-compose -f docker-compose-v1.0.yml up -d
services:
jenkins:
image: jenkins/jenkins:2.439
container_name: jenkins
privileged: true
user: root
ports:
- "9090:8080"
- "50001:50000"
volumes:
- ./jenkins_home:/var/jenkins_home # 如果不配置到云服务器路径下,则可以配置 jenkins_home 会创建一个数据卷使用
- /var/run/docker.sock:/var/run/docker.sock
- /usr/bin/docker:/usr/local/bin/docker
- ./maven/conf/settings.xml:/usr/local/maven/conf/settings.xml # 这里只提供了 maven 的 settings.xml 主要用于修改 maven 的镜像地址
- ./jdk/jdk1.8.0_202:/usr/local/jdk1.8.0_202 # 提供了 jdk1.8,如果你需要其他版本也可以配置使用。
environment:
- JAVA_OPTS=-Djenkins.install.runSetupWizard=false # 禁止安装向导「如果需要密码则不要配置」docker exec jenkins cat /var/jenkins_home/secrets/initialAdminPassword
restart: unless-stopped

volumes:
jenkins_home:

Jenkins Docker 执行安装脚本。



  • ./jenkins_home:/var/jenkins_home 是在云服务器端挂一个映射路径,方便可以重新安装后 Jenkins 依然存在。你也可以配置为 jenkins_home:/var/jenkins_home 这样是自动挂在 volumes jenkins_home 数据卷下。

  • docker 两个 docker 的配置是为了可以在 Jenkins 中使用 Docker 命令,这样才能在 Docker 安装的 Jenkins 容器内,使用 Docker 服务。

  • ./maven/conf/settings.xml:/usr/local/maven/conf/settings.xml 为了在 Jenkins 中使用映射的 Maven 配置。

  • ./jdk/jdk1.8.0_202:/usr/local/jdk1.8.0_202 用于在 Jenkins 中使用 jdk1.8

  • JAVA_OPTS=-Djenkins.install.runSetupWizard=false 这个是一个禁止安装向导,配置为 false 后,则 Jenkins 不会让你设置密码,也不会一开始就安装一堆插件。如果你需要安装向导可以注释掉这个配置。并且当提示你获取密码时,你可以执行;docker exec jenkins cat /var/jenkins_home/secrets/initialAdminPassword 获取到登录密码。


1.3 执行安装





[root@lavm-aqhgp9nber dev-ops]# docker-compose -f docker-compose-v1.0.yml up -d
[+] Building 0.0s (0/0)
[+] Running 1/0
✔ Container jenkins Running

执行脚本 docker-compose -f docker-compose-v1.0.yml up -d 后,这样执行完毕后,则表明已经安装成功了💐。


2. 插件安装


地址:http://localhost:9090/ - 登录Jenkins









  • 1~2步,设置镜像源,设置后重启一下 Jenkins。

  • 3~4步,下载插件,先下载安装 chinese 汉化插件,方便不太熟悉 Jenkins 的伙伴更好的知道页面都是啥内容。

  • 5步,所有的插件安装完成后,都需要重启才会生效。安装完 chinese 插件,重启在进入到 Jenkins 就是汉化的页面了

  • 除了以上步骤,你还需要同样的方式安装 maven、git、docker 插件。

  • 注意,因为网络问题你可以再做过程中,提示失败。没关系,你可以再搜这个插件,再重新下载。它会把失败的继续下载。


3. 全局工具配置


地址:http://localhost:9090/manage/configureTools/





用于构建部署的 SpringBoot 应用的环境,都需要在全局工具中配置好。包括;Maven、JDK、Git、Docker。注意这里的环境路径配置,如果配置了是会提示你没有对应的路径文件夹。


4. 添加凭证


地址:http://localhost:9090/manage/credentials/store/system/domain/_/






  • 配置了Git仓库的连接凭证,才能从Git仓库拉取代码。

  • 如果你还需要操作如 ssh 也需要配置凭证。


三、新建任务


一个任务就是一条构建发布部署项目的操作。


1. 配置任务





xfg-dev-tech-jenkins

2. 配置Git





# 你可以 fork 这个项目,到自己的仓库进行使用
https://gitcode.net/KnowledgePlanet/ddd-scene-solution/xfg-dev-tech-content-moderation.git

3. 配置Maven






  • 在高级中设置 Maven 配置的路径 /usr/local/maven/conf/settings.xml。这样才能走自己配置的阿里云镜像仓库。


clean install -Dmaven.test.skip=true

3. 配置Shell


# 先删除之前的容器和镜像文件
if [ "$(docker ps -a | grep xfg-dev-tech-app)" ]; then
docker stop xfg-dev-tech-app
docker rm xfg-dev-tech-app
fi
if [ "$(docker images -q xfg-dev-tech-app)" ]; then
docker rmi xfg-dev-tech-app
fi

#
重新生成
cd /var/jenkins_home/workspace/xfg-dev-tech-jenkins/xfg-dev-tech-app
docker build -t xiaofuge/xfg-dev-tech-app .
docker run -itd -p 8091:8091 --name xfg-dev-tech-app xiaofuge/xfg-dev-tech-app





  • 当你熟悉后还可以活学活用,比如这里只是做build 但不做run执行操作。具体的部署可以通过 docker compose 执行部署脚本。

  • 另外如果你有发布镜像的诉求,也可以在这里操作。


四、测试验证


1. 工程准备


工程https://gitcode.net/KnowledgePlanet/road-map/xfg-dev-tech-jenkins 你可以fork到自己的仓库进行使用,你的账号密码就是 CSDN 的账号密码。


@SpringBootApplication
@RestController()
@RequestMapping("/api/")
public class Application {

public static void main(String[] args) {
SpringApplication.run(Application.class);
}

/**
* http://localhost:8091/api/test
*/

@RequestMapping(value = "/test", method = RequestMethod.GET)
public ResponseBodyEmitter test(HttpServletResponse response) {
response.setContentType("text/event-stream");
response.setCharacterEncoding("UTF-8");
response.setHeader("Cache-Control", "no-cache");

ResponseBodyEmitter emitter = new ResponseBodyEmitter();

String[] words = new String[]{"嗨,臭宝。\r\n", "恭喜💐 ", "你的", " Jenkins ", " 部", "署", "测", "试", "成", "功", "了啦🌶!"};
new Thread(() -> {
for (String word : words) {
try {
emitter.send(word);
Thread.sleep(250);
} catch (IOException | InterruptedException e) {
throw new RuntimeException(e);
}
}
}).start();

return emitter;
}

}


2. CI&CD - 构建发布


地址http://localhost:9090/job/xfg-dev-tech-jenkins/






  • 点击构建项目,最终会完成构建和部署成功。运行到这代表你全部操作完成了。


3. 验证结果


地址http://localhost:9000/#!/2/docker/containers





访问http://localhost:8091/api/test






  • 运行到这代表着你已经完整的走完了 Jenkins CI&CD 流程。


作者:小傅哥
来源:juejin.cn/post/7329573732597710874
收起阅读 »

https 协议是安全传输,为啥还要再加密?

背景这几天,我们家娃吃奶粉的量嗷嗷的涨起来了。我这颗小鹿乱撞的心,忍不住去面了两家互联网金融公司。因为没有准备,结果你懂的~这两家共同都有一个共同点,特别关系安全问题,尤其是看到你用过 okhttp3,那不得给你撸脱毛了就不算完事儿。协议HTTP vs HTT...
继续阅读 »

背景

这几天,我们家娃吃奶粉的量嗷嗷的涨起来了。我这颗小鹿乱撞的心,忍不住去面了两家互联网金融公司。
因为没有准备,结果你懂的~
这两家共同都有一个共同点,特别关系安全问题,尤其是看到你用过 okhttp3,那不得给你撸脱毛了就不算完事儿。

协议

HTTP vs HTTPS

我们都知道,https 相比于之前的 http 多了一层, 如下:

image.png
HTTP是一个基于TCP/IP通信协议来传递数据的协议,TCP/IP通信协议只有四层,从上往下依次为:应用层、传输层、网络层、数据链路层这四层,大学课本上的计算机网络知识是不是来了。但是,HTTP 协议在在网上进行传输用的是明文,就像某鸟给你发的快递,你的手机号、姓名都是写的清清楚楚,用户看着都瑟瑟发抖。
后来京东和顺丰意识到了这一点,就对手机号中间四位做了加密处理,姓名中间那几个个字也看不到了,甚至快递小哥给你打电话都是虚拟号码,你自己的电话只有自己心里清楚。
HTTPS 也是这个路子,为了解决 HTTP 明文传输存在的安全问题,在应用层和传输层之间加了一层安全层:SSL/TLS。
SSL: Secure Socket Layer, 安全套接层
TLS: Transport Layer Security,传输层安全协议
关于 HTTP 和 HTTPS 的对比文章,知乎上有一大坨,这里贴几篇,喜欢的可以多撸几遍:

HTTPS优缺点

  1. 使用 HTTP 明文传输存在被窃听风险,HTTPS 通过内容加密解决,注意这一步用的是对称加密哦
  2. 使用 HTTP 明文传输存在通信内容被篡改风险,HTTPS 通过校验机制解决
  3. 使用 HTTP 明文传输存在冒充服务风险,HTTPS 通过身-份-证书认证机制解决

通过以上三点,我们知道HTTPS 相对于 HTTP 有很强的安全感,但是对于一些app 这种安全级别还不够,如果出现以下意外咋整?

我干脆去问了讯飞星火,他是这么回答我的:

讯飞星火

我又去问了 ChatGPT,他的回答是这样的

ChatGPT

这俩货说的差不多,HTTPS 只能保证传输过程的安全性,不能保证全链路的安全性,更不能满足特定安全标准的规范诉求,所以我们需要根据安全的级别要求,采取不同加密策略。

既然如此,我们使用的三方网络库这里特指 okhttp 是怎么全链路传输安全的?我们接着往下唠:

OKhttp 库

Okhttp 库是基于HTTP和HTTPS协议封装的网络请求库,用于 Android 和 Java 应用中的网络通信。库在这儿:github.com/square/okht… ,

我们可以先了解一下okhttp 库是如何保证传输安全的~

问:OKhttp是如何支持https协议,以保证传输安全的,尤其是如何进行防止侦听、篡改、拦截信息?

AI 回答:
OkHttp是一个流行的开源HTTP客户端库,由Square公司开发。它提供了对HTTP和HTTPS的支持,并通过内置的TLS(Transport Layer Security)来确保HTTPS连接的安全性。以下是OkHttp如何支持HTTPS协议以及保障传输安全的一些关键方面:

  1. TLS/SSL支持:

    • OkHttp使用Java的javax.net.ssl包来支持TLS/SSL协议。TLS是HTTP的安全版本,用于加密通信内容,防止中间人攻击、窃听和篡改。
    • OkHttp通过SSLSocketFactoryHostnameVerifier等类来处理TLS/SSL握手和验证服务器证书的过程。
  2. 证书验证:

    • OkHttp会验证服务器提供的SSL证书,确保通信双方的身份合法。如果服务器的证书无效或无法验证,OkHttp会拒绝连接,从而防止中间人攻击。
  3. 证书锁定(Certificate Pinning):

    • OkHttp支持证书锁定机制,即在应用中预先指定服务端的证书信息。这样可以防止使用伪造证书的攻击,即使系统受到信任的根证书被破坏,应用仍然可以保持安全。
  4. 连接池和持久连接:

    • OkHttp通过连接池(Connection Pool)来重用已建立的连接,从而减少握手次数,提高性能。持久连接也有助于减少建立连接时的潜在风险。
  5. 安全配置:

    • OkHttp允许开发者配置TLS版本、密码套件(Cipher Suites)等参数,以满足特定的安全要求。
  6. 拦截器(Interceptors):

    • OkHttp提供了拦截器机制,可以在HTTP请求和响应的过程中插入自定义的逻辑。这使得开发者可以在请求或响应阶段添加额外的安全检查或处理逻辑,从而增强安全性。

总的来说,OkHttp通过支持TLS/SSL、证书验证、证书锁定、连接池等安全机制,以及提供灵活的配置和拦截器功能,确保在HTTPS连接中防止侦听、篡改和拦截信息的风险。开发者在使用OkHttp时,应该合理配置相关参数,以满足应用的安全需求。

好家伙,okhttp 是支持合理配置安全参数的,而且其拦截器机制可以很方便的进行安全检查,那还等什么,走一波~

策略一:对称加密(加密内容)

这里主打一个,客户端不想让别人看到自己的传输内容,想当然对自己的请求内容进行加密处理。基于这种思路我们封装一个 EncryptedOkHttpClient,代码如下:

public static OkHttpClient createEncryptedOkHttpClient() {
// 创建一个OkHttpClient.Builder
OkHttpClient.Builder builder = new OkHttpClient.Builder();

// 添加自定义拦截器,用于加密请求内容
builder.addInterceptor(new EncryptionInterceptor());

// 创建OkHttpClient实例
return builder.build();
}

注释里已经写了,通过EncryptionInterceptor拦截器对请求进行加密处理,这里选择加密请求体 RequestBody image.png 在encryptRequestBody方法中,RequestBody 依赖 okio 的 Buffer 类转换为ByteArray用于加密,加密算法选择对称加密算法 AES 加密字节数据,实现如下:

private RequestBody encryptRequestBody(RequestBody originalRequestBody) throws IOException {
// 从原始RequestBody中读取字节数据
// Read the byte data from the original RequestBody using Okio
Buffer buffer = new Buffer();
originalRequestBody.writeTo(buffer);
byte[] bytes = buffer.readByteArray();

// 使用对称加密算法(AES)加密字节数据
byte[] encryptedBytes = encryptWithAES(bytes, SECRET_KEY);

// 创建新的RequestBody
return RequestBody.create(originalRequestBody.contentType(), encryptedBytes);
}

可以看到,AES 使用了encryptWithAES方法加密字节数据,同时传了SECRET_KEY这个密钥,那我们看看 AES 是怎么加密的:

private byte[] encryptWithAES(byte[] input, String key) {
try {
SecretKey secretKey = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), AES_ALGORITHM);
Cipher cipher = Cipher.getInstance(AES_ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
return cipher.doFinal(input);
} catch (Exception e) {
e.printStackTrace();
return new byte[0];
}
}

四行代码搞定,首先通过SecretKeySpec类将SECRET_KEY字符串加密成 SecretKey 对象,然后Cipher以加密模式 对密钥进行初始化然后加密 input 也就是转换为字节数组的请求体。 加密完成了,服务器当然要进行解密,解密方法如下:

public static String decrypt(String encryptedText) {
try {
byte[] encryptedData = Base64.decode(encryptedText,Base64.DEFAULT);

SecretKey secretKey = new SecretKeySpec(SECRET_KEY.getBytes(StandardCharsets.UTF_8), AES_ALGORITHM);
Cipher cipher = Cipher.getInstance(AES_ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, secretKey);

byte[] decryptedBytes = cipher.doFinal(encryptedData);

return new String(decryptedBytes, StandardCharsets.UTF_8);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}

可以看到,解密过程使用了相同AES算法和密钥SECRET_KEY,这就是对称加密使用一把钥匙上锁和开锁。但是这种加密算法有很大的问题:

首先,这把钥匙如果想通过网络传输让服务端知道,传输过程中被劫持了密钥就会暴露。

另外,SECRET_KEY是硬编码在代码中的,这也不安全,这可咋整啊?

千里之堤,溃于hacker

为了防止这种中间人攻击的问题,非对称加密开始表演了~

策略二:非对称加密

非对称加密是一把锁两把钥匙:公钥和私钥。前者是给大家伙用的,谁都能够来配一把公钥进行数据加密,但是要对加密数据进行解密,只能使用私钥。

假设我们用公钥加密一份数据,就不怕拦截了。因为只有拿着私钥的服务端才能解密数据,我们拿着服务器提供的公钥把策略一中的对称密钥给加密了,那不就解决了网络传输密钥的问题了。对的,HTTPS 也是这么做的,按照这个思路我们再添加一个 MixtureEncryptionInterceptor 拦截器。

// 添加自定义拦截器,用服务器非对称加密的公钥加密对称加密的密钥,然后用对称加密密钥加密请求内容
builder.addInterceptor(new MixtureEncryptionInterceptor());

MixtureEncryptionInterceptor 拦截器同样实现 Interceptor 接口如下:

image.png

其 intercept 方法跟 EncryptionInterceptor 一模一样,具体的变化在 encryptRequestBody() 方法中。具体实现如下:

private RequestBody encryptRequestBody(RequestBody originalRequestBody) throws IOException {
// 生成对称加密的密钥
byte[] secretKeyBytes = generateSecretKey();
SecretKeySpec secretKeySpec = new SecretKeySpec(secretKeyBytes, "AES");
// 使用服务器的公钥加密对称加密的密钥
byte[] encryptedSecretKey = encryptWithPublicKey(secretKeyBytes, SERVER_PUBLIC_KEY);
// 从原始 RequestBody 中读取字节数据
Buffer buffer = new Buffer();
originalRequestBody.writeTo(buffer);
byte[] bytes = buffer.readByteArray();

// 使用对称加密算法(AES)加密请求体
byte[] encryptedRequestBodyBytes = encryptWithAES(bytes, secretKeySpec);

// 创建新的 RequestBody,将加密后的密钥和请求体一并传输
return RequestBody.create(null, concatenateArrays(encryptedSecretKey, encryptedRequestBodyBytes));
}

如代码中注释,整个混合加密共 4 个步骤,依次是:

  1. 生成对称加密的密钥,用来加密传输内容。代码如下:
/**
* try block 里使用的是加密算法和随机数生成器,生成的较为复杂的密钥
* catch block 里使用的是示范性的非安全密钥
* @return
*/

private byte[] generateSecretKey() {
// 生成对称加密的密钥
try {
// 创建KeyGenerator对象,指定使用AES算法
KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");

// 初始化KeyGenerator对象,设置密钥长度为128位
keyGenerator.init(128, new SecureRandom());

// 生成密钥
SecretKey secretKey = keyGenerator.generateKey();

// 获取密钥的字节数组表示形式
byte[] keyBytes = secretKey.getEncoded();

// 打印密钥的字节数组表示形式
for (byte b : keyBytes) {
Log.d(TAG,b + " ");
}
return keyBytes;
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
// 这里简单地示范了生成密钥的过程,实际上可以使用更复杂的方法来生成密钥
return "YourSecretKey".getBytes(StandardCharsets.UTF_8);
}

}

如注释所言,上面try block 里使用的是加密算法和随机数生成器,生成的较为复杂的密钥,catch block 里使用的是示范性的非安全密钥。这里主要是想说明生成对称密钥的方式有很多,但是硬编码生成密钥那是不推荐的,因为太不安全了,很容易被恶意用户获取到。

  1. 使用服务器的公钥加密对称加密的密钥,防止被破解
private byte[] encryptWithPublicKey(byte[] input, String publicKeyString) {
try {
// 封装 PublicKey
byte[] keyBytes = Base64.decode(publicKeyString, Base64.DEFAULT);
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
PublicKey publicKey = keyFactory.generatePublic(keySpec);

Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
cipher.init(Cipher.ENCRYPT_MODE, publicKey);

return cipher.doFinal(input);
} catch (NoSuchAlgorithmException | InvalidKeySpecException | NoSuchPaddingException |
InvalidKeyException | BadPaddingException | IllegalBlockSizeException e) {
e.printStackTrace();
return new byte[0];
}
}

将服务端提供的公钥字符串转化成字节数组,然后通过 RSA 非对称算法加密 input,也就是我们的对称密钥。

注意:Cipher.getInstance("RSA/ECB/PKCS1Padding") 表示获取一个Cipher对象,该对象使用RSA算法、ECB模式和PKCS1填充方式。

  1. 使用对称加密算法(AES)加密请求体,请求体仍然要用对称加密密钥加密,只是对称加密密钥用公钥保护起来
private byte[] encryptWithAES(byte[] input, SecretKeySpec secretKeySpec) {
try {
Cipher cipher = Cipher.getInstance("AES");
cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec);
return cipher.doFinal(input);
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException |
BadPaddingException | IllegalBlockSizeException e) {
e.printStackTrace();
return new byte[0];
}

}

非对称加密加密内容,策略一已经实现了。

  1. 创建新的 RequestBody,将加密后的密钥和请求体一并传输,这样就算 hacker 拦截了请求解析出请求体的数据,也无法直接获取到原始对称密钥。 加密完成后,通过 concatenateArrays 方法将加密后的密钥和请求体,实现如下:
private byte[] concatenateArrays(byte[] a, byte[] b) {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
try {
outputStream.write(a);
outputStream.write(b);
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return outputStream.toByteArray();
}

非对称加密解决了密钥网络传输的问题,但是 hacker 还是可以伪装成目标服务端,骗取客户端的密钥。在伪装成客户端,用服务端的公钥加密自己篡改的内容,目标服务端对此无法辨别真伪。这就需要证书校验。

策略三:证书校验(单向认证)

okhttp3 提供了CertificatePinner这个类用于证书校验,CertificatePinner 可以验证服务器返回的证书是否是预期的证书。在创建createEncryptedOkHttpClient()方法中,添加证书代码如下:

image.png

okhttp 会利用其内置的证书固定机制来校验服务器返回证书的有效性。如果证书匹配,请求会继续进行;如果不匹配,OkHttp会抛出一个异常,通常是一个SSLPeerUnverifiedException,表明证书验证失败。验证过程在CertificatePinner 类的check()方法中,如下:

/**
* Confirms that at least one of the certificates pinned for {@code hostname} is in {@code
* peerCertificates}. Does nothing if there are no certificates pinned for {@code hostname}.
* OkHttp calls this after a successful TLS handshake, but before the connection is used.
*
* @throws SSLPeerUnverifiedException if {@code peerCertificates} don't match the certificates
* pinned for {@code hostname}.
*/

public void check(String hostname, List peerCertificates)
throws SSLPeerUnverifiedException {
List pins = findMatchingPins(hostname);
if (pins.isEmpty()) return;

if (certificateChainCleaner != null) {
peerCertificates = certificateChainCleaner.clean(peerCertificates, hostname);
}

for (int c = 0, certsSize = peerCertificates.size(); c < certsSize; c++) {
X509Certificate x509Certificate = (X509Certificate) peerCertificates.get(c);

// Lazily compute the hashes for each certificate.
ByteString sha1 = null;
ByteString sha256 = null;

for (int p = 0, pinsSize = pins.size(); p < pinsSize; p++) {
Pin pin = pins.get(p);
if (pin.hashAlgorithm.equals("sha256/")) {
if (sha256 == null) sha256 = sha256(x509Certificate);
if (pin.hash.equals(sha256)) return; // Success!
} else if (pin.hashAlgorithm.equals("sha1/")) {
if (sha1 == null) sha1 = sha1(x509Certificate);
if (pin.hash.equals(sha1)) return; // Success!
} else {
throw new AssertionError();
}
}
}

// If we couldn't find a matching pin, format a nice exception.
StringBuilder message = new StringBuilder()
.append("Certificate pinning failure!")
.append("\n Peer certificate chain:");
for (int c = 0, certsSize = peerCertificates.size(); c < certsSize; c++) {
X509Certificate x509Certificate = (X509Certificate) peerCertificates.get(c);
message.append("\n ").append(pin(x509Certificate))
.append(": ").append(x509Certificate.getSubjectDN().getName());
}
message.append("\n Pinned certificates for ").append(hostname).append(":");
for (int p = 0, pinsSize = pins.size(); p < pinsSize; p++) {
Pin pin = pins.get(p);
message.append("\n ").append(pin);
}
throw new SSLPeerUnverifiedException(message.toString());
}

从校验方法中得知,

  1. 可以没有固定证书
  2. 证书加密使用sha256/sha1
  3. 证书校验失败会抛出AssertionError错误
  4. 获取不到匹配的固定证书,会抛异常SSLPeerUnverifiedException

可以看到,使用相当方便。但是它有一个问题:请求之前需要预先知道服务端证书的 hash 值。就是说如果证书到期需要更换,老版本的应用就无法获取到更新的证书 hash 值了,老用户要统一升级。这~~~

策略四:创建SSLContext认证(客户端、服务端双向认证)

除了固定证书校验,还有一种基于 SSLContext 的校验方式。在建立HTTPS连接时,在客户端它依赖 SSLContext 和 TrustManager 来验证服务端证书。这里我们通过一createTwoWayAuthClient()方法实现如下:

private static OkHttpClient createTwoWayAuthClient() throws IOException, CertificateException, KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException, KeyManagementException {
// 服务器证书
InputStream serverCertStream = TwoWayAuthHttpClient.class.getResourceAsStream("/server_certificate.crt");
X509Certificate serverCertificate = readCertificate(serverCertStream);
if (serverCertStream != null) {
serverCertStream.close();
}

// 客户端证书和私钥
InputStream clientCertStream = TwoWayAuthHttpClient.class.getResourceAsStream("/client_centificate.p12");
KeyStore clientKeyStore = KeyStore.getInstance("PKCS12");
clientKeyStore.load(clientCertStream, "client_password".toCharArray());
if (clientCertStream != null) {
clientCertStream.close();
}

// 创建 KeyManagerFactory 和 TrustManagerFactory
KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
keyManagerFactory.init(clientKeyStore, "client_password".toCharArray());

// 创建信任管理器,信任服务器证书
TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
trustStore.load(null, null);
trustStore.setCertificateEntry("server", serverCertificate);
trustManagerFactory.init(trustStore);

// 初始化 SSL 上下文
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(keyManagerFactory.getKeyManagers(), trustManagerFactory.getTrustManagers(), new SecureRandom());

// 创建 OkHttpClient
return new OkHttpClient.Builder()
.sslSocketFactory(sslContext.getSocketFactory(), (X509TrustManager) trustManagerFactory.getTrustManagers()[0])
.build();
}

private static X509Certificate readCertificate(InputStream inputStream) throws CertificateException {
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
return (X509Certificate) certificateFactory.generateCertificate(inputStream);
}
  1. 加载服务器证书

    • 使用getResourceAsStream从类路径中加载服务器证书文件(.crt格式)。
    • 通过readCertificate方法读取证书内容,并生成一个X509Certificate对象。
    • 关闭输入流以释放资源。

注意:/server_certificate.crt可以动态加载服务器自签名证书的办法避免更新旧版本应用

  1. 加载客户端证书和私钥

    • 同样使用getResourceAsStream从类路径中加载客户端证书和私钥文件(.p12格式,通常是PKCS#12格式的密钥库)。
    • 创建一个KeyStore实例,并使用PKCS12算法加载客户端证书和私钥。密码为"client_password"
    • 关闭输入流。
  2. 创建KeyManagerFactory和TrustManagerFactory

    • KeyManagerFactory用于管理客户端的私钥和证书,以便在建立SSL/TLS连接时使用。
    • TrustManagerFactory用于管理信任的证书,以便在建立SSL/TLS连接时验证服务器的证书。
    • 使用默认算法初始化这两个工厂,并分别加载客户端的密钥库和信任的服务器证书。
  3. 初始化SSLContext

    • 创建一个SSLContext实例,指定使用TLS协议。
    • 使用之前创建的KeyManagerFactoryTrustManagerFactory初始化SSLContext。这会将客户端的私钥和证书,以及信任的服务器证书整合到SSL/TLS握手过程中。
  4. 创建OkHttpClient

    • 使用OkHttpClient.Builder创建一个新的OkHttpClient实例。
    • 配置SSL套接字工厂和信任管理器,以确保在建立连接时使用两向认证。
    • 构建并返回配置好的OkHttpClient实例。

这样客户端发起请求时,会将客户端证书发送给服务端,同时会校验服务端握手时返回的证书。校验逻辑如下:

image.png

这样整个双向校验工作就完成了。

封装

腾讯云有个同学封装了库,主要给服务端使用的,看的挺有味道,可以参考 cloud.tencent.com/developer/a…

总结

okhttp 作为一个支持 HTTPS 协议的网络库,同时支持对称加密非对称加密客户端证书校验客户端、服务端双向证书校验等安全加密方式,足见其强大的功能。

此外,为了兼顾性能:它使用证书校验保证通信双方的合法性,使用对称加密加密传输内容保证性能,使用非对称加密加密对称密钥防止hacker 拦截,整体提高了网络通信的安全性。

FAQ

文章被郭霖老师转发后,同学们也提出了一些疑问:
Q: HTTPS为啥不能保证全链路安全?

  1. 端点安全性: 如果你的手机、电脑、服务器中毒了,不管输入啥私密信息,都会被病毒软件截胡,https 管不了这事儿。需要杀毒软件大显身手了,给腾讯手机管家做个广告~
  2. 中间人攻击: hacker 通过非法方式获得 CA 证书,满足了 https 的安全策略,可以与客户端通信。okhttp 可以通过证书锁定(Certificate Pinning)的方式,只跟特定的服务器通讯,自签名证书不通过,就算 hacker 黑了 CA 机构你也不怕
  3. 协议漏洞:okhttp 团队也会定期更新修复漏洞,所以版本该升级升级

Q: SSLContext如何动态更新证书

其实这个问题的关键还是不理解 your_certificate.crt 下载过程中被攻击了咋办。首先,第一版应用的证书秘密存储。其次,后期更新的过程中,下载链路是安全的,自动替换最新的证书并通过安全校验就 OL

Q:PKCS1 有安全问题,建议使用 OAEP

import javax.crypto.Cipher;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;

public class RSAUtil {
public static byte[] encrypt(byte[] data, PublicKey publicKey) throws Exception {
Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
return cipher.doFinal(data);
}

public static byte[] decrypt(byte[] encryptedData, PrivateKey privateKey) throws Exception {
Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
cipher.init(Cipher.DECRYPT_MODE, privateKey);
return cipher.doFinal(encryptedData);
}

public static PublicKey getPublicKey(byte[] publicKeyBytes) throws Exception {
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(publicKeyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
return keyFactory.generatePublic(keySpec);
}

public static PrivateKey getPrivateKey(byte[] privateKeyBytes) throws Exception {
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
return keyFactory.generatePrivate(keySpec);
}
}

Q:证书固定问题

Certificate Pinning 涉及到涉及到三层证书:根证书(Root Certificate)、中间证书(Intermediate Certificate)和服务器证书(Server Certificate)。每个证书都有自己的公钥,因此在证书固定中需要验证这三个证书的公钥。 具体做法是,将服务器证书和根证书的 hash 值添加到证书固定中,这样,在建立连接时,除了验证服务器证书的公钥外,还会验证中间证书和根证书的公钥,确保整个证书链的完整性和真实。

这里以 example.com为例:

import okhttp3.CertificatePinner;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;

public class CertificatePinningExample {

public static void main(String[] args) throws Exception {
OkHttpClient client = new OkHttpClient.Builder()
.certificatePinner(new CertificatePinner.Builder()
.add("example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") // 服务器证书的哈希值
.add("example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") // 根证书的哈希值
.build())
.build();

Request request = new Request.Builder()
.url("https://example.com")
.build();

try (Response response = client.newCall(request).execute()) {
System.out.println(response.body().string());
}
}
}

AI 是个好东西~

参考文章


作者:hongyi0609
来源:juejin.cn/post/7333162360360796171
收起阅读 »

用位运算维护状态码,同事直呼牛X!

位运算是一种非常高效的运算方式。在算法考察中比较常见,它使用位级别的操作来表示和控制状态,这在管理多个布尔标志或状态时尤其有用。那么业务代码中我们如何使用位运算呢? 位运算基础 我们先来回顾一下位运算的基础: 与(AND)运算:只有当两个位都是1时,结果才是...
继续阅读 »

位运算是一种非常高效的运算方式。在算法考察中比较常见,它使用位级别的操作来表示和控制状态,这在管理多个布尔标志或状态时尤其有用。那么业务代码中我们如何使用位运算呢?


位运算基础


我们先来回顾一下位运算的基础:



  • 与(AND)运算:只有当两个位都是1时,结果才是1(a & b)。

  • 或(OR)运算:如果两个位中至少有一个为1,那么结果就是1(a | b)。

  • 异或(XOR)运算:如果两个位不同,则结果为1(a ^ b)。

  • 非(NOT)运算:反转位的值(~a)。

  • 左移:将位向左移动,右侧填充0(a << b)。

  • 右移:将位向右移动,左侧填充0(a >> b)。


业务状态码应用


如何通过位运算维护业务状态码呢?我们可以在一个整数中存储多个布尔值,每个位代表一个不同的状态或标志。


让我们将上述课程状态的例子修改为管理订单状态的示例。假设一个订单有以下几种状态:已创建(Created)、已支付(Paid)、已发货(Shipped)、已完成(Completed)。


定义状态常量


我们首先定义这些状态作为常量,并为每个状态分配一个位:



  • 已创建(Created): 0001 (1)

  • 已支付(Paid): 0010 (2)

  • 已发货(Shipped): 0100 (4)

  • 已完成(Completed): 1000 (8)


Java 实现


接下来,我们在Java中实现一个OrderStatus类来管理这些状态:


public class OrderStatus {

    private static final int CREATED = 1;   // 0001
    private static final int PAID = 2;      // 0010
    private static final int SHIPPED = 4;   // 0100
    private static final int COMPLETED = 8// 1000

    private int status;

    public OrderStatus() {
        this.status = CREATED; // 默认状态为已创建
    }

    // 添加状态
    public void addStatus(int status) {
        this.status |= status;
    }

    // 移除状态
    public void removeStatus(int status) {
        this.status &= ~status;
    }

    // 检查是否有特定状态
    public boolean hasStatus(int status) {
        return (this.status & status) == status;
    }

    // 示例输出
    public static void main(String[] args) {
        OrderStatus orderStatus = new OrderStatus();

        System.out.println("-------订单已支付-----------");
        // 假设订单已支付
        orderStatus.addStatus(PAID);
        System.out.println("创建订单是否创建 " + orderStatus.hasStatus(CREATED));
        System.out.println("创建订单是否支付 " + orderStatus.hasStatus(PAID));

        // 假设订单已发货
        System.out.println("-------订单已发货-----------");
        orderStatus.addStatus(SHIPPED);
        System.out.println("创建订单是否发货 " + orderStatus.hasStatus(SHIPPED));

        // 假设订单已完成
        System.out.println("-------假设订单已完成-----------");
        orderStatus.addStatus(COMPLETED);
        System.out.println("创建订单是否完成 " + orderStatus.hasStatus(COMPLETED));
    }
}

运行结果:


截屏2024-03-06 12.09.07.png


在这个例子中,我们通过OrderStatus类使用位运算来管理订单的不同状态。这种方式允许订单在其生命周期中拥有多个状态,而且能够高效地检查、添加或删除这些状态。当订单状态变化时,我们只需要简单地调用相应的方法来更新状态。这样实现后相信同事肯定对你刮目的!


作者:半亩方塘立身
来源:juejin.cn/post/7343138804482408448
收起阅读 »

通过ip查询归属地 要小心了

背景 最近公司做了一些营销活动,投入资金进行了流量推广,pv、UV都做了统计。老板说,我要看下用户的区域分布的数据。 以前的文章我讲过,pv、UV如何统计?我们是基于ip进行统计的。用的ip能获取到,那通过ip查询归属地就ok了。 思维扩展下,ip 查询归属地...
继续阅读 »

背景


最近公司做了一些营销活动,投入资金进行了流量推广,pv、UV都做了统计。老板说,我要看下用户的区域分布的数据。


以前的文章我讲过,pv、UV如何统计?我们是基于ip进行统计的。用的ip能获取到,那通过ip查询归属地就ok了。


思维扩展下,ip 查询归属地的的场景还蛮多的,我列举一些:


场景



  1. 网络安全调查:当发生网络攻击或恶意行为时,通过查询IP地址的归属地可以帮助调查人员追踪攻击者的位置和身份,进而采取相应的应对措施。

  2. 电商网站反欺诈:电商平台可以通过查询IP的归属地来检测是否有异常行为,如异地登录或使用虚假身份信息下单,从而防止欺诈行为发生。

  3. 广告定向投放:在在线广告市场中,根据用户所在地区进行IP归属地查询可以帮助广告主精准定位目标受众,提高广告投放效果和ROI。

  4. 地理位置服务:地图应用、天气预报和周边生活服务等可以利用IP归属地查询来确定用户的大概地理位置,提供个性化的地理服务和信息。

  5. 网站流量分析:网站管理员可以利用IP归属地查询来分析网站访问的地域分布情况,评估市场覆盖范围,制定针对性的营销策略和内容优化计划。


这些具体的使用场景说明了IP归属地查询在网络安全、营销推广、个性化服务等方面的重要作用,能够帮助用户更好地理解用户行为和优化业务流程。


谷歌搜索了下,第三方提供的ip查询归属地服务,挺多的,但是收费、收费、收费!!!免费也有些,但是怕不稳定。


无意间找到了ip2region这个项目,一直持续维护更新,试用后,效果杠杆的。那我们怎么用的,继续往下看


ip2region


Ip2region 是什么


ip2region - 是一个离线IP地址定位库和IP定位数据管理框架,10微秒级别的查询效率,提供了众多主流编程语言的 xdb 数据生成和查询客户端实现。


Ip2region 特性


1、IP 数据管理框架


xdb 支持亿级别的 IP 数据段行数,默认的 region 信息都固定了格式:国家|区域|省份|城市|ISP,缺省的地域信息默认是0。 region 信息支持完全自定义,例如:你可以在 region 中追加特定业务需求的数据,例如:GPS信息/国际统一地域信息编码/邮编等。也就是你完全可以使用 ip2region 来管理你自己的 IP 定位数据。


2、数据去重和压缩


xdb 格式生成程序会自动去重和压缩部分数据,默认的全部 IP 数据,生成的 ip2region.xdb 数据库是 11MiB,随着数据的详细度增加数据库的大小也慢慢增大。


3、极速查询响应


即使是完全基于 xdb 文件的查询,单次查询响应时间在十微秒级别,可通过如下两种方式开启内存加速查询:



  1. vIndex 索引缓存 :使用固定的 512KiB 的内存空间缓存 vector index 数据,减少一次 IO 磁盘操作,保持平均查询效率稳定在10-20微秒之间。

  2. xdb 整个文件缓存:将整个 xdb 文件全部加载到内存,内存占用等同于 xdb 文件大小,无磁盘 IO 操作,保持微秒级别的查询效率。


Ip2region 支持那些语言


Ip2region大部分主流语言都支持,支持的语言如下:



Ip2region怎么用


在这里,我以golang语言作为演示,其他语言,可以看下官方文档


例子:我需要查询ip为:218.63.140.248 的归属地


下载ip2region.xdb包


访问ip2region 项目,ip的库文件在data目录下,点击下载即可



package 获取


go get github.com/lionsoul2014/ip2region/binding/golang

完全基于文件的查询


package main

import (
"fmt"
"github.com/lionsoul2014/ip2region/binding/golang/xdb"
"time"
)

func main() {
//dbPath写入你下载的ip2region.xdb文件的路径,我这里放在了当前目录下
var dbPath = "ip2region.xdb"
searcher, err := xdb.NewWithFileOnly(dbPath)
if err != nil {
fmt.Printf("failed to create searcher: %s\n", err.Error())
return
}
defer searcher.Close()
// 查询218.63.140.248对应的地址
var ip = "218.63.140.248"
var tStart = time.Now()
region, err := searcher.SearchByStr(ip)
if err != nil {
fmt.Printf("failed to SearchIP(%s): %s\n", ip, err)
return
}
fmt.Printf("{region: %s, took: %s}\n", region, time.Since(tStart))
// 备注:并发使用,每个 goroutine 需要创建一个独立的 searcher 对象。
}

查询结果


此ip的归属地为: 中国云南省昆明市电信



缓存整个 xdb 数据


可以预先加载整个 ip2region.xdb 到内存,完全基于内存查询,类似于之前的 memory search 查询。


package main

import (
"fmt"
"github.com/lionsoul2014/ip2region/binding/golang/xdb"
"time"
)

func main() {
//dbPath写入你下载的ip2region.xdb文件的路径,我这里放在了当前目录下
var dbPath = "ip2region.xdb"
// 1、从 dbPath 加载整个 xdb 到内存
cBuff, err := xdb.LoadContentFromFile(dbPath)
if err != nil {
fmt.Printf("failed to load content from `%s`: %s\n", dbPath, err)
return
}

// 2、用全局的 cBuff 创建完全基于内存的查询对象。
searcher, err := xdb.NewWithBuffer(cBuff)
if err != nil {
fmt.Printf("failed to create searcher with vector index: %s\n", err)
return
}
defer searcher.Close()
// 查询218.63.140.248对应的地址
var ip = "218.63.140.248"
var tStart = time.Now()
region, err := searcher.SearchByStr(ip)
if err != nil {
fmt.Printf("failed to SearchIP(%s): %s\n", ip, err)
return
}
fmt.Printf("{region: %s, took: %s}\n", region, time.Since(tStart))
// 备注:并发使用,每个 goroutine 需要创建一个独立的 searcher 对象。
}

查询结果:



方案比对



  • 基于文件的查询,响应时间:38us

  • 基于缓存的查询,响应时间:10.29µs


生成环境使用建议使用方式为:基于缓存的查询


生产如何使用


以上的演示,只是个demo,如果要放在线上如何使用呢?



  1. 以sdk的形式嵌入到项目,使用基于缓存的查询方式。

  2. ip查询的场景很多,可以单独构建一个ip查询的公共服务,提高给各个业务线使用


sdk接入的方式,用到的业务线都需要对接一次,ip2region.xdb如果有更新,所有用到的项目都要自己去更新升级db文件,维护成本太高。如果你的项目比较单一,sdk接入也是不错的


我们的方案:因为我业务线相对太多,如果各个业务线自己接,维护的成本太高。我们决定构建IP查询归属地公共服务,往外提供查询的能力。后续服务的升级、维护等,统一在公共服务里面来做。


作者:柯柏技术笔记
来源:juejin.cn/post/7340950101534982179
收起阅读 »

在开源项目中看到一个改良版的雪花算法,现在它是你的了。

你好呀,我是歪歪。 在 Seata 的官网上看到一篇叫做“关于新版雪花算法的答疑”的文章。 seata.io/zh-cn/blog/… 看明白之后,我觉得还是有点意思的,结合自己的理解和代码,加上画几张图,给你拆解一下 Seata 里面的“改良版雪花算法...
继续阅读 »

你好呀,我是歪歪。


在 Seata 的官网上看到一篇叫做“关于新版雪花算法的答疑”的文章。



seata.io/zh-cn/blog/…




看明白之后,我觉得还是有点意思的,结合自己的理解和代码,加上画几张图,给你拆解一下 Seata 里面的“改良版雪花算法”。


虽然是在 Seata 里面看到的,但是本篇文章的内容和 Seata 框架没有太多关系,反而和数据库的基础知识有关。


所以,即使你不了解 Seata 框架,也不影响你阅读。


当你理解了这个类的工作原理之后,你完全可以把这个只有 100 多行的类搬运到你的项目里面,然后就变成你的了。


你懂我意思吧。



先说问题


如果你的项目中涉及到需要一个全局唯一的流水号,比如订单号、流水号之类的,又或者在分库分表的情况下,需要一个全局唯一的主键 ID 的时候,就需要一个算法能生成出这样“全局唯一”的数据。


一般来说,我们除了“全局唯一”这个基本属性之外,还会要求生成出来的 ID 具有“递增趋势”,这样的好处是能减少 MySQL 数据页分裂的情况,从而减少数据库的 IO 压力,提升服务的性能。


此外,在当前的市场环境下,不管你是啥服务,张口就是高并发,我们也会要求这个算法必须得是“高性能”的。


雪花算法,就是一个能生产全局唯一的、递增趋势的、高性能的分布式 ID 生成算法。


关于雪花算法的解析,网上相关的文章比雪花还多,我这里就不展开了,这个玩意,应该是“面试八股文”中重点考察模块,分布式领域中的高频考题之一,如果是你的盲区的话,赶紧去了解一下。


比如一个经典的面试题就是:雪花算法最大的缺点是什么?



背过题的小伙伴应该能立马答出来:时钟敏感。


因为在雪花算法中,由于要生成单调递增的 ID,因此它利用了时间的单调递增性,所以是强依赖于系统时间的。


如果系统时间出现了回拨,那么生成的 ID 就可能会重复。


而“时间回拨”这个现象,是有可能出现的,不管是人为的还是非人为的。


当你回答出这个问题之后,面试官一般会问一句:那如果真的出现了这种情况,应该怎么办呢?


很简单,正常来说只要不是不是有人手贱或者出于泄愤的目的进行干扰,系统的时间漂移是一个在毫秒级别的极短的时间。


所以可以在获取 ID 的时候,记录一下当前的时间戳。然后在下一次过来获取的时候,对比一下当前时间戳和上次记录的时间戳,如果发现当前时间戳小于上次记录的时间戳,所以出现了时钟回拨现象,对外抛出异常,本次 ID 获取失败。


理论上当前时间戳会很快的追赶上上次记录的时间戳。


但是,你可能也注意到了,“对外抛出异常,本次 ID 获取失败”,意味着这段时间内你的服务对外是不可使用的。


比如,你的订单号中的某个部分是由这个 ID 组成的,此时由于 ID 生成不了,你的订单号就生成不了,从而导致下单失败。


再比如,在 Seata 里面,如果是使用数据库作为 TC 集群的存储工具,那么这段时间内该 TC 就是处于不可用状态。


你可以简单的理解为:基础组件的错误导致服务不可用。


再看代码


基于前面说的问题,Seata 才提出了“改良版雪花算法”。



seata.io/zh-cn/blog/…




在介绍改良版之前,我们先把 Seata 的源码拉下来,瞅一眼。


在源码中,有一个叫做 IdWorker 的类:



io.seata.common.util.IdWorker



我带你看一下它的提交记录:



2020 年 5 月 4 日第一次提交,从提交时的信息可以看出来,这是把分布式 ID 的生成策略修改为 snowflake,即雪花算法。


同时我们也能在代码中找到前面提到的“对外抛出异常,本次 ID 获取失败”相关代码,即 nextId 方法,它的比较方式就是用当前时间戳和上次获取到的时间戳做对比:



io.seata.common.util.IdWorker#nextId




这个类的最后一次提交是 2020 年 12 月 15 日:



这一次提交对于 IdWorker 这个类进行了大刀阔斧的改进,可以看到变化的部分非常的多:



我们重点关注刚刚提到的 nextId 方法:



整个方法从代码行数上来看都可以直观的看到变化,至少没有看到抛出异常了。


这段代码到底是怎么起作用的呢?


首先,我们得理解 Seata 的改良思路,搞明白思路了,再说代码就好理解一点。


在前面提到的文章中 Seata 也说明了它的核心思路,我带着你一起过一下:



原版的雪花算法 64 位 ID 是分配这样的:



可以看到,时间戳是在最前面的,因为雪花算法利用了时间的单调递增的特性。


所以,如果前面的时间戳一旦出现“回退”的情况,即打破了“时间的单调递增”这个前提条件,也就打破了它的底层设计。


它能怎么办?


它只能给你抛出异常,开始摆烂了。


然后我主要给你解释一下里面的节点 ID 这个玩意。


节点 ID 可以理解为分布式应用中的一个服务,一个服务的节点 ID 是固定的。


可以看到节点 ID 长度为二进制的 10 位,也就是说最多可以服务于 1024 台机器,所以你看 Seata 最开始提交的版本里面,有一个在 1024 里面随机的动作。


因为算法规定了,节点 ID 最多就是 2 的 10 次方,所以这里的 1024 这个值就是这样来的:



包括后面有大佬觉得用这个随机算法一点都不优雅,就把这部分改成了基于 IP 去获取:



看起来有点复杂,但是我们仔细去分析最后一行:



return ((ipAddressByteArray[ipAddressByteArray.length - 2] & 0B11) << Byte.SIZE) + (ipAddressByteArray[ipAddressByteArray.length - 1] & 0xFF);



变量 & 0B11 运算之后的最大值就是 0B11 即 3。


Byte.SIZE = 8。


所以,3 << 8,对应二进制 1100000000,对应十进制 768。


变量 & 0xFF 运算之后的最大值就是 0xFF 即 255。


768+255=1023,取值范围都还是在 [0,1023] 之间。


然后你再看现在最新的算法里面,官方的老哥们认为获取 IP 的方式不够好:



所以又把这个地方从使用 IP 地址改成了获取 Mac 地址。



最后一行是这样的:



return ((mac[4] & 0B11) << 8) | (mac[5] & 0xFF);



还是我刚刚说的 0B11 << 8 和 0xFF。


那么理论上的最大值就是 768 | 255 ,算出来还是 1023。


所以不管你怎么玩出花儿来,这个地方搞出来的数的取值范围就只能是 [0,1023] 之间。


别问,问就是规定里面说了,算法里面只给节点 ID 留了 10 位长度。


最后,就是这个 12 位长度的序列号了:



这个玩意没啥说的,就是一个单纯的、递增的序列号而已。


既然 Seata 号称是改良版,那么具体体现在什么地方呢?


简单到你无法想象:



是的,仅仅是把时间戳和节点 ID 换个位置就搞定了。


然后每个节点的时间戳是在 IdWorker 初始化的时候就设置完成了,具体体现到代码上是这样的:



io.seata.common.util.IdWorker#initTimestampAndSequence




主要看第一行:



long timestamp = getNewestTimestamp();



可以看到在 getNewestTimestamp 方法里面获取了一次当前时间,然后减去了一个 twepoch 变量。


twepoch 是什么玩意?



是 2020-05-03 的时间戳。


至于为什么是这个时间,我想作者应该是在 2020 年 5 月 3 日写下的关于 IdWorker 的第一行代码,所以这个日期是 IdWorker 的生日。


作者原本完全可以按照一般程序员的习惯,写 2020 年 1 月 1 日的,但是说真的,这个日期到底是 2020-01-01 还是 2020-05-03 对于框架来说完全不重要,所以还不如给它赋予一个特殊的日期。


他真的,我哭死...


那么为什么要用当前时间戳减去 twepoch 时间戳呢?


你想,如果仅仅用 41 位来表示时间戳,那么时间戳的最大值就是 2 的 41 次方,转化为十进制是这么多 ms:



然后再转化为时间:



也就是说,在雪花算法里面,41 位时间戳最大可以表示的时间是 2039-09-07 23:47:35。


算起来也没几年了。


但是,当我们减去 2020-05-03 的时间戳之后,计算的起点不一样了,这一下,咔咔的,就能多用好多年。


twepoch 就是这么个用途。


然后,我们回到这一行代码:



前一行,我们把 41 位的时间戳算好了,按照 Seata 的设计,时间戳之后就是 12 位的序列号了呀:



所以这里就是把时间戳左移 12 位,好把序列号的位置给腾出来。


最后,算出来的值,就是当前这个节点的初始值,即 timestampAndSequence。


所以,你看这个 AtomicLong 类型的变量的名字取的,叫做 timestampAndSequence。


timestamp 和 Sequence,一个字段代表了两个含义,多贴切。


Long 类型转化为二进制一共 64 位,前 11 位不使用,中间的 41 位代表时间戳,最后的 12 位代表序列号,一个字段,两个含义。


程序里面使用的时候也是在一起使用,用 Long 来存储,在内存里面也是放在一块的:



优雅,实在优雅。


上一次看到这么优雅的代码,还是线程池里面的 ctl 变量:



现在 timestampWithSequence 已经就位了,那么获取下一 ID 的时候是怎么搞的呢?


看一下 nextId 方法:




io.seata.common.util.IdWorker#nextId





标号为 ① 的地方是基于 timestampWithSequence 进行递增,即 +1 操作。


标号为 ② 的地方是截取低 53 位,也就是 41 位的时间戳和 12 位的序列号。


标号为 ③ 的地方就是把高 11 位替换为前面说过的值在 [0,1023] 之间的 workerId。


好,现在你再仔细的想想,在前面描述的获取 ID 的过程中,是不是只有在初始化的时候获取过一次系统时间,之后和它就再也没有关系了?


所以,Seata 的分布式 ID 生成器,不再依赖于时间。


然后,你再想想另外一个问题:


由于序列号只有 12 位,它的取值范围就是 [0,4095]。


如果我们序列号就是生成到了 4096 导致溢出了,怎么办呢?


很简单,序列号重新归 0,溢出的这一位加到时间戳上,让时间戳 +1。


那你再进一步想想,如果让时间戳 +1 了,那么岂不是会导致一种“超前消费”的情况出现,导致时间戳和系统时间不一致了?


朋友,慌啥啊,不一致就不一致呗,反正我们现在也不依赖于系统时间了。


然后,你想想,如果出现“超前消费”,意味着什么?


意味着在当前这个毫秒下,4096 个序列号不够用了。


4096/ms,约 400w/s。


你啥场景啊,怎么牛偪?


(哦,原来是面试场景啊,那懂了~)


另外,官网还抛出了另外一个问题:这样持续不断的"超前消费"会不会使得生成器内的时间戳大大超前于系统的时间戳,从而在重启时造成 ID 重复?


你想想,理论上确实是有可能的。假设我时间戳都“超前消费”到一个月以后了。


那么在这期间,你服务发生重启时我会重新获取一次系统时间戳,导致出现“时间回溯”的情况。


理论上确实有可能。


但是实际上...


看看官方的回复:



别问,问就是不可能,就算出现了,最先崩的也不是我这个地方。


好,到这里,我终于算是铺垫完成了,前面的东西就算从你脑中穿脑而过了,你啥都记不住的话,你就抓住这个图,就完事了:



现在,你再仔细的看这个图,我问你一个问题:



改良版的算法是单调递增的吗?



在单节点里面,它肯定是单调递增的,但是如果是多个节点呢?


在多个节点的情况下,单独看某个节点的 ID 是单调递增的,但是多个节点下并不是全局单调递增。


因为节点 ID 在时间戳之前,所以节点 ID 大的,生成的 ID 一定大于节点 ID 小的,不管时间上谁先谁后。


这一点我们也可以通过代码验证一下,代码的意思是三个节点,每个节点各自生成 5 个 ID:



从输出来看,一眼望去,生成的 ID 似乎是乱序的,至少在全局的角度下,肯定不是单调递增的:


但是我们把输出按照节点 ID 进行排序,就变成了这样,单节点内严格按照单调递增,没毛病:



而在原版的雪花算法中,时间戳在高位,并且始终以系统时钟为准,每次生成的时候都会严格和系统时间进行对比,确保没有发生时间回溯,这样可以保证早生成的 ID 一定小于晚生成的 ID ,只有当 2 个节点恰好在同一时间戳生成 ID 时,2 个 ID 的大小才由节点 ID 决定。


这样看来,Seata 的改进算法是不是错的?


好,我再说一次,前面的所有的内容都是铺垫,就是为了引出这个问题,现在问题抛出来了,你得读懂并理解这个问题,然后再继续往下看。



分析一波


分析之前,先抛出官方的回答:



我先来一个八股文热身:请问为什么不建议使用 UUID 作为数据库的主键 ID ?


就是为了避免触发 MySQL 的页分裂从而影响服务性能嘛。


比如当前主键索引的情况是这样的:



如果来了一个 433,那么直接追加在当前最后一个记录 432 之后即可。



但是如果我们要插入一个 20 怎么办呢?


那么数据页 10 里面已经放满了数据,所以会触发页分裂,变成这样:



进而导致上层数据页的分裂,最终变成这样的一个东西:



上面的我们可以看出页分裂伴随着数据移动,所以我们应该尽量避免。


理想的情况下,应该是把一页数据塞满之后,再新建另外一个数据页,这样 B+ tree 的最底层的双向链表永远是尾部增长,不会出现上面画图的那种情况:在中间的某个节点发生分裂。


那么 Seata 的改良版的雪花算法在不具备“全局的单调递增性”的情况下,是怎么达到减少数据库的页分裂的目的的呢?


我们还是假设有三个节点,我用 A,B,C 代替,在数值上 A < B < C,采用的是改良版的雪花算法,在初始化的情况下是这样的。



假设此时,A 节点申请了一个流水号,那么基于前面的分析,它一定是排在 A-seq1 之后,B-seq1 之前的。


但是这个时候数据页里面的数据满了,怎么办?


分裂呗:



又来了 A-seq3 怎么办?


问题不大,还放的下:



好,这个时候数据页 7 满了,但是又来了 A-seq4,怎么办?


只有继续分裂了:



看到这个分裂的时候,你有没有嗦出一丝味道,是不是有点意思了?


因为在节点 A 上生成的任何 ID 都一定小于在节点 B 上生成的任何 ID,节点 B 和节点 C 同理。


在这个范围内,所有的 ID 都是单调递增的:



而这样的范围最多有多少个?


是不是有多少个节点,就有多少个?


那么最多有多少个节点?



2 的 10 次方,1024 个节点。


所以官方的文章中有这样的一句话:



新版算法从全局角度来看,ID 是无序的,但对于每一个 workerId,它生成的 ID 都是严格单调递增的,又因为 workerId 是有限的,所以最多可划分出 1024 个子序列,每个子序列都是单调递增的。



经过前面的分析,每个子序列总是单调递增的,所以每个子序列在有限次的分裂之后,最终都会达到稳态。


或者用一个数学上的说法:该算法是收敛的。


再或者,我给你画个图:



我画的时候尽力了,至于你看懂看不懂的,就看天意了。


如果看不懂的话,自信一点,不要怀疑自己,就是我画的不好。大胆的说出来:什么玩意?这画的都是些啥,看求不懂。呸,垃圾作者。



页分裂


前面写的所有内容,你都能在官网上我前面提到的两个文章中找到对应的部分。


但是关于页分裂部分,官方并没有进行详细说明。本来也是这样的,人家只是给你说自己的算法,没有必要延伸的太远。


既然都说到页分裂了,那我来补充一个我在学习的时候看到的一个有意思的地方。


也就是这个链接,这一节的内容就是来源于这个链接中:



mysql.taobao.org/monthly/202…



还是先搞个图:



问,在上面的这个 B+ tree 中,如果我要插入 9,应该怎么办?


因为数据页中已经没有位置了,所以肯定要触发页分裂。


会变成这样:



这种页分裂方式叫做插入点(insert point)分裂。


其实在 InnoDB 中最常用的是另外一种分裂方式,中间点(mid point)分裂。


如果采用中间点(mid point)分裂,上面的图就会变成这样:



即把将原数据页面中的 50% 数据移动到新页面,这种才是普遍的分裂方法。


这种分裂方法使两个数据页的空闲率都是 50%,如果之后的数据在这两个数据页上的插入是随机的话,那么就可以很好地利用空闲空间。


但是,如果后续数据插入不是随机,而是递增的呢?


比如我插入 10 和 11。


插入 10 之后是这样的:



插入 11 的时候又要分页了,采用中间点(mid point)分裂就变成了这样:



你看,如果是在递增的情况下,采用中间点(mid point)分裂,数据页 8 和 20 的空间利用率只有 50%。


因为数据的填充和分裂的永远是右侧页面,左侧页面的利用率只有 50%。


所以,插入点(insert point)分裂是为了优化中间点(mid point)分裂的问题而产生的。


InnoDB 在每个数据页上专门有一个叫做 PAGE_LAST_INSERT 的字段,记录了上次插入位置,用来判断当前插入是是否是递增或者是递减的。


如果是递减的,数据页则会向左分裂,然后移动上一页的部分数据过去。


如果判定为递增插入,就在当前点进行插入点分裂。


比如还是这个图:



上次插入的是记录 8,本次插入 9,判断为递增插入,所以采用插入点分裂,所以才有了上面这个图片。


好,那么问题就来了,请听题:


假设出现了这种情况,阁下又该如何应对?



在上面这个图的情况下,我要插入 10 和 9:


当插入 10 的时候,按 InnoDB 遍历 B+ tree 的方法会定位到记录 8,此时这个页面的 PAGE_LAST_INSERT 还是 8。所以会被判断为递增插入,在插入点分裂:



同理插入 9 也是这样的:



最终导致 9、10、11 都独自占据一个 page,空间利用率极低。


问题的根本原因在于每次都定位到记录 8(end of page),并且都判定为递增模式。


哦豁,你说这怎么办?


答案就藏在这一节开始的时候我提到的链接中:



前面我画的所有的图都是在没有并发的情况下展开的。


但是在这个部分里面,牵扯到了更为复杂的并发操作,同时也侧面解释了为什么 InnoDB 在同一时刻只能有有一个结构调整(SMO)进行。


这里面学问就大了去了,有兴趣的可以去了解一下 InnoDB 在 B+ tree 并发控制上的限制,然后再看看 Polar index 的破局之道。


反正我是学不动了。


哦,对了。前面说了这么多,还只是聊了页分裂的情况。


有分裂,就肯定有合并。


那么什么时候会触发页合并呢?


页合并会对我们前面探讨的 Seata 的改良版雪花算法带来什么影响呢?


别问了,别问了,学不动了,学不动了。



自己看一下吧:



最后,如果本文对你有一点点帮助的话,点个免费的赞,求个关注,不过分吧?



作者:why技术
来源:juejin.cn/post/7264387737276203065
收起阅读 »

半小时到秒级,京东零售定时任务优化怎么做的?

导言:京东零售技术团队通过真实线上案例总结了针对海量数据批处理任务的一些通用优化方法,除了供大家借鉴参考之外,也更希望通过这篇文章呼吁大家在平时开发程序时能够更加注意程序的性能和所消耗的资源,避免在流量突增时给系统带来不必要的压力。 业务背景: 站外广告投放平...
继续阅读 »

导言:京东零售技术团队通过真实线上案例总结了针对海量数据批处理任务的一些通用优化方法,除了供大家借鉴参考之外,也更希望通过这篇文章呼吁大家在平时开发程序时能够更加注意程序的性能和所消耗的资源,避免在流量突增时给系统带来不必要的压力。


业务背景:


站外广告投放平台在做推广管理状态优化重构的时候,引入了四个定时任务。分别是单元时间段更新更新任务,计划时间段更新任务,单元预算撞线恢复任务,计划预算撞线恢复任务。


时间段更新更新任务:


由于单元上可以设置分时段投放,最小粒度是半个小时,每天没半个小时都已可以被广告主设置为可投放或者不可投放,当个广告主修改了,这个时间段,我们可以通过binlog来异步更新这个状态,但是,随着时间的流逝,单元有可能在上半个小时处于可投放状态,来到下半个小时就处于不可投放状态。此时我们的程序是无法感知的,只能通过定时任务,计算每个单元在当前时间段是否需要被更新子状态。计划时间段更新任务类似,也需要半个小时跑一次。


单元预算恢复任务:


当单元的当天日预算被消耗完之后,我们接收到计费的信号后会把该单元的状态更新为预算已用完子状态。但是到第二天凌晨,随着时间的到来,需要把昨天带有预算已用完子状态的单元全部查出来,然后计算当前是否处于撞线状态进行状态更新,此时大部分预算已用完的单元都处于可播放状态,所以这个定时任务只需要一天跑一次,计划类似。


本次以单元和计划的时间段更新为例,因为时间段每半个小时需要跑一次,且数据量多。


数据库:


我们的数据库64分片,一主三从,分片键user_id(用户id)。


定时任务数据源:


我们选取只有站外广告在用的表dsp_show_status作为数据源,这个表总共8500万(85625338)条记录。包含三层物料层级分别是计划,单元,创意通过type字段区分,包含四大媒体(字节,腾讯,百度,快手)和京东播放的物料,可以通过campaignType字段区分。


机器配置和垃圾回收器:


单台机器用的8C16G


-Xms8192m -Xmx8192m -XX:MaxMetaspaceSize=1024m -XX:MetaspaceSize=1024m -XX:MaxDirectMemorySize=1966m -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:ParallelGCThreads=8


定时任务处理逻辑


对于单元,


第一步:先查出来出来dsp_show_status 最大主键区间MaxAutoPk和最小区间MinAutoPk。


第二步:根据Ducc里设置的步长,和条件,去查询dsp_show_status表得出数据。其中条件包含层级单元,腾讯渠道(只有腾讯渠道的单元上有分时段投放),不包含投放已过期的数据(已过期的单元肯定不在投放时间段)


伪代码:


startAutoPk=minAutoPk;
while (startAutoPk <= maxAutoPk) {
//每次循环的开始区间
startAutoPkFinal = startAutoPk;
//每次循环的结束区间
endAutoPkFinal = Math.min(startAutoPk + 步长, maxAutoPk);
List showSatusVoList =
showStatusConsumer.betweenListByParam(
startAutoPkL, endAutoPkL,
条件(type=2单元层级,不包含已过期的数据,腾讯渠道))
startAutoPk = endAutoPkFinal + 1;
}

第三步:遍历第二步查询出来showSatusVoList,得到集合单元ids,然后根据集合ids去批量查询单元扩展表,取出单元扩展表里每个单元对应的start_time,end_time,time_range_price_coef字段。进行子状态计算。


计算逻辑伪代码:


1、当前时间

2、end_time <当前时间 ,子状态为 单元投放已结束


3、start_time<当前时间

4、其他,移除单元未开始投放,单元投放已结束,单元不在投放时间段 三个子状态


然后对这批单元按上面的四种情况进行分组,总共分为四组。如果查询来的dsp_show_status表的子状态和算出来的子状态一样则不加入分组,如果不一样则加入相应分组。


最后对这批单元对应的dsp_show_status表里的记录进行四次批量更新。


计划时间段任务处理逻辑类似,但是查询出来的数据源不包含腾讯渠道的,因为腾讯的渠道的时间段在单元上,计划上没有。


任务执行现象:


(一阶段)任务执行时间长且CPU利用率高


按某个pin调试任务,逻辑上落数据没有问题,但是任务时长在五分钟左右。当时是说产品可以接受这个时间子状态更新延迟。


但当不按pin调试进行计划时间段任务更新时,相对好点,十分钟左右,cpu不到50%。


进行单元时间段任务更新时,机器的cpu是这样的:



cpu80%,且执行了半个小时才执行完成。 如果这样,按业务需求,这个批次执行完成就要继续执行下一次了,肯定是不满需求的。


那怎么缩短CPU利用率,缩短任务执行时间呢?听我慢慢讲解。


(二阶段)分析数据源,调大步长缩短任务运行时间


上面这个情况肯定满足不了业务需求的。


第一感觉优化的方向应该往着数据分布上想,于是去分析dsp_show_status表里的数据,发现表里数据稀疏主要是因为两个点。


(1)程序问题 这个表里不仅存在站外的数据,还因为某些程序问题无意落了站内的数据。我们查询数据的时候卡了计划类型,不会处理站内的数据。但是表里存在会增大主键区间。导致我们每个批次出来的数据比较稀疏。


(2)业务背景 由于百度量小,字节则最近进行了升级,历史物料不多,快手之前完全处于停投。所以去除出腾讯渠道,计划需要处理的数据量比较少18万(182934)。但是腾讯侧一直没有进行升级,而且量大,所以需要处理的单元比较多130万左右(1309692 )。


于是我们为了避免每个批次查出来要处理数据比较少,导致空跑,调大了步长。


再次执行任务


果然有效,计划时间段任务计,cpu虽然上去了,但是任务5分钟就执行完了。


执行执行单元时间段更新的时候,时间缩短到十几分钟,但是cpu却是这样的,顶着100%cpu跑任务。



道路且长,那我们怎么解决这个cpu问题呢,请看下一阶段。


(三阶段)减少临时对象大小和无效日志,避免多次ygc


这个cpu确实令人悲伤。当时我们


第一想法是,为了尽快满足产品需求,先用我们的组件事件总线进行负载(底层是用的mq)到多台机器。这样不但解决了cpu利用率高的问题,还能解决任务执行时间长的问题。这个想法确实能解决问题,但是还是耗用机器资源。


第二想法是,由于时间段在表里是个json存储,在执行查询的时候不好进行条件查询。于是想着单独在建一张表,拉平时间段,在进行查询的时候直接查新建的表,不再查询存储json时间段的表。但是这张表相当于异构了数据源,不但要新建表还要考虑这张表的维护。


于是我们继续分析cpu高用在哪里,理论上这个定时任务是IO型任务,cpu利用率应该比较低。在执行任务的时候,我们仔细观察了机器的监控,发现在执行单元时段更新任务时,机器每分钟不断地进行多次ygc。之前刚和组内同学分享过gc相关知识。这里说一下,虽然我们的机器用的是G1垃圾回收器,没有进行full gc,但是G1在ygc的时候会比jdk1.8默认的垃圾回收器要更耗资源,因为G1还要mixgc兼顾回收老年代的垃圾。G1用于响应优先,默认的垃圾回收器吞吐量优先。这样的批量任务其实更适合用默认垃圾回收器。


不断进行ygc肯定是因为我们在执行任务的时候产生大量的临时对象导致的。


这里我们采取了两条有效措施:


(1)去掉无效日志 由于调试时加了大量日志,java进行序列化的时候会产生比原来的对象占用更多内存的临时变量。于是我们去掉了所有的无效日志。


(2)减少临时对象占用的内存 代码对象的个数肯定不能减少,于是我们我们减少对象的的大小。之前是我们用的proxy工程现成接口,把表里的每个字段都查出来了,但是表里那么多字段,实际我们每张表也就用2-3个字段。于是我们为这个定时任务写了专用的查询接口,每个接口只查我们需要的字段。


结果果然有效,单元时间段更新任务从原来的顶着100%cpu跑了十几分钟,瞬间降到了cpu不到60%,五分钟执行完成。ycg次数也有明显的下降。


刷数任务: 这两个措施到底多有效呢,说另一个栗子也与这个需求相关。在没有减少临时变量大小(把单元表和单元扩展表中的所有字段都查出来)把单元表的启停状态和单元扩展表的审核状态刷到dsp_show_status时,涉及1400百万数据,刷了两个小时也没刷完,最后怕影响物料传输工程查询数据库给停了。之后减少临时变量后,九分钟就刷完了。


经过上述的优化看似皆大欢喜,但还存在很大的问题。给大家看一个监控图。



看完这个监控图,我们慌了,计划和单元更新时间段任务每半个小时运行一次,都给数据库带来了200万qpm的增长,这无疑给我们的数据库带来了巨大隐患。


此时总结下来存在两个问题有待解决。


(1)怎么减少与数据库的交互次数 ,消除给数据库带来的安全隐患。


(2)怎么降低任务的执行的时间, 五分钟的子状态更新延迟是不可以接受的。对广告主来说更是严重的bug。


这两个问题让我们觉得这个任务还有很大的优化空间,于是我们继续分析优化。下一阶段的措施很好的解决了这两个问题。


(四阶段)基于游标查询数据源,基于数据库分片批量更新,降低数据库交互次数,避免空跑缩短任务运行时间。


对于上面的问题,我们分析这么大的调用量主要用在了哪里。


发现由于站内数据的存在和历史数据的删除以及dsp_show_status和其他表公用一个主键id生成序列,导致dsp_show_status表的MaxAutoPk到达90多亿。


也就是所及时我们步长达到2万,光查询数据调用次数就达到了45万次,在加上每次都有可能产生小于四次的更新操作。也就是一个定时任务都会产生高大100万的qpm,两个任务产生200万也就符合预期了。于是我们把步长调整为4万,qpm降到了130万左右,但还是很高。



于是我们继续分析,就单元时间段更新任务而言,其实我们需要查出来的数据也就是上面提到的腾讯的130万左右(1309692 )。但是我们查询了45万次且步长是2万。也就是说我们每次查出来的数据还是很稀疏且个数不确定,如果忙盲目的调大步长,很可能由于某个区间数据量特别多导致负载不均衡,还有可能rpc超时。


那怎么才能做到每次查出来数据个数就是我们的设置的步长呢,我们想到了mysql里面的游标查询。但是jed弹性数据库并不支持,于是我们就要手动实现游标的逻辑。此时我们考虑dsp_show_status是否有唯一主键能标识唯一记录。假如主键不唯一,就有可能出现漏查和重复查询的情况。幸运的是我们的jed数据库所有的表里都有唯一主键。于是我们手写了一个游标查询。


(1)游标查询


伪代码如下


//上层业务代码
Long maxId = null;
do {
showStatuses = showStatusConsumer.betweenListByParam(
startAutoPkL, endAutoPkL, maxId,每次批次要查出来的数据,
其他条件(type=2单元层级,不包含已过期的数据,腾讯渠道)
)

if (CollectionsJ.isEmpty(showStatuses)) {
//如果为空的,直接推出,代表已经查到最后了。
break;
}
//循环变量值叠加,查出来的数据最后一行的id,数据库进行了升序,也就是这批记录的最大id
maxId = showStatuses.get(showStatuses.size() - 1).getId();

//处理查出来的数据
processShowStatuses( showStatuses);

} while (CollectionsJ.isNotEmpty(showStatuses));


//下层sql

SELECT
id,cga_id,status_bitmap1,user_id
FROM dsp_show_status
<where>
id BETWEEN #{startAutoPk,jdbcType=BIGINT} AND #{endAutoPk,jdbcType=BIGINT}
//param.maxId 上一批次查出数据的最大maxId
<if test="param.maxId != null">
AND id >#{param.maxId,jdbcType=BIGINT}

<----!其他条件------>

order by id
<if test="param.batchSize != null">
//上层传过来的每个批次要查询的出来的数据量
limit #{param.batchSize}



这里可以思考一下基于游标的查询方式在什么场景下有效? 如果有效需要满足一下两个条件


1.jed表里有唯一键,且基于唯一键查询排序


2.区间满足查询条件的记录越稀疏越有效


这里要一定注意排序的顺序,是升序不是降序。如果你无意间按降序排序,那么每次查询的都是最后的满足条件的batch大小的数据。


(2)深度分页引起慢sql


此时组内同学提出了一个疑问,深度分页引起慢sql问题。这里解释一下到底会不会产生慢sql。


当进行分页的时候一般sql会这样写


select *
from dsp_show_status
where 其他查询条件
limit 50000000 , 10;

当limit 的初始位置非常靠后时,即使压中查询条件里的二级索引,也需从二级索引得到的主键索引去加载所有的磁盘记录,然后扫描50000000行记录取50000000到-50000010条返回,这里涉及到记录的扫描,和多次磁盘到内存的IO,所以比较耗时。


但是我们的sql


select *
from dsp_show_status
where 其他查询条件
and id >maxId
oder by id
limit 100

当maxId非常大时,比如50000000 时,mysql压中查询条件的里的二级索引,得到主键索引。然后MySQL会直接过滤掉 id<50000000 的主键id,然后从主键50000000开始查询数据库得到满足条件的100条记录。所以他会非常快,并不是产生慢sql。实际sql执行只需要37毫秒。



(3) 按数据库分片进行批量更新


但是又遇到了另一个数据库长事务问题,由于使用了基于游标的方式,查出来的数据都是需要进行计算的数据,且任务运行时间缩短到到30秒。那在进行数据更新时,每次批量更新都比之前(不使用游标的方式)更新的数据量要多,且并发度高。其次由于批量更新的时候更新多个单元id,这些id不一定属于某一个user_id,所以在执行更新的时候没有带分片键,此时数据库jed网关又出现了问题。


当时业务日志的报错的信息是这样的,出现了执行时间超过了30秒的sql,被kill掉:


{"error":true,"exception":{"@type":"org.springframework.jdbc.UncategorizedSQLException","cause":{"@type":"com.mysql.cj.jdbc.exceptions.MySQLQueryInterruptedException","errorCode":1317,"localizedMessage":"transaction rolled back to reverse changes of partial DML execution: target: dsp_ads.c4-c8.primary: vttablet: (errno 2013) due to context deadline exceeded, elapsed time: 30.000434219s, killing query ID 3511786 (CallerID: )","message":"transaction rolled back to reverse changes of partial DML execution: target: dsp_ads.c4-c8.primary: vttablet: (errno 2013) due to context deadline exceeded, elapsed time: 30.000434219s, killing query ID 3511786 (CallerID: )","sQLState":"70100","stackTrace":[{"className":"com.mysql.cj.jdbc.exceptions.SQLError","fileName":"SQLError.java","lineNumber":126,"methodName":"createSQLException","nativeMethod":false},{"className":"com.mysql.cj.jdbc.exceptions.SQLError","fileName":"SQLError.java","lineNumber":97,"methodName":"createSQLException","nativeMethod":false},


数据库的监控也发现了异常,任务执行的时候出现了大量的MySQL rollbakc:



当时联系dba suport ,dba排查后告诉我们,我们的批量更新sql在数据库执行非常快,但是我们用了长事务超过30秒没有提交,所以被kill掉了。但是我们检查了我们的代码,发现并没有使用事务,且我们的事务是单库跨rpc事务,从发起事务到提交事务对于数据库来说执行时间非常快,并不会出现长事务。我们百思不得其解,经过思考我们觉得可能是jed网关出现了问题,jed网关的同学给的答复是。由于没有带分片键导致jed网关会把sql分发到64分片,如果某个分片上没有符合条件的记录,就会产生间隙锁,其他sql更新的时候一直锁更待从而导致事务一直没有提交出现长事务。


对于网关同学给我们的答复,我们仍然持有怀疑态度。本来我们想改下数据库的隔离级别验证一下这个回复,但是jed并不支持数据库隔离级别的更改。


但是无论如何我们知道了是因为我们批量更新时不带分片键导致的,但是如果按userId进行更新,将会导致原来只需要一次进行更新,现在需要多次更新。于是我们想到循环64分片数据库进行批量更新。但是jed并不支持执行sql时指定分片, 于是我们给他们提了需求。


后来我们想到了折中的方式,我们按数据库分片对要执行的单元id进行分组,保证每个分组对应的单元id落到数据库的一个分片上,并且执行更新的时候加上userId集合。这个方案要求jed网关在执行带有多个分片键sql时能进行路由。这边jed的同事验证了一下是可以的。



于是我们在进行更新的时候对这些ids按数据库分片进行了分组。


伪代码如下:


//按数据库分片进行分组
adgroups.stream().collect(Collectors.groupingBy(Adgroup::shardKey));
// 按计算每个userId对象的数据库分片,BinaryHashUtil是jed网关的jar包
public String shardKey() {
try {
return BinaryHashUtil.getShardByVindex(ShardEnum.SIXTY_FOUR_SHARDS, this.userId);
} catch (SQLException ex) {

throw new ApplicationException(ex);
}
}

在上述的刷数任务中能够执行那么快,并且更新数据没有报错,一方面也得益于这个按数据库分片进行分组更新数据


(4)优化效果


经过基于游标查询的方式进行任务优化,就单元时间段更新时。从原来的五分钟,瞬间降为30秒完成。cpu不到65% 。由于计划记录更稀疏,所以更快。



对数据库的查询更新操作,也从原来的也从原来的200万qpm降为2万多(早上高峰的时候),低峰的时候甚至不到两万。当我们把batchSize设置为100时,通过计算单元的130多万/100 +计划的18万/100=1.4万次qpm 也是符合预期的。


查询db监控:



更新db的监控,也符合预期



虽然引入基于游标的方式进行查询非常有效,把原来的200万qpm数据库交互降到了2万,把任务运行时间从5分钟降到了30秒。但是仔细分析你还会发现,还存在如下问题。


1、单台机器cpu高, 仍然在60%,对于健康的程序来说,这个数值仍然不被接受。


2、查询和更新数据量严重不符, 每次定时任务更新只更新了上万行记录,但是我们却查出来了上百万(130万)行记录进行子状态,这无疑还在浪费CPU和磁盘IO资源。


监控如下


每次查询出来的记录数:



每次需要更新的记录数:



经过上面的不断优化,我们更加相信,资源不能被浪费,作为程序员应该追求极致。于是我们还继续优化。解决上面两个问题


(五阶段)异构要更新状态的数据源,降低数据库交互次数,降低查询出来的数据量,降低机器cpu利用率。


为了减少无效数据查询和计算,我们还是决定冗余数据,但是不是像前面提到的新建一张表,而是在dsp_show_status 表里冗余一个nextTime字段,来存储这个物料下一次需要被定时任务拉起更改状态的时间戳,(也就是物料在投放时间段子状态和不在投放时间段子状态转变的时间戳),举个栗子,广告主设置某个单元早上8点开始投放,晚上8点结束投放,其他时间不投放。那早8点的时候,这个单元就会被我们的定时任务扫描到,然后计算更新这个单元从不投放变为投放,同时计算比较投放时间段,下一个状态变更的时间段,经过计算得知,广告主在晚上8点需要状态变更,也就是从投放变为不投放,那nextTime字段就落晚上8点的时间戳。这个字段的维护逻辑分为两部分,一部分是广告主主动更改了时间段需要更新计算这个nextTime,另一部分是定时任务拉起这个物料更改完子状态后,再次计算下一次需要被拉起的nextTime。


这样我们定时任务在查询数据源的时候只需新增一个查询条件(因为是存的是时间戳,所以需要卡个范围)就可以查出我们需要真正要更新的数据了。


当维护投放时间段这个异构数据,就要考虑异构数据和源数据的一致性问题。假如某次定时任务执行失败了,就会导致nextTime 和投放时间段数据不一致,此时我们的解决办法时,关闭基于nextTime的优化查询,进行上一阶段(第四阶段)基于游标的全量更新。


sql查询增加条件:
next_time_change between ADDTIME(#{param.nextTimeChange}, '-2:0:0')
and ADDTIME(#{param.nextTimeChange}, '0:30:0')

优化之后我们每次查询出来的记录从130万降到了1万左右。


11点的时候计划和单元总共查出来6000个,监控如下:



11点的时候计划和单元总共更新5000个,由于查询数据源的时候卡了时间戳范围,所以符合预期,查出来的个数基本就是要更新的记录。监控如下:



查询次数也从原来的1万次降到了200次。监控如下:



机器的监控如下cpu只用了28%,且只ygc了1次,任务执行时间30秒内完成。



这个增加next_time 这个字段进行查询的思路,和之前做监控审核中的创意定时任务类似。创意表20亿行数据,怎么从20亿行记录表里实时找出哪些创意正在审核中。当时的想法也是维护一个异构的redis数据源,送审的时候把数据写入redis,审核消息过来后再移除。但是当我们分析数据源的时候,幸运的发现审核中的创意在20亿数据中只占几万,大部分创意都是在审核通过和审核驳回,之前大家都了解到建立索引要考虑索引的区分度,但是在这种数据分布严重不均匀的场景,我们建立yn_status联合索引,在取数据源的时候,直接压数据库索引取出数据,sql执行的非常快,20毫秒左右就能执行完成,避免走了很多弯路。


你以为优化结束了? 不,合格的程序员怎么允许系统中存在cpu不稳定的场景存在,即使只增加28%


(六阶段)负载均衡,消除所有风险,让系统程序稳定运行。


消除单台机器cpu不稳定的最有效办法就是,把大任务拆分为小任务,然后分发到不同的机器上进行执行。我们的定时任务本来就是按批次进行查询计算的,所以本身就是小任务。剩下的就是分发任务,很多人想到的就是利用mq的负载进行分发,但是mq不可控,不可控制失败重试时间。如果一个小任务失败了,下次什么时候被拉起重试就不得而知了,或许半个小时以后?这里用到了我们非常牛逼的一个组件,可重试总线进行负载,支持自定义重试频率,支持自动识别无效重试,防止重试叠加。


负载后的机器cpu是这样的



优化效果数据汇总:


这里列一下任务从写出来到被优化后的数据对比。


优化前,cpu增加80%,任务运行半个小时,查询数据库次数百万次,查询出来130万行记录。


优化后,cpu增加1%,任务30秒以内,查询数据库200次,查询出来1万行记录。


写到最后:


通过本次优化让我收获许多,最大的收获是让我深刻明白了,对于编码人员,要时刻考虑资源的消耗。举个不太恰当的栗子,假如每个人在工程里都顺手打印一行无效日志,随着时间的积累整个工程都会到处打印在无效日志。毫不夸张的讲,或许只是因为你多打印了一行log.info日志,在请求量猛增达到一定程度时都会导致机器和应用的不良连锁反应。建议大家在开发的时候在关键点加上关键日志,并且合理利用Debugger,结合ducc进行动态日志调整排查问题。


作者:京东零售广告研发 董舒展
来源:juejin.cn/post/7339742783236702271
收起阅读 »

MyBatis-Plus 效能提升秘籍:掌握这些注解,事半功倍!

MyBatis-Plus是一个功能强大的MyBatis扩展插件,它提供了许多便捷的注解,让我们在开发过程中能够更加高效地完成数据库操作,本文将带你一一了解这些注解,并通过实例来展示它们的魅力。一、@Tablename注解这个注解用于指定实体类对应的数据库表名。...
继续阅读 »

MyBatis-Plus是一个功能强大的MyBatis扩展插件,它提供了许多便捷的注解,让我们在开发过程中能够更加高效地完成数据库操作,本文将带你一一了解这些注解,并通过实例来展示它们的魅力。

一、@Tablename注解

这个注解用于指定实体类对应的数据库表名。如果你的表名和实体类名不一致,就需要用到它:

@TableName("user_info")
public class UserInfo {
// 类的属性和方法
}

在上述代码中,即使实体类名为UserInfo,但通过@TableName注解,我们知道它对应数据库中的"user_info"表。

二、@Tableld注解

每个数据库表都有主键,@TableId注解用于标识实体类中的主键属性。通常与@TableName配合使用,确保主键映射正确。

AUTO(0),
NONE(1),
INPUT(2),
ASSIGN_ID(3),
ASSIGN_UUID(4),
/** @deprecated */
@Deprecated
ID_WORKER(3),
/** @deprecated */
@Deprecated
ID_WORKER_STR(3),
/** @deprecated */
@Deprecated
UUID(4);

Description

  • INPUT 如果开发者没有手动赋值,则数据库通过自增的方式给主键赋值,如果开发者手动赋值,则存入该值。

  • AUTO 默认就是数据库自增,开发者无需赋值。

  • ASSIGN_ID MP 自动赋值,雪花算法。

  • ASSIGN_UUID 主键的数据类型必须是 String,自动生成 UUID 进行赋值。

// 自己赋值
//@TableId(type = IdType.INPUT)
// 默认使用的雪花算法,长度比较长,所以使用Long类型,不用自己赋值
@TableId
private Long id;

测试

@Test
void save(){
// 由于id加的有注解,这里就不用赋值了
Student student = new Student();
student.setName("天明");
student.setAge(18);
mapper.insert(student);
}

Description

雪花算法

雪花算法是由Twitter公布的分布式主键生成算法,它能够保证不同表的主键的不重复性,以及相同表的主键的有序性。

核心思想:

  • 长度共64bit(一个long型)。

  • 首先是一个符号位,1bit标识,由于long基本类型在Java中是带符号的,最高位是符号位,正数是0,负数是1,所以id一般是正数,最高位是0。

  • 41bit时间截(毫秒级),存储的是时间截的差值(当前时间截 - 开始时间截),结果约等于69.73年。

  • 10bit作为机器的ID(5个bit是数据中心,5个bit的机器ID,可以部署在1024个节点)。

  • 12bit作为毫秒内的流水号(意味着每个节点在每毫秒可以产生 4096 个 ID)。

Description

优点: 整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞,并且效率较高。

三、@TableField注解

当你的实体类属性名与数据库字段名不一致时,@TableField注解可以帮助你建立二者之间的映射关系。

  • 映射非主键字段,value 映射字段名;

  • exist 表示是否为数据库字段 false,如果实体类中的成员变量在数据库中没有对应的字段,则可以使用 exist,VO、DTO;

  • select 表示是否查询该字段;

  • fill 表示是否自动填充,将对象存入数据库的时候,由 MyBatis Plus 自动给某些字段赋值,create_time、update_time。

Description

自动填充

1)给表添加 create_time、update_time 字段。

Description

2)实体类中添加成员变量。

package com.md.entity;

import com.baomidou.mybatisplus.annotation.*;
import com.md.enums.StatusEnum;
import lombok.Data;
import java.util.Date;

@Data
@TableName(value = "student")
public class Student {
@TableId
private Long id;

// 当该字段名称与数据库名字不一致
@TableField(value = "name")
private String name;

// 不查询该字段
@TableField(select = false)
private Integer age;

// 当数据库中没有该字段,就忽略
@TableField(exist = false)
private String gender;

// 第一次添加填充
@TableField(fill = FieldFill.INSERT)
private Date createTime;

// 第一次添加的时候填充,但之后每次更新也会进行填充
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;

}

3)创建自动填充处理器。

注意:不要忘记添加 @Component 注解。

package com.md.handler;

import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;

import java.util.Date;

/**
* @author md
* @Desc 对实体类中使用的自动填充注解进行编写
* @date 2020/10/26 17:29
*/
// 加入注解才能生效
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {

@Override
public void insertFill(MetaObject metaObject) {
this.setFieldValByName("createTime", new Date(), metaObject);
this.setFieldValByName("updateTime", new Date(), metaObject);
}

@Override
public void updateFill(MetaObject metaObject) {
this.setFieldValByName("updateTime", new Date(), metaObject);
}
}

4)测试

@Test
void save(){
// 由于id加的有注解,这里就不用赋值了
Student student = new Student();
student.setName("韩立");
student.setAge(11);
// 时间自动填充
mapper.insert(student);
}

Description

5)更新

当该字段发生变化的时候时间会自动更新。

@Test
void update(){
Student student = mapper.selectById(1001);
student.setName("韩信");
mapper.updateById(student);
}

Description

四、@TableLogic注解

在很多应用中,数据并不是真的被删除,而是标记为已删除状态。@TableLogic注解用于标识逻辑删除字段,通常配合逻辑删除功能使用。

1、逻辑删除

物理删除: 真实删除,将对应数据从数据库中删除,之后查询不到此条被删除的数据。

逻辑删除: 假删除,将对应数据中代表是否被删除字段的状态修改为“被删除状态”,之后在数据库中仍旧能看到此条数据记录。

使用场景: 可以进行数据恢复。

2、实现逻辑删除

step1: 数据库中创建逻辑删除状态列。
Description

step2: 实体类中添加逻辑删除属性。

@TableLogic
@TableField(value = "is_deleted")
private Integer deleted;

3、测试

测试删除: 删除功能被转变为更新功能。

-- 实际执行的SQL
update user set is_deleted=1 where id = 1 and is_deleted=0

测试查询: 被逻辑删除的数据默认不会被查询。

-- 实际执行的SQL
select id,name,is_deleted from user where is_deleted=0

你还在苦恼找不到真正免费的编程学习平台吗?可以试试【云端源想】!课程视频、知识库、微实战、云实验室、一对一咨询……你想要的全部学习资源这里都有,重点是现在还是免费的!点这里即可查看!

五、@Version注解

乐观锁是一种并发控制策略,@Version注解用于标识版本号字段,确保数据的一致性。

乐观锁

Description
标记乐观锁,通过 version 字段来保证数据的安全性,当修改数据的时候,会以 version 作为条件,当条件成立的时候才会修改成功。

version = 2

  • 线程1:update … set version = 2 where version = 1
  • 线程2:update … set version = 2 where version = 1

1.数据库表添加 version 字段,默认值为 1。

2.实体类添加 version 成员变量,并且添加 @Version。

package com.md.entity;

import com.baomidou.mybatisplus.annotation.*;
import com.md.enums.StatusEnum;
import lombok.Data;
import java.util.Date;

@Data
@TableName(value = "student")
public class Student {
@TableId
private Long id;
@TableField(value = "name")
private String name;
@TableField(select = false)
private Integer age;
@TableField(exist = false)
private String gender;
@TableField(fill = FieldFill.INSERT)
private Date createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;

@Version
private Integer version; //版本号

}

3.注册配置类

在 MybatisPlusConfig 中注册 Bean。

package com.md.config;

import com.baomidou.mybatisplus.extension.plugins.OptimisticLockerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
* @author md
* @Desc
* @date 2020/10/26 20:42
*/
@Configuration
public class MyBatisPlusConfig {
/**
* 乐观锁
*/
@Bean
public OptimisticLockerInterceptor optimisticLockerInterceptor(){
return new OptimisticLockerInterceptor();
}
}

六、@EnumValue注解

mp框架对枚举进行处理的一个注解。

使用场景: 创建枚举类,在需要存储数据库的属性上添加@EnumValue注解。

public enum SexEnum {

MAN(1, "男"),
WOMAN(2, "女");

@EnumValue
private Integer key;
}

MyBatis-Plus的注解是开发者的好帮手,它们简化了映射配置,提高了开发效率。希望以上的介绍能帮助新手朋友们快速理解和运用这些常用注解,让你们在MyBatis-Plus的世界里游刃有余!记得实践是最好的学习方式,快去动手试试吧!

收起阅读 »

技术人的绩效评审发年终奖那些事儿

前言 这几天陆续开工了,收益不好的公司,没有年会,没有年终奖,没有开工红包,没有团建,也没有聚餐,唯一有的可能是降薪裁员... 收益好的公司开了年会,年终奖加倍... 接下来就来聊聊关于技术人的绩效评审以及年终奖那些事儿 以下基于个人经历展开讨论和思考,如果...
继续阅读 »


前言


这几天陆续开工了,收益不好的公司,没有年会,没有年终奖,没有开工红包,没有团建,也没有聚餐,唯一有的可能是降薪裁员...


收益好的公司开了年会,年终奖加倍...


接下来就来聊聊关于技术人的绩效评审以及年终奖那些事儿



以下基于个人经历展开讨论和思考,如果有不同的观点,欢迎交流探讨



关于年终奖


一般来讲,只要公司收益好,一般是多少都会发点年终奖的,例如13、4、5、6,7薪,过节费,项目奖,年终奖,xx绩效奖,部门xx奖等等



公司要是效益不好的话,可能就是这些各种发钱的项目就没有了,可能会有一点过节费,如果效益差到工资社保都拖欠的话,可能就是考虑能不能年底也发点工资的情况了


总的来说年终奖主要和公司收益挂钩,公司收益好,多少有点,收益不好,无


如果发年终奖的话,在相关制度下总量可能就那么多,发到个人这边一般就是和绩效挂钩了,绩效评级高,同类型岗位情况下发的多点,绩效低,发的少,那么公司是怎么对技术人进行绩效评审呢



微小型公司可能不需要绩效,发钱基本老板一个人就决定了


特殊情况的公司或者部门各种特殊的情况也有



这里分享一下我司今年出的年终奖方案



以下半年部门净收入目标完成率作为基础指标,实现目标销售净额的,按3%计提奖金池,未完成目标的,按3%×目标完成率计提奖金池,超过目标的,超过部分按10%增加奖金池。由分管领导与部门沟通后参照员工年终评估结果出具分配方案(年终评估为D或E员工无奖金)



负责人,其他前台部门等年终奖的计算方式是另外的方式,有兴趣的可以留言讨论,这里先不展开了


员工评估 D, E 是有硬指标的,具体占比百分之几咱也不知道,每年的评审结果都是只有领导知道


由于公司业务效益不行,净收入为负,亏损状态,所以我们这个部门的员工年终奖——无



关于绩效评审


先来看看我司对技术人员工的评估方式


第一步:直系领导直接打分,提交对应的表到人力部门


第二步:人力部门根据任务系统中的个人任务情况,以及考勤情况打分


第三步:人力总监最终决定给xxx员工涨薪,发奖金



日常任务由直系领导安排发放,包括任务工时评估,注意了,这里任务工时不是开发者评估的,大部分都是负责人直接评好写到周任务表上去的,有的任务用时会和开发者咨询协商


人力部门看的那个任务系统中的任务,一般是业务线负责人不忙的时候根据腾讯文档周任务表中的任务后期补上去的


日常的很多临时工作在周任务表上体现不出来的,然后很多周任务表中的任务没有写到任务系统中,任务系统中的任务只是创建,开始,完成状态,没有工时的体现


由于任务系统是人力部门单独推动的,最终的情况就是实际工作内容和任务系统记录其实是脱节状态,为了建任务而建任务


不同业务线的直系领导角色也不一样,有的是纯管理,有的是半后端开发半管理,有的是产品



总结一下就是直系领导决定主要的打分情况,人力部门根据基于一线负责人的打分结合考勤和任务情况,决定要不要涨薪,发多少奖金


以上就是我司绩效评审的一些情况,这也是相当一部分比例公司(部门/团队)的常规操作


从客观数据角度来看,基本没有体现岗位产出方面客观可量化的指标,唯一能量化的指标就是一个考勤了,两三个人的主观评价就决定了一个技术人的升职、加薪、辞退、奖金发放的问题


万物都有存在的道理,毕竟,大部分公司都是草台班子,甚至更水


从客观数据角度思考对于技术人的工作评审


先来看看技术人的实际工作都有哪些?


我们拿技术人中的开发人员来举例,从一个任务安排到工作完成提交都有哪些常见步骤:


参加需求评审会,了解需求,设计实现方案,代码编写,提交代码,线上测试,修复bug,输出文档,技术分享等等


开发人员工作产出一般通俗点讲就是开发了多少需求,功能,解决了多少bug等等


在技术圈内一般讨论一个开发人员是否大佬,是否能力出众的标准一般是:



  1. 解决问题的快慢程度(不局限于技术问题)

  2. 掌握技术栈的深度和广度

  3. 是否能和其他不同的工种、部门良好协作

  4. 技术方案执行落地能够良好取舍

  5. 定期更新技术栈,使用相对合适的技术解决业务问题

  6. 提交功能的bug数量

  7. 代码可读性是否良好

  8. 代码是否足够简洁优雅

  9. 开发的功能是否健壮

  10. 其他人接手可维护性是否容易

  11. 输出的文档是否专业,格式简洁明了

  12. ...


相对来讲,开发人员工作产出基本都是可以量化的,关于工作量化的问题,鲁迅说: 任何岗位的工作都是可以量化的


为了评审量化产出,也不能为了量化而量化,那样没任何意义了,浪费团队大把时间扯皮,还影响工作


关于量化产出激励团队方面,我个人比较喜欢敏捷开发那种模式,相对公正,公开


例如:在敏捷开发里面是日常工作是按照评估的人天,人时等方式


任务评估为了保证相对公平性是有这么一个前提的,团队岗位不是一个人评估


例如前端开发岗位,至少俩个人,一个人评估1人天,一个人评估10人天,这显然是有明显差距的,这种情况下一般 master 角色和团队其他成员参与讨论决定,master 也就是项目经理或者项目负责人的角色进行一定的把控


任务是个人根据需求池中的任务自己拉到个人任务表里,而不是单纯的分配,这种机制有个很不错的方式,就是优秀的人在一起会激发更强大的创造力



这里推荐看下美剧 《硅谷》 中 Dinesh 和 Gilfoyle 敏捷开发时的竞争桥段



还有就是不同的开发任务难易程度不一样,举个例子,有两个小任务,同样都是2人天,第一个任务需要大量的尝试新方案,进行相应的测试,并编写一定数据量的代码;第二个任务是使用现有方案,直接写一定量的代码,如常见的业务需求


这种情况的任务如果在项目工期相对紧张的情况,任务是自我选择的话绝大多数都会选择第二个任务,虽然量大,没有风险,不会出现研究过程某个地方卡壳导致任务延期(延期也有对应的处理机制,这里先不讨论)


但是这样很可能会出现难度高的任务会放到最后,没人做了,所以要有人为把控优先级,并且进行合理安排进行一定的调整


同样都是2天的任务,接受度是不一样的,这种任务一般会增加一个难度系数,也有的是进行难度分类,例如:难,一般,容易


最终任务产出统计时,工时还需要乘以难度系数


理想情况下以上的操作基本能实现大部分开发工作产出的量化,对于年终的工作评审结果相对公平客观



注意!!!


这里有个点,敏捷的机制是激励那些那些高效率高产出的人,是一种公开透明的激励机制


良好落地敏捷也是需要一定条件的,如团队需要有一定规模,并且要进行培训和认同这种机制,而且岗位对应人员至少是一个人,级别不能相差太大等等



每一种机制都是基于特定人群设计的,如果用错了人群可能机制流程就变成了纯形式化了


基于客观数据的评审方式


简单分析了一下后,基于客观数据相对公正的评审方式可看如下例子


日常任务得分 * 权重系数 + 直系领导打分 * 权重系数 + 部门领导/CXO 打分 * 权重系统 + 人事考勤得分 * 权重系数 + 其他得分


日常任务得分权重 > 领导打分权重 > ...


单纯衡量技术人的工作的话,应该主要以产出为主,其他环节占比相对小一点


相对来讲这种方式各方都有参与环节,基于数据数据说话是比较客观公正的


以上都是理想状态,一个公司的做事风格和方式和创始人有着直接关系,最终落地后的是个什么东西还得看公司老板和高管是如何做的


写在最后


这个世界没有绝对的公平合理



欢迎大家讨论交流,如果喜欢本文章或感觉文章有用,动动你那发财的小手点赞、收藏、关注再走呗 ^_^ 


微信公众号:草帽Lufei




作者:草帽lufei
来源:juejin.cn/post/7337630368907198502
收起阅读 »

麻了,一个操作把MySQL主从复制整崩了

前言 最近公司某项目上反馈mysql主从复制失败,被运维部门记了一次大过,影响到了项目的验收推进,那么究竟是什么原因导致的呢?而主从复制的原理又是什么呢?本文就对排查分析的过程做一个记录。 主从复制原理 我们先来简单了解下MySQL主从复制的原理。 主库m...
继续阅读 »

前言


最近公司某项目上反馈mysql主从复制失败,被运维部门记了一次大过,影响到了项目的验收推进,那么究竟是什么原因导致的呢?而主从复制的原理又是什么呢?本文就对排查分析的过程做一个记录。


主从复制原理


我们先来简单了解下MySQL主从复制的原理。




  1. 主库master 服务器会将 SQL 记录通过 dump 线程写入到 二进制日志binary log 中;

  2. 从库slave 服务器开启一个 io thread 线程向服务器发送请求,向 主库master 请求 binary log。主库master 服务器在接收到请求之后,根据偏移量将新的 binary log 发送给 slave 服务器。

  3. 从库slave 服务器收到新的 binary log 之后,写入到自身的 relay log 中,这就是所谓的中继日志。

  4. 从库slave 服务器,单独开启一个 sql thread 读取 relay log 之后,写入到自身数据中,从而保证主从的数据一致。


以上是MySQL主从复制的简要原理,更多细节不展开讨论了,根据运维反馈,主从复制失败主要在IO线程获取二进制日志bin log超时,一看主数据库的binlog日志竟达到了4个G,正常情况下根据配置应该是不超过300M。



binlog写入机制


想要了解binlog为什么达到4个G,我们来看下binlog的写入机制。


binlog的写入时机也非常简单,事务执行过程中,先把日志写到 binlog cache ,事务提交的时候,再把binlog cache写到binlog文件中。因为一个事务的binlog不能被拆开,无论这个事务多大,也要确保一次性写入,所以系统会给每个线程分配一个块内存作为binlog cache




  1. 上图的write,是指把日志写入到文件系统的page cache,并没有把数据持久化到磁盘,所以速度比较快

  2. 上图的fsync,才是将数据持久化到磁盘的操作, 生成binlog日志中


生产上MySQL中binlog中的配置max_binlog_size为250M, 而max_binlog_size是用来控制单个二进制日志大小,当前日志文件大小超过此变量时,执行切换动作。,该设置并不能严格控制Binlog的大小,尤其是binlog比较靠近最大值而又遇到一个比较大事务时,为了保证事务的完整性,可能不做切换日志的动作,只能将该事务的所有$QL都记录进当前日志,直到事务结束。一般情况下可采取默认值。


所以说怀疑是不是遇到了大事务,因而我们需要看看binlog中的内容具体是哪个事务导致的。


查看binlog日志


我们可以使用mysqlbinlog这个工具来查看下binlog中的内容,具体用法参考官网:https://dev.mysql.com/doc/refman/8.0/en/mysqlbinlog.html



  1. 查看binlog日志


./mysqlbinlog --no-defaults --base64-output=decode-rows -vv /mysqldata/mysql/binlog/mysql-bin.004816|more


  1. 以事务为单位统计binlog日志文件中占用的字节大小


./mysqlbinlog --no-defaults --base64-output=decode-rows -vv /mysqldata/mysql/binlog/mysql-bin.004816|grep GTID -B1|grep '^# at' | awk '{print $3}' | awk 'NR==1 {tmp=$1} NR>1 {print ($1-tmp, tmp, $1); tmp=$1}'|sort -n -r|more


生产中某个事务竟然占用4个G。



  1. 通过start-positionstop-position统计这个事务各个SQL占用字节大小


./mysqlbinlog --no-defaults --base64-output=decode-rows --start-position='xxxx' --stop-position='xxxxx' -vv /mysqldata/mysql/binlog/mysql-bin.004816 |grep '^# at'| awk '{print $3}' | awk 'NR==1 {tmp=$1} NR>1 {print ($1-tmp, tmp, $1); tmp=$1}'|sort -n -r|more


发现最大的一个SQL竟然占用了32M的大小,那超过10M的大概有多少个呢?



  1. 通过超过10M大小的数量


./mysqlbinlog --no-defaults --base64-output=decode-rows --start-position='xxxx' --stop-position='xxxxx' -vv /mysqldata/mysql/binlog/mysql-bin.004816|grep '^# at' | awk '{print $3}' | awk 'NR==1 {tmp=$1} NR>1 {print ($1-tmp, tmp, $1); tmp=$1}'|awk '$1>10000000 {print $0}'|wc -l


统计结果显示竟然有200多个,毛估一下,也有近4个G了



  1. 根据pos, 我们看下究竟是什么SQL导致的


./mysqlbinlog --no-defaults --base64-output=decode-rows --start-position='xxxx' --stop-position='xxxxx' -vv /mysqldata/mysql/binlog/mysql-bin.004816|grep '^# atxxxx' -C5| grep -v '###' | more


根据sql,分析了下,这个表正好有个blob字段,统计了下blob字段总合大概有3个G大小,然后我们业务上有个导入操作,这是一个非常大的事务,会频繁更新这表中记录的更新时间,导致生成binlog非常大。


问题: 明明只是简单的修改更新时间的语句,压根没有动blob字段,为什么生产的binlog这么大?因为生产的binlog采用的是row模式。


binlog的模式


binlog日志记录存在3种模式,而生产使用的是row模式,它最大的特点,是很精确,你更新表中某行的任何一个字段,会记录下整行的内容,这也就是为什么blob字段都被记录到binlog中,导致binlog非常大。此外,binlog还有statementmixed两种模式。



  1. STATEMENT模式 ,基于SQL语句的复制



  • 优点: 不需要记录每一行数据的变化,减少binlog日志量,节约IO,提高性能。

  • 缺点: 由于只记录语句,所以,在statement level下 已经发现了有不少情况会造成MySQL的复制出现问题,主要是修改数据的时候使用了某些定的函数或者功能的时候会出现。



  1. ROW模式,基于行的复制


5.1.5版本的MySQL才开始支持,不记录每条sql语句的上下文信息,仅记录哪条数据被修改了,修改成什么样了。



  • 优点: binlog中可以不记录执行的sql语句的上下文相关的信息,仅仅只需要记录那一条被修改。所以rowlevel的日志内容会非常清楚的记录下每一行数据修改的细节。不会出现某些特定的情况下的存储过程或function,以及trigger的调用和触发无法被正确复制的问题

  • 缺点: 所有的执行的语句当记录到日志中的时候,都将以每行记录的修改来记录,会产生大量的日志内容。



  1. MIXED模式


从5.1.8版本开始,MySQL提供了Mixed格式,实际上就是StatementRow的结合。


Mixed模式下,一般的语句修改使用statment格式保存binlog。如一些函数,statement无法完成主从复制的操作,则采用row格式保存binlog


总结


最终分析下来,我们定位到原来是由于大事务+blob字段大致binlog非常大,最终我们采用了修改业务代码,将blob字段单独拆到一张表中解决。所以,在设计开发过程中,要尽量避免大事务,同时在数据库建模的时候特别考虑将blob字段独立成表。


作者:JAVA旭阳
来源:juejin.cn/post/7231473194339532861
收起阅读 »

使用java自己简单搭建内网穿透

思路 内网穿透是一种网络技术,适用于需要远程访问本地部署服务的场景,比如你在家里搭建了一个网站或者想远程访问家里的电脑。由于本地部署的设备使用私有IP地址,无法直接被外部访问,因此需要通过公网IP实现访问。通常可以通过购买云服务器获取一个公网IP来实现这一目的...
继续阅读 »

思路


内网穿透是一种网络技术,适用于需要远程访问本地部署服务的场景,比如你在家里搭建了一个网站或者想远程访问家里的电脑。由于本地部署的设备使用私有IP地址,无法直接被外部访问,因此需要通过公网IP实现访问。通常可以通过购买云服务器获取一个公网IP来实现这一目的。


实际上,内网穿透的原理是将位于公司或其他工作地点的私有IP数据发送到云服务器(公网IP),再从云服务器发送到家里的设备(私有IP)。从私有IP到公网IP的连接是相对简单的,但是从公网IP到私有IP就比较麻烦,因为公网IP无法直接找到私有IP。


为了解决这个问题,我们可以让私有IP主动连接公网IP。这样,一旦私有IP连接到了公网IP,公网IP就知道了私有IP的存在,它们之间建立了连接关系。当公网IP收到访问请求时,就会通知私有IP有访问请求,并要求私有IP连接到公网IP。这样一来,公网IP就建立了两个连接,一个是用于访问的连接,另一个是与私有IP之间的连接。最后,通过这两个连接之间的数据交换,实现了远程访问本地部署服务的目的。


代码操作


打开IDEA创建一个mave项目,删除掉src,创建两个模块clientservice,一个是在本地的运行,一个是在云服务器上运行的,这边socket(tcp)连接,我使用的是AIO,AIO的函数回调看起来好复杂。


先编写service服务端,创建两个ServerSocket服务,一个是监听16000的,用来外来连接的,另一是监听16088是用来client访问的,也就是给serviceclient之间交互用的。先讲一个extListener他是监听16000,当有外部请求来时,也就是在公司访问时,先判断registerChannel是不是有clientservice,没有就关闭连接。有的话就下发指令告诉client有访问了赶快给我连接,连接会存在channelQueue队列里,拿到连接后,两个连接交换数据就行。


private static final int extPort = 16000;
private static final int clintPort = 16088;


private static AsynchronousSocketChannel registerChannel;

static BlockingQueue<AsynchronousSocketChannel> channelQueue = new LinkedBlockingQueue<>();

public static void main(String[] args) throws IOException {

final AsynchronousServerSocketChannel listener =
AsynchronousServerSocketChannel.open().bind(new InetSocketAddress("192.168.1.10", clintPort));

listener.accept(null, new CompletionHandler<AsynchronousSocketChannel, Void>() {
public void completed(AsynchronousSocketChannel ch, Void att) {

// 接受连接,准备接收下一个连接
listener.accept(null, this);

// 处理连接
clintHandle(ch);
}

public void failed(Throwable exc, Void att) {
exc.printStackTrace();
}
});


final AsynchronousServerSocketChannel extListener =
AsynchronousServerSocketChannel.open().bind(new InetSocketAddress("localhost", extPort));

extListener.accept(null, new CompletionHandler<AsynchronousSocketChannel, Void>() {

private Future<Integer> writeFuture;

public void completed(AsynchronousSocketChannel ch, Void att) {
// 接受连接,准备接收下一个连接
extListener.accept(null, this);

try {
//判断是否有注册连接
if(registerChannel==null || !registerChannel.isOpen()){
try {
ch.close();
} catch (IOException e) {
e.printStackTrace();
}
return;
}
//下发指令告诉需要连接
ByteBuffer bf = ByteBuffer.wrap(new byte[]{1});
if(writeFuture != null){
writeFuture.get();
}
writeFuture = registerChannel.write(bf);

AsynchronousSocketChannel take = channelQueue.take();

//clint连接失败的
if(take == null){
ch.close();
return;
}

//交换数据
exchangeDataHandle(ch,take);

} catch (Exception e) {
e.printStackTrace();
}

}

public void failed(Throwable exc, Void att) {
exc.printStackTrace();
}
});

Scanner in = new Scanner(System.in);
in.nextLine();


}

看看clintHandle方法是怎么存进channelQueue里的,很简单client发送0,就认为他是注册的连接,也就交互的连接直接覆盖registerChannel,发送1的话就是用来交换数据的,扔到channelQueue,发送2就异常的连接。


private static void clintHandle(AsynchronousSocketChannel ch) {

final ByteBuffer buffer = ByteBuffer.allocate(1);
ch.read(buffer, null, new CompletionHandler<Integer, Void>() {
public void completed(Integer result, Void attachment) {
buffer.flip();
byte b = buffer.get();
if (b == 0) {
registerChannel = ch;
} else if(b == 1){
channelQueue.offer(ch);
}else{
//clint连接不到
channelQueue.add(null);
}

}

public void failed(Throwable exc, Void attachment) {
exc.printStackTrace();
}
});
}

再编写client客户端,dstHostdstPort是用来连接service的ip和端口,看起来好长,实际上就是client连接service,第一个连接成功后向service发送了个0告诉他是注册的连接,用来交换数据。当这个连接收到service发送的1时,就会创建新的连接去连接service


private static final String dstHost = "192.168.1.10";
private static final int dstPort = 16088;

private static final String srcHost = "localhost";
private static final int srcPort = 3389;


public static void main(String[] args) throws IOException {

System.out.println("dst:"+dstHost+":"+dstPort);
System.out.println("src:"+srcHost+":"+srcPort);

//使用aio
final AsynchronousSocketChannel client = AsynchronousSocketChannel.open();

client.connect(new InetSocketAddress(dstHost, dstPort), null, new CompletionHandler<Void, Void>() {
public void completed(Void result, Void attachment) {
//连接成功
byte[] bt = new byte[]{0};
final ByteBuffer buffer = ByteBuffer.wrap(bt);
client.write(buffer, null, new CompletionHandler<Integer, Void>() {
public void completed(Integer result, Void attachment) {

//读取数据
final ByteBuffer buffer = ByteBuffer.allocate(1);
client.read(buffer, null, new CompletionHandler<Integer, Void>() {
public void completed(Integer result, Void attachment) {
buffer.flip();

if (buffer.get() == 1) {
//发起新的连
try {
createNewClient();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
buffer.clear();
// 这里再次调用读取操作,实现循环读取
client.read(buffer, null, this);
}

public void failed(Throwable exc, Void attachment) {
exc.printStackTrace();
}
});


}

public void failed(Throwable exc, Void attachment) {
exc.printStackTrace();
}
});


}

public void failed(Throwable exc, Void attachment) {
exc.printStackTrace();
}
});
Scanner in = new Scanner(System.in);
in.nextLine();

}

createNewClient方法,尝试连接本地服务,如果失败就发送2,成功就发送1,这个会走 serviceclintHandle方法,成功的话就会让两个连接交换数据。


private static void createNewClient() throws IOException {

final AsynchronousSocketChannel dstClient = AsynchronousSocketChannel.open();
dstClient.connect(new InetSocketAddress(dstHost, dstPort), null, new CompletionHandler<Void, Void>() {
public void completed(Void result, Void attachment) {

//尝试连接本地服务
final AsynchronousSocketChannel srcClient;
try {
srcClient = AsynchronousSocketChannel.open();
srcClient.connect(new InetSocketAddress(srcHost, srcPort), null, new CompletionHandler<Void, Void>() {
public void completed(Void result, Void attachment) {

byte[] bt = new byte[]{1};
final ByteBuffer buffer = ByteBuffer.wrap(bt);
Future<Integer> write = dstClient.write(buffer);
try {
write.get();
//交换数据
exchangeData(srcClient, dstClient);
exchangeData(dstClient, srcClient);
} catch (Exception e) {
closeChannels(srcClient, dstClient);
}


}

public void failed(Throwable exc, Void attachment) {
exc.printStackTrace();
//失败
byte[] bt = new byte[]{2};
final ByteBuffer buffer = ByteBuffer.wrap(bt);
dstClient.write(buffer);
}
});

} catch (IOException e) {
e.printStackTrace();
//失败
byte[] bt = new byte[]{2};
final ByteBuffer buffer = ByteBuffer.wrap(bt);
dstClient.write(buffer);
}

}

public void failed(Throwable exc, Void attachment) {
exc.printStackTrace();
}
});
}

下面是exchangeData交换数据方法,看起好麻烦,效果就类似IOUtils.copy(InputStream,OutputStream),一个流写入另一个流。


private static void exchangeData(AsynchronousSocketChannel ch1, AsynchronousSocketChannel ch2) {
try {
final ByteBuffer buffer = ByteBuffer.allocate(1024);

ch1.read(buffer, null, new CompletionHandler<Integer, CompletableFuture<Integer>>() {

public void completed(Integer result, CompletableFuture<Integer> readAtt) {

CompletableFuture<Integer> future = new CompletableFuture<>();

if (result == -1 || buffer.position() == 0) {
// 处理连接关闭的情况或者没有数据可读的情况

try {
readAtt.get(3,TimeUnit.SECONDS);
} catch (Exception e) {
e.printStackTrace();
}

closeChannels(ch1, ch2);
return;
}

buffer.flip();

CompletionHandler readHandler = this;

ch2.write(buffer, future, new CompletionHandler<Integer, CompletableFuture<Integer>>() {
@Override
public void completed(Integer result, CompletableFuture<Integer> writeAtt) {

if (buffer.hasRemaining()) {
// 如果未完全写入,则继续写入
ch2.write(buffer, writeAtt, this);

} else {
writeAtt.complete(1);
// 清空buffer并继续读取
buffer.clear();
if(ch1.isOpen()){
ch1.read(buffer, writeAtt, readHandler);
}
}

}

@Override
public void failed(Throwable exc, CompletableFuture<Integer> attachment) {
if(!(exc instanceof AsynchronousCloseException)){
exc.printStackTrace();
}
closeChannels(ch1, ch2);
}
});

}

public void failed(Throwable exc, CompletableFuture<Integer> attachment) {
if(!(exc instanceof AsynchronousCloseException)){
exc.printStackTrace();
}
closeChannels(ch1, ch2);
}
});

} catch (Exception ex) {
ex.printStackTrace();
closeChannels(ch1, ch2);
}

}

private static void closeChannels(AsynchronousSocketChannel ch1, AsynchronousSocketChannel ch2) {
if (ch1 != null && ch1.isOpen()) {
try {
ch1.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (ch2 != null && ch2.isOpen()) {
try {
ch2.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}

测试


我这边就用虚拟机来测试,用云服务器就比较麻烦,得登录账号,增加开放端口规则,上传代码。我这边用Hyper-V快速创建了虚拟机,创建一个windows 10 MSIX系统,安装JDK8,下载地址:http://www.azul.com/downloads/?… 。怎样把本地编译好的class放到虚拟机呢,虚拟机是可以访问主机ip的,我们可以弄一个web的文件目录下载给虚拟机访问,人生苦短我用pyhton,下面python简单代码


if __name__ == '__main__':
# 定义服务器的端口
PORT = 8000

# 创建请求处理程序
Handler = http.server.SimpleHTTPRequestHandler

# 设置工作目录
os.chdir("C:\netTunnlDemo\client\target")

# 创建服务器
with socketserver.TCPServer(("", PORT), Handler) as httpd:
print(f"服务启动在端口 {PORT}")
httpd.serve_forever()

到class的目录下运行cmd,执行java -cp . org.example.Main,windows 默认远程端口3389。


最后效果


QQ截图20240225075018.png


总结


使用AIO导致代码长,逻辑并不复杂,完整代码,供个人学习:断续/netTunnlDemo (gitee.com)


作者:cloudy491
来源:juejin.cn/post/7338973258895802431
收起阅读 »

MyBatis实现多行合并(collection标签使用)

一、举个栗子 现有如下表结构,用户表、角色表、用户角色关联表。 一个用户有多个角色,一个角色有可以给多个用户,也即常见的多对多场景。 现有这样一个需求,分页查询用户数据,除了用户ID和用户名称字段,还要查出这个用户的所有角色。 从上面的表格我们可以看出,用...
继续阅读 »

一、举个栗子


现有如下表结构,用户表、角色表、用户角色关联表。
一个用户有多个角色,一个角色有可以给多个用户,也即常见的多对多场景
在这里插入图片描述


现有这样一个需求,分页查询用户数据,除了用户ID和用户名称字段,还要查出这个用户的所有角色
在这里插入图片描述
从上面的表格我们可以看出,用户有三个,但每个人的角色不止一个,而且有重复的角色,这里角色的数据从多行合并到了1行


二、难点分析


SQL存在的问题:



想使用SQL实现上面的效果不是不可以,但是很复杂且效率低下,尤其这个地方还需要分页,所以为了保证查询效率,我们需要把逻辑放到服务端来写;



服务端存在的问题:



服务端可以把需要的数据都查询出来,然后自己判断整合,首先十分复杂不说,而且这里有个问题:如果角色也是一个查询条件如何处理呢?



三、解决方案


核心方案就是使用Mybatis的collection标签自动实现多行合并。


下面是collection标签的一些介绍
在这里插入图片描述


常见写法


<resultMap id="ExtraBaseResultMap" type="com.example.mybatistest.entity.UserInfoDO">
<!--
WARNING - @mbg.generated
-->

<result column="user_id" jdbcType="INTEGER" property="userId"/>
<result column="user_name" jdbcType="INTEGER" property="userName"/>
<collection javaType="java.util.ArrayList" ofType="com.example.mybatistest.entity.MyRole"
property="roleList">

<result column="role_id" jdbcType="INTEGER" property="roleId"/>
<result column="role_name" jdbcType="VARCHAR" property="roleName"/>
</collection>
</resultMap>

四、尝试一下


1. 准备材料


(1)数据库脚本


/*
Navicat Premium Data Transfer

Source Server : 本机
Source Server Type : MySQL
Source Server Version : 80021
Source Host : localhost:3306
Source Schema : mybatis-test

Target Server Type : MySQL
Target Server Version : 80021
File Encoding : 65001

Date: 23/06/2022 19:16:34
*/


SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for my_role
-- ----------------------------
DROP TABLE IF EXISTS `my_role`;
CREATE TABLE `my_role` (
`role_id` int NOT NULL COMMENT '角色主键',
`role_code` varchar(32) DEFAULT NULL COMMENT '角色code',
`role_name` varchar(32) DEFAULT NULL COMMENT '角色名称',
PRIMARY KEY (`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

-- ----------------------------
-- Records of my_role
-- ----------------------------
BEGIN;
INSERT INTO `my_role` VALUES (1, 'admin', '超级管理员');
INSERT INTO `my_role` VALUES (2, 'visitor', '游客');
COMMIT;

-- ----------------------------
-- Table structure for my_user
-- ----------------------------
DROP TABLE IF EXISTS `my_user`;
CREATE TABLE `my_user` (
`user_id` int NOT NULL COMMENT '用户主键',
`user_name` varchar(32) DEFAULT NULL COMMENT '用户名称',
`user_gender` tinyint DEFAULT NULL COMMENT '用户性别,1:男/2:女/3:未知',
PRIMARY KEY (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

-- ----------------------------
-- Records of my_user
-- ----------------------------
BEGIN;
INSERT INTO `my_user` VALUES (1, '用户1', 1);
COMMIT;

-- ----------------------------
-- Table structure for my_user_role_rel
-- ----------------------------
DROP TABLE IF EXISTS `my_user_role_rel`;
CREATE TABLE `my_user_role_rel` (
`rel_id` int NOT NULL COMMENT '角色主键',
`role_id` int DEFAULT NULL COMMENT '角色ID',
`user_id` int DEFAULT NULL COMMENT '用户ID',
PRIMARY KEY (`rel_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

-- ----------------------------
-- Records of my_user_role_rel
-- ----------------------------
BEGIN;
INSERT INTO `my_user_role_rel` VALUES (1, 1, 1);
INSERT INTO `my_user_role_rel` VALUES (2, 2, 1);
COMMIT;

SET FOREIGN_KEY_CHECKS = 1;


(2)pom.xml


<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">

<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>mybatis-test</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>mybatis-test</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.22</version>
</dependency>
<!-- 我这里使用的是mybatis-plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.3.2</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-extension</artifactId>
<version>3.3.2</version>
</dependency>
<dependency>
<groupId>javax.persistence</groupId>
<artifactId>javax.persistence-api</artifactId>
</dependency>

<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>


(3)application.properties


# 数据库配置
spring.datasource.type=com.zaxxer.hikari.HikariDataSource
spring.datasource.url=jdbc:mysql://localhost:3306/mybatis-test?characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
spring.datasource.username=root
spring.datasource.password=xxx
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# mybatis
mybatis.configuration.auto-mapping-behavior=full
mybatis.configuration.map-underscore-to-camel-case=true
mybatis-plus.mapper-locations=classpath*:/mybatis/mapper/*.xml

2. 项目代码


(1)目录结构


在这里插入图片描述


(2)各类代码


MybatisTestApplication.java


import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@MapperScan({"com.example.mybatistest.mapper"})
public class MybatisTestApplication {

public static void main(String[] args) {
SpringApplication.run(MybatisTestApplication.class, args);
}

}


QueryController.java


import com.example.mybatistest.entity.UserInfoDO;
import com.example.mybatistest.service.UserRoleRelService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/mybatis")
public class QueryController {

@Autowired
private UserRoleRelService userRoleRelService;

@GetMapping("/queryList")
public List<UserInfoDO> queryList() {
return userRoleRelService.queryList();
}
}

UserRoleRelService.java


import com.example.mybatistest.entity.UserInfoDO;

import java.util.List;

public interface UserRoleRelService {
List<UserInfoDO> queryList();
}

UserRoleRelServiceImpl.java


import com.example.mybatistest.entity.UserInfoDO;
import com.example.mybatistest.repository.MyUserRoleRelRepository;
import com.example.mybatistest.service.UserRoleRelService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class UserRoleRelServiceImpl implements UserRoleRelService {

@Autowired
private MyUserRoleRelRepository myUserRoleRelRepository;

@Override
public List<UserInfoDO> queryList() {
return myUserRoleRelRepository.queryList();
}
}

MyUserRoleRelRepository.java


import com.example.mybatistest.entity.UserInfoDO;

import java.util.List;

public interface MyUserRoleRelRepository {
List<UserInfoDO> queryList();
}

MyUserRoleRelRepositoryImpl.java


import com.example.mybatistest.entity.UserInfoDO;
import com.example.mybatistest.mapper.MyUserRoleRelMapper;
import com.example.mybatistest.repository.MyUserRoleRelRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public class MyUserRoleRelRepositoryImpl implements MyUserRoleRelRepository {
@Autowired
public MyUserRoleRelMapper myUserRoleRelMapper;

@Override
public List<UserInfoDO> queryList() {
return myUserRoleRelMapper.queryList();
}
}

MyUserRoleRelMapper.java


import com.example.mybatistest.entity.UserInfoDO;
import org.apache.ibatis.annotations.Mapper;

import java.util.List;

@Mapper
public interface MyUserRoleRelMapper {
List<UserInfoDO> queryList();
}

MyRole.java


import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;


@Builder
@Data
@TableName("my_role")
@NoArgsConstructor
@AllArgsConstructor
public class MyRole {

@Column(name = "role_id")
private Integer roleId;

@Column(name = "role_name")
private String roleName;
}

MyUserRoleRel.java


import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;

@Builder
@Data
@TableName("my_user_role_rel")
@NoArgsConstructor
@AllArgsConstructor
public class MyUserRoleRel {

@Column(name = "rel_id")
private Integer relId;

@Column(name = "user_id")
private Integer userId;

@Column(name = "role_id")
private Integer roleId;
}

UserInfoDO.java


import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;

@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserInfoDO {
private Integer userId;

private String userName;

private List<MyRole> roleList;
}

MyUserRoleRelMapper.xml


<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mybatistest.mapper.MyUserRoleRelMapper">
<resultMap id="BaseResultMap" type="com.example.mybatistest.entity.MyUserRoleRel">
<!--
WARNING - @mbg.generated
-->

<result column="rel_id" jdbcType="INTEGER" property="relId"/>
<result column="role_id" jdbcType="INTEGER" property="roleId"/>
<result column="user_id" jdbcType="INTEGER" property="userId"/>
</resultMap>
<resultMap id="ExtraBaseResultMap" type="com.example.mybatistest.entity.UserInfoDO">
<!--
WARNING - @mbg.generated
-->

<result column="user_id" jdbcType="INTEGER" property="userId"/>
<result column="user_name" jdbcType="INTEGER" property="userName"/>
<collection javaType="java.util.ArrayList" ofType="com.example.mybatistest.entity.MyRole"
property="roleList">

<result column="role_id" jdbcType="INTEGER" property="roleId"/>
<result column="role_name" jdbcType="VARCHAR" property="roleName"/>
</collection>
</resultMap>
<select id="queryList" resultMap="ExtraBaseResultMap">
SELECT
t3.user_id,
t3.user_name,
t2.role_id,
t2.role_name
FROM
my_user_role_rel t1
LEFT JOIN my_role t2 ON t1.role_id = t2.role_id
LEFT JOIN my_user t3 ON t1.user_id = t3.user_id
</select>
</mapper>

3. 实现效果


在这里插入图片描述


这里可以看到roleList里面有两条数据,说明mybatis已经自动聚合完成了。


4. 一些缺点


缺点1、查询条件不能支持很多


虽然Mybatis可以帮我们实现多行合并的功能,但并不是没有问题的。
当使用角色当做查询条件时,由于角色已经指定了,那么roleList里面必定只有这一个角色,不再会有聚合效果,也就看不到这个用户所有的角色了。
我只能说具体看产品要求吧,大部分时候上面那种问题产品都是可以接受的。


缺点2、不支持分页


这个缺点也看业务场景吧,产品可以接受就用,不能接受就别用,我这里只是介绍有这么一个办法。


作者:summo
来源:juejin.cn/post/7337849561479708735
收起阅读 »

微服务下,如何实现多设备同时登录或强制下线?

分享技术,用心生活 前言:你有没有遇到过这样的需求,产品要求实现同一个用户根据后台设置允许同时登录,或者不准同时登录时,需要强制踢下线前一个的场景。本文将带领大家实现一个简单的这种场景需求。 先来看一下简单的时序图,方便后续理解。 sequenceDi...
继续阅读 »

分享技术,用心生活





前言:你有没有遇到过这样的需求,产品要求实现同一个用户根据后台设置允许同时登录,或者不准同时登录时,需要强制踢下线前一个的场景。本文将带领大家实现一个简单的这种场景需求。





先来看一下简单的时序图,方便后续理解。


sequenceDiagram
用户->>过滤器: 请求登录
过滤器->>业务系统: 是否允许同时登录
业务系统-->>过滤器: 返回是/否
过滤器-->>用户: 登录成功(踢下线)

首先我们需要有一个后台设置开关来控制允不允许用户多设备同时登录的功能(没有也无妨,假定允许),其次在登录后,需要保存用户的userId-token的关系缓存。再回头看上面的时序图,是不是已经能理解实现的原理了。


如果你的架构是微服务,那么可以使用redis来存登录关系缓存,单体架构可以直接存session即可。本文是微服务架构,所以采用的是redis。


本文的前提都是基于同一个用户的情况下,下文不再赘述。


1 构造登录缓存关系


如果要实现同一用户多设备同时登录,那必然需要在session(微服务中可以用redis做session共享)中能找到用户的每一个登录状态,如果只是简单的缓存用户信息是实现不了的,登录时那就必须要有一个唯一值token,这样每次登录token不一样,但是指向的用户是同一个。


user


usertoken中维护的是前缀:用户id,这里不需要维护多个,因为用的reids的hash数据类型,多个登录时,添加新行即可;user部分,这里维护的是多个,即登录一次就有一条记录;因为根据业务需要,后续需要从缓存中获取用户其他信息。



  • 允许多设备同时登录:usertoken只有1条,user可能会有多条

  • 不允许多设备同时登录(有则强制下线):usertoken只有1条,user只有1条


    /**
* 登录成功后缓存用户信息
*
* @param req
* @return
*/

public void cacheUserInfo(CacheUserInfoReqDTO req) {
// 1、缓存用户信息
cacheUser(req);
cacheAuth(req.getUid(), req.getRoles(), req.getPermissions());

// 2、更新token与userId关系
String userTokenRelationKey = RedisKeyHelper.getUserTokenRelationKey(req.getEntId() + SymbolConstant.COLON + req.getUid());
redisAdapter.set(userTokenRelationKey, req.getToken(), RedisTtl.USER_LOGIN_SUCCESS);
}

2 过滤器配置


登录鉴权部分和用户登录状态上下文不在本文范围内,此处忽略


登录成功后,每一个请求到达过滤器时,通过请求header中的token来获取登录信息;因为我们存的缓存key前缀都包含userId,所以要想得到用户信息,需要使用到redis的scan命令来获取。(scan最好配置count来限制,保证性能


@Override
protected Mono<Void> innerFilter(ServerWebExchange exchange, WebFilterChain chain) {
String token = filterContext.getToken();
if (StringUtils.isBlank(token)) {
throw new DataValidateException(GatewayReturnCodes.TOKEN_MISSING);
}

// scan获取user的key
String userKey = "";
Set<String> scan = redisAdapter.scan(GatewayRedisKeyPrefix.USER_KEY.getKey() + "*" + token);
if (scan.isEmpty()) {
throw new DataValidateException(GatewayReturnCodes.TOKEN_EXPIRED_LOGIN_SUCCESS);
}
userKey = scan.iterator().next();

MyUser myUser = (MyUser) redisAdapter.get(userKey);
if (myUser == null) {
throw new BusinessException(GatewayReturnCodes.TOKEN_EXPIRED_LOGIN_SUCCESS);
}

// 将用户信息塞入http header
// do something...
return chain.filter(exchange.mutate().request(newServerHttpRequest).build());
}

这样保证即使有多设备同时登录,也能获取到登录信息和上下文。


3 如何做强制下线呢?


其实也很简单,在登录前可以通过AOP方式做校验,如果已登录了,那么这里就清除session或用户缓存,再继续进行正常登录即可。再简单一点可以直接在登录service中添加校验


核心逻辑


 String userTokenRelationKey = RedisKeyHelper.getUserTokenRelationKey(req.getEntId() + SymbolConstant.COLON + userEntList.get(0).getUserId());
String redisToken = (String) redisAdapter.get(userTokenRelationKey);
if (StringUtils.isNotEmpty(redisToken) && !redisToken.equals(token)) {
throw new BusinessException(UserReturnCodes.MULTI_DEVICE_LOGIN);
}

这里用于判断是否已有登录,并返回给前端提示。用于前端其他业务处理
如果不需要给前端提示,不用返回前端,直接进行清除session或用户缓存逻辑。


 String userTokenRelationKey = RedisKeyHelper.getUserTokenRelationKey(req.getEntId() + SymbolConstant.COLON + userEntity.getId());
// 获取当前已用户的登录token
String redisToken = (String) redisAdapter.get(userTokenRelationKey);
// 踢下线之前全部登录
Response<Void> exitLoginResponse = gatewayRpc.allExit(ExitLoginReqDTO.builder().token(redisToken).userId(userEntity.getId()).build());

4 演示



  • 演示强制下线


这里我用用户id为4做演示


先正常第一次登录,提示成功,并且redis中有1条user记录
redis_succ
redis_succ


再次登录,我这里是返回给前端处理了,所以会有提示信息。


login


前端效果


message


最后,扩展一下,如果要实现登录后强制修改默认密码、登录时间段限制等场景,你会怎么实现呢?


作者:临时工
来源:juejin.cn/post/7258155447831920700
收起阅读 »

听我一句劝,业务代码中,别用多线程。

你好呀,我是歪歪。 前几天我在网上冲浪,看到一个哥们在吐槽,说他工作三年多了,没使用过多线程。 虽然八股文背的滚瓜烂熟,但是没有在实际开发过程中写的都是业务代码,没有使用过线程池,心里还是慌得一比。 我只是微微一笑,这不是很正常吗? 业务代码中一般也使不上多线...
继续阅读 »

你好呀,我是歪歪。


前几天我在网上冲浪,看到一个哥们在吐槽,说他工作三年多了,没使用过多线程。


虽然八股文背的滚瓜烂熟,但是没有在实际开发过程中写的都是业务代码,没有使用过线程池,心里还是慌得一比。


我只是微微一笑,这不是很正常吗?


业务代码中一般也使不上多线程,或者说,业务代码中不知不觉你以及在使用线程池了,你再 duang 的一下搞一个出来,反而容易出事。


所以提到线程池的时候,我个人的观点是必须把它吃得透透的,但是在业务代码中少用或者不用多线程。


关于这个观点,我给你盘一下。


Demo


首先我们还是花五分钟搭个 Demo 出来。


我手边刚好有一个之前搭的一个关于 Dubbo 的 Demo,消费者、生产者都有,我就直接拿来用了:



这个 Demo 我也是跟着网上的 quick start 搞的:



cn.dubbo.apache.org/zh-cn/overv…




可以说写的非常详细了,你就跟着官网的步骤一步步的搞就行了。


我这个 Demo 稍微不一样的是我在消费者模块里面搞了一个 Http 接口:



在接口里面发起了 RPC 调用,模拟从前端页面发起请求的场景,更加符合我们的开发习惯。


而官方的示例中,是基于了 SpringBoot 的 CommandLineRunner 去发起调用:



只是发起调用的方式不一样而已,其他没啥大区别。


需要说明的是,我只是手边刚好有一个 Dubbo 的 Demo,随手就拿来用了,但是本文想要表达的观点,和你使不使用 Dubbo 作为 RPC 框架,没有什么关系,道理是通用的。


上面这个 Demo 启动起来之后,通过 Http 接口发起一次调用,看到控制台服务提供方和服务消费方都有对应的日志输出,准备工作就算是齐活儿了:



上菜


在上面的 Demo 中,这是消费者的代码:



这是提供者的代码:



整个调用链路非常的清晰:



来,请你告诉我这里面有线程池吗?


没有!


是的,在日常的开发中,我就是写个接口给别人调用嘛,在我的接口里面并没有线程池相关的代码,只有 CRUD 相关的业务代码。


同时,在日常的开发中,我也经常调用别人提供给我的接口,也是一把梭,撸到底,根本就不会用到线程池。


所以,站在我,一个开发人员的角度,这个里面没有线程池。


合理,非常合理。


但是,当我们换个角度,再看看,它也是可以有的。


比如这样:



反应过来没有?


我们发起一个 Http 调用,是由一个 web 容器来处理这个请求的,你甭管它是 Tomcat,还是 Jetty、Netty、Undertow 这些玩意,反正是个 web 容器在处理。


那你说,这个里面有线程池吗?


在方法入口处打个断点,这个 http-nio-8081-exec-1 不就是 Tomcat 容器线程池里面的一个线程吗:



通过 dump 堆栈信息,过滤关键字可以看到这样的线程,在服务启动起来,啥也没干的情况下,一共有 10 个:



朋友,这不就是线程池吗?


虽然不是你写的,但是你确实用了。


我写出来的这个 test 接口,就是会由 web 容器中的一个线程来进行调用。所以,站在 web 容器的角度,这里是有一个线程池的:



同理,在 RPC 框架中,不管是消费方,还是服务提供方,也都存在着线程池。


比如 Dubbo 的线程池,你可以看一下官方的文档:



cn.dubbo.apache.org/zh-cn/overv…




而对于大多数的框架来说,它绝不可能只有一个线程池,为了做资源隔离,它会启用好几个线程池,达到线程池隔离,互不干扰的效果。


比如参与 Dubbo 一次调用的其实不仅一个线程池,至少还有 IO 线程池和业务线程池,它们各司其职:



我们主要关注这个业务线程池。


反正站在 Dubbo 框架的角度,又可以补充一下这个图片了:



那么问题来了,在当前的这个情况下?


当有人反馈:哎呀,这个服务吞吐量怎么上不去啊?


你怎么办?


你会 duang 的一下在业务逻辑里面加一个线程池吗?



大哥,前面有个 web 容器的线程池,后面有个框架的线程池,两头不调整,你在中间加个线程池,加它有啥用啊?


web 容器,拿 Tomcat 来说,人家给你提供了线程池参数调整的相关配置,这么一大坨配置,你得用起来啊:



tomcat.apache.org/tomcat-9.0-…




再比如 Dubbo 框架,都给你明说了,这些参数属于性能调优的范畴,感觉不对劲了,你先动手调调啊:



你把这些参数调优弄好了,绝对比你直接怼个线程池在业务代码中,效果好的多。


甚至,你在业务代码中加入一个线程池之后,反而会被“反噬”。


比如,你 duang 的一下怼个线程池在这里,我们先只看 web 容器和业务代码对应的部分:



由于你的业务代码中有线程池的存在,所以当接受到一个 web 请求之后,立马就把请求转发到了业务线程池中,由线程池中的线程来处理本次请求,从而释放了 web 请求对应的线程,该线程又可以里面去处理其他请求。


这样来看,你的吞吐量确实上去了。


在前端来看,非常的 nice,请求立马得到了响应。


但是,你考虑过下游吗?


你的吞吐量上涨了,下游同一时间处理的请求就变多了。如果下游跟不上处理,顶不住了,直接就是崩给你看怎么办?



而且下游不只是你一个调用方,由于你调用的太猛,导致其他调用方的请求响应不过来,是会引起连锁反应的。


所以,这种场景下,为了异步怼个线程池放着,我觉得还不如用消息队列来实现异步化,顶天了也就是消息堆积嘛,总比服务崩了好,这样更加稳妥。


或者至少和下游勾兑一下,问问我们这边吞吐量上升,你们扛得住不。


有的小伙伴看到这里可能就会产生一个疑问了:歪师傅,你这个讲得怎么和我背的八股文不一样啊?


巧了,你背过的八股文我也背过,现在我们来温习一下我们背过的八股文。


什么时候使用线程池呢?


比如一个请求要经过若干个服务获取数据,且这些数据没有先后依赖,最终需要把这些数据组合起来,一并返回,这样经典的场景:



用户点商品详情,你要等半天才展示给用户,那用户肯定骂骂咧咧的久走了。


这个时候,八股文上是怎么说的:用线程池来把串行的动作改成并行。



这个场景也是增加了服务 A 的吞吐量,但是用线程池就是非常正确的,没有任何毛病。


但是你想想,我们最开始的这个案例,是这个场景吗?



我们最开始的案例是想要在业务逻辑中增加一个线程池,对着一个下游服务就是一顿猛攻,不是所谓的串行改并行,而是用更多的线程,带来更多的串行。


这已经不是一个概念了。


还有一种场景下,使用线程池也是合理的。


比如你有一个定时任务,要从数据库中捞出状态为初始化的数据,然后去调用另外一个服务的接口查询数据的最终状态。



如果你的业务代码是这样的:


//获取订单状态为初始化的数据(0:初始化 1:处理中 2:成功 3:失败)
//select * from order where order_status=0;
ArrayList initOrderInfoList = queryInitOrderInfoList();
//循环处理这批数据
for(OrderInfo orderInfo : initOrderInfoList){
    //捕获异常以免一条数据错误导致循环结束
    try{
        //发起rpc调用
        String orderStatus = queryOrderStatus(orderInfo.getOrderId);
        //更新订单状态
        updateOrderInfo(orderInfo.getOrderId,orderStatus);  
    } catch (Exception e){
        //打印异常
    }
}

虽然你框架中使用了线程池,但是你就是在一个 for 循环中不停的去调用下游服务查询数据状态,是一条数据一条数据的进行处理,所以其实同一时间,只是使用了框架的线程池中的一个线程。


为了更加快速的处理完这批数据,这个时候,你就可以怼一个线程池放在 for 循环里面了:


//循环处理这批数据
for(OrderInfo orderInfo : initOrderInfoList){
    //使用线程池
    executor.execute(() -> {
        //捕获异常以免一条数据错误导致循环结束
        try {
            //发起rpc调用
            String orderStatus = queryOrderStatus(orderInfo.getOrderId);
            //更新订单状态
            updateOrderInfo(orderInfo.getOrderId, orderStatus);
        } catch (Exception e) {
            //打印异常
        }
    });
}


需要注意的是,这个线程池的参数怎么去合理的设置,是需要考虑的事情。


同时这个线程池的定位,就类似于 web 容器线程池的定位。


或者这样对比起来看更加清晰一点:



定时任务触发的时候,在发起远程接口调用之前,没有线程池,所以我们可以启用一个线程池来加快数据的处理。


而 Http 调用或者 RPC 调用,框架中本来就已经有一个线程池了,而且也给你提供了对应的性能调优参数配置,那么首先考虑的应该是把这个线程池充分利用起来。


如果仅仅是因为异步化之后可以提升服务响应速度,没有达到串行改并行的效果,那么我更加建议使用消息队列。


好了,本文的技术部分就到这里啦。


下面这个环节叫做[荒腔走板],技术文章后面我偶尔会记录、分享点生活相关的事情,和技术毫无关系。我知道看起来很突兀,但是我喜欢,因为这是一个普通博主的生活气息。


荒腔走板



不知道你看完文章之后,有没有产生一个小疑问:最开始部分的 Demo 似乎用处并不大?


是的,我最开始构思的行文结构是是基于 Demo 在源码中找到关于线程池的部分,从而引出其实有一些我们“看不见的线程池”的存在的。


原本周六我是有一整天的时间来写这篇文章,甚至周五晚上还特意把 Demo 搞定,自己调试了一番,该打的断点全部打上,并写完 Demo 那部分之后,我才去睡觉的,想得是第二天早上起来直接就能用。


按照惯例周六睡个懒觉的,早上 11 点才起床,自己慢条斯理的做了一顿午饭,吃完饭已经是下午 1 点多了。


本来想着在沙发上躺一会,结果一躺就是一整个下午。期间也想过起来写一会文章,坐在电脑前又飞快的躺回到沙发上,就是觉得这个事情索然无味,当下的那一刻就想躺着,然后无意识的刷手机,原本是拿来写文章中关于源码的部分的时间就这样浪费了。


像极了高中时的我,周末带大量作业回家,准备来个悬梁刺股,弯道超车,结果变成了一睡一天,捏紧刹车。


高中的时候,时间浪费了是真的可惜。


现在,不一样了。


荒腔走板这张图片,就是我躺在沙发上的时候,别人问我在干什么时随手拍的一张。


我并不为躺了一下午没有干正事而感到惭愧,浪费了的时间,才是属于自己的时间。


很久以前我看到别人在做一些浪费时间的事情的时候,我心里可能会嘀咕几句,劝人惜时。


这两年我不会了,允许自己做自己,允许别人做别人。


作者:why技术
来源:juejin.cn/post/7297980721590272040
收起阅读 »

MyBatis-Plus快速入门指南:零基础学习也能轻松上手

在Java开发的世界里,持久层框架的选择对于项目的成功至关重要。今天,我们要聊的主角是MyBatis-Plus——一个增强版的MyBatis,它以其强大的功能、简洁的代码和高效的性能,正在成为越来越多开发者的新宠。那么,MyBatis-Plus到底是什么?又该...
继续阅读 »

在Java开发的世界里,持久层框架的选择对于项目的成功至关重要。今天,我们要聊的主角是MyBatis-Plus——一个增强版的MyBatis,它以其强大的功能、简洁的代码和高效的性能,正在成为越来越多开发者的新宠。

那么,MyBatis-Plus到底是什么?又该如何快速入门呢?让我们一起探索这个强大的工具。

一、MyBatis-Plus简介

1、简介

MyBatis-Plus (opens new window)(简称 MP)是一个 MyBatis (opens new window)的增强工具,在 MyBatis 的基础上只做增强不做改变,为简化开发、提高效率而生。

Description

2、特性

无侵入: 只做增强不做改变,引入它不会对现有工程产生影响,如丝般顺滑。

损耗小: 启动即会自动注入基本 CURD,性能基本无损耗,直接面向对象操作,BaseMapper。

强大的 CRUD 操作: 内置通用 Mapper、通用 Service,仅仅通过少量配置即可实现单表大部分 CRUD 操作,更有强大的条件构造器,满足各类使用需求,简单的CRUD操作不用自己编写。

支持 Lambda 形式调用: 通过 Lambda 表达式,方便的编写各类查询条件,无需再担心字段写错。

支持主键自动生成: 支持多达 4 种主键策略(内含分布式唯一 ID 生成器 - Sequence),可自由配置,完美解决主键问题。

支持 ActiveRecord 模式: 支持 ActiveRecord 形式调用,实体类只需继承 Model 类即可进行强大的 CRUD 操作。

支持自定义全局通用操作: 支持全局通用方法注入( Write once, use anywhere )。

内置代码生成器: 采用代码或者 Maven 插件可快速生成 Mapper 、 Model 、 Service 、 Controller 层代码,支持模板引擎,更有超多自定义配置等您来使用(自动生成代码)。

内置分页插件: 基于 MyBatis 物理分页,开发者无需关心具体操作,配置好插件之后,写分页等同于普通 List 查询。

分页插件支持多种数据库: 支持 MySQL、MariaDB、Oracle、DB2、H2、HSQL、SQLite、Postgre、SQLServer 等多种数据库。

内置性能分析插件: 可输出 SQL 语句以及其执行时间,建议开发测试时启用该功能,能快速揪出慢查询。

内置全局拦截插件: 提供全表 delete 、 update 操作智能分析阻断,也可自定义拦截规则,预防误操作。

3、框架结构

Description

二、快速入门

1.开发环境

2.创建数据库和表

1)创建表单

CREATE DATABASE `mp_study` /*!40100 DEFAULT CHARACTER SET utf8mb4 */;
use `mp_study`;
CREATE TABLE `user` (
`id` bigint(20) NOT NULL COMMENT '主键ID',
`name` varchar(30) DEFAULT NULL COMMENT '姓名',
`age` int(11) DEFAULT NULL COMMENT '年龄',
`email` varchar(50) DEFAULT NULL COMMENT '邮箱',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

2)添加数据

INSERT INTO user (id, name, age, email) VALUES
(1, 'Jone', 18, 'test1@baomidou.com'),
(2, 'Jack', 20, 'test2@baomidou.com'),
(3, 'Tom', 28, 'test3@baomidou.com'),
(4, 'Sandy', 21, 'test4@baomidou.com'),
(5, 'Billie', 24, 'test5@baomidou.com');

3. 创建SpringBoot工程

1)初始化工程

2)导入依赖

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>

4. 编写代码

1)配置application.yml

# DataSource Config
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/mybatis_plus?useSSL=false&useUnicode=true&characterEncoding=utf-8&serverTimezone=GMT%2B8
username: root
password: 1234

2)启动类

在Spring Boot启动类中添加@MapperScan注解,扫描mapper包

@MapperScan("cn.frozenpenguin.mapper")
@SpringBootApplication
public class MybatisPlusStudyApplication {

public static void main(String[] args) {
SpringApplication.run(MybatisPlusStudyApplication.class, args);
}
}

3)添加实体类

@Data//lombok注解
public class User {
private Long id;
private String name;
private Integer age;
private String email;
}

4)添加mapper
BaseMapper是MyBatis-Plus提供的模板mapper,其中包含了基本的CRUD方法,泛型为操作的实体类型

public interface UserMapper extends BaseMapper<User> {
}

5)测试

@Autowired
private UserMapper userMapper;

@Test
void test01(){
List<User> users = userMapper.selectList(null);
for (User user : users) {
System.out.println(user);
}
}

结果
Description
注意:

IDEA在 userMapper 处报错,因为找不到注入的对象,因为类是动态创建的,但是程序可以正确执行。为了避免报错,可以在mapper接口上添加 @Repository注解。

6)添加日志

我们所有的sql现在是不可见的,我们希望知道它是怎么执行的,所以我们必须要看日志!

在application.yml中配置日志输出

# 配置日志
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
mapper-locations:

三、基本CRUD

1.插入

 @Test
void insert()
User user = new User(null, "lisi", 2, "aaa@qq.com");
int insert = userMapper.insert(user);
System.out.println("受影响行数"+insert);
//1511332162436071425
System.out.println(user.getId());
}

id设置为null,却插入了1511332162436071425,这是因为MyBatis-Plus在实现插入数据时,会默认基于雪花算法的策略生成id。

2.删除

1)通过id删除记录

@Test
void testDeleteById(){
//DELETE FROM user WHERE id=?
int result = userMapper.deleteById(1);
System.out.println("受影响行数:"+result);
}
  1. 通过id批量删除记录
@Test
void testDeleteBatchIds(){
//DELETE FROM user WHERE id IN ( ? , ? , ? )
int result = userMapper.deleteBatchIds(ids);
System.out.println("受影响行数:"+result);
}
  1. 通过map条件删除记录
@Test
void testDeleteByMap(){
//DELETE FROM user WHERE name = ? AND age = ?
Map<String,Object> map=new HashMap<>();
map.put("age",12);
map.put("name","lisi");
int result = userMapper.deleteByMap(map);
System.out.println("受影响行数:"+result);
}

3. 修改

@Test
void testUpdateById(){
//SELECT id,name,age,email FROM user WHERE id=?
User user = new User(10L, "hello", 12, null);
int result = userMapper.updateById(user);
//注意:updateById参数是一个对象
System.out.println("受影响行数:"+result);
}

4.自动填充

  • 创建时间、修改时间!这些个操作都是自动化完成的,我们不希望手动更新!

  • 阿里巴巴开发手册:所有的数据库表:gmt_create、gmr_modified、几乎所有的表都要配置上!而且需要自动化!

方式一:数据库级别(工作中不允许修改数据库)

1)在表中新增字段 create_time, update_time;

Description

2)再次测试插入方法,我们需要先把实体类同步!

3)再次更新查看结果即可。

Description

方式二:代码级别

  • 删除数据库的默认值,更新操作

  • 实体类的字段属性上需要加注解

@TableField(fill = FieldFill.INSERT)
private Date createTime;

@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
  • 编写处理器处理注解
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
// 起始版本 3.3.0(推荐使用)
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
}


@Override
public void updateFill(MetaObject metaObject) {
// 起始版本 3.3.0(推荐)
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
}
  • 测试插入

  • 测试更新、观察时间即可

在这里给大家分享一下【云端源想】学习平台,无论你是初学者还是有经验的开发者,这里都有你需要的一切。包含课程视频、知识库、微实战、云实验室、一对一咨询等等,现在功能全部是免费的,

点击这里,立即开始你的学习之旅!

5.查询

  • 与查询基本一致;

  • 根据id查询用户信息;

  • 根据多个id查询多个用户信息;

  • 通过map条件查询用户信息;

  • 查询所有数据;

@Test
void test01(){
List<User> users = userMapper.selectList(null);
for (User user : users) {
System.out.println(user);
}
}

通过观察BaseMapper中的方法,大多方法中都有Wrapper类型的形参,此为条件构造器,可针 对于SQL语句设置不同的条件,若没有条件,则可以为该形参赋值null,即查询(删除/修改)所有数据。

6.通用Service

说明:

  • 通用 Service CRUD 封装IService接口,进一步封装 CRUD;

  • 采用 get 查询单行;

  • remove 删除;

  • list 查询集合;

  • page 分页;

  • 前缀命名方式区分 Mapper 层避免混淆;

  • 泛型 T 为任意实体对象;

  • 建议如果存在自定义通用 Service 方法的可能,请创建自己的 IBaseService 继承 Mybatis-Plus 提供的基类;

  • 官网地址:https://baomidou.com/pages/49cc81/#service-crud-%E6%8E%A5%E5%8F%A3。

1)IService

MyBatis-Plus中有一个接口 IService和其实现类 ServiceImpl,封装了常见的业务层逻辑 详情查看源码IService和ServiceImpl。

2)创建Service接口和实现

/**
* UserService继承IService模板提供的基础功能
*/
public interface UserService extends IService<User> {
}
/**
* ServiceImpl实现了IService,提供了IService中基础功能的实现
* 若ServiceImpl无法满足业务需求,则可以使用自定的UserService定义方法,并在实现类中实现
*/
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
}

3)测试查询记录数

@Test
void testGetCount(){
long count = userService.count();
System.out.println("总记录数:" + count);
}

4)测试批量插入

@Test
void testSaveBatch(){
// SQL长度有限制,海量数据插入单条SQL无法实行,
// 因此MP将批量插入放在了通用Service中实现,而不是通用Mapper
ArrayList<User> users = new ArrayList<>();
for (int i = 0; i < 5; i++) {
User user = new User();
user.setName("lyl"+i);
user.setAge(20+i);
users.add(user);
}
//SQL:INSERT INTO t_user ( username, age ) VALUES ( ?, ? )
userService.saveBatch(users);
}

原理:先把user对象存到list(存在内存中),然后直接save集合list中的所有user。

MyBatis-Plus作为MyBatis的增强版,不仅继承了MyBatis的所有优点,还在此基础上做了大量的改进和扩展。它的出现,无疑为Java开发者提供了一个更为强大、便捷的数据操作工具。

技术的世界总是在不断进步,而我们作为开发者,也需要不断学习新的工具和技术。MyBatis-Plus正是这样一把钥匙,它能打开高效数据操作的大门。希望本文能帮助您快速入门MyBatis-Plus!

收起阅读 »

请立即停止编写 Dockerfiles 并使用 docker init

本文翻译自 medium 论坛,原文链接:medium.com/@akhilesh-m… , 原文作者: Akhilesh Mishra 您是那种觉得编写 Dockerfile 和 docker-compose.yml 文件很痛苦的人之一吗? 我承认,我就是...
继续阅读 »

本文翻译自 medium 论坛,原文链接:medium.com/@akhilesh-m… , 原文作者:

Akhilesh Mishra


您是那种觉得编写 Dockerfile 和 docker-compose.yml 文件很痛苦的人之一吗?



我承认,我就是其中之一。



我总是想知道我是否遵循了 Dockerfile、 docker-compose 文件的最佳编写实践,我害怕在不知不觉中引入了安全漏洞。


但是现在,我不必再担心这个问题了,感谢 Docker 的优秀开发人员,他们结合了生成式人工智能,创建了一个 CLI 实用工具 — docker init。


介绍 docker init


微信截图_20240224145630.png


几天前,Docker 推出了 docker init 的通用版本。我已经尝试过,发现它非常有用,迫不及待地想在日常生活中使用它。


什么是 docker init?


docker init 是一个命令行应用程序,可帮助初始化项目中的 Docker 资源。它根据项目的要求创建 Dockerfiles、docker-compose 文件和 .dockerignore 文件。


这简化了为项目配置 Docker 的过程,节省时间并降低复杂性。



最新版本的 docker init 支持 Go、Python、Node.js、Rust、ASP.NET、PHP 和 Java。目前它只能于 Docker Desktop 一起使用,也就是说大家目前在 Linux 系统中是无法使用 docker init 的。



如何使用 docker init?


使用 docker init 很简单,只需几个简单的步骤。首先,转到您要在其中设置 Docker 资源的项目目录。


举个例子,我来创建一个基本的 Flask 应用程序。


一、创建 app.py 以及 requirements.txt


touch app.py requirements.txt

将以下代码复制到相应文件中


# app.py
from flask import Flask

app = Flask(__name__)

@app.route('/')
def hello_docker():
return '<h1> hello world </h1'

if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0')

# requirements.txt
Flask

二、使用 docker init 初始化


docker init 将扫描您的项目并要求您确认并选择最适合您的应用程序的模板。选择模板后,docker init 会要求您提供一些特定于项目的信息,自动为您的项目生成必要的 Docker 资源。


现在让我们来执行 docker init。


docker init

出现如下结果,



接下来要做的就是选择应用程序平台,在我们的示例中,我们使用 python。它将建议您的项目的推荐值,例如 Python 版本、端口、入口点命令。



您可以选择默认值或提供所需的值,它将创建您的 docker 配置文件以及动态运行应用程序的说明。



让我们来看看这个自动生成的配置是什么样子。


三、生成 Dockerfile 文件


# syntax=docker/dockerfile:1

# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/engine/reference/builder/

ARG PYTHON_VERSION=3.11.7
FROM python:${PYTHON_VERSION}-slim as base

# Prevents Python from writing pyc files.
ENV PYTHONDONTWRITEBYTECODE=1

# Keeps Python from buffering stdout and stderr to avoid situations where
# the application crashes without emitting any logs due to buffering.
ENV PYTHONUNBUFFERED=1

WORKDIR /app

# Create a non-privileged user that the app will run under.
# See https://docs.docker.com/go/dockerfile-user-best-practices/
ARG UID=10001
RUN adduser \
--disabled-password \
--gecos "" \
--home "/nonexistent" \
--shell "/sbin/nologin" \
--no-create-home \
--uid "${UID}" \
appuser

# Download dependencies as a separate step to take advantage of Docker's caching.
# Leverage a cache mount to /root/.cache/pip to speed up subsequent builds.
# Leverage a bind mount to requirements.txt to avoid having to copy them int0
# int0 this layer.
RUN --mount=type=cache,target=/root/.cache/pip \
--mount=type=bind,source=requirements.txt,target=requirements.txt \
python -m pip install -r requirements.txt

# Switch to the non-privileged user to run the application.
USER appuser

# Copy the source code int0 the container.
COPY . .

# Expose the port that the application listens on.
EXPOSE 5000

# Run the application.
CMD gunicorn 'app:app' --bind=0.0.0.0:5000

看看它,它写了一个比我更好的 Dockerfile。



它遵循人们在所有 Linkedin 和 Medium 帖子中不断告诉我们的所有性能和安全最佳实践。



docker-compose.yml



它编写了 docker-compose 配置来运行应用程序。由于我们的应用程序不包含与数据库的任何连接,因此它注释掉了数据库容器可能需要的代码。


如果您想在 Flask 应用程序中使用数据库,请从 docker-compose 文件中取消注释 db 服务配置,创建一个包含机密的本地文件,然后运行该应用程序。它还为我们生成了 .dockerignore 文件。


为什么使用 docker init?


docker init 使 Docker 化变得轻而易举,特别是对于 Docker 新手来说。它消除了编写 Dockerfile 和其他配置文件的手动任务,从而节省时间并最大限度地减少错误。


它使用模板根据您的应用程序类型自定义 Docker 设置,同时遵循行业最佳实践。


总结一下


总而言之,docker init 完成了上面这一切。



  • 它可以编写比这里 90% 的孩子更好的 Docker 配置。

  • 像书呆子一样遵循最佳实践。

  • 当安全人员的工具生成包含数百个您从未想过存在的漏洞的报告时,可以节省时间、精力和来自安全人员的讽刺评论。


最后需要说明的是,就像任何其他基于人工智能的工具一样,这个工具也不完美。不要盲目相信它生成的配置。我建议您在使用配置之前再次检查下配置。



如果觉得这篇文章翻译不错的话,不妨点赞加关注,我会更新更多技术干货、项目教学、经验分享的文章。



作者:程序员wayn
来源:juejin.cn/post/7338717224435531826
收起阅读 »

不可不知的Redis秘籍:事务命令全攻略!

在数据处理的世界里,事务(Transaction)是一个不可或缺的概念。它们确保了在一系列操作中,要么所有的操作都成功执行,要么都不执行。这就像是一个“全有或全无”的规则,保证了数据的一致性和完整性。今天,我们就来聊聊Redis事务的使用,看看如何通过它来提升...
继续阅读 »

在数据处理的世界里,事务(Transaction)是一个不可或缺的概念。它们确保了在一系列操作中,要么所有的操作都成功执行,要么都不执行。这就像是一个“全有或全无”的规则,保证了数据的一致性和完整性。

今天,我们就来聊聊Redis事务的使用,看看如何通过它来提升我们的数据操作效率和安全性。

一、Redis事务的概念

Redis 事务的本质是一组命令的集合。事务支持一次执行多个命令,一个事务中所有命令都会被序列化。在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中。

Description

总结来说: redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令。

Redis事务没有隔离级别的概念

批量操作在发送 EXEC 命令前被放入队列缓存,并不会被实际执行,也就不存在事务内的查询要看到事务里的更新,事务外查询不能看到。

Redis不保证原子性

Redis中,单条命令是原子性执行的,但事务不保证原子性,且没有回滚。事务中任意命令执行失败,其余的命令仍会被执行。

redis事务的执行阶段

  • 开始事务(multi)。
  • 命令入队。
  • 执行事务(exec)

Description

二、Redis事务优缺点

对于Redis事务的概念我们已经有了基本的了解,下面我们再来看看它都有哪些优缺点。

优点:

  • 一次性按顺序执行多个Redis命令,不受其他客户端命令请求影响;

  • 事务中的命令要么都执行(命令间执行失败互相不影响),要么都不执行(比如中间有命令语法错误);

缺点:

  • 事务执行时,不能保证原子性;

  • 命令入队每次都需要和服务器进行交互,增加带宽;

注意:

  • 当事务中命令语法使用错误时,最终会导致事务执行不成功,即事务内所有命令都不执行;

  • 当事务中命令知识逻辑错误,就比如给字符串做加减乘除操作时,只能在执行过程中发现错误,这种事务执行中失败的命令不影响其他命令的执行。

三、Redis事务相关命令

Redis事务可以通过一系列命令来执行多个操作,并确保这些操作可以原子性地执行。以下是Redis事务的相关命令及其作用:

MULTI: 开启一个事务。在调用此命令后,Redis 会将后续的命令逐个放入队列中,直到接收到 EXEC 命令为止。

EXEC: 执行事务中的所有操作命令。一旦调用 EXEC 命令,Redis 会原子性地执行队列中的所有命令。

DISCARD: 取消事务,放弃执行事务块中的所有命令。如果不想继续执行事务中的操作,可以使用 DISCARD 命令来清除当前事务队列。

WATCH: 监视一个或多个键,如果在事务执行之前这些键被其他命令所改动,那么事务将会被打断。

UNWATCH: 取消所有由 WATCH 命令监视的键。如果不想继续监视某些键,可以使用 UNWATCH 命令来取消监视。

需要注意的是,在事务执行过程中,其他客户端提交的命令请求不会插入到事务执行命令序列中,这保证了事务的隔离性。同时,Redis 事务提供了批量操作缓存的功能,即在发送 EXEC 命令前,所有操作都会被放入队列缓存。

在这里给大家分享一下【云端源想】学习平台,无论你是初学者还是有经验的开发者,这里都有你需要的一切。包含课程视频、知识库、微实战、云实验室、一对一咨询等等,现在功能全部是免费的,点击这里,立即开始你的学习之旅!

四、Redis事务的使用

使用Redis事务的步骤如下:

  • 使用MULTI命令开启一个事务。

  • 在事务中执行需要的命令,如SET、GET等。

  • 使用EXEC命令提交事务,将事务中的命令一次性发送给Redis服务器执行。

  • 如果需要取消事务,可以使用DISCARD命令。

Description

下面通过一些示例来讲解一下这些命令的使用方法:

1、正常执行

192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> set aa AA
QUEUED
192.168.xxx.21:6379> set bb BB
QUEUED
192.168.xxx.21:6379> set cc CC
QUEUED
192.168.xxx.21:6379> set dd DD
QUEUED
192.168.xxx.21:6379> exec
1) OK
2) OK
3) OK
4) OK
192.168.xxx.21:6379> get aa
"AA"

首先,通过执行multi命令开始一个事务块。然后,依次执行了四个set命令,将键"aa"、“bb”、“cc"和"dd"分别设置为对应的值"AA”、“BB”、“CC"和"DD”。

每个set命令执行后返回的结果为"QUEUED",表示该命令已被加入到事务队列中等待执行。

接下来,通过执行exec命令来提交事务,一次性执行事务队列中的所有命令。执行结果为每个命令的返回值,即"OK"。最后,通过执行get aa命令获取键"aa"的值,返回结果为"AA"。

2、取消事务

192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> set aa 11
QUEUED
192.168.xxx.21:6379> set ee EE
QUEUED
192.168.xxx.21:6379> discard
OK
192.168.xxx.21:6379> get aa
"AA"
192.168.xxx.21:6379> get ee
(nil)
192.168.xxx.21:6379>

示例代码中,首先,通过执行multi命令开始一个事务块。然后,依次执行了两个set命令,将键"aa"设置为值"11",将键"ee"设置为值"EE"。每个set命令执行后返回的结果为"QUEUED",表示该命令已被加入到事务队列中等待执行。

接下来,通过执行discard命令来取消事务,放弃执行事务块内的所有命令。执行结果为"OK"。

最后,通过执行get aa命令获取键"aa"的值,返回结果为"AA"。而执行get ee命令获取键"ee"的值时,由于之前已经取消了事务,所以返回结果为"(nil)",表示该键不存在。

3、事务队列中存在命令错误

如果在事务队列中存在命令性错误(类似于java编译性错误),则执行EXEC命令时,所有命令都不会执行

192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> set aa 22
QUEUED
192.168.xxx.21:6379> set bb 33
QUEUED
192.168.xxx.21:6379> setq cc 44
(error) ERR unknown command 'setq'
192.168.xxx.21:6379> set ff FF
QUEUED
192.168.xxx.21:6379> exec
(error) EXECABORT Transaction discarded because of previous errors.
192.168.xxx.21:6379> get ff
(nil)
192.168.xxx.21:6379> get bb
"BB"
192.168.xxx.21:6379>

首先,通过执行multi命令开始一个事务块。然后,依次执行了三个set命令,将键"aa"设置为值"22",将键"bb"设置为值"33",将键"cc"设置为值"44"。每个set命令执行后返回的结果为"QUEUED",表示该命令已被加入到事务队列中等待执行。

然而,在执行第三个set命令时,出现了错误。因为Redis中并没有名为"setq"的命令,所以返回结果为"(error) ERR unknown command ‘setq’"。

接下来,通过执行exec命令来提交事务,一次性执行事务队列中的所有命令。由于之前已经出现了错误,导致事务被中断,所以执行结果为"(error) EXECABORT Transaction discarded because of previous errors."。

最后,通过执行get ff命令获取键"ff"的值时,由于事务被中断,所以返回结果为"(nil)“,表示该键不存在。而执行get bb命令获取键"bb"的值时,由于事务被中断,所以返回结果为"BB”。

4、事务队列中存在语法错误

如果在事务队列中存在语法性错误(类似于java的1/0的运行时异常),则执行EXEC命令时,其他正确命令会被执行,错误命令抛出异常。

192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> incr aa
QUEUED
192.168.xxx.21:6379> set ff FF
QUEUED
192.168.xxx.21:6379> set bb 22
QUEUED
192.168.xxx.21:6379> exec
1) (error) ERR value is not an integer or out of range
2) OK
3) OK
192.168.xxx.21:6379> get bb
"22"
192.168.xxx.21:6379> get ff
"FF"
192.168.xxx.21:6379>

错误原因:字符串不能累加1

5、watch监控

watch 命令可以监控一个或多个键,一旦有其中一个键被修改(被删除),后面的事务就不会执行了。监控一直持续到 EXEC 命令(事务中的命令是在exec之后才执行的,所以在multi命令后可以修改watch监控的键值)

假设我们通过watch命令在事务执行之前监控了多个Keys,倘若在watch之后有任何Key的值发生了变化,exec命令执行的事务都将被放弃,同时返回Null multi-bulk应答以通知调用者事务执行失败。

(1)、执行watch,不执行multi、exec

192.168.xxx.21:6379> get aa
"AA"
192.168.xxx.21:6379> watch aa
OK
192.168.xxx.21:6379> set aa 11
OK
192.168.xxx.21:6379> get aa
"11"
192.168.xxx.21:6379>

(2)、执行 watch 命令,通知执行 MULTI、exec

192.168.xxx.21:6379> set aa Aa
OK
192.168.xxx.21:6379> get aa
"Aa"
192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> set aa 11
QUEUED
192.168.xxx.21:6379> exec
(nil)
192.168.xxx.21:6379> get aa
"Aa"
192.168.xxx.21:6379>

(3)、exec 执行之后,会自动执行 UNWatch 命令,撤销监听操作

192.168.xxx.21:6379> set aa Aa
OK
192.168.xxx.21:6379> get aa
"Aa"
192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> set aa 11
QUEUED
192.168.xxx.21:6379> exec
(nil)
192.168.xxx.21:6379> get aa
"Aa"
192.168.xxx.21:6379> set aa 11
OK
192.168.xxx.21:6379> get aa
"11"
192.168.xxx.21:6379>

(4) 、unwatch撤销监听

192.168.xxx.21:6379> get bb
"BBB"
192.168.xxx.21:6379> watch bb
OK
192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> unwatch
QUEUED
192.168.xxx.21:6379> set bb 222
QUEUED
192.168.xxx.21:6379> exec
1) OK
2) OK
192.168.xxx.21:6379> get bb
"222"
192.168.xxx.21:6379>

以上就是Redis事务的概念及相关命令的使用,Redis事务是一个非常强大的工具,它可以帮助我们在处理数据的时候保持数据的一致性和完整性。通过使用Redis事务,可以让我们的数据操作更高效、更安全。

希望这篇文章能够帮助你更好地理解和使用Redis事务!

收起阅读 »

应用容器化后为什么性能下降这么多?

1. 背景 随着越来越多的公司拥抱云原生,从原先的单体应用演变为微服务,应用的部署方式也从虚机变为容器化,容器编排组件k8s也成为大多数公司的标配。然而在容器化以后,我们发现应用的性能比原先在虚拟机上表现更差,这是为什么呢?。 2. 压测结果 2.1 容器化之...
继续阅读 »

1. 背景


随着越来越多的公司拥抱云原生,从原先的单体应用演变为微服务,应用的部署方式也从虚机变为容器化,容器编排组件k8s也成为大多数公司的标配。然而在容器化以后,我们发现应用的性能比原先在虚拟机上表现更差,这是为什么呢?。


2. 压测结果


2.1 容器化之前的表现


应用部署在虚拟机下,我们使用wrk工具进行压测,压测结果如下:


image.png


从压测结果看,平均RT1.68msqps716/s\color{red}{平均RT为1.68ms,qps为716/s},我们再来看下机器的资源使用情况,cpu基本已经被打满。
image.png


2.2 容器化后的表现


使用wrk工具进行压测,结果如下:
image.png


从压测结果看,平均RT2.11msqps554/s\color{red}{平均RT为2.11ms,qps为554/s},我们再来看下机器的资源使用情况,cpu基本已经被打满。
image.png


2.3 性能对比结果


性能对比虚拟机容器
RT1.68ms2.11ms
QPS716/s554/s


总体性能下降:RT(25%)、QPS(29%)



3. 原因分析


3.1 架构差异


由于应用在容器化后整体架构的不同、访问路径的不同,将可能导致应用容器化后性能的下降,于是我们先来分析下两者架构的区别。我们使用k8s作为容器编排基础设施,网络插件使用calico的ipip模式,整体架构如下所示。


x3.png


这里需要说明,虽然使用calico的ipip模式,由于pod的访问为service的nodePort模式,所以不会走tunl0网卡,而是从eth0经过iptables后,通过路由到calico的calixxx接口,最后到pod。


3.2性能分析


在上面压测结果的图中,我们容器化后,cpu的软中断si使用率明显高于原先虚拟机的si使用率,所以我们使用perf继续分析下热点函数。


image.png
为了进一步验证是否是软中断的影响,我们使用perf进一步统计软中断的次数。


image.png



我们发现容器化后比原先软中断多了14%,到这里,我们能基本得出结论,应用容器化以后,需要更多的软中断的网络通信导致了性能的下降。



3.3 软中断原因


由于容器化后,容器和宿主机在不同的网络namespace,数据需要在容器的namespace和host namespace之间相互通信,使得不同namespace的两个虚拟设备相互通信的一对设备为veth pair,可以使用ip link命令创建,对应上面架构图中红色框内的两个设备,也就是calico创建的calixxx和容器内的eth0。我们再来看下veth设备发送数据的过程


static netdev_tx_t veth_xmit(struct sk_buff *skb, struct net_device *dev)
{
...
if (likely(veth_forward_skb(rcv, skb, rq, rcv_xdp)
...
}

static int veth_forward_skb(struct net_device *dev, struct sk_buff *skb,
struct veth_rq *rq, bool xdp)
{
return __dev_forward_skb(dev, skb) ?: xdp ?
veth_xdp_rx(rq, skb) :
netif_rx(skb);//中断处理
}


/* Called with irq disabled */
static inline void ____napi_schedule(struct softnet_data *sd,
struct napi_struct *napi)
{
list_add_tail(&napi->poll_list, &sd->poll_list);
//发起软中断
__raise_softirq_irqoff(NET_RX_SOFTIRQ);
}

通过虚拟的veth发送数据和真实的物理接口没有区别,都需要完整的走一遍内核协议栈,从代码分析调用链路为veth_xmit -> veth_forward_skb -> netif_rx -> __raise_softirq_irqoff,veth的数据发送接收最后会使用软中断的方式,这也刚好解释了容器化以后为什么会有更多的软中断,也找到了性能下降的原因。


4. 优化策略


原来我们使用calico的ipip模式,它是一种overlay的网络方案,容器和宿主机之间通过veth pair进行通信存在性能损耗,虽然calico可以通过BGP,在三层通过路由的方式实现underlay的网络通信,但还是不能避免veth pari带来的性能损耗,针对性能敏感的应用,那么有没有其他underly的网络方案来保障网络性能呢?那就是macvlan/ipvlan模式,我们以ipvlan为例稍微展开讲讲。


4.1 ipvlan L2 模式


IPvlan和传统Linux网桥隔离的技术方案有些区别,它直接使用linux以太网的接口或子接口相关联,这样使得整个发送路径变短,并且没有软中断的影响,从而性能更优。如下图所示:


ipvlan l2 mode


上图是ipvlan L2模式的通信模型,可以看出container直接使用host eth0发送数据,可以有效减小发送路径,提升发送性能。


4.2 ipvlan L3 模式


ipvlan L3模式,宿主机充当路由器的角色,实现容器跨网段的访问,如下图所示:


ipvlan L3 mode


4.3 Cilium


除了使用macvlan/ipvlan提升网络性能外,我们还可以使用Cilium来提升性能,Cilium为云原生提供了网络、可观测性、网络安全等解决方案,同时它是一个高性能的网络CNI插件,高性能的原因是优化了数据发送的路径,减少了iptables开销,如下图所示:


cilium netwok


虽然calico也支持ebpf,但是通过benchmark的对比,Cilium性能更好,高性能名副其实,接下来我们来看看官网公布的一些benchmark的数据,我们只取其中一部分来分析,如下图:


xxxx2
xxxx3


无论从QPS和CPU使用率上Cilium都拥有更强的性能。


5. 总结


容器化带来了敏捷、效率、资源利用率的提升、环境的一致性等等优点的同时,也使得整体的系统复杂度提升一个等级,特别是网络问题,容器化使得整个数据发送路径变长,排查难度增大。不过现在很多网络插件也提供了很多可观测性的能力,帮助我们定位问题。


我们还是需要从实际业务场景出发,针对容器化后性能、安全、问题排查难度增大等问题,通过优化架构,增强基础设施建设才能让我们在云原生的路上越走越远。


最后,感谢大家观看,也希望和我讨论云原生过程中遇到的问题。


5. 参考资料


docs.docker.com/network/dri…


cilium.io/blog/2021/0…


作者:云之舞者
来源:juejin.cn/post/7268663683881828413
收起阅读 »

记一种不错的缓存设计思路

之前与同事讨论接口性能问题时听他介绍了一种缓存设计思路,觉得不错,做个记录供以后参考。 场景 假设有个以下格式的接口: GET /api?keys={key1,key2,key3,...}&types={1,2,3,...} 其中 keys 是业务...
继续阅读 »

之前与同事讨论接口性能问题时听他介绍了一种缓存设计思路,觉得不错,做个记录供以后参考。


场景


假设有个以下格式的接口:


GET /api?keys={key1,key2,key3,...}&types={1,2,3,...}


其中 keys 是业务主键列表,types 是想要取到的信息的类型。


请求该接口需要返回业务主键列表对应的业务对象列表,对象里需要包含指定类型的信息。


业务主键可能的取值较多,千万量级,type 取值范围为 1-10,可以任意组合,每种 type 对应到数据库是 1-N 张表,示意:


redis-cache-design.drawio.png


现在设想这个接口遇到了性能瓶颈,打算添加 Redis 缓存来改善响应速度,应该如何设计?


设计思路


方案一:


最简单粗暴的方法是直接使用请求的所有参数作为缓存 key,请求的返回内容为 value。


方案二:


如果稍做一下思考,可能就会想到文首我提到的觉得不错的思路了:



  1. 使用 业务主键:表名 作为缓存 key,表名里对应的该业务主键的记录作为 value;

  2. 查询时,先根据查询参数 keys,以及 types 对应的表,得到所有 key1:tb_1_1key1:tb_1_2 这样的组合,使用 Redis 的 mget 命令,批量取到所有缓存中存在的信息,剩下没有命中的,批量到数据库里查询到结果,并放入缓存;

  3. 在某个表的数据有更新时,只需刷新 涉及业务主键:该表名 的缓存,或令其失效即可。


小结


在以上两种方案之间做评估和选择,考虑几个方面:



  • 缓存命中率;

  • 缓存数量、占用空间大小;

  • 刷新缓存是否方便;


稍作思考和计算,就会发现此场景下方案二的优势。


另外,就是需要根据实际业务场景,如业务对象复杂度、读写次数比等,来评估合适的缓存数据的粒度和层次,是对应到某一级组合后的业务对象(缓存值对应存储 + 部分逻辑),还是最基本的数据库表/字段(存储的归存储,逻辑的归逻辑)。


作者:mzlogin
来源:juejin.cn/post/7271597656118394899
收起阅读 »

HTTP请求头中的Authorization

当使用HTTP请求中的Authorization头时,表示传入的是认证信息。具体的认证类型由凭证前缀指明。以下是Authorization头中常见的几种认证机制: 基本认证(Basic Authentication): Authorization: Basi...
继续阅读 »

当使用HTTP请求中的Authorization头时,表示传入的是认证信息。具体的认证类型由凭证前缀指明。以下是Authorization头中常见的几种认证机制:



  1. 基本认证(Basic Authentication):


    Authorization: Basic base64(用户名:密码)

    这是最常见的一种,涉及将用户名和密码以base64格式编码并与请求一起发送。需要注意,Basic 后面有空格, 未使用HTTPS时基本认证不够安全
    实际使用例子,比如:


    curl -u "admin:P@88w0rd" -H "Accept: application/json" http://localhost:8090/api/v1alpha1/users

    curl -u “username:password” 就相当于在请求的请求头中添加keyAuthorizationvalueadmin:P@88w0rd,这是一种认证方式。
    对账号密码进行base64编码之后


    echo -n "admin:P@88w0rd" | base64

    得到:YWRtaW46UEA4OHcwcmQ=
    ,上方的curl也可以写成:


    curl -H "Authorization: Basic YWRtaW46UEA4OHcwcmQ=" -H "Accept:  application/json" http://localhost:8090/api/v1alpha1/users


  2. Bearer令牌(Bearer Token):


    Authorization: Bearer <令牌>

    这通常与OAuth 2.0一起使用。<令牌>通常是通过单独的认证过程获取的长寿命访问令牌。


  3. 摘要认证(Digest Authentication):


    Authorization: Digest username="用户名", realm="领域", nonce="随机数", uri="URI", response="响应", opaque="opaque", qop=auth, nc=00000001, cnonce="cnonce"

    摘要认证比基本认证更安全,涉及挑战-响应机制来验证客户端。挑战-响应机制(Challenge-Response Mechanism,在这种机制中,服务器通过向客户端发送一个随机的挑战(challenge),并期望客户端使用其凭据(通常是密码)生成一个对应的响应(response)来证明其身份,服务端收到响应后验证身份)


  4. API密钥(API Key):


    Authorization: ApiKey <API密钥>

    API密钥通常用于API请求中的身份验证。密钥包含在Authorization头中。


  5. Bearer令牌(JWT):


    Authorization: Bearer eyJhbGciOiJIUzI1NiIsIn...

    JSON Web Tokens(JWT)通常在现代身份验证系统中使用。令牌包含在Bearer方案中。


  6. 自定义方案(Custom Schemes):
    一些应用程序或服务可能定义了自己的自定义认证方案。例如:


    Authorization: CustomScheme 自定义数据



以上的使用的scheme,如BasicBearer,Digest,ApiKey是约定俗成的,大家都这样使用,具体认证类型取决于服务器的要求和实现的协议,针对自己的业务也可以自定义scheme。也可以参考正在与之交互的服务或API的文档,以确定Authorization头的正确格式。


作者:星夜晚晚
来源:juejin.cn/post/7329573746464718857
收起阅读 »

双token和无感刷新token(简单写法,一文说明白,不墨迹)

为什么有这篇小作文?最近要给自己的项目加上token自动续期,但是在网上搜的写法五花八门,有的光前端部分就写了几百行代码,我看着费劲,摸了半天也没有实现,所以决定自己造轮子项目构成后端部分:使用golang的gin框架起的服务前端部分:vue+elementu...
继续阅读 »

为什么有这篇小作文?

最近要给自己的项目加上token自动续期,但是在网上搜的写法五花八门,有的光前端部分就写了几百行代码,我看着费劲,摸了半天也没有实现,所以决定自己造轮子

项目构成

  • 后端部分:使用golang的gin框架起的服务
  • 前端部分:vue+elementui

先说后端部分,后端逻辑相对前端简单点,关键两步

  1. 登陆接口生成双token
"github.com/dgrijalva/jwt-go"
func (this UserController) DoLogin(ctx *gin.Context) {
username := ctx.Request.FormValue("username")
passWord := ctx.Request.FormValue("password")
passMd5 := middlewares.CreateMD5(passWord)
expireTime := time.Now().Add(10 * time.Second).Unix() //token过期时间10秒,主要是测试方便
refreshTime := time.Now().Add(20 * time.Second).Unix() //刷新的时间限制,超过20秒重新登录
user := modules.User{}
err := modules.DB.Model(&modules.User{}).Where("username = ? AND password = ?", username, passMd5).Find(&user).Error
if err != nil {
ctx.JSON(400, gin.H{
"success": false,
"message": "用户名或密码错误",
})
} else {
println("expireTime", string(rune(expireTime)))
myClaims := MyClaims{
user.Id,
jwt.StandardClaims{
ExpiresAt: expireTime,
},
}
myClaimsRefrrsh := MyClaims{
user.Id,
jwt.StandardClaims{
ExpiresAt: refreshTime,
},
}
jwtKey := []byte("lyf123456")
tokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaims)
tokenStr, err := tokenObj.SignedString(jwtKey)
tokenFresh := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaimsRefrrsh)
tokenStrRefresh, err2 := tokenFresh.SignedString(jwtKey)
if err != nil && err2 != nil {
ctx.JSON(200, gin.H{
"message": "生成token失败",
"success": false,
})
} else {
ctx.JSON(200, gin.H{
"message": "登录成功",
"success": true,
"token": tokenStr,//数据请求的token
"refreshToken": tokenStrRefresh,//刷新token用的
})
}
}
}
  1. 刷新token的方法
func (this UserController) RefrshToken(ctx *gin.Context) {
tokenData := ctx.Request.Header.Get("Authorization") //这里是个关键点,刷新token时也要带上token,不过这里是前端传的refreshToken
if tokenData == "" {
ctx.JSON(401, gin.H{
"message": "token为空",
"success": false,
})
ctx.Abort()
return
}
tokenStr := strings.Split(tokenData, " ")[1]
_, claims, err := middlewares.ParseToken(tokenStr)
expireTime := time.Now().Add(10 * time.Second).Unix()
refreshTime := time.Now().Add(20 * time.Second).Unix()
if err != nil {
ctx.JSON(400, gin.H{
"success": false,
"message": "token传入错误",
})
} else {
myClaims := MyClaims{
claims.Uid,
jwt.StandardClaims{
ExpiresAt: expireTime,
},
}
myClaimsRefrrsh := MyClaims{
claims.Uid,
jwt.StandardClaims{
ExpiresAt: refreshTime,
},
}
jwtKey := []byte("lyf123456")
tokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaims)
tokenStr, err := tokenObj.SignedString(jwtKey)
tokenFresh := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaimsRefrrsh)
tokenStrRefresh, err2 := tokenFresh.SignedString(jwtKey)
if err != nil && err2 != nil {
ctx.JSON(400, gin.H{
"message": "生成token失败",
"success": false,
})
} else {
ctx.JSON(200, gin.H{
"message": "刷新token成功",
"success": true,
"token": tokenStr,
"refreshToken": tokenStrRefresh,
})
}
}
}
  1. 路由中间件里验证token
package middlewares

import (
"strings"

"github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/gin"
)

type MyClaims struct {
Uid int
jwt.StandardClaims
}

func AuthMiddleWare(c *gin.Context) {
tokenData := c.Request.Header.Get("Authorization")
if tokenData == "" {
c.JSON(401, gin.H{
"message": "token为空",
"success": false,
})
c.Abort()
return
}
tokenStr := strings.Split(tokenData, " ")[1]
token, _, err := ParseToken(tokenStr)
if err != nil || !token.Valid {
// 这里我感觉觉是个关键点,我看别人写的,过期了返回401,但是前端的axios的响应拦截器里捕获不到,所以我用201状态码,
c.JSON(201, gin.H{
"message": "token已过期",
"success": false,
})
c.Abort()
return
} else {
c.Next()
}
}

func ParseToken(tokenStr string) (*jwt.Token, *MyClaims, error) {
jwtKey := []byte("lyf123456")
// 解析token
myClaims := &MyClaims{}
token, err := jwt.ParseWithClaims(tokenStr, myClaims, func(token *jwt.Token) (interface{}, error) {
return jwtKey, nil
})
return token, myClaims, err
}

总结一下:后端部分三步,1.登陆时生成双token,2,路由中间件里验证token,过期时返回201状态码(201是我私人定的,并不是行业标准)。3,刷新token的方法里也和登陆接口一样返回双token

前端部分

前端部分在axios封装时候加拦截器判断token是否过期,我这里跟别人写的最大的不同点是:我创建了两个axios对象,一个正常数据请求用(server),另一个专门刷新token用(serverRefreshToken),这样写的好处是省去了易错的判断逻辑

import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '../router'
//数据请求用
const server=axios.create({
baseURL:'/shopApi',
timeout:5000
})
// 刷新token专用
const serverRefreshToken=axios.create({
baseURL:'/shopApi',
timeout:5000
})
//获取新token的方法
async function getNewToken(){
let res=await serverRefreshToken.request({
url:`/admin/refresh`,
method:"post",
})
if(res.status==200){
sessionStorage.setItem("token",res.data.token)
sessionStorage.setItem("refreshToken",res.data.refreshToken)
return true
}else{
ElMessage.error(res.data.message)
router.push('/login')
return false
}
}
//这里是正常获取数据用的请求拦截器,主要作用是给所有请求的请求头里加上token
server.interceptors.request.use(config=>{
let token=""
token=sessionStorage.getItem("token")
if(token){
config.headers.Authorization="Bearer "+token
}
return config
},error=>{
Promise.reject(error)
})
//这里是正常获取数据用的响应拦截器,正常数据请求都是200状态码,当拦截到201状态码时,代表token过期了,
server.interceptors.response.use(async(res)=>{
if(res.status==201){
//获取新token
let bl=await getNewToken()
if(bl){
//获取成功新token之后,把刚才token过期拦截到的请求重新发一遍,获取到数据之后把res覆盖掉
//这里是个关键点,下边这行代码里的第二个res是token过期后被拦截的那个请求,config里是该请求的详细信息,重新请求后返回的是第一个res,把失败的res覆盖掉,这里有点绕,文字不好表达,
res=await server.request(res.config)
}
}
return res
},error=>{
if(error.response.status==500||error.response.status==401||error.response.status==400){
router.push('/login')
ElMessage.error(error.response.data.message)
Promise.reject(error)
}

})
//这里是刷新token专用的axios对象,他的作用是给请求加上刷新token专用的refreshToken
serverRefreshToken.interceptors.request.use(config=>{
let token=""
token=sessionStorage.getItem("refreshToken")
if(token){
config.headers.Authorization="Bearer "+token
}
return config
},error=>{
Promise.reject(error)
})
export default server

总结一下,前端部分:1,正常数据请求和刷新token用的请求分开了,各司其职。省去复杂的判断。2,获取新的token和refreshToken后更新原来旧的token和refreshToken。(完结)


作者:锋行天下
来源:juejin.cn/post/7337876697427148811

为什么有这篇小作文?

最近要给自己的项目加上token自动续期,但是在网上搜的写法五花八门,有的光前端部分就写了几百行代码,我看着费劲,摸了半天也没有实现,所以决定自己造轮子

项目构成

  • 后端部分:使用golang的gin框架起的服务
  • 前端部分:vue+elementui

先说后端部分,后端逻辑相对前端简单点,关键两步

  1. 登陆接口生成双token
"github.com/dgrijalva/jwt-go"
func (this UserController) DoLogin(ctx *gin.Context) {
username := ctx.Request.FormValue("username")
passWord := ctx.Request.FormValue("password")
passMd5 := middlewares.CreateMD5(passWord)
expireTime := time.Now().Add(10 * time.Second).Unix() //token过期时间10秒,主要是测试方便
refreshTime := time.Now().Add(20 * time.Second).Unix() //刷新的时间限制,超过20秒重新登录
user := modules.User{}
err := modules.DB.Model(&modules.User{}).Where("username = ? AND password = ?", username, passMd5).Find(&user).Error
if err != nil {
ctx.JSON(400, gin.H{
"success": false,
"message": "用户名或密码错误",
})
} else {
println("expireTime", string(rune(expireTime)))
myClaims := MyClaims{
user.Id,
jwt.StandardClaims{
ExpiresAt: expireTime,
},
}
myClaimsRefrrsh := MyClaims{
user.Id,
jwt.StandardClaims{
ExpiresAt: refreshTime,
},
}
jwtKey := []byte("lyf123456")
tokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaims)
tokenStr, err := tokenObj.SignedString(jwtKey)
tokenFresh := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaimsRefrrsh)
tokenStrRefresh, err2 := tokenFresh.SignedString(jwtKey)
if err != nil && err2 != nil {
ctx.JSON(200, gin.H{
"message": "生成token失败",
"success": false,
})
} else {
ctx.JSON(200, gin.H{
"message": "登录成功",
"success": true,
"token": tokenStr,//数据请求的token
"refreshToken": tokenStrRefresh,//刷新token用的
})
}
}
}
  1. 刷新token的方法
func (this UserController) RefrshToken(ctx *gin.Context) {
tokenData := ctx.Request.Header.Get("Authorization") //这里是个关键点,刷新token时也要带上token,不过这里是前端传的refreshToken
if tokenData == "" {
ctx.JSON(401, gin.H{
"message": "token为空",
"success": false,
})
ctx.Abort()
return
}
tokenStr := strings.Split(tokenData, " ")[1]
_, claims, err := middlewares.ParseToken(tokenStr)
expireTime := time.Now().Add(10 * time.Second).Unix()
refreshTime := time.Now().Add(20 * time.Second).Unix()
if err != nil {
ctx.JSON(400, gin.H{
"success": false,
"message": "token传入错误",
})
} else {
myClaims := MyClaims{
claims.Uid,
jwt.StandardClaims{
ExpiresAt: expireTime,
},
}
myClaimsRefrrsh := MyClaims{
claims.Uid,
jwt.StandardClaims{
ExpiresAt: refreshTime,
},
}
jwtKey := []byte("lyf123456")
tokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaims)
tokenStr, err := tokenObj.SignedString(jwtKey)
tokenFresh := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaimsRefrrsh)
tokenStrRefresh, err2 := tokenFresh.SignedString(jwtKey)
if err != nil && err2 != nil {
ctx.JSON(400, gin.H{
"message": "生成token失败",
"success": false,
})
} else {
ctx.JSON(200, gin.H{
"message": "刷新token成功",
"success": true,
"token": tokenStr,
"refreshToken": tokenStrRefresh,
})
}
}
}
  1. 路由中间件里验证token
package middlewares

import (
"strings"

"github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/gin"
)

type MyClaims struct {
Uid int
jwt.StandardClaims
}

func AuthMiddleWare(c *gin.Context) {
tokenData := c.Request.Header.Get("Authorization")
if tokenData == "" {
c.JSON(401, gin.H{
"message": "token为空",
"success": false,
})
c.Abort()
return
}
tokenStr := strings.Split(tokenData, " ")[1]
token, _, err := ParseToken(tokenStr)
if err != nil || !token.Valid {
// 这里我感觉觉是个关键点,我看别人写的,过期了返回401,但是前端的axios的响应拦截器里捕获不到,所以我用201状态码,
c.JSON(201, gin.H{
"message": "token已过期",
"success": false,
})
c.Abort()
return
} else {
c.Next()
}
}

func ParseToken(tokenStr string) (*jwt.Token, *MyClaims, error) {
jwtKey := []byte("lyf123456")
// 解析token
myClaims := &MyClaims{}
token, err := jwt.ParseWithClaims(tokenStr, myClaims, func(token *jwt.Token) (interface{}, error) {
return jwtKey, nil
})
return token, myClaims, err
}

总结一下:后端部分三步,1.登陆时生成双token,2,路由中间件里验证token,过期时返回201状态码(201是我私人定的,并不是行业标准)。3,刷新token的方法里也和登陆接口一样返回双token

前端部分

前端部分在axios封装时候加拦截器判断token是否过期,我这里跟别人写的最大的不同点是:我创建了两个axios对象,一个正常数据请求用(server),另一个专门刷新token用(serverRefreshToken),这样写的好处是省去了易错的判断逻辑

import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '../router'
//数据请求用
const server=axios.create({
baseURL:'/shopApi',
timeout:5000
})
// 刷新token专用
const serverRefreshToken=axios.create({
baseURL:'/shopApi',
timeout:5000
})
//获取新token的方法
async function getNewToken(){
let res=await serverRefreshToken.request({
url:`/admin/refresh`,
method:"post",
})
if(res.status==200){
sessionStorage.setItem("token",res.data.token)
sessionStorage.setItem("refreshToken",res.data.refreshToken)
return true
}else{
ElMessage.error(res.data.message)
router.push('/login')
return false
}
}
//这里是正常获取数据用的请求拦截器,主要作用是给所有请求的请求头里加上token
server.interceptors.request.use(config=>{
let token=""
token=sessionStorage.getItem("token")
if(token){
config.headers.Authorization="Bearer "+token
}
return config
},error=>{
Promise.reject(error)
})
//这里是正常获取数据用的响应拦截器,正常数据请求都是200状态码,当拦截到201状态码时,代表token过期了,
server.interceptors.response.use(async(res)=>{
if(res.status==201){
//获取新token
let bl=await getNewToken()
if(bl){
//获取成功新token之后,把刚才token过期拦截到的请求重新发一遍,获取到数据之后把res覆盖掉
//这里是个关键点,下边这行代码里的第二个res是token过期后被拦截的那个请求,config里是该请求的详细信息,重新请求后返回的是第一个res,把失败的res覆盖掉,这里有点绕,文字不好表达,
res=await server.request(res.config)
}
}
return res
},error=>{
if(error.response.status==500||error.response.status==401||error.response.status==400){
router.push('/login')
ElMessage.error(error.response.data.message)
Promise.reject(error)
}

})
//这里是刷新token专用的axios对象,他的作用是给请求加上刷新token专用的refreshToken
serverRefreshToken.interceptors.request.use(config=>{
let token=""
token=sessionStorage.getItem("refreshToken")
if(token){
config.headers.Authorization="Bearer "+token
}
return config
},error=>{
Promise.reject(error)
})
export default server

总结一下,前端部分:1,正常数据请求和刷新token用的请求分开了,各司其职。省去复杂的判断。2,获取新的token和refreshToken后更新原来旧的token和refreshToken。(完结)


作者:锋行天下
来源:juejin.cn/post/7337876697427148811
收起阅读 »

听说你会架构设计?来,弄一个公交&地铁乘车系统

1. 引言 1.1 上班通勤的日常 “叮铃铃”,“叮铃铃”,早上七八点,你还在温暖的被窝里和闹钟“斗智斗勇”。 突然,你意识到已经快迟到了,于是像个闪电侠一样冲进卫生间,速洗漱,急穿衣,左手抄起手机,右手拿起面包,边穿衣边啃早餐。 这个时候,通勤的老难题又摆...
继续阅读 »

1. 引言


1.1 上班通勤的日常


“叮铃铃”,“叮铃铃”,早上七八点,你还在温暖的被窝里和闹钟“斗智斗勇”。



突然,你意识到已经快迟到了,于是像个闪电侠一样冲进卫生间,速洗漱,急穿衣,左手抄起手机,右手拿起面包,边穿衣边啃早餐。


这个时候,通勤的老难题又摆在了你面前:要不要吃完这口面包、刷牙和洗脸,还是先冲出门赶车?


好不容易做出了一个艰难的决定——放下面包,快步冲出门。你拿出手机,点开了熟悉的地铁乘车 App 或公交地铁乘车码小程序。


然后,一张二维码在屏幕上亮了起来,这可是你每天通勤的“敲门砖”。





你快步走到地铁站,将手机二维码扫描在闸机上,"嗖"的一声,闸机打开,你轻松通过,不再需要排队买票,不再被早高峰的拥挤闹心。


你走进地铁车厢,挤到了一个角落,拿出手机,开始计划一天的工作。


1.2 公交&地铁乘车系统


正如上文所说,人们只需要一台手机,一个二维码就可以完成上班通勤的所有事项。


那这个便捷的公交或地铁乘车系统是如何设计的呢?它背后的技术和架构是怎样支撑着你我每天的通勤生活呢?


今天让我们一起揭开这个现代都市打工人通勤小能手的面纱,深入探讨乘车系统的设计与实现


在这个文章中,小❤将带你走进乘车系统的世界,一探究竟,看看它是如何在短短几年内从科幻电影中走出来,成为我们日常生活不可或缺的一部分。


2. 需求设计


2.1 功能需求




  • 用户注册和登录: 用户可以通过手机应用或小程序注册账号,并使用账号登录系统。

  • 路线查询: 用户可以查询地铁的线路和站点信息,包括发车时间、车票价格等。

  • 获取乘车二维码: 系统根据用户的信息生成乘车二维码。

  • 获取地铁实时位置: 用户可以查询地铁的实时位置,并查看地铁离当前站台还有多久到达。

  • 乘车扫描和自动支付: 用户在入站和出站时通过扫描二维码来完成乘车,系统根据乘车里程自动计算费用并进行支付。

  • 交易记录查询: 用户可以查询自己的交易历史记录,包括乘车时间、金额、线路等信息。


2.2 乘车系统的非功能需求


乘车系统的用户量非常大,据《中国主要城市通勤检测报告-2023》数据显示,一线城市每天乘公交&地铁上班的的人数普遍超过千万,平均通勤时间在 45-60 分钟,并集中在早高峰和晚高峰时段。


所以,设计一个热点数据分布非均匀、人群分布非均匀的乘车系统时,需要考虑如下几点:



  • 用户分布不均匀,一线城市的乘车系统用户,超出普通城市几个数量级。

  • 时间分布不均匀,乘车系统的设计初衷是方便上下班通勤,所以早晚高峰的用户数会高出其它时间段几个数量级。

  • 高并发: 考虑到公交车/地铁系统可能同时有大量的用户在高峰时段使用,系统需要具备高并发处理能力。

  • 高性能: 为了提供快速的查询和支付服务,系统需要具备高性能,响应时间应尽可能短。

  • 可扩展性: 随着用户数量的增加,系统应该容易扩展,以满足未来的需求。

  • 可用性: 系统需要保证24/7的可用性,随时提供服务。

  • 安全和隐私保护: 系统需要确保用户数据的安全和隐私,包括支付信息和个人信息的保护。


3. 概要设计


3.1 核心组件




  • 前端应用: 开发手机 App 和小程序,提供用户注册、登录、查询等功能。

  • 后端服务: 设计后端服务,包括用户管理、路线查询、二维码管理、订单处理、支付系统等。

  • 数据库: 使用关系型数据库 MySQL 集群存储用户信息、路线信息、交易记录等数据。

  • 推送系统: 将乘车后的支付结果,通过在线和离线两种方式推送给用户手机上。

  • 负载均衡和消息队列: 考虑使用负载均衡和消息队列技术来提高系统性能。


3.2 乘车流程


1)用户手机与后台系统的交互


交互时序图如下:



1. 用户注册和登录: 用户首先需要在手机应用上注册并登录系统,提供个人信息,包括用户名、手机号码、支付方式等。


2. 查询乘车信息: 用户可以使用手机应用查询公交车/地铁的路线和票价信息,用户可以根据自己的出行需求选择合适的线路。


3. 生成乘车二维码: 用户登录后,系统会生成一个用于乘车的二维码,这个二维码可以在用户手机上随时查看。这个二维码是城市公交系统的通用乘车二维码,同时该码关联到用户的账户和付款方式,用户可以随时使用它乘坐任何一辆公交车或地铁。


2)用户手机与公交车的交互


交互 UML 状态图如下:




  1. 用户进站扫码: 当用户进入地铁站时,他们将手机上的乘车码扫描在进站设备上。这个设备将扫描到的乘车码发送给后台系统。

  2. 进站数据处理: 后台系统接收到进站信息后,会验证乘车码的有效性,检查用户是否有进站记录,并记录下进站的时间和地点。

  3. 用户出站扫码: 用户在乘车结束后,将手机上的乘车码扫描在出站设备上。

  4. 出站数据处理: 后台系统接收到出站信息后,会验证乘车码的有效性,检查用户是否有对应的进站记录,并记录下出站的时间和地点。


3)后台系统的处理



  1. 乘车费用计算: 基于用户的进站和出站地点以及乘车规则,后台系统计算乘车费用。这个费用可以根据不同的城市和运营商有所不同。

  2. 费用记录和扣款: 系统记录下乘车费用,并从用户的付款方式(例如,支付宝或微信钱包)中扣除费用。

  3. 乘车记录存储: 所有的乘车记录,包括进站、出站、费用等信息,被存储在乘车记录表中,以便用户查看和服务提供商进行结算。

  4. 通知用户: 如果有需要,系统可以向用户发送通知,告知他们的乘车费用已被扣除。

  5. 数据库交互: 在整个过程中,系统需要与数据库交互来存储和检索用户信息、乘车记录、费用信息等数据。


3. 详细设计


3.1 数据库设计



  • 用户信息表(User) ,包括用户ID、手机号、密码、支付方式、创建时间等。

  • 二维码表 (QRCode) ,包括二维码ID、用户ID、城市ID、生成时间、有效期及二维码数据等。

  • 车辆&地铁车次表 (Vehicle) ,包括车辆ID、车牌或地铁列车号、车型(公交、地铁)、扫描设备序列号等。

  • 乘车记录表 (TripRecord) ,包括记录ID、用户ID、车辆ID、上下车时间、起止站点等。

  • 支付记录表 (PaymentRecord) ,包括支付ID、乘车记录ID、交易时间、交易金额、支付方式、支付状态等。


以上是一些在公交车&地铁乘车系统中需要设计的数据库表及其字段的基本信息,后续可根据具体需求和系统规模,还可以进一步优化表结构和字段设计,以满足性能和扩展性要求。


详细设计除了要设计出表结构以外,我们还针对两个核心问题进行讨论:



  • 最短路线查询



  • 乘车二维码管理


3.2 最短路线查询


根据交通部门给的公交&地铁路线,我们可以绘制如下站点图:



假设图中的站点有 A-F,涉及到的交通工具有地铁 1 号线和 2 路公交,用户的起点和终点分别为 A、F 点。我们可以使用 Dijkstra 算法来求两点之间的最短路径,具体步骤为:


步骤已遍历集合未遍历集合
1选入A,此时最短路径 A->A = 0,再以 A 为中间点,开始寻找下一个邻近节点{B、C、D、E、F},其中与 A 相邻的节点有 B 和 C,AB=6,AC=3。接下来,选取较短的路径节点 C 开始遍历
2选取C,A->C=3,此时已遍历集合为{A、C},以 A 和 C 为中间点,开始寻找下一个邻近节点{B、D、E、F},其中与 A、C 相邻的节点有 B 和 D,AB=6,ACD=3+4=7。接下来,选取较短的路径节点 B 开始遍历
3选取B,A->B=6,此时已遍历集合为{A、C、B},A 相邻的节点已经遍历结束,开始寻找和 B、C 相近的节点{D、E、F},其中与 B、C 相邻的节点有 D,节点 D 在之前已经有了一个距离记录(7),现在新的可选路径是 ABD=6+5=11。显然第一个路径更短,于是将 D 的最近距离 7 加入到集合中
4选取D,A->D=7,此时已遍历集合为{A、C、B、D},寻找 D 相邻的节点{E、F},其中 DE=2,DF=3,选取最近路径的节点 E 加入集合
5选取 E,A->E=7+2=9,此时已遍历集合为{A、C、B、D、E},继续寻找 D 和 E 相近的节点{F},其中 DF=3,DEF=2+5=7,于是F的最近距离为7+3=10.
6选取F,A->F=10,此时遍历集合为{A、C、B、D、E、F}所有节点已遍历结束,从 A 点出发,它们的最近距离分别为{A=0,C=3,B=6,D=7,E=9,F=10}

在用户查询路线之前,交通部门会把公交 & 地铁的站点经纬度信息输入到路线管理系统,并根据二维的空间经纬度编码存储对应的站点信息。


我们设定西经为负,南纬为负,所以地球上的经度范围就是[-180, 180],纬度范围就是[-90,90]。如果以本初子午线、赤道为界,地球可以分成 4 个部分。



根据这个原理,我们可以先将二维的空间经纬度编码成一个字符串,来唯一标识用户或站点的位置信息。再通过 Redis 的 GeoHash 算法,来获取用户出发点附近的所有站点信息。


GeoHash 算法的原理是将一个位置的经纬度换算成地址编码字符串,表示在某个矩形区域,通过这个算法可以快速找到同一个区域的所有站点


一旦获得了起始地点的经纬度,系统就可以根据附近的站点信息,调用路线管理系统来查找最佳的公交或地铁路线。


一旦用户选择了一条路线,导航引擎启动并提供实时导航指引。导航引擎可能会使用地图数据和 GPS 定位来指导用户前往起止站点。


3.3 乘车二维码管理


乘车码是通过 QR 码(Quick Response Code)技术生成的,它比传统的 Bar Code 条形码能存更多的信息,也能表示更多的数据类型,如图所示:



二维码的生成非常简单,拿 Go 语言来举例,只需引入一个三方库:


import "github.com/skip2/go-qrcode"

func main() {
    qr,err:=qrcode.New("https://mp.weixin.qq.com",qrcode.Medium)
if err != nil {
    log.Fatal(err)
else {
    qr.BackgroundColor = color.RGBA{50,205,50,255//定义背景色
    qr.ForegroundColor = color.White //定义前景色
    qr.WriteFile(256,"./wechatgzh_qrcode.png"//转成图片保存
    }
}

以下是该功能用户和系统之间的交互、二维码信息存储、以及高并发请求处理的详细说明:



  1. 用户与系统交互: 用户首先在手机 App 上登录,系统会验证用户的身份和付款方式。一旦验证成功,系统根据用户的身份信息和付款方式,动态生成一个 QR 码,这个 QR 码包含了用户的标识信息和相关的乘车参数。

  2. 二维码信息存储: 生成的二维码信息需要在后台进行存储和关联。通常,这些信息会存储在一个专门的数据库表中,该表包含以下字段:



    • 二维码ID:主键ID,唯一标识一个二维码。

    • 用户ID:与乘车码关联的用户唯一标识。

    • 二维码数据:QR码的内容,包括用户信息和乘车参数。

    • 生成时间:二维码生成的时间戳,用于后续的验证和管理。

    • 有效期限:二维码的有效期,通常会设置一个时间限制,以保证安全性。



  3. 高并发请求处理: 在高并发情况下,大量的用户会同时生成和扫描二维码,因此需要一些策略来处理这些请求:



    • 负载均衡: 后台系统可以采用负载均衡技术,将请求分散到多个服务器上,以分担服务器的负载。

    • 缓存优化: 二维码的生成是相对耗时的操作,可以采用 Redis 来缓存已生成的二维码,避免重复生成。

    • 限制频率: 为了防止滥用,可以限制每个用户生成二维码的频率,例如,每分钟只允许生成 5  次,这可以通过限流的方式来实现。




总之,通过 QR 码技术生成乘车码,后台系统需要具备高并发处理的能力,包括负载均衡、缓存和频率限制等策略,以确保用户能够快速获得有效的乘车二维码。


同时,二维码信息需要被安全地存储和管理,比如:加密存储以保护用户的隐私和付款信息。



不清楚如何限流的,可以看我之前的这篇文章:若我问到高可用,阁下又该如何应对呢?



4. 乘车系统的发展


4.1 其它设计


除此之外,公交车或地铁的定位和到站时间计算可能还涉及定位设备、GPS 系统、NoSQL 数据库、用户 TCP 连接管理系统等核心组件,并通过实时数据采集、位置处理、到站时间计算和信息推送等流程来为用户提供准确的乘车信息。


同时,自动支付也是为了方便用户的重要功能,可以通过与第三方支付平台的集成来实现。


4.2 未来发展


公交车/地铁乘车系统的未来发展可以包括以下方向:



  • 智能化乘车: 引入智能设备,如人脸自动识别乘客、人脸扣款等。

  • 大数据分析: 利用大数据技术分析乘车数据,提供更好的服务。


在设计和发展过程中,也要不断考虑用户体验、性能和安全,确保系统能够满足不断增长的需求。


由于篇幅有限,文章就到此结束了。


希望读者们能对公交&地铁乘车系统的设计有更深入的了解,并和小❤一起期待未来更多的交通创新解决方案叭~


作者:xin猿意码
来源:juejin.cn/post/7287495466514055202
收起阅读 »

前端要懂的Docker部分

前言 最近学习部署的时候,发现前端部署可以通过多种方式来进行部署,web服务器,Docker,静态部署:Github page,网站托管平台:Vercel,还有一些自动化部署的东西目前还没有学到... Docker开始 简介 Docker是一个应用打包,分发,...
继续阅读 »

前言


最近学习部署的时候,发现前端部署可以通过多种方式来进行部署,web服务器,Docker,静态部署:Github page,网站托管平台:Vercel,还有一些自动化部署的东西目前还没有学到...


Docker开始


简介


Docker是一个应用打包,分发,部署的工具,它就相当于一个容器,将你想要的一些依赖、第三方库、环境啥的和代码进行一块打包为镜像,并上传到镜像仓库里面,这样别人可以直接从镜像仓库里面直接拉去这个代码来进行运行,可以适应不同的电脑环境,打造了一个完全封闭的环境。



  • 打包:将软件运行所需要的依赖、第三方库、软件打包到一起,变成一个安装包

  • 分发:将你打包好的安装包上传到一个镜像 仓库,其他人可以很方便的获取安装

  • 部署:拿着安装包就可以一个命令运行起来你的应用,自动模拟出一摸一样的运行环境


为什么使用Docker



Docker 的出现主要是为了解决以下问题:“在我的机器上运行正常,但为什么到你的机器上就运行不正常了?”。



平常开发的时候,项目在本地跑的好好的,但是其他人想要在他的电脑上去跑你的应用程序,但是却跑不起来,他得配置数据库,Web 服务器,插件啥的,非常不方便。Docker的出现解决了这些问题。


基本术语


镜像:理解为软件安装包,可以方便的进行传播和安装,它包含了运行应用程序所需的所有元素,包括代码、运行时环境、库、环境变量和配置文件。



  • 镜像可以通过Dockerfile来进行创建

  • Dockerfile就相当于一个脚本,编写一些命令,他会识别这些命令并且执行


容器:软件安装后的状态,每个软件运行的环境都是独立的、隔离的,称之为容器。


仓库:仓库是存放 Docker 镜像的地方。仓库允许你分享你的镜像,你可以将你的镜像推送(push)到仓库,也可以从仓库拉取(pull)其他人分享的镜像。



  • Docker 提供了一个公共的仓库 Docker Hub,你可以在上面上传你的镜像,或者寻找你需要的镜像。


常见命令



  • docker run: 用于从 Docker 镜像启动一个容器。例如,docker run -p 8080:80 -d my-app 将从名为 "my-app" 的 Docker 镜像启动一个新的容器,并将容器的 8080 端口映射到主机的 80 端口。

  • docker build: 用于从 Dockerfile 构建 Docker 镜像。例如,docker build -t my-app . 将使用当前目录中的 Dockerfile 构建一个名为 "my-app" 的 Docker 镜像。

  • docker pull: 用于从 Docker Hub 或其他 Docker 注册服务器下载 Docker 镜像。例如,docker pull nginx 将从 Docker Hub 下载官方的 Nginx 镜像。

  • docker push: 用于将 Docker 镜像推送到 Docker Hub 或其他 Docker 注册服务器。例如,docker push my-app 将名为 "my-app" 的 Docker 镜像推送到你的 Docker Hub 账户。

  • docker ps: 用于列出正在运行的 Docker 容器。添加 -a 选项(docker ps -a)可以列出所有容器,包括已停止的。

  • docker stop: 用于停止正在运行的 Docker 容器。例如,docker stop my-container 将停止名为 "my-container" 的 Docker 容器。

  • docker rm: 用于删除 Docker 容器。例如,docker rm my-container 将删除名为 "my-container" 的 Docker 容器。

  • docker rmi: 用于删除 Docker 镜像。例如,docker rmi my-app 将删除名为 "my-app" 的 Docker 镜像。

  • docker logs: 用于查看 Docker 容器的日志。例如,docker logs my-container 将显示名为 "my-container" 的 Docker 容器的日志。

  • docker exec: 用于在正在运行的 Docker 容器中执行命令。例如,docker exec -it my-container bash 将在名为 "my-container" 的 Docker 容器中启动一个 bash shell。
    docker常见命令



ps:不会使用就:docker load --help



编写Dockerfile


FROM nginx:latest
# 定义作者
MAINTAINER Merikle

#删除目录下的default.conf文件
#RUN rm /etc/nginx/conf.d/default.conf
#设置时区
RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo 'Asia/Shanghai' >/etc/timezone

#将本地nginx.conf配置覆盖nginx配置
COPY nginx.conf /etc/nginx/nginx.conf
# 将dist文件中的内容复制到 /usr/share/nginx/html/ 这个目录下面
COPY dist/ /usr/share/nginx/html/
#声名端口
EXPOSE 8748

RUN echo 'web project build success!!'

FROM:指定基础镜像


RUN:一般指安装的过程


COPY:拷贝本地文件到镜像的指定目录


ENV:环境变量


EXPOSE:指定容器运行时监听到的端口,是给镜像的使用者看的


ENTRYPOINT:镜像中应用的启动命令,容器运行时调用


编写完Dockerfile生成镜像:


docker build -t test:v1 .


运行镜像:


docker run -p 8080:8080 --name test-hello test:v1


意思:跑在8080端口将test:v1命名为text-hello


之后你可以发布到上面说的仓库里面




  • 命令行登录账号: docker login -u username

  • 新建一个tag,名字必须跟你注册账号一样 docker tag test:v1 username/test:v1

  • 推上去 docker push username/test:v1

  • 部署试下 docker run -dp 8080:8080 username/test:v1


实践一下Docker部署前端项目


核心思想:


将前端打包的dist放到nginx里面,然后添加一个nginx.conf文件。


docker的话就是多了一个Dockerfile文件来构建镜像而已。


nginx.conf文件的编写


#nginx.conf文件编写
#user nobody;
worker_processes 1;

#error_log logs/error.log;
#error_log logs/error.log notice;
#error_log logs/error.log info;

#pid logs/nginx.pid;


events {
worker_connections 1024;
}


http {
include mime.types;
default_type application/octet-stream;

#log_format main '$remote_addr - $remote_user [$time_local] "$request" '
# '$status $body_bytes_sent "$http_referer" '
# '"$http_user_agent" "$http_x_forwarded_for"';

#access_log logs/access.log main;

sendfile on;
#tcp_nopush on;

#keepalive_timeout 0;
keepalive_timeout 65;

#gzip on;

server {
#监听的端口
listen 8748;
#请填写绑定证书的域名或者IP
server_name 121.199.29.3;

gzip on;
gzip_min_length 1k;
gzip_comp_level 9;
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png;
gzip_vary on;
gzip_disable "MSIE [1-6]\.";

#charset koi8-r;

#access_log logs/host.access.log main;

location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}

#error_page 404 /404.html;

# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}

# proxy the PHP scripts to Apache listening on 127.0.0.1:80
#
#location ~ \.php$ {
# proxy_pass http://127.0.0.1;
#}

# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
#
#location ~ \.php$ {
# root html;
# fastcgi_pass 127.0.0.1:9000;
# fastcgi_index index.php;
# fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
# include fastcgi_params;
#}

# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
#
#location ~ /\.ht {
# deny all;
#}
}


# another virtual host using mix of IP-, name-, and port-based configuration
#
#server {
# listen 8000;
# listen somename:8080;
# server_name somename alias another.alias;

# location / {
# root html;
# index index.html index.htm;
# }
#}


# HTTPS server
#
#server {
# listen 443 ssl;
# server_name localhost;

# ssl_certificate cert.pem;
# ssl_certificate_key cert.key;

# ssl_session_cache shared:SSL:1m;
# ssl_session_timeout 5m;

# ssl_ciphers HIGH:!aNULL:!MD5;
# ssl_prefer_server_ciphers on;

# location / {
# root html;
# index index.html index.htm;
# }
#}

}


Dockerfile文件的编写


# Dockerfile文件

FROM nginx:latest
# 定义作者
MAINTAINER Merikle

#删除目录下的default.conf文件
#RUN rm /etc/nginx/conf.d/default.conf
#设置时区
RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo 'Asia/Shanghai' >/etc/timezone

#将本地nginx.conf配置覆盖nginx配置
COPY nginx.conf /etc/nginx/nginx.conf
# 将dist文件中的内容复制到 /usr/share/nginx/html/ 这个目录下面
COPY dist/ /usr/share/nginx/html/
#声名端口
EXPOSE 8748

RUN echo 'web project build success!!'


然后将这些压缩成zip(或者本地打包为镜像,然后上传到镜像仓库里面,在服务器里面拉取镜像就可以了),然后传到服务器中,进行解压,构建镜像并且运行镜像来进行前端部署。


部署node项目


Dockerfile的编写


FROM node:11
MAINTAINER Merikle

#
复制代码
ADD . /mongo-server

#
设置容器启动后的默认运行目录
WORKDIR /mongo-server

#
运行命令,安装依赖
# RUN 命令可以有多个,但是可以用 && 连接多个命令来减少层级。
# 例如 RUN npm install && cd /app && mkdir logs
RUN npm install --registry=https://registry.npm.taobao.org

#
CMD 指令只能一个,是容器启动后执行的命令,算是程序的入口。
# 如果还需要运行其他命令可以用 && 连接,也可以写成一个shell脚本去执行。
# 例如 CMD cd /app && ./start.sh
CMD node app.js

然后构建镜像


发布镜像到hub上面


在服务器上面进行拉取镜像并且运行镜像。


部署mongodb


这个还没有部署,之后再说啦。


作者:Charlotten
来源:juejin.cn/post/7332290436345905187
收起阅读 »

SQL中为什么不要使用1=1

最近看几个老项目的SQL条件中使用了1=1,想想自己也曾经这样写过,略有感触,特别拿出来说道说道。 编写SQL语句就像炒菜,每一种调料的使用都会影响菜品的最终味道,每一个SQL条件的加入也会影响查询的执行效率。那么 1=1 存在什么样的问题呢?为什么又会使用呢...
继续阅读 »

最近看几个老项目的SQL条件中使用了1=1,想想自己也曾经这样写过,略有感触,特别拿出来说道说道。


编写SQL语句就像炒菜,每一种调料的使用都会影响菜品的最终味道,每一个SQL条件的加入也会影响查询的执行效率。那么 1=1 存在什么样的问题呢?为什么又会使用呢?


为什么会使用 1=1?


在动态构建SQL查询时,开发者可能会不确定最终需要哪些条件。这时候,他们就会使用“1=1”作为一个始终为真的条件,让接下来的所有条件都可以方便地用“AND”连接起来,就像是搭积木的时候先放一个基座,其他的积木块都可以在这个基座上叠加。


就像下边这样:


SELECT * FROM table WHERE 1=1
<if test="username != null">
AND username = #{username}
</if>
<if test="age > 0">
AND age = #{age}
</if>

这样就不用在增加每个条件之前先判断是否需要添加“AND”。


1=1 带来的问题


性能问题


我们先来了解一下数据库查询优化器的工作原理。查询优化器就像是一个聪明的图书管理员,它知道如何最快地找到你需要的书籍。当你告诉它所需书籍的特征时,它会根据这些信息选择最快的检索路径。比如你要查询作者是“谭浩强”的书籍,它就选择先通过作者索引找到书籍索引,再通过书籍索引找到对应的书籍,而不是费力的把所有的书籍遍历一遍。


但是,如果我们告诉它一些无关紧要的信息,比如“我要一本书,它是一本书”,这并不会帮助管理员更快地找到书,反而可能会让他觉得困惑。一个带有“1=1”的查询可能会让数据库去检查每一条记录是否满足这个始终为真的条件,这就像是图书管理员不得不检查每一本书来确认它们都是书一样,显然是一种浪费。


不过这实际上可能也不会产生问题,因为现代数据库的查询优化器已经非常智能,它们通常能够识别出像 1=1 这样的恒真条件,并在执行查询计划时优化掉它们。在许多情况下,即使查询中包含了1=1,数据库的性能也不会受到太大影响,优化器会在实际执行查询时将其忽略。


代码质量


不过,我们仍然需要避免在查询中包含 1=1,有以下几点考虑:



  1. 代码清晰性:即使数据库可以优化掉这样的条件,但对于阅读SQL代码的人来说,1=1可能会造成困惑。代码的可读性和清晰性非常重要,特别是在团队协作的环境中。

  2. 习惯养成:即使在当前的数据库系统中1=1不会带来性能问题,习惯了写不必要的代码可能会在其他情况下引入实际的性能问题。比如,更复杂的无用条件可能不会那么容易被优化掉。

  3. 优化器的限制:虽然现代优化器很强大,但它们并不是万能的。在某些复杂的查询场景中,即使是简单的 1=1 也可能对优化器的决策造成不必要的影响,比如索引的使用。

  4. 跨数据库兼容性:不同的数据库管理系统(DBMS)可能有不同的优化器能力。一个系统可能轻松优化掉1=1,而另一个系统则可能不那么高效。编写不依赖于特定优化器行为的SQL语句是一个好习惯。


编写尽可能高效、清晰和准确的SQL语句,不仅有助于保持代码的质量,也让代码具有更好的可维护性和可扩展性。


替代 1=1 的更佳做法


现在开发者普遍使用ORM框架来操作数据库了,还在完全手写拼SQL的同学可能需要反思下了,这里给两个不同ORM框架下替代1=1的方法。


假设我们有一个用户信息表 user,并希望根据传入的参数动态地过滤用户。


首先是Mybatis


<!-- MyBatis映射文件片段 -->
<select id="selectUsersByConditions" parameterType="map" resultType="com.example.User">
SELECT * FROM user
<where>
<!-- 使用if标签动态添加条件 -->
<if test="username != null and username != ''">
AND username = #{username}
</if>
<if test="age > 0">
AND age = #{age}
</if>
<!-- 更多条件... -->
</where>
</select>

在 MyBatis 中,避免使用 WHERE 1=1 的典型方法是利用动态SQL标签(如 )来构建条件查询。 标签会自动处理首条条件前的 AND 或 OR。当没有满足条件的 或其他条件标签时, 标签内部的所有内容将被忽略,从而不会生成多余的 AND 或 WHERE 子句。


再看看 Entity Framework 的方法:


var query = context.User.AsQueryable();
if (!string.IsNullOrEmpty(username))
{
query = query.Where(b => b.UserName.Contains(username));
}
if (age>0)
{
query = query.Where(b => b.Age = age);
}
var users = query.ToList();

这是一种函数式编程的写法,最终生成SQL时,框架会决定是否在条件前增加AND,而不需要人为的增加 1=1。


总结


“1=1”在SQL语句中可能看起来无害,但实际上它是一种不良的编程习惯,可能会导致性能下降。就像在做饭时不会无缘无故地多加调料一样,我们在编写SQL语句时也应该避免添加无意义的条件。


每一行代码都应该有它存在的理由,不要让你的数据库像一个困惑的图书管理员,浪费时间在不必要的事情上。


作者:萤火架构
来源:juejin.cn/post/7337513754970095667
收起阅读 »

对代码封装的一点思考

之前看过一篇文章讲代码封装的过程,下面从简述文章的内容开始梳理下自己对代码封装的一点思考. --- 以下是之前文章内容简述 --- 在业务迭代的初期,代码中有一段业务逻辑A,由于A逻辑在项目中使用比较广泛,于是对A逻辑进行了代码逻辑的封装,包括参数的预设等 ...
继续阅读 »

之前看过一篇文章讲代码封装的过程,下面从简述文章的内容开始梳理下自己对代码封装的一点思考.


--- 以下是之前文章内容简述 ---


在业务迭代的初期,代码中有一段业务逻辑A,由于A逻辑在项目中使用比较广泛,于是对A逻辑进行了代码逻辑的封装,包括参数的预设等

初始化封装

随着业务的发展,又出现了B模块,B模块中几乎可以直接使用A逻辑,但是需要对A逻辑进行一些小小的改动,比如入参的调整、逻辑分支的增加

再次封装

这种通过修改较小范围代码就能实现功能的方式在实际开发中是很常规的操作,它并没有不好,只是当这种修改变的次数多的时候,最开始封装的逻辑A就变成了‘四不像’,它似乎可以服务于A模块,但是确有些冗余,出现了‘我好像看不懂这段代码’,‘这段代码还是别改了’,‘你改吧 我是改不了’的场景。在多次封装之后,逻辑A容易出现以下的问题



  • 入参不可控

  • 逻辑分支复杂,不清晰,修改范围不确定,容易对其他模块产生影响

  • 复用度逐渐下降,解决方法



    1. 将就不再继续新增逻辑,变成'老大难'问题,总有爆发的一天

    2. 重写相关逻辑,功能回测范围大,影响范围不可控,容易出现漏测等




--- 以下是正文 ---


提前封装或者过度封装对实际业务的编写都有很大影响,最实际的感受就是这段代码不好写,这个逻辑看不懂。封装的代码逻辑是结果,封装的出发点是因,从出发点去看而不是面向结果的封装似乎更能编写出可维护性的代码。以下是自己对封装的一点思考。


降低复杂度


复杂度

通过对业务中分散的相似逻辑提取,就能一定程度上降低项目的复杂度和重复度的问题。在修改相应的业务逻辑时候就相对集中和可控。新增逻辑的修改也能做到应用范围的同步。


解耦逻辑


在设计模式中需要设计的模块尽量遵循单一职责,但是在实际编写代码的过程中很容易出现模块功能的耦合,比如常见的:



  • 视图层逻辑与数据层逻辑耦合

  • 非相关逻辑的入侵 比如视图的逻辑需要感知请求状态的改变做相应的逻辑处理


通过将相关的逻辑都聚合到模块封装的内部,就能较低其他模块对不相关逻辑的感知,也就做到了解耦。在下面的例子中,通过将A逻辑的关联逻辑都封装进A模块中,降低了功能模块对封装逻辑的感知。内部封装

相关例子:



  • ahooks中的useRequest中,就通过对请求状态和数据的操作就行封装,将数据相关操作做到了内聚,减轻了视图层的负担

  • Antd pro components的table


分层


分层封装

在例子中,通过将逻辑A中的相关逻辑提取到上层,分层的方式实现了模块功能的内聚与解耦。在前端的MVC中就通过将相关操作封装成Controller,来完成逻辑的解耦


复用(一致性,可控性)


从降低复杂度、逻辑结构、分层设计最终的目标是达到封装代码的复用。通过复用能控制代码的一致性,实现修改代码行为的可控。



-- 写在最后 --


代码的优化或者设计是应该在理解业务的基础上,从相对大的视角出发来看的。在实际做的时候容易出现缺少全局视角的设计缺失和不完善,也容易出现过度设计,需要仔细去把握这个度量。


作者:前端小板凳
来源:juejin.cn/post/7337354931479085096
收起阅读 »

分类树,我从2s优化到0.1s

前言 分类树查询功能,在各个业务系统中可以说随处可见,特别是在电商系统中。 但就是这样一个简单的分类树查询功能,我们却优化了5次。 到底是怎么回事呢? 背景 我们的网站使用了SpringBoot推荐的模板引擎:Thymeleaf,进行动态渲染。 它是一个XM...
继续阅读 »

前言


分类树查询功能,在各个业务系统中可以说随处可见,特别是在电商系统中。



但就是这样一个简单的分类树查询功能,我们却优化了5次。


到底是怎么回事呢?


背景


我们的网站使用了SpringBoot推荐的模板引擎:Thymeleaf,进行动态渲染。


它是一个XML/XHTML/HTML5模板引擎,可用于Web与非Web环境中的应用开发。


它提供了一个用于整合SpringMVC的可选模块,在应用开发中,我们可以使用Thymeleaf来完全代替JSP或其他模板引擎,如Velocity\FreeMarker等。


前端开发写好Thymeleaf的模板文件,调用后端接口获取数据,进行动态绑定,就能把想要的内容展示给用户。


由于当时这个是从0-1的新项目,为了开快速开发功能,我们第一版接口,直接从数据库中查询分类数据,组装成分类树,然后返回给前端。


通过这种方式,简化了数据流程,快速把整个页面功能调通了。


第1次优化


我们将该接口部署到dev环境,刚开始没啥问题。


随着开发人员添加的分类越来越多,很快就暴露出性能瓶颈。


我们不得不做优化了。


我们第一个想到的是:加Redis缓存


流程图如下:

于是暂时这样优化了一下:



  1. 用户访问接口获取分类树时,先从Redis中查询数据。

  2. 如果Redis中有数据,则直接数据。

  3. 如果Redis中没有数据,则再从数据库中查询数据,拼接成分类树返回。

  4. 将从数据库中查到的分类树的数据,保存到Redis中,设置过期时间5分钟。

  5. 将分类树返回给用户。


我们在Redis中定义一个了key,value是一个分类树的json格式转换成了字符串,使用简单的key/value形式保存数据。


经过这样优化之后,dev环境的联调和自测顺利完成了。


第2次优化


我们将这个功能部署到st环境了。


刚开始测试同学没有发现什么问题,但随着后面不断地深入测试,隔一段时间就出现一次首页访问很慢的情况。


于是,我们马上进行了第2次优化。


我们决定使用Job定期异步更新分类树到Redis中,在系统上线之前,会先生成一份数据。


当然为了保险起见,防止Redis在哪条突然挂了,之前分类树同步写入Redis的逻辑还是保留。


于是,流程图改成了这样:

增加了一个job每隔5分钟执行一次,从数据库中查询分类数据,封装成分类树,更新到Redis缓存中。


其他的流程保持不变。


此外,Redis的过期时间之前设置的5分钟,现在要改成永久。


通过这次优化之后,st环境就没有再出现过分类树查询的性能问题了。


第3次优化


测试了一段时间之后,整个网站的功能快要上线了。


为了保险起见,我们需要对网站首页做一次压力测试。


果然测出问题了,网站首页最大的qps是100多,最后发现是每次都从Redis获取分类树导致的网站首页的性能瓶颈。


我们需要做第3次优化。


该怎么优化呢?


答:加内存缓存。


如果加了内存缓存,就需要考虑数据一致性问题。


内存缓存是保存在服务器节点上的,不同的服务器节点更新的频率可能有点差异,这样可能会导致数据的不一致性。


但分类本身是更新频率比较低的数据,对于用户来说不太敏感,即使在短时间内,用户看到的分类树有些差异,也不会对用户造成太大的影响。


因此,分类树这种业务场景,是可以使用内存缓存的。


于是,我们使用了Spring推荐的caffine作为内存缓存。


改造后的流程图如下:



  1. 用户访问接口时改成先从本地缓存分类数查询数据。

  2. 如果本地缓存有,则直接返回。

  3. 如果本地缓存没有,则从Redis中查询数据。

  4. 如果Redis中有数据,则将数据更新到本地缓存中,然后返回数据。

  5. 如果Redis中也没有数据(说明Redis挂了),则从数据库中查询数据,更新到Redis中(万一Redis恢复了呢),然后更新到本地缓存中,返回返回数据。



需要注意的是,需要改本地缓存设置一个过期时间,这里设置的5分钟,不然的话,没办法获取新的数据。



这样优化之后,再次做网站首页的压力测试,qps提升到了500多,满足上线要求。
最近我建了新的技术交流群,打算将它打造成高质量的活跃群,欢迎小伙伴们加入。


我以往的技术群里技术氛围非常不错,大佬很多。


image.png


加微信:su_san_java,备注:加群,即可加入该群。


第4次优化


之后,这个功能顺利上线了。


使用了很长一段时间没有出现问题。


两年后的某一天,有用户反馈说,网站首页有点慢。


我们排查了一下原因发现,分类树的数据太多了,一次性返回了上万个分类。


原来在系统上线的这两年多的时间内,运营同学在系统后台增加了很多分类。


我们需要做第4次优化。


这时要如何优化呢?


限制分类树的数量?


答:也不太现实,目前这个业务场景就是有这么多分类,不能让用户选择不到他想要的分类吧?


这时我们想到最快的办法是开启nginxGZip功能。


让数据在传输之前,先压缩一下,然后进行传输,在用户浏览器中,自动解压,将真实的分类树数据展示给用户。


之前调用接口返回的分类树有1MB的大小,优化之后,接口返回的分类树的大小是100Kb,一下子缩小了10倍。


这样简单的优化之后,性能提升了一些。


第5次优化


经过上面优化之后,用户很长一段时间都没有反馈性能问题。


但有一天公司同事在排查Redis中大key的时候,揪出了分类树。之前的分类树使用key/value的结构保存数据的。


我们不得不做第5次优化。


为了优化在Redis中存储数据的大小,我们首先需要对数据进行瘦身。


只保存需要用到的字段。


例如:


@AllArgsConstructor
@Data
public class Category {

private Long id;
private String name;
private Long parentId;
private Date inDate;
private Long inUserId;
private String inUserName;
private List children;
}

像这个分类对象中inDate、inUserId和inUserName字段是可以不用保存的。


修改自动名称。


例如:


@AllArgsConstructor
@Data
public class Category {
/**
* 分类编号
*/

@JsonProperty("i")
private Long id;

/**
* 分类层级
*/

@JsonProperty("l")
private Integer level;

/**
* 分类名称
*/

@JsonProperty("n")
private String name;

/**
* 父分类编号
*/

@JsonProperty("p")
private Long parentId;

/**
* 子分类列表
*/

@JsonProperty("c")
private List children;
}

由于在一万多条数据中,每条数据的字段名称是固定的,他们的重复率太高了。


由此,可以在json序列化时,改成一个简短的名称,以便于返回更少的数据大小。


这还不够,需要对存储的数据做压缩。


之前在Redis中保存的key/value,其中的value是json格式的字符串。


其实RedisTemplate支持,value保存byte数组


先将json字符串数据用GZip工具类压缩成byte数组,然后保存到Redis中。


再获取数据时,将byte数组转换成json字符串,然后再转换成分类树。


这样优化之后,保存到Redis中的分类树的数据大小,一下子减少了10倍,Redis的大key问题被解决了。




作者:苏三说技术
来源:juejin.cn/post/7233012756315963452
收起阅读 »

听说你会架构设计?来,弄一个红包系统

大家好,我是小❤,一个漂泊江湖多年的 985 非科班程序员,曾混迹于国企、互联网大厂和创业公司的后台开发攻城狮。 1. 引言 当我那天拿着手机,正在和朋友们的微信群里畅聊着八卦新闻和即将到来的周末计划时,忽然一条带着喜意的消息扑面而来,消息正中间赫然写着八个大...
继续阅读 »

大家好,我是小❤,一个漂泊江湖多年的 985 非科班程序员,曾混迹于国企、互联网大厂和创业公司的后台开发攻城狮。


1. 引言


当我那天拿着手机,正在和朋友们的微信群里畅聊着八卦新闻和即将到来的周末计划时,忽然一条带着喜意的消息扑面而来,消息正中间赫然写着八个大字:恭喜发财,大吉大利



抢红包!!相信大部分人对此都不陌生,自 2015 年春节以来,微信就新增了各类型抢红包功能,吸引了数以亿万级的用户参与体验,今天,我们就来聊一聊这个奇妙有趣的红包系统。


2. 概要设计


2.1 系统特点



抢红包系统从功能拆分,可以分为包红包、发红包、抢红包和拆红包 4 个功能。


对于系统特性来说,抢红包系统和秒杀系统类似。



每次发红包都是一次商品秒杀流程,包括商品准备,商品上架,查库存、减库存,以及秒杀开始,最终的用户转账就是红包到账的过程。


2.2 难点


相比秒杀活动,微信发红包系统的用户量更大,设计更加复杂,需要重视的点更多,主要包括以下几点。


1、高并发


海量并发请求,秒杀只有一次活动,但红包可能同一时刻有几十万个秒杀活动。


比如 2017 鸡年除夕,微信红包抢红包用户数高达 3.42 亿,收发峰值 76 万/秒,发红包 37.77 亿 个。


2、安全性要求


红包业务涉及资金交易,所以一定不能出现超卖、少卖的情况。



  • 超卖:发了 10 块钱,结果抢到了 11 块钱,多的钱只能系统补上,如此为爱发电应用估计早就下架了;

  • 少卖:发了 10 块钱,只抢了 9 块,多的钱得原封不动地退还用户,否则第二天就接到法院传单了。


3、严格事务


参与用户越多,并发 DB 请求越大,数据越容易出现事务问题,所以系统得做好事务一致性


这也是一般秒杀活动的难点所在,而且抢红包系统涉及金钱交易,所以事务级别要求更高,不能出现脏数据


3. 概要设计


3.1 功能说明


抢红包功能允许用户在群聊中发送任意个数和金额的红包,群成员可以抢到随机金额的红包,但要保证每个用户的红包金额不小于 0.01 元



抢红包的详细交互流程如下:



  1. 用户接收到抢红包通知,点击通知打开群聊页面;

  2. 用户点击抢红包,后台服务验证用户资格,确保用户尚未领取过此红包;

  3. 若用户资格验证通过,后台服务分配红包金额并存储领取记录;

  4. 用户在微信群中看到领取金额,红包状态更新为“已领取”;

  5. 异步调用支付接口,将红包金额更新到钱包里。


3.2 数据库设计


红包表 redpack 的字段如下:



  • id: 主键,红包ID

  • userId: 发红包的用户ID

  • totalAmount: 总金额

  • surplusAmount: 剩余金额

  • total: 红包总数

  • surplusTotal: 剩余红包总数


该表用来记录用户发了多少红包,以及需要维护的剩余金额。


红包记录表 redpack_record 如下:



  • id: 主键,记录ID

  • redpackId: 红包ID,外键

  • userId: 用户ID

  • amount: 抢到的金额


记录表用来存放用户具体抢到的红包信息,也是红包表的副表。


3.3 发红包



  1. 用户设置红包的总金额和个数后,在红包表中增加一条数据,开始发红包;

  2. 为了保证实时性和抢红包的效率,在 Redis 中增加一条记录,存储红包 ID 和总人数 n``;

  3. 抢红包消息推送给所有群成员。


3.4 抢红包


从 2015 年起,微信红包的抢红包和拆红包就分离了,用户点击抢红包后需要进行两次操作。


这也是为什么明明有时候抢到了红包,点开后却发现该红包已经被领取完了



抢红包的交互步骤如下:



  1. 抢红包:抢操作在 Redis 缓存层完成,通过原子递减的操作来更新红包个数,个数递减为 0 后就说明抢光了。

  2. 拆红包:拆红包时,首先会实时计算金额,一般是通过二倍均值法实现(即 0.01 到剩余平均值的 2 倍之间)。

  3. 红包记录:用户获取红包金额后,通过数据库的事务操作累加已经领取的个数和金额,并更新红包表和记录表。

  4. 转账:为了提升效率,最终的转账为异步操作,这也是为什么在春节期间,红包领取后不能立即在余额中看到的原因。


上述流程,在一般的秒杀活动中随处可见,但是,红包系统真的有这么简单吗?


当用户量过大时,高并发下的事务一致性怎么保证,数据分流如何处理,红包的数额分配又是怎么做的,接下来我们一一探讨。


4. 详细设计


由于是秒杀类设计,以及 money 分发,所以我们重点关注抢红包时的高并发解决方案和红包分配算法。


4.1 高并发解决方案


首先,抢红包系统的用户量很大,如果几千万甚至亿万用户同时在线发抢红包,请求直接打到数据库,必然会导致后端服务过载甚至崩溃。


而在这种业务量下,简单地对数据库进行扩容不仅会让成本消耗剧增,另一方面由于存在磁盘的性能瓶颈,所以大概率解决不了问题。


所以,我们将解决方案集中在 减轻系统压力、提升响应速度 上,接下来会从缓存、加锁、异步分治等方案来探讨可行性。


1、缓存


和大多数秒杀系统设计相似,由于抢红包时并发很高,如果直接操作 DB 里的数据表,可能触发 DB 锁的逻辑,导致响应不及时。



所以,我们可以在 DB 落盘之前加一层缓存,先限制住流量,再处理红包订单的数据更新。


这样做的优点是用缓存操作替代了磁盘操作,提升了并发性能,这在一般的小型秒杀活动中非常有效!


但是,随着微信使用发&抢红包的用户量增多,系统压力增大,各种连锁反应产生后,数据一致性的问题逐渐暴露出来:



  • 假设库存减少的内存操作成功,但是 DB 持久化失败了,会出现红包少发的问题;

  • 如果库存操作失败,DB 持久化成功,又可能会出现红包超发的问题。


而且在几十万的并发下,直接对业务加锁也是不现实的,即便是乐观锁。


2、加锁


在关系型 DB 里,有两种并发控制方法:分为乐观锁(又叫乐观并发控制,Optimistic Concurrency Control,缩写 “OCC”)和悲观锁(又叫悲观并发,Pessimistic Concurrency Control,缩写“PCC”)。



悲观锁在操作数据时比较悲观,认为别的事务可能会同时修改数据,所以每次操作数据时会先把数据锁住,直到操作完成


乐观锁正好相反,这种策略主打一个“信任”的思想,认为事务之间的数据竞争很小,所以在操作数据时不会加锁,直到所有操作都完成到提交时才去检查是否有事务更新(通常是通过版本号来判断),如果没有则提交,否则进行回滚。


在高并发场景下,由于数据操作的请求很多,所以乐观锁的吞吐量更大一些。但是从业务来看,可能会带来一些额外的问题:



  1. 抢红包时大量用户涌入,但只有一个可以成功,其它的都会失败并给用户报错,导致用户体验极差;

  2. 抢红包时,如果第一时间有很多用户涌入,都失败回滚了。过一段时间并发减小后,反而让手慢的用户抢到了红包

  3. 大量无效的更新请求和事务回滚,可能给 DB 造成额外的压力,拖慢处理性能。


总的来说,乐观锁适用于数据竞争小,冲突较少的业务场景,而悲观锁也不适用于高并发场景的数据更新。


因此对于抢红包系统来说,加锁是非常不适合的。


3、异步分治


综上所述,抢红包时不仅要解决高并发问题、还得保障并发的顺序性,所以我们考虑从队列的角度来设计。


我们知道,每次包红包、发红包、抢红包时,也有先后依赖关系,因此我们可以将红包 ID 作为一个唯一 Key,将发一次红包看作一个单独的 set,各个 set 相互独立处理



这样,我们就把海量的抢红包系统分成一个个的小型秒杀系统,在调度处理中,通过对红包 ID 哈希取模,将一个个请求打到多台服务器上解耦处理。


然后,为了保证每个用户抢红包的先后顺序,我们把一个红包相关的操作串行起来,放到一个队列里面,依次消费。


从上述 set 分流我们可以看出,一台服务器可能会同时处理多个红包的操作,所以,为了保证消费者处理 DB 不被高并发打崩,我们还需要在消费队列时用缓存来限制并发消费数量


抢红包业务消费时由于不存储数据,只是用缓存来控制并发。所以我们可以选用大数据量下性能更好的 Memcached。


除此之外,在数据存储上,我们可以用红包 ID 进行哈希分表,用时间维度对 DB 进行冷热分离,以此来提升单 set 的处理性能。


综上所述,抢红包系统在解决高并发问题上采用了 set 分治、串行化队列、双维度分库分表 等方案,使得单组 DB 的并发性能得到了有效提升,在应对数亿级用户请求时取得了良好的效果。


4.2 红包分配算法


抢红包后,我们需要进行拆红包,接下来我们讨论一下红包系统的红包分配算法。


红包金额分配时,由于是随机分配,所以有两种实现方案:实时拆分和预先生成。


1、实时拆分


实时拆分,指的是在抢红包时实时计算每个红包的金额,以实现红包的拆分过程。


这个对系统性能和拆分算法要求较高,例如拆分过程要一直保证后续待拆分红包的金额不能为空,不容易做到拆分的红包金额服从正态分布规律。


2、预先生成


预先生成,指的是在红包开抢之前已经完成了红包的金额拆分,抢红包时只是依次取出拆分好的红包金额。


这种方式对拆分算法要求较低,可以拆分出随机性很好的红包金额,但通常需要结合队列使用。


3、二倍均值法


综合上述优缺点考虑,以及微信群聊中的人数不多(目前最高 500 人),所以我们采用实时拆分的方式,用二倍均值法来生成随机红包,只满足随机即可,不需要正态分布。



故可能出现很大的红包差额,但这更刺激不是吗🐶



使用二倍均值法生成的随机数,每次随机金额会在 0.01 ~ 剩余平均值*2 之间。


假设当前红包剩余金额为 10 元,剩余个数为 5,10/5 = 2,则当前用户可以抢到的红包金额为:0.01 ~ 4 元之间。


4、算法优化


用二倍均值法生成的随机红包虽然接近平均值,但是在实际场景下:微信红包金额的随机性和领取的顺序有关系,尤其是金额不高的情况下


于是,小❤耗费 “巨资” 在微信群发了多个红包,得出了这样一个结论:如果发出的 红包总额 = 红包数*0.01 + 0.01,比如:发了 4 个红包,总额为 0.05,则最后一个人领取的红包金额一定是 0.02



无一例外:



所以,红包金额算法大概率不是随机分配,而是在派发红包之前已经做了处理。比如在红包金额生成前,先生成一个不存在的红包,这个红包的总额为 0.01 * 红包总数


而在红包金额分配的时候,会对每个红包的随机值基础上加上 0.01,以此来保证每个红包的最小值不为 0。


所以,假设用户发了总额为 0.04 的个数为 3 的红包时,需要先提取 3*0.01 到 "第四个" 不存在的红包里面,于是第一个人抢到的红包随机值是 0 ~ (0.04-3*0.01)/3


由于担心红包超额,所以除数的商是向下取二位小数,0 ~ (0.04-3*0.01)/3 ==> (0 ~ 0) = 0,再加上之前提取的保底值 0.01,于是前两个抢到的红包金额都是 0.01。最后一个红包的金额为红包余额,即 0.02


算法逻辑用 Go 语言实现如下:


    import (
   "fmt"
   "math"
   "math/rand"
   "strconv"
)

type RedPack struct {
    SurplusAmount float64 // 剩余金额
     SurplusTotal int // 红包剩余个数
}

// 取两位小数
func remainTwoDecimal(num float64) float64 {
    numStr := strconv.FormatFloat(num, 'f'264)
    num, _ = strconv.ParseFloat(numStr, 64)
    return num
}

// 获取随机金额的红包
func getRandomRedPack(rp *RedPack) float64 {
    if rp.SurplusTotal <= 0 {
        // 该红包已经被抢完了
        return 0
    }

    if rp.SurplusTotal == 1 {
        return remainTwoDecimal(rp.SurplusAmount + 0.01)
    }

       // 向下取整
    avgAmount := math.Floor(100*(rp.SurplusAmount/float64(rp.SurplusTotal))) / float64(100)
    avgAmount = remainTwoDecimal(avgAmount)

       // 生成随机数种子
    rand.NewSource(time.Now().UnixNano())

    var max float64
    if avgAmount > 0 {
        max = 2*avgAmount - 0.01
    } else {
        max = 0
    }
    money := remainTwoDecimal(rand.Float64()*(max) + 0.01)

    rp.SurplusTotal -= 1
    rp.SurplusAmount = remainTwoDecimal(rp.SurplusAmount + 0.01 - money)

    return money
}

// 实现主函数
func main() {
    rp := &RedPack{
        SurplusAmount: 0.06,
        SurplusTotal:  5,
    }
    rp.SurplusAmount -= 0.01 * float64(rp.SurplusTotal)
    total := rp.SurplusTotal
    for i := 0; i < total; i++ {
        fmt.Println(getRandomRedPack(rp))
    }
}

打印结果:



0.01、0.01、0.01、0.01、0.02



喜大普奔,符合预期!


5. 总结


设计一个红包系统不仅要考虑海量用户的并发体验和数据一致性,还得保障用户资金的安全


这种技术难点,对于传统的 “秒杀系统” 有过之而无不及。


本文主要探讨了高并发场景下的设计方案和红包分配的算法,覆盖了目前红包系统常见的几大难题。




作者:xin猿意码
来源:juejin.cn/post/7312352501406908452
收起阅读 »

代码要同时推送到 gitee 和 github 该怎么办?教你两招!

前言 我们作为特色社会主义社会中的一份子,跟国外程序员不一样的地方在于,别人能轻松访问 github, 我们却要借梯子,或者用国产的 gitee。 作为有追求的程序员,作为成年人,当然是两个我都要! 那么如何优雅地把本地代码同时维护到 gitee 和 gith...
继续阅读 »

前言


我们作为特色社会主义社会中的一份子,跟国外程序员不一样的地方在于,别人能轻松访问 github, 我们却要借梯子,或者用国产的 gitee


作为有追求的程序员,作为成年人,当然是两个我都要!


那么如何优雅地把本地代码同时维护到 giteegithub上呢?这里带给大家2个方法。


push 2个 remote original


假如我们在 github 创建了一个仓库,那么本地 clone 下来后,主分支(main)是默认跟github 仓库的主分支(main) 关联的。这样直接在 VSCode 里面点 同步的圈圈 就会自动同步,如下图。


image.png


现在我又要推到 gitee,怎么办,很简单,新增一个 remote 源,并命名为 gitee,默认的 origin 已经跟 github 关联了。


git remote add gitee 

这样我们就有2个源,在 VSCodeGit Graph 中可以直接 push 到2个源中,2个都勾选上。


image.png


点击 Yes, push 就可以推上去了。爽歪歪


gitee 仓库镜像管理:gitee -> github


本来上面的方法用得好好的,但是我的梯子质量不好,时不时推不上 github ( gitee 倒是轻轻松松,没有失败过),然后我又得重新 push,有的时候要重试好多次才行,非常浪费时间!!


然后我就发现 gitee 原来是可以同步推到 github 的,根本就不用我们手动操作,点赞!


image.png


官方链接在这里 仓库镜像管理(Gitee<->Github 双向同步),我就不做搬运工了,给几个图:


image.png


image.png


其中还需要到 github 获取 token,方法如下:


image.png


官方链接在这里 如何申请 GitHub 私人令牌?


我设置成功如下图:


image.png



token生成的时候要设置过期时间,尽量设置长一点比如一年。



image.png


推到 gitee 的代码会自动同步到 github, 我们也可以手动点右侧的 更新 按钮,手动同步。


经我检测,是成功的,刚刚推不上去 github 的代码,通过 gitee 同步过去了,本地也能看到代码是同步的。


image.png



有一个需要注意的是,你只能推到你自己的github仓库,不能推到你的github组织的仓库。



以我的仓库为例:



因为选推送的目标仓库时压根就不能选组织,只能选个人!但是源仓库可以是个人的或者组织的。


总结


本文介绍了2种同步 gitee 和 github 仓库的方法,视情况选择:



  • 当目标仓库是个人时,第二种会比较方便,推上到 gitee 后,会自动同步到 github

  • 当目标仓库是组织时,不能用第二种,只能用第一种,自己手动 push 到2个仓库,需要你的梯子质量好,不然可能推不上 github


最后把我每天都看的美女分享给大家~养眼啊


pretty-girl.png


作者:菲鸽
来源:juejin.cn/post/7327353620232339506
收起阅读 »