开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

当Transactional碰到锁,有个大坑,要小心。

发表于 2021-08-24

你好呀,我是why。

前几天在某平台看到一个技术问题,很有意思啊。

涉及到的两个技术点,大家平时开发使用的也比较多,但是属于一个小细节,深挖下去,还是有点意思的。

来,先带你看一下问题是什么,同时给你解读一下这个问题:

首先,这位同学给出了一个代码片段:

他说他有一个 func 方法,这个方法里面干了两件事:

  • 1.先查询数据库里面的商品库存。
  • 2.如果还有库存,那么对库存进行减一操作,模拟商品卖出。

对于第二件事,提问的同学其实写了两个操作在里面,所以我再细分一下:

  • 2.1 对库存进行减一操作。
  • 2.2 在订单表插入订单数据。

很显然,这两个操作都会对数据库进行操作,且应该是应该原子性的操作。

所以,在方法上加了一个 @Transactional 注解。

接着,为了解决并发访问的问题,他用 lock 把整个代码包裹了起来,保证在单体结构下,同一时刻只有一个请求能去执行减少库存,生成订单的操作。

非常的完美。

首先,先把大前提申明一下:MySQL 数据库的隔离机制使用的是可重复读级别。

这个时候,问题就来了。

如果是高并发的情况下,假设真的就有多个线程同时调用 func 方法。

要保证一定不能出现超卖的情况,那么就需要事务的开启与提交能完整的包裹在 lock 与 unlock之间。

显然事务的开启一定是在 lock 之后的。

故关键在于事务的提交是否一定在 unlock 之前?

如果事务的提交在 unlock 之前,没有问题。

因为事务已经提交了,代表库存一定减下来了,而这个时候锁还没释放,所以,其他线程也进不来。

画个简单的示意图如下:

等 unlock 之后,再进来一个线程,执行查询数据库的操作,那么查询到的值一定是减去库存之后的值。

但是,如果事务的提交是在 unlock 之后,那么有意思的事情就出现了,你很有可能发生超卖的情况。

上面的图就变成了这样的了,注意最后两个步骤调换了:

举个例子。

假设现在库存就只有一个了。

这个时候 A,B 两个线程来请求下单。

A 请求先拿到锁,然后查询出库存为一,可以下单,走了下单流程,把库存减为 0 了。

但是由于 A 先执行了 unlock 操作,释放了锁。

B 线程看到后马上就冲过来拿到了锁,并执行了查询库存的操作。

注意了,这个时候 A 线程还没来得及提交事务,所以 B 读取到的库存还是 1,如果程序没有做好控制,也走了下单流程。

哦豁,超卖了。

所以,再次重申问题:

在上面的示例代码的情况下,如果事务的提交在 unlock 之前,是没有问题的。但是如果在 unlock 之后是会有问题的。

那么事务的提交到底是在 unlock 之前还是之后呢?

这个事情,先把问题听懂了,接着我们先按下不表。你可以简单的思考一下。

我想先聊聊这句被我轻描淡写,一笔带过,你大概率没有注意到的话:

显然事务的开启一定是在 lock 之后的。

这句话,不是我说的,是提问的同学说的:

你有没有一丝丝疑问?

怎么就显然了?哪里就显然了?为什么不是一进入方法就开启事务了?

请给我证据。

来吧,瞅一眼证据。

事务开启时机

证据,我们需要去源码里面找。

另外,我不得不多说一句 Spring 在事务这块的源码写的非常的清晰易懂,看起来基本上没有什么障碍。

所以如果你不知道怎么去啃源码,那么事务这块源码,也许是你撕开源码的一个口子。

好了,不多说了,去找答案。

答案就藏在这个方法里面的:

org.springframework.jdbc.datasource.DataSourceTransactionManager#doBegin

先看我下面框起来的那一行日志:

Switching JDBC Connection [HikariProxyConnection@946359486 wrapping com.mysql.jdbc.JDBC4Connection@7a24806] to manual commit

你知道的,我是个技术博主,偶尔教点单词。

Switching,转换。

Connection,链接。

manual commit,手动提交。

Switching … to …,把什么转换为什么。

没想到吧,这次学技术的同时不仅学了几个单词,还会了一个语法。

所以,上面那句话翻译过来就非常简单了:

把数据库连接切换为手动提交。

然后,我们看一下打印这行日志的代码逻辑,也就是被框起来的代码部分。

我单独拿出来:

逻辑非常清晰,就是把连接的 AutoCommit 参数从 ture 修改为 false。

那么现在问题就来了,这个时候,事务启动了吗?

我觉得没启动,只是就绪了而已。

启动和就绪还是有一点点差异的,就绪是启动之前的步骤。

那么事务的启动有哪些方式呢?

  • 第一种:使用启动事务的语句,这种是显式的启动事务。比如 begin 或 start transaction 语句。与之配套的提交语句是 commit,回滚语句是 rollback。
  • 第二种:autocommit 的值默认是 1,含义是事务的自动提交是开启的。如果我们执行 set autocommit=0,这个命令会将这个线程的自动提交关掉。意味着如果你只执行一个 select 语句,这个事务就启动了,而且并不会自动提交。这个事务持续存在直到你主动执行 commit 或 rollback 语句,或者断开连接。

很显然,在 Spring 里面采用的是第二种方式。

而上面的代码 con.setAutoCommit(false) 只是把这个链接的自动提交关掉。

事务真正启动的时机是什么时候呢?

前面说的 begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作 InnoDB 表的语句,事务才算是真正启动。

如果你想要马上启动一个事务,可以使用 start transaction with consistent snapshot 这个命令。需要注意的是这个命令在读已提交的隔离级别(RC)下是没意义的,和直接使用 start transaction 一个效果。

回到在前面的问题:什么时候才会执行第一个 SQL 语句?

就是在 lock 代码之后。

所以,显然事务的开启一定是在 lock 之后的。

这一个简单的“显然”,先给大家铺垫一下。

接下来,给大家上给动图看一眼,更加直观。

首先说一下这个 SQL:

select * from information_schema.innodb_trx;

不多解释,你只要知道这是查询当前数据库有哪些事务正在执行的语句就行。

你就注意看下面的动图,是不是第 27 行查询语句执行完成之后,查询事务的语句才能查出数据,说明事务这才真正的开启:

最后,我们把目光转移到这个方法的注释上:

写这么长一段注释,意思就是给你说,这个参数我们默认是 ture,原因就是在某些 JDBC 的驱动中,切换为自动提交是一个很重的操作。

那么在哪设置的为 true 呢?

没看到代码,我一般是不死心的。

所以,一起去看一眼。

setAutoCommit 这个方法有好几个实现类,我也不知道具体会走哪一个:

所以,我们可以在下面这个接口打上一个断点:

java.sql.Connection#setAutoCommit

然后重启程序,IDE 会自动帮你判断走那个实现类的:

可以看到,默认确实是 true。

等等,你不会真的以为我是想让你看这个 true 吧?

我是想让你知道这个调试技巧啊。

不知道有多少个小伙伴曾经问过我:这个接口实现类好多啊,我怎么知道在哪打断点啊?

我说:很简单啊,就在每个实现类的第一行代码打上断点就好了。

然后他说:别闹,我经常给你的文章一键三联。

我当时就被感动了,既然是这样的好读者,我当然把可以直接在接口上打断点的这个小技巧教给他啦。

好了,不扯远了。

再说一个小细节,这一小节就收尾。

你再去看这小节的开头,我直接说答案藏在这个方法里面:

org.springframework.jdbc.datasource.DataSourceTransactionManager#doBegin

直接把答案告诉你了,隐去了探索的过程。

但是这个东西,就像是数学公式推导一样,省略了一步,就会让人看起来一脸懵逼。

就像下面这个小耗子一样:

所以,我是怎么知道在这个地方打断点的呢?

答案就是调用栈。

先给大家看一下我的代码:

啥也先不管,上来就先在 26 行,方法入口处打上断点,跑起来:

诶,你看这个调用栈,我框起来的这个地方:

看这个名字,你就不好奇吗?

它简直就是在跳着脚,在喊你:点我,快,愣着干啥,你TM快点我啊。我这里有秘密!

然后,我就这样轻轻的一点,就到了这里:

org.springframework.transaction.interceptor.TransactionAspectSupport#invokeWithinTransaction

这里有个切面,可以理解为 try 里面就是在执行我们的业务代码逻辑:

而在 try 代码块,执行我们的业务代码之前,有这样的一行代码:

找到这里了,你就在这一行代码之前,再轻轻的打个断点,然后调试进去,就能找到这一小节开始的时候,说的这个方法:

org.springframework.jdbc.datasource.DataSourceTransactionManager#doBegin

不信?你看嘛,我不骗你。

它们之间只隔了三个调用:

这样就找到答案了。

调用栈,另一个调试源码小技巧,屡试不爽,送给你。

之前还是之后

好了,前面是开胃菜,可能有的同学吃开胃菜就已经弄饱了。

没事,现在上正餐,再按一按还是能吃进去的。

还是拿前面的这份代码来说事,流程就是这样的:

  • 1.先拿锁。
  • 2.查询库存。
  • 3.判断是否还有库存。
  • 4.有库存则执行减库存,创建订单的逻辑。
  • 5.没有库存则返回。
  • 6.释放锁。

所以代码是这样的:

完全符合我们之前的那份代码片段,有事务,也有锁:

回到我们最开始抛出来的问题:

在上面的示例代码的情况下那么事务的提交到底是在 unlock 之前还是之后呢?

我们可以带入一个具体的场景。

比如我数据库里面有 10 个顶配版的 iPad,原价 1.6w 元一台,现在单价 1w 一个,这个价格够秒杀吧?

反正一共就 10 台,所以,我的数据库里面是这样的,

然后我搞 100 个人来抢东西,不过分吧?

我这里用 CountDownLatch 来模拟一下并发:

执行一下,先看结果,立马就见分晓:

动图右边的部分:

上面是浏览器请求,触发 Controller 的代码。

然后中间是产品表,有 10 个库存。

最下面是订单表,没有一条数据。

触发了代码之后,库存为 0 了,没有问题。

但是,订单居然有 20 笔!

也就是说超卖了 10 个ipad pro 顶配版!

超卖的,可不在活动预算范围内啊!

那可就是一个 1.6w 啊,10 个就是 16w 啊。

就这么其貌不扬,人畜无害,甚至看起来猥猥琐琐的代码,居然让我亏了整整 16w 。

其实,结果出现了,答案也就随之而来了。

在上面的示例代码的情况下,事务的提交在 unlock 之后。

其实你仔细分析后,猜也能猜出来,肯定是在 unlock 之后的。

而且上面的描述“unlock之后”其实是有一定的迷惑性的,因为释放锁是一个比较特别的操作。

换一个描述,就比较好理解了:

在上面的示例代码的情况下,事务的提交在方法运行结束之后。

你细品,这个描述是不是迷惑性就没有那么强了,甚至你还会恍然大悟:这不是常识吗?

为什么是方法结束之后,分析具体原因之前,我想先简单分析一下这样的代码写出来的原因。

我猜可能是这样的。

最开始的代码结构是这样:

然后,写着写着发现不对,并发的场景下,库存是一个共享的资源,这玩意得加锁啊。

于是搞了这出:

后面再次审查代码的时候,发现:哟,这个第三步得是一个事务操作才行呀。

于是代码就成了这样:

演进路线非常合理,最终的代码看起来也简直毫无破绽。

但是问题到底出在哪里了呢?

找答案

答案还是在这个类里面:

org.springframework.transaction.interceptor.TransactionAspectSupport#invokeWithinTransaction

前面我们聊事务开启的时候,说的是第 382 行代码。

然后 try 代码块里面执行的是我们的业务代码。

现在,我们要研究事务的提交了,所以主要看我框起来的地方。

首先 catch 代码块里面,392 行,看方法名称已经非常的见名知意了:

completeTransactionAfterThrowing 在抛出异常之后完成事务的提交。

你看我的代码,只是用到了 @Transactional 注解,并没有指定异常。

那么问题就来了:

Spring 管理的事务,默认回滚的异常是什么呢?

如果你不知道答案,就可以带着问题去看源码。

如果你知道答案,但是没有亲眼看到对应的代码,那么也可以去寻找源码。

如果你知道答案,也看过这部分源码,温故而知新。

先说答案:默认回滚的异常是 RuntimeException 或者 Error。

我只需要在业务代码里面抛出一个 RuntimeException 的子类,比如这样的:

然后在 392 行打上断点,开始调试就完事了:

只需要往下调试几步,你就能走到这个方法来:

org.springframework.transaction.interceptor.RuleBasedTransactionAttribute#rollbackOn

发现这个 winner 对象为空,接着走了这个逻辑:

return super.rollbackOn(ex);

答案就藏着这行代码的背后:

如果异常类型是 RuntimeException 或者 Error 的子类,那么就返回 true,即需要回滚,调用 rollback 方法:

如果返回为 false,则表示不需要回滚,调用 commit 方法:

那么怎么让它返回 false 呢?

很简单嘛,这样一搞就好了:

框架给你留了口子,你就把它用起来。

当我把代码改成上面那样,然后重新启动项目,再次访问代码。

我们去寻找出现指定异常不回滚的具体的实现逻辑在哪。

其实也在我们刚刚看到的方法里面:

你看,这个时候 winner 不为 null 了。它是一个 NoRollbackRuleAttribute 对象了。

所以就走入这行代码,返回 false 了:

return !(winner instanceof NoRollbackRuleAttribute);

于是,就成功走到了 else 分支里面,出了异常也 commit 了,你说神奇不神奇:

写到这里的时候,我突然想到了一个骚操作,甚至有可能变成一道沙雕面试题:

这个操作骚不骚,到底会回滚呢还是不回滚呢?

如果你在项目里看到这样的代码肯定是要骂一句傻逼的。

但是面试官就喜欢搞这些阴间的题目。

我想到这个问题的时候,我也不知道答案是什么,但是我知道答案还是在源码里面:

首先,从结果上可以直观的看到,经过 for 循环之后, winner 是 RollbackRuleAttribute 对象,所以下面的代码返回 true,需要回滚:

return !(winner instanceof NoRollbackRuleAttribute);

问题就变成了 winner 为什么经过 for 循环之后是 RollbackRuleAttribute?

答案需要你自己去调试一下,很容易就明白了,我描述起来比较费劲。

简单一句话:导致 winner 是 RollbackRuleAttribute 的原因,就是因为被循环的这个 list 是先把 RollbackRuleAttribute 对象 add 了进去。

那么为什么 RollbackRuleAttribute 对象先加入到集合呢?

org.springframework.transaction.annotation.SpringTransactionAnnotationParser#parseTransactionAnnotation(org.springframework.core.annotation.AnnotationAttributes)

别问,问就是因为代码是这样写的。

为什么代码要这样写呢?

我想可能设计这块代码的开发人员觉得 rollbackFor 的优先级比 noRollbackFor 高吧。

再来一个问题:

Spring 源码怎么匹配当前这个异常是需要回滚的?

别想那么复杂,大道至简,直接递归,然后一层层的找父类,对比名称就完事了。

你注意截图里面的注释:

一个是 Found it!

表示找到了,匹配上了,用了感叹号表示很开心。

一个是 If we’ve gone as far as we can go and haven’t found it…

啥意思呢,这个 as far as 在英语里面是一个连词,表示“直到..为止..”的意思。引导的是状语从句,强调的是程度或范围。

所以,上面这句话的意思就是:

如果我们已经走到我们能走的最远的地方,还没匹配上,代码就只能这样写了:

异常类,最远的地方就是 Throwable.class。没匹配上,就返回 -1。

好了,通过两个没啥卵用的知识点,顺带学了点实战英语,关于业务代码出了异常回滚还是提交这一块的代码就差不多了。

但是我还是建议大家亲自去 Debug 一下,可太有意思了。

然后我们接着聊正常场景下的提交。

这个代码块里面,try 我们也聊了,catch 我们也聊了。

就差个 finally 了。

我看网上有的文章说 finally 里面就是 commit 的地方。

错了啊,老弟。

这里只是把数据库连接给重置一下。

方法上已经给你说的很清楚了:

Spring 的事务是基于 ThreadLocal 来做的。在当前的这个事务里面,可能有一些隔离级别、回滚类型、超时时间等等的个性化配置。

不管是这个事务正常返回还是出现异常,只要它完事了,就得给把这些个性化的配置全部恢复到默认配置。

所以,放到了 finally 代码块里面去执行了。

真正的 commit 的地方是这行代码:

那么问题又来了:

走到这里来了,事务一定会提交吗?

话可别说的那么绝对,兄弟,看代码:

org.springframework.transaction.support.AbstractPlatformTransactionManager#commit

在 commit 之前还有两个判断,如果事务被标记为 rollback-only 了,还是得回滚。

而且,你看日志。

我这事务还没提交呢,锁就被释放了?

接着往下看 commit 相关的逻辑,我们就会遇到老朋友:

HikariCP,SpringBoot 2.0 之后的默认连接池,强得一比,在之前的文章里面介绍过。

关于事务的提交,就不大篇幅的介绍了。

给大家指个路:

com.mysql.cj.protocol.a.NativeProtocol#sendQueryString

在这个方法的入口处打上断点:

然后你会发现很多的 SQL 都会经过这个地方。

所以,为了你顺利调试,你需要在断点上设置一下:

这样只有 SQL 语句是 commit 的时候才会停下来。

又一个调试小细节,送给你,不客气。

现在,我们知道原因了,那我现在把代码稍微变一下:

把 ReentrantLock 换成了 synchronized。

那你说这个代码还会不会有问题?

说没有问题的同学请好好反思一下。

这个地方的原理和前面讲的东西是一模一样的呀,肯定也是有问题的。

这个加锁方式就是错误的。

所以你记住了,以后面试官问你 @Transactional 的时候,你把标准答案先背一遍之后,如果你对锁这块的知识点非常的熟悉,就可以在不经意间说一下结合锁用的时候的异常场景。

别说你写的,就说你 review 代码的时候发现的,深藏功与名。

另外记得扩展一下,现在都是集群服务了,加锁得上分布式锁。

但是原理还这个原理。

既然都聊到分布式锁了,这和面试官又得大战几个回合。

是你主动提起的,把面试官引到了你的主战场,拿几分,不过分吧。

一个面试小技巧,送给你,不客气。

解决方案

现在我们知道问题的原因了。

解决方案其实都呼之欲出了嘛。

正确的使用锁,把整个事务放在锁的工作范围之内:

这样,就可以保证事务的提交一定是在 unlock 之前了。

对不对?

说对的同学,今天就先到这里,请回去等通知啊。

别被带到沟里去了呀,朋友。

你仔细想想这个事务会生效吗?

提示到这里还没想明白的同学,赶紧去搜一下事务失效的几种场景。

我这里说一个能正常使用的场景:

只是这种自己注入自己的方式,我觉得很恶心。

如果项目里面出现了这样的代码,一定是代码分层没有做好,项目结构极其混乱。

不推荐。

还可以使用编程式事务的方式去写,自己去控制事务的开启、提交、回滚。

比直接使用 @Transactional 靠谱。

除此之外,还有一个骚一点的解决方案。

其他地方都不动,就只改一下 @Transactional 这个地方:

把隔离级别串行化,再次跑测试用例,绝对不会出现超卖的情况。

甚至都不需要加锁的逻辑。

你觉得好吗?

好啥啊?

串行化性能跟不上啊!

这玩意太悲观了,对于同一行的数据,读和写的时候都会进行加锁操作。当读写锁出现冲突的时候,后面来的事务就排队等着。

这个骚操作,知道就行了,别用。

你就当是一个没啥卵用的知识点就行了。

但是,如果你们是一个不追求性能的场景,这个没有卵用的知识点就变成骚操作了。

rollback-only

前面提到了这个 rollback-only,为了更好的行文,所以我一句话就带过了,其实它也是很有故事的,单独拿一节出来简单说一下,给大家模拟一下这个场景。

以后你见到这个异常就会感觉很亲切。

Spring 的事务传播级别默认是 REQUIRED,含义是如果当前没有事务,就新建一个事务,如果上下文中已经有一个事务,则共享这个事务。

直接上代码:

这里有 sellProduct、sellProductBiz 两个事务,sellProductBiz 是内层事务,它会抛出了异常。

当执行整个逻辑的时候,会抛出这个异常:

Transaction rolled back because it has been marked as rollback-only

根据这个异常的堆栈,可以找到这个地方,在前面出现过:

所以,我们只需要分析这个 if 条件为什么满足了,就大概摸清楚脉络了。

if (!shouldCommitOnGlobalRollbackOnly() && defStatus.isGlobalRollbackOnly())

前面的 shouldCommitOnGlobalRollbackOnly 默认为 false:

问题就精简为了:defStatus.isGlobalRollbackOnly() 为什么是true?

为什么?

因为 sellProductBiz 抛出异常后,会调用 completeTransactionAfterThrowing 方法执行回滚逻辑。

肯定是这个方法里面搞事情了啊。

org.springframework.transaction.support.AbstractPlatformTransactionManager#processRollback

在这里,把链接的 rollbackOnly 置为了 true。

所以,后面的事务想要 commit 的时候,一检查这个参数,哦豁,回滚吧。

大概就是这样的:

如果这不是你期望的异常,怎么解决呢?

理解了事务的传播机制就简单的一比:

就这样,新开个事务,跑起来没毛病,互不干扰。

最后说一句

好了,看到了这里安排个关注吧,周更原创很累的,需要一点正反馈。

感谢您的阅读,我坚持原创,十分欢迎并感谢您的关注。

我是 why,你也可以叫我小歪,一个主要写代码,经常写文章,偶尔拍视频的程序猿。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Go Web 入门与实战系列:高性能 Web 框架 Gin

发表于 2021-08-24

这是我参与8月更文挑战的第6天,活动详情查看: 8月更文挑战

Web 应用程序是一种可以通过 Web 访问的应用程序,Web 程序的最大好处是用户很容易访问应用程序,用户只需要有浏览器即可,不需要再安装其他软件。Web 应用对于身处互联网时代的我们来说太普遍。无论哪一种语言,只要它能够开发出与人类交互的软件,它就必然会支持 Web 应用开发。
本系列文章将会介绍 Go Web 的应用与实践。欢迎关注。

Golang 提供的 net/http 库已经很不错了,对于 http 的协议的实现非常好,基于此再造框架,也不会是难事,因此生态中出现了很多框架,如 Gin、gorilla/mux、Echo 等。

Gin Web 框架 Gin 介绍

Gin 是 Golang 的一个微框架,封装比较优雅,API 友好,源码注释比较明确,已经发布了 1.0 版本。具有快速灵活,容错方便等特点。其实对于 Golang 而言,Web 框架的依赖要远比 Python,Java 之类的要小。自身的 net/http 足够简单,性能也非常不错。框架更像是一些常用函数或者工具的集合。借助框架开发,不仅可以省去很多常用的封装带来的时间,也有助于团队的编码风格和形成规范。下面我们将具体介绍 Gin 的使用实践。

安装

首先需要安装,比较简单,使用 go get 即可:

1
go复制代码

$ go get -u github.com/gin-gonic/gin

1
2


目前最新的版本是 v1.4。

使用方式

通过引入如下的包:

1
2
3
4
go复制代码import (
"github.com/gin-gonic/gin"
"net/http" //可选,当使用 http.StatusOK 这类的常量时需引入
)

使用 Gin 实现 HTTP 服务器

Gin 官方的文档很详细,这里提供一个 /ping 接口,请求响应一个 JSON 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
go复制代码package main

import "github.com/gin-gonic/gin"

func main() {
router := gin.Default()
router.GET("/ping", func(c *gin.Context) {
c.JSON(200, gin.H{
"message": "pong",
})
})
r.Run(:8000) // 默认监听 0.0.0.0:8080
}

我们起了一个 Web 服务器,监听 8000 端口,响应结果如下:

gin-web.jpg

简单几行代码,就能实现一个 Web 服务。使用 gin 的 Default 方法创建一个路由 handler。然后通过 HTTP 方法绑定路由规则和路由函数。不同于 net/http 库的路由函数,gin 进行了封装,把 request 和 response 都封装到 gin.Context 的上下文环境。最后是启动路由的 Run 方法监听端口。麻雀虽小,五脏俱全。当然,除了 GET 方法,gin 也支持 POST、PUT、DELETE、OPTION 等常用的 Restful 方法。

Restful 路由

gin 的路由来自 httprouter 库。因此 httprouter 具有的功能,gin 也具有,不过 gin 不支持路由正则表达式:

1
2
3
4
go复制代码	router.GET("/user/:name", func(c *gin.Context) {
name := c.Param("name")
c.String(http.StatusOK, "Hello %s", name)
})

冒号加上一个参数名组成路由参数。可以使用 c.Params 的方法读取其值。当然这个值是字符串类型。诸如 /user/aoho、/user/world 都可以被处理器匹配,而 /user、/user/ 和 /user/aoho/ 不会被匹配。

除了冒号(:),gin还提供了星号(*)处理参数,星号能匹配的规则就更多。

1
2
3
4
5
6
go复制代码        router.GET("/user/:name/*action", func(c *gin.Context) {
name := c.Param("name")
action := c.Param("action")
message := name + " is " + action
c.String(http.StatusOK, message)
})

该处理器可以匹配 /user/aoho/,也可以匹配 /user/aoho/send。如果没有其他的路由匹配 /user/aoho,请求也会转发到 /user/aoho/。

Gin 的中间件

中间件的意思就是,对一组接口的统一操作,可以把逻辑提取出来,类似于横切关注点,常用于一些记录 log,错误 handler,还有就是对部分接口的鉴权。

比如有一组 API 接口是用户登入后的操作,我们就需要在进入每个 API 接口前都进行权限的验证。有了中间件后,我们只需要创建一个中间件,权限的验证放到中间件,然后把 这个中间件绑定到那一组 API 上即可。下面就实现一个简易的鉴权中间件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
go复制代码func AuthMiddleWare() gin.HandlerFunc {
return func(c *gin.Context) {
token := c.Request.Header.Get("Authorization")
authorized := check(token) //调用认证方法
if authorized {
c.Next()
return
}
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Unauthorized",
})
c.Abort()
return
}
}

func main() {
r := gin.Default()

r.GET("/path", AuthMiddleWare(), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": "ok"})
})
}

我们定义了一个 AuthMiddleWare 中间件,中间件的功能是提测请求的头部 Authorization,将获取的 token 调用认证方法判断,是否是合法的 token。在处理器中,增加 AuthMiddleWare() 中间件即可。

小结

总的来说,Gin 是一个轻巧而强大的 Golang Web 框架,路由性能高,在各种 Web 框架中处于领先地位。Gin 框架一直是敏捷开发中的利器,能让开发者很快的上手并做出应用。

阅读最新文章,关注公众号:aoho求索

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

高效编程:在IntelliJ IDEA中使用VIM

发表于 2021-08-24

硬核干货分享,欢迎关注【Java补习课】成长的路上,我们一起前行 !

《高可用系列文章》 已收录在专栏,欢迎关注!

概述

Vim是一个功能强大、高度可定制的文本编辑器; 具体有多强大,我现在还没体会到(orz)。在日新月异的互联网时代中,vim 可以算是一个历史悠久的产物,从诞生到现在都没有别的编辑器能替代(感兴趣可了解一下 Emacs)。目前主流的VSCode、Intellij 全家桶和Chrome等,都支持/提供了 vim plugin。

学习材料

  • # Vim 从入门到精通 : Github 8k 🌟 Vim 学习教程
  • # 简明 VIM 练级攻略:陈皓 的 VIM 存活攻略
  • # 游戏:VIM 大冒险: 通过游戏的方式学习 VIM

准备工作

vim 功能强大,相信计算机行业的各位大佬都清楚。但是在我们的工作中,没有vim一样能够进行工作,能够排查问题,能过编辑页面等。

学习 vim 是一个枯燥且难熬的过程,你需要2周时间的苦练,而不是3天热度。希望大家能够坚持下来,摆脱 CV工程师 的 title,起码也得多个h,j,k,l。

附一张超清 VIM 键位图 ,原图地址:cenalulu.github.io/linux/all-v…
image.png

IDEA 安装 VIM

IntelliJ IDEA -> Perferences -> Plugins

网络不好的情况下,可在官网直接下载 Plugin: plugins.jetbrains.com/plugin/164-…

image.png

VIM 移动命令

初阶移动

  • h, j,k,l: 左,下,上,右 移动
  • 0,^,$:行首,行首字母,行尾
  • gg,G,50G:文件头,文件尾,跳转到指定行

演示操作:

h-> j->k->l->0->^->$

move.gif

进阶移动

  • w & W:w右移一个单词(标点分割), W右移一个字串(空格/换行分割)
  • b & B:b左移一个单词(标点分割), B右移一个字串(空格/换行分割)
  • { & }: 移动到段首,尾部 , 换行符分割
  • Ctrl + u/d:上/下翻页

演示操作:

w -> b -> W -> B

move2.gif

实际开发中移动操作

比如,查看41行的 initialize() 方法的实现。

41G -> 2w -> ⌘+⌥+B

move3.gif

VIM 编辑命令

选中命令

  • v:可视化模式,通过 移动命令,选中相应的内容。

select.gif

插入命令

  • i & I:i在光标字符前插入,I在行首插入。
  • a & A:a在光标字符后添加,A在行尾添加。

删除命令

  • d+motion :删除指令

常用操作:

  • v 选中后删除d
  • dd:删除当前行,并把删除的行存到剪贴板里
  • db/w:删除上/下一个单词

操作演示:
v -> d -> dd -> db -> dw -> dd

delete.gif

修改命令

  • c [number] motion:更改类操作符的工作方式跟删除类是一致。

常用指令: cw 修改当前整个单词,删除当前单词后,编辑正确内容

update.gif

  • 除了上一节所说的cw,我也可以键入c$,用来修改当前字符到行末的所有内容

撤销命令

  • u 撤销最近的一个修改动作;
  • U 撤销当前行上的所有修改。
  • ctrl + R: 重放命令- 回退撤销操作

复制粘贴命令

  • y [number] motion : 复制命令,常用的有 yy 复制一行,yw 复制下一个单词,y$ 复制到行尾。
  • [number] p : 粘贴复制内容 , 可粘贴多次 5p

copy.gif

VIM 命令模式

在普通模式下,输入:即可进入

  • s/executor/asyncExecutor/g :替换当前行所有 executor 为 asyncExecutor
  • :g/^\s*$/d: 删除空行以及只有空格的行
  • %s/#.*//g: 删除#之后的字符

宏录制

  • qa 把你的操作记录在寄存器 a。
  • 于是 @a 会replay被录制的宏。
  • @@ 是一个快捷键用来replay最新录制的宏。

示例

在一个只有一行且这一行只有“1”的文本中,键入如下命令:

  • qaYp<C-a>q→
+ `qa` 开始录制
+ `Yp` 复制行.
+ `<C-a>` 增加1.
+ `q` 停止录制.
  • @a → 在1下面写下 2
  • @@ → 在2 正面写下3
  • 现在做 100@@ 会创建新的100行,并把数据增加到 103.

qa.gif

点关注,不迷路

本文带大家手把手学习了一波 VIM 的入门教学,通过结合 IDEA,来让我们在开发过程中,不断熟悉 VIM的语法,拒绝 Ctrl + C/V 工程师,由现在做起~

好了各位,以上就是这篇文章的全部内容了,我后面会每周都更新几篇高质量的大厂面试和常用技术栈相关的文章。感谢大伙能看到这里,如果这个文章写得还不错, 求三连!!! 感谢各位的支持和认可,我们下篇文章见!

我是 九灵 ,有需要交流的童鞋可以关注公众号:Java 补习课! 如果本篇博客有任何错误,请批评指教,不胜感激 !

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

深挖技术栈(第二期) 之 Nacos 源码分析 首先要说的~

发表于 2021-08-24
1
复制代码作者:麦芽

本文使用开源版本如下

Nacos Server 版本:1.4.1

Nacos config-start 版本:2.2.5-RELEASE

首先要说的~

为什么要看源码:

1、**提升技术功底:**学习源码里的优秀设计思想,比如一些疑难问题的解决思路,还有一些优秀的设计模式,整体提升自己的技术功底

2、**深度掌握技术框架:**源码看多了,对于一个新技术或框架的掌握速度会有大幅提升,看下框架demo大致就能知道底层的实现,技术框架更新再快也不怕

3、**快速定位线上问题:**遇到线上问题,特别是框架源码里的问题(比如bug),能够快速定位,这就是相比其他没看过源码的人的优势

4、**知其然知其所以然:**对技术有追求的人必做之事,使用了一个好的框架,很想知道底层是如何实现的

5、**拥抱开源社区:**参与到开源项目的研发,结识更多大牛,积累更多优质人脉

看源码方法:

1、**先使用:**先看官方文档快速掌握框架的基本使用

2、**抓主线:**找一个demo入手,顺藤摸瓜快速静态看一遍框架的主线源码,画出源码主流程图,切勿一开始就陷入源码的细枝末节,否则会把自己绕晕,凭经验猜

3、**画图做笔记:**总结框架的一些核心功能点,从这些功能点入手深入到源码的细节,边看源码边画源码走向图,并对关键源码的理解做笔记,把源码里的闪光点都记录下来,后续借鉴到工作项目中,也可以边看源码边debug源码执行过程,观察一些关键变量的值

4、**整合总结:**所有功能点的源码都分析完后,回到主流程图再梳理一遍,争取把自己画的所有图都在脑袋里做一个整合

1、什么是 Nacos?

引用官方文档:nacos.io/zh-cn/docs/…

**Nacos 致力于帮助您发现、配置和管理微服务。Nacos 提供了一组简单易用的特性集,帮助您快速实现动态 服务发现、服务配置、服务元数据及流量管理。

**Nacos 的关键特性包括:

  • 服务发现和服务健康监测
  • 动态配置服务
  • 动态 DNS 服务
  • 服务及其元数据管理

1.1 Nacos 架构

NamingService: 命名服务,注册中心核心接口

ConfigService:配置服务,配置中心核心接口

OpenAPI文档:nacos.io/zh-cn/docs/…

2、Nacos注册中心

2.1 Nacos注册表结构

2.2 Nacos注册中心核心功能

  • 服务注册:Nacos Client会通过发送REST请求的方式向Nacos Server注册自己的服务,提供自身的元数据,比如ip地址、端口等信息。Nacos Server接收到注册请求后,就会把这些元数据信息存储在一个双层的内存Map中。
  • 服务心跳:在服务注册后,Nacos Client会维护一个定时心跳来持续通知Nacos Server,说明服务一直处于可用状态,防止被剔除。默认5s发送一次心跳。
  • 服务同步:Nacos Server集群之间会互相同步服务实例,用来保证服务信息的一致性。 leader raft
  • 服务发现:服务消费者(Nacos Client)在调用服务提供者的服务时,会发送一个REST请求给Nacos Server,获取上面注册的服务清单,并且缓存在Nacos Client本地,同时会在Nacos Client本地开启一个定时任务定时拉取服务端最新的注册表信息更新到本地缓存
  • 服务健康检查:Nacos Server会开启一个定时任务用来检查注册服务实例的健康情况,对于超过15s没有收到客户端心跳的实例会将它的healthy属性置为false(客户端服务发现时不会发现),如果某个实例超过30秒没有收到心跳,直接剔除该实例(被剔除的实例如果恢复发送心跳则会重新注册)

2.3 Spring Cloud Alibaba Nacos快速开始

此处参考官方文档:nacos.io/zh-cn/docs/…

3、Nacos注册中心源码分析

源码流程还是围绕核心功能展开,具体为以下几个方面:

服务注册:Nacos Client会通过发送REST请求的方式向Nacos Server注册自己的服务,提供自身的元数据,比如ip地址、端口等信息。Nacos Server接收到注册请求后,就会把这些元数据信息存储在一个双层的内存Map中。

服务心跳:在服务注册后,Nacos Client会维护一个定时心跳来持续通知Nacos Server,说明服务一直处于可用状态,防止被剔除。默认5s发送一次心跳。

服务健康检查:Nacos Server会开启一个定时任务用来检查注册服务实例的健康情况,对于超过15s没有收到客户端心跳的实例会将它的healthy属性置为false(客户端服务发现时不会发现),如果某个实例超过30秒没有收到心跳,直接剔除该实例(被剔除的实例如果恢复发送心跳则会重新注册)

服务发现:服务消费者(Nacos Client)在调用服务提供者的服务时,会发送一个REST请求给Nacos Server,获取上面注册的服务清单,并且缓存在Nacos Client本地,同时会在Nacos Client本地开启一个定时任务定时拉取服务端最新的注册表信息更新到本地缓存

服务同步:Nacos Server集群之间会互相同步服务实例,用来保证服务信息的一致性。

详细源码流程图:

https://www.processon.com/view/link/60f7e7d3e0b34d16fa90a72f

流程中会涉及通过rest接口来与服务端进行通信.

建议先了解下Nacos Open Api :nacos.io/zh-cn/docs/…

[

](www.processon.com/view/link/6…)

4、Nacos配置中心

4.1 Nacos配置中心的使用

参考官方:github.com/alibaba/spr…

4.2 Config相关配置

Nacos 数据模型 Key 由三元组唯一确定, Namespace默认是空串,公共命名空间(public),分组默认是 DEFAULT\_GROUP

  • 支持配置的动态更新
当动态配置刷新时,会更新到 Enviroment中,因此这里每隔一秒中从Enviroment中获取配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码@SpringBootApplication
public class NacosConfigApplication {

public static void main(String[] args) throws InterruptedException {
ConfigurableApplicationContext applicationContext = SpringApplication.run(NacosConfigApplication.class, args);

while(true) {
//当动态配置刷新时,会更新到 Enviroment中,因此这里每隔一秒中从Enviroment中获取配置
String userName = applicationContext.getEnvironment().getProperty("common.name");
String userAge = applicationContext.getEnvironment().getProperty("common.age");
System.err.println("common name :" + userName + "; age: " + userAge);
TimeUnit.SECONDS.sleep(1);
}
}
}
  • 支持profile粒度的配置
spring-cloud-starter-alibaba-nacos-config 在加载配置的时候,不仅仅加载了以 dataid 为 spring.application.name.{spring.application.name}.spring.application.name.{file-extension:properties} 为前缀的基础配置,还加载了dataid为 spring.application.name−{spring.application.name}-spring.application.name−{profile}.file−extension:properties的基础配置。在日常开发中如果遇到多套环境下的不同配置,可以通过Spring提供的{file-extension:properties} 的基础配置。在日常开发中如果遇到多套环境下的不同配置,可以通过Spring 提供的 file−extension:properties的基础配置。在日常开发中如果遇到多套环境下的不同配置,可以通过Spring提供的{spring.profiles.active} 这个配置项来配置。
1
ini复制代码spring.profiles.active=dev
  • 支持自定义 namespace 的配置
用于进行租户粒度的配置隔离。不同的命名空间下,可以存在相同的 Group 或 Data ID 的配置。Namespace 的常用场景之一是不同环境的配置的区分隔离,例如开发测试环境和生产环境的资(如配置、服务)隔离等。


 在没有明确指定 ${spring.cloud.nacos.config.namespace} 配置的情况下, 默认使用的是 Nacos 上 Public 这个namespace。如果需要使用自定义的命名空间,可以通过以下配置来实现:
1
arduino复制代码spring.cloud.nacos.config.namespace=71bb9785-231f-4eca-b4dc-6be446e12ff8
  • 支持自定义 Group 的配置
Group是组织配置的维度之一。通过一个有意义的字符串(如 Buy 或 Trade )对配置集进行分组,从而区分 Data ID 相同的配置集。当您在 Nacos 上创建一个配置时,如果未填写配置分组的名称,则配置分组的名称默认采用 DEFAULT\_GROUP 。配置分组的常见场景:不同的应用或组件使用了相同的配置类型,如 database\_url 配置和 MQ\_topic 配置。

在没有明确指定 ${spring.cloud.nacos.config.group} 配置的情况下,默认是DEFAULT_GROUP 。如果需要自定义自己的 Group,可以通过以下配置来实现:

1
ini复制代码spring.cloud.nacos.config.group=DEVELOP_GROUP
  • 支持自定义扩展的 Data Id 配置
Data ID 是组织划分配置的维度之一。Data ID 通常用于组织划分系统的配置集。一个系统或者应用可以包含多个配置集,每个配置集都可以被一个有意义的名称标识。Data ID 通常采用类 Java 包(如 com.taobao.tc.refund.log.level)的命名规则保证全局唯一性。此命名规则非强制。

通过自定义扩展的 Data Id 配置,既可以解决多个应用间配置共享的问题,又可以支持一个应用有多个配置文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ini复制代码# 自定义 Data Id 的配置
#不同工程的通用配置 支持共享的 DataId
spring.cloud.nacos.config.sharedConfigs[0].data-id= common.yaml
spring.cloud.nacos.config.sharedConfigs[0].group=REFRESH_GROUP
spring.cloud.nacos.config.sharedConfigs[0].refresh=true

# config external configuration
# 支持一个应用多个 DataId 的配置
spring.cloud.nacos.config.extensionConfigs[0].data-id=ext-config-common01.properties
spring.cloud.nacos.config.extensionConfigs[0].group=REFRESH_GROUP
spring.cloud.nacos.config.extensionConfigs[0].refresh=true

spring.cloud.nacos.config.extensionConfigs[1].data-id=ext-config-common02.properties
spring.cloud.nacos.config.extensionConfigs[1].group=REFRESH_GROUP

4.2 配置的优先级

Spring Cloud Alibaba Nacos Config 目前提供了三种配置能力从 Nacos 拉取相关的配置。

  • A: 通过 spring.cloud.nacos.config.shared-configs 支持多个共享 Data Id 的配置
  • B: 通过 spring.cloud.nacos.config.ext-config[n].data-id 的方式支持多个扩展 Data Id 的配置
  • C: 通过内部相关规则(应用名、应用名+ Profile )自动生成相关的 Data Id 配置

当三种方式共同使用时,他们的一个优先级关系是:A < B < C

优先级从高到低:

  1. nacos-config-product.yaml 精准配置
  2. nacos-config.yaml 同工程不同环境的通用配置
  3. ext-config: 不同工程 扩展配置
  4. shared-dataids 不同工程通用配置

4.5 @RefreshScope

@Value注解可以获取到配置中心的值,但是无法动态感知修改后的值,需要利用@RefreshScope注解

1
2
3
4
5
6
7
8
9
10
11
12
less复制代码@RestController
@RefreshScope
public class TestController {

@Value("${common.age}")
private String age;

@GetMapping("/common")
public String hello() {
return age;
}
}

5、Nacos配置中心源码分析

详细源码流程图:

www.processon.com/view/link/6…

5.1 配置中心架构

配置中心使用demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
csharp复制代码public class ConfigServerDemo {

public static void main(String[] args) throws NacosException, InterruptedException {
String serverAddr = "localhost";
String dataId = "nacos-config-demo.yaml";
String group = "DEFAULT_GROUP";
Properties properties = new Properties();
properties.put(PropertyKeyConst.SERVER_ADDR, serverAddr);
//获取配置服务
ConfigService configService = NacosFactory.createConfigService(properties);
//获取配置
String content = configService.getConfig(dataId, group, 5000);
System.out.println(content);
//注册监听器
configService.addListener(dataId, group, new Listener() {
@Override
public void receiveConfigInfo(String configInfo) {
System.out.println("===recieve:" + configInfo);
}

@Override
public Executor getExecutor() {
return null;
}
});

//发布配置
//boolean isPublishOk = configService.publishConfig(dataId, group, "content");
//System.out.println(isPublishOk);
//发送properties格式
configService.publishConfig(dataId,group,"common.age=30", ConfigType.PROPERTIES.getType());

Thread.sleep(3000);
content = configService.getConfig(dataId, group, 5000);
System.out.println(content);

// boolean isRemoveOk = configService.removeConfig(dataId, group);
// System.out.println(isRemoveOk);
// Thread.sleep(3000);

// content = configService.getConfig(dataId, group, 5000);
// System.out.println(content);
// Thread.sleep(300000);
}
}

5.2 nacos config client源码

配置中心核心接口ConfigService

5.2.1 获取配置

获取配置的主要方法是 NacosConfigService 类的 getConfig 方法,通常情况下该方法直接从本地文件中取得配置的值,如果本地文件不存在或者内容为空,则再通过 HTTP GET 方法从远端拉取配置,并保存到本地快照中。当通过 HTTP 获取远端配置时,Nacos 提供了两种熔断策略,一是超时时间,二是最大重试次数,默认重试三次。

5.2.2 注册监听器

配置中心客户端会通过对配置项注册监听器达到在配置项变更的时候执行回调的功能

  • NacosConfigService#getConfigAndSignListener
  • ConfigService#addListener
Nacos 可以通过以上方式注册监听器,它们内部的实现均是调用 ClientWorker 类的 addCacheDataIfAbsent。其中 CacheData 是一个维护配置项和其下注册的所有监听器的实例,所有的 CacheData 都保存在 ClientWorker 类中的原子 cacheMap 中,其内部的核心成员有:

5.2.3 配置长轮询

ClientWorker 通过其下的两个线程池完成配置长轮询的工作,一个是单线程的 executor,每隔 10ms 按照每 3000 个配置项为一批次捞取待轮询的 cacheData 实例,将其包装成为一个 LongPollingTask 提交进入第二个线程池 executorService 处理。

5.3 nacos config server源码分析

5.3.1 配置dump

服务端启动时就会依赖 DumpService 的 init 方法,从数据库中 load 配置存储在本地磁盘上,并将一些重要的元信息例如 MD5 值缓存在内存中。服务端会根据心跳文件中保存的最后一次心跳时间,来判断到底是从数据库 dump 全量配置数据还是部分增量配置数据(如果机器上次心跳间隔是 6h 以内的话)。

全量 dump 当然先清空磁盘缓存,然后根据主键 ID 每次捞取一千条配置刷进磁盘和内存。增量 dump 就是捞取最近六小时的新增配置(包括更新的和删除的),先按照这批数据刷新一遍内存和文件,再根据内存里所有的数据全量去比对一遍数据库,如果有改变的再同步一次,相比于全量 dump 的话会减少一定的数据库 IO 和磁盘 IO 次数。

5.3.2 配置发布

发布配置的代码位于 ConfigController#publishConfig中。集群部署,请求一开始也只会打到一台机器,这台机器将配置插入Mysql中进行持久化。服务端并不是针对每次配置查询都去访问 MySQL ,而是会依赖 dump 功能在本地文件中将配置缓存起来。因此当单台机器保存完毕配置之后,需要通知其他机器刷新内存和本地磁盘中的文件内容,因此它会发布一个名为 ConfigDataChangeEvent 的事件,这个事件会通过 HTTP 调用通知所有集群节点(包括自身),触发本地文件和内存的刷新。

5.3.3 处理长轮询

客户端会有一个长轮询任务,拉取服务端的配置变更,服务端处理逻辑在LongPollingService类中,其中有一个 Runnable 任务名为ClientLongPolling,服务端会将受到的轮询请求包装成一个 ClientLongPolling 任务,该任务持有一个 AsyncContext 响应对象,通过定时线程池延后 29.5s 执行。比客户端 30s 的超时时间提前 500ms 返回是为了最大程度上保证客户端不会因为网络延时造成超时.

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

进阶学Python:Python的os模块和sys模块!

发表于 2021-08-24

这是我参与8月更文挑战的第21天,活动详情查看:8月更文挑战

前言

三毛曾经感慨:

“心之何如,有似万丈迷津,横亘千里,其中并无舟子可以渡人,除了自渡,他人爱莫能助。” ——生命的洪流中,终其一生,我们都在学会怎么做自己的摆渡人。

关于安装和汉化可以观看博主的这篇文章《VsCode下载安装及汉化 》以及Python系列:windows10配置Python3.0开发环境!,安装完毕重启VsCode!


Come on!os模块

os模块 主要用于与操作系统打交道,Python 中的 os模块 包含普遍的操作系统功能,即如果你希望你的程序与操作系统平台无关,那么 os模块 就尤为重要。其方法如下

  • os.getcwd()   获取当前工作目录,即当前python脚本工作的目录路径
  • os.chdir(“dirname”)    改变当前脚本工作目录;相当于shell下cd
  • os.curdir   返回当前目录: (‘.’)
  • os.pardir   获取当前目录的父目录字符串名:(‘..’)
  • os.makedirs(‘dirname1/dirname2’)   可生成多层递归目录
  • os.removedirs(‘dirname1’)    若目录为空,则删除,并递归到上一级目录,如若也为空,则删除,依此类推
  • os.mkdir(‘dirname’)   生成单级目录;相当于shell中mkdir dirname
  • os.rmdir(‘dirname’)   删除单级空目录,若目录不为空则无法删除,报错;相当于shell中rmdir dirname
  • os.listdir(‘dirname’)   列出指定目录下的所有文件和子目录,包括隐藏文件,并以列表方式打印
  • os.remove()   删除一个文件
  • os.rename(“oldname”,”newname”)   重命名文件/目录
  • os.stat(‘path/filename’)   获取文件/目录信息
  • os.sep   输出操作系统特定的路径分隔符,win下为”",Linux下为”/“
  • os.linesep   输出当前平台使用的行终止符,win下为”\t\n”,Linux下为”\n”
  • os.pathsep   输出用于分割文件路径的字符串 win下为;,Linux下为:
  • os.name   输出字符串指示当前使用平台。win->’nt’; Linux->’posix’
  • os.system(“bash command”)   运行shell命令,直接显示
  • os.environ   获取系统环境变量
  • os.path.abspath(path)   返回path规范化的绝对路径
  • os.path.split(path)   将path分割成目录和文件名二元组返回
  • os.path.dirname(path)   返回path的目录。其实就是os.path.split(path)的第一个元素
  • os.path.basename(path)   返回path最后的文件名。如何path以/或\结尾,那么就会返回空值。即os.path.split(path)的第二个元素
  • os.path.exists(path)   如果path存在,返回True;如果path不存在,返回False
  • os.path.isabs(path)   如果path是绝对路径,返回True
  • os.path.isfile(path)   如果path是一个存在的文件,返回True。否则返回False
  • os.path.isdir(path)   如果path是一个存在的目录,则返回True。否则返回False
  • os.path.join(path1[, path2[, …]])   将多个路径组合后返回,第一个绝对路径之前的参数将被忽略
  • os.path.getatime(path)   返回path所指向的文件或者目录的最后存取时间
  • os.path.getmtime(path)   返回path所指向的文件或者目录的最后修改时间

下列代码最好是去 cmd(命令指示符) 里面试试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
python复制代码#!/usr/bin/env python
# -*- encoding: utf-8 -*-
'''
@File : osdemo.py
@Time : 2019/10/21 10:34:18
@Author : YongJia Chen
@Version : 1.0
@Contact : chen867647213@163.com
@License : (C)Copyright 2018-2019, Liugroup-NLPR-CASIA
@Desc : None
'''

# here put the import lib

import os

#1、os.getcwd() 获取当前工作目录,即当前python脚本工作的目录路径
os.getcwd()

#2、os.chdir("dirname") 改变当前脚本工作目录;相当于shell下cd
os.chdir(r"指定的目录地址")
os.getcwd()

#3、os.makedirs('dirname1/dirname2') 可生成多层递归目录
os.makedirs(r"test1\test2")
#在当前目录下生成 test1\test2

#4、os.removedirs('dirname1') 若目录为空,则删除,并递归到上一级目录,如若也为空,则删除,依此类推
os.removedirs(r"test1\test2")
#当前目录下的test1\test2均删除,若test1下有其他文件则不删

#5、os.mkdir('dirname') 生成单级目录;相当于shell中mkdir dirname
os.listdir()

#6、os.rmdir('dirname') 删除单级空目录,若目录不为空则无法删除,报错;相当于shell中rmdir dirname
os.mkdir(r"test1\test2")

#7、os.rmdir('dirname') 删除单级空目录,若目录不为空则无法删除,报错;相当于shell中rmdir dirname
os.rmdir(r"test1")

#8、os.listdir('dirname') 列出指定目录下的所有文件和子目录,包括隐藏文件,并以列表方式打印
os.listdir()

#9、os.remove() 删除一个文件
os.rename(r"test1", r"test001")

#10、os.stat('path/filename') 获取文件/目录信息
os.stat(r"test001")
os.stat_result(st_mode=16895,
st_ino=21110623253299938,
st_dev=1653245188,
st_nlink=1,
st_uid=0,
st_gid=0,
st_size=0,
st_atime=1529501427,
st_mtime=1529501427,
st_ctime=1529499311)

#11、os.system("bash command") 运行shell命令,直接显示
os.system("ipconfig")

#12、os.path.abspath(path) 返回path规范化的绝对路径
os.path.abspath(r"test1/test2")

#13、os.path.split(path) 将path分割成目录和文件名二元组返回
dir1 = os.path.abspath(r"test1/test2")
os.path.split(dir1)

#13、os.path.dirname(path) 返回path的目录。其实就是os.path.split(path)的第一个元素
os.path.dirname(dir1)

#14、os.path.basename(path) 返回path最后的文件名。如何path以/或\结尾,那么就会返回空值。即os.path.split(path)的第二个元素
os.path.basename(dir1)

#15、os.path.exists(path) 如果path存在,返回True;如果path不存在,返回False
os.path.exists(dir1)

#16、os.path.join(path1[, path2[, ...]]) 将多个路径组合后返回,第一个绝对路径之前的参数将被忽略
os.path.join(r"指定的目录地址.py", r"指定的目录地址.py")

#17、os.path.getatime(path) 返回path所指向的文件或者目录的最后存取时间
os.path.getatime(r"指定的目录地址.py")

#18、os.path.getmtime(path) 返回path所指向的文件或者目录的最后修改时间
os.path.getmtime(r"指定的目录地址.py")

os模块 就是针对间接的对操作系统进行操作,是程序与操作系统的接口,使得程序与任何操作系统平台无关。


sys 模块

sys模块 是针对 Python解释器 作相关操作。

  • sys.argv 命令行参数List,第一个元素是程序本身路径
  • sys.exit(n) 退出程序,正常退出时exit(0)
  • sys.version 获取Python解释程序的版本信息
  • sys.maxint 最大的Int值
  • sys.path 返回模块的搜索路径,初始化时使用PYTHONPATH环境变量的值
  • sys.platform 返回操作系统平台名称

下列代码最好是去 cmd(命令指示符) 里面试试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
python复制代码#!/usr/bin/env python
# -*- encoding: utf-8 -*-
'''
@File : sysdemo.py
@Time : 2019/10/21 10:38:12
@Author : YongJia Chen
@Version : 1.0
@Contact : chen867647213@163.com
@License : (C)Copyright 2018-2019, Liugroup-NLPR-CASIA
@Desc : None
'''

# here put the import lib

import sys, time
#1、sys.version 获取Python解释程序的版本信息
sys.version
# '3.7.4 (tags/v3.7.4:e09359112e, Jul 8 2019, 20:34:20) [MSC v.1916 64 bit (AMD64)]'

#2、sys.path 返回模块的搜索路径,初始化时使用PYTHONPATH环境变量的值
sys.path
# ['', 'C:\\Users\\lenovo\\AppData\\Local\\Programs\\Python\\Python37\\python37.zip', 'C:\\Users\\lenovo\\AppData\\Local\\Programs\\Python\\Python37\\DLLs', 'C:\\Users\\lenovo\\AppData\\Local\\Programs\\Python\\Python37\\lib', 'C:\\Users\\lenovo\\AppData\\Local\\Programs\\Python\\Python37', 'C:\\Users\\lenovo\\AppData\\Roaming\\Python\\Python37\\site-packages', 'C:\\Users\\lenovo\\AppData\\Local\\Programs\\Python\\Python37\\lib\\site-packages', 'C:\\Users\\lenovo\\AppData\\Local\\Programs\\Python\\Python37\\lib\\site-packages\\beautifulsoup4-4.8.0-py3.7.egg', 'C:\\Users\\lenovo\\AppData\\Local\\Programs\\Python\\Python37\\lib\\site-packages\\soupsieve-1.9.4-py3.7.egg']

#进度条
for i in range(100):
sys.stdout.write("$") #先写入缓存中,一次性打印在屏幕上
time.sleep(0.1)
sys.stdout.flush() #每次均刷新在屏幕上

Come on!额外简单说下pip和pip3

pip和pip3是用于当你同时安装了:python2和python3时用于区分安装的版本

import sys:需要先pip3 install sys

import requests:需要先pip3 install requests

同理如果需要想要的包就—>import XXX:pip3 install XXX

快去看看吧!


🎉最后

  • 更多参考精彩博文请看这里:陈永佳的博客
  • 喜欢博主的小伙伴可以加个关注、点个赞哦,持续更新嘿嘿!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

linux笔记:极简方式安装mysql,建议收藏

发表于 2021-08-24

这是我参与 8 月更文挑战的第 24 天,活动详情查看: 8月更文挑战

数据库是在日常项目中最常用的工具,安装是我们必须掌握的技能,本文记录了楼主安装的全过程。保证无异常,建议收藏,使用时直接复制即可。

1.下载

最好别再官网下载了,贼慢,可以在csdn上寻找linux安装吧,或者百度网盘。以下附上网盘地址。
链接:pan.baidu.com/s/1zkCN7Sc8…
提取码:f8ei

2.解压

新建一个文件夹,然后进入在其中解压。

1
复制代码tar -xvf mysql-5.7.26-linux-glibc2.12-x86_64.tar.gz

3.移动并重命名

1
bash复制代码mv mysql-5.7.26-linux-glibc2.12-x86_64 /usr/local/mysql

4.创建mysql用户组和用户并修改权限

进入数据库压缩的目录。执行操作

1
2
3
4
5
6
bash复制代码cd /user/local/mysql

groupadd mysql
useradd -r -g mysql mysql
mkdir -p /data/mysql #创建目录
chown mysql:mysql -R /data/mysql #赋予权限

5.配置my.cnf

1
bash复制代码vim /etc/my.cnf

配置如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ini复制代码[mysqld]
bind-address=0.0.0.0
port=3306
user=mysql
basedir=/usr/local/mysql
datadir=/data/mysql
socket=/tmp/mysql.sock
log-error=/data/mysql/mysql.err
pid-file=/data/mysql/mysql.pid
#character config
character_set_server=utf8mb4
symbolic-links=0
explicit_defaults_for_timestamp=true
lower_case_table_names=1

6.初始化数据库

1
bash复制代码cd /usr/local/mysql/bin/
1
javascript复制代码./mysqld --defaults-file=/etc/my.cnf --basedir=/usr/local/mysql/ --datadir=/data/mysql/ --user=mysql --initialize

注意,一定要和系统的版本对上,32位下32位的安装包,其中阿里云的系统默认的是32位,请与安装包对应,否则会抛出以下异常mysqld: mysqld: cannot execute binary file。

7.查看密码

1
bash复制代码cat /data/mysql/mysql.err

要注意 要包括那个.而且mysql输入密码没有光标的,直接复制粘贴也可以。

8.启动mysql

1.配置

先将mysql.server放置到/etc/init.d/mysql中。

1
bash复制代码cp /usr/local/mysql/support-files/mysql.server /etc/init.d/mysql

2.启动服务

1
2
perl复制代码service mysql start
ps -ef|grep mysql

3.停止服务命令

1
arduino复制代码service mysql stop

9.登录

先登入初始密码,上文中查看出来的那一串。直接复制就可以。

1
bash复制代码./mysql -u root -p

登陆成功

10.更改密码

第一次启动是不附带账号密码的,所以我们在第一次启动时需要设置账号密码。

1
2
3
ini复制代码SET PASSWORD = PASSWORD('123456');
ALTER USER 'root'@'localhost' PASSWORD EXPIRE NEVER;
FLUSH PRIVILEGES;

11.更改配置

如果想使用navicat连接,还需要使用执行以下命令。

1
2
3
ini复制代码use mysql                                            #访问mysql库
update user set host = '%' where user = 'root'; #使root能再任何host访问
FLUSH PRIVILEGES;

如果不希望每次都到bin目录下使用mysql命令则执行以下命令ln -s /usr/local/mysql/bin/mysql /usr/bin

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

树、二叉树与手写AVL,树型数据结构详解

发表于 2021-08-24

本文为掘金社区首发签约文章,未获授权禁止转载。

数据结构——树,如约而至了,甚至比我想的要早上那么几天,我很少夸自己写的东西,但今天真的是满满干货,希望大家能够点赞支持。

  1. 树的基础概念

树是一种特别常用的数据结构,在计算机文件系统或者计算机数据库系统中,都往往会采用树数据结构。

比如在文件系统中会采用树数据结构作为文件目录层级,在数据库系统中则会用树数据结构作为数据库的索引,特别是B树。

这里我借用一下百度百科上面的定义:

树 是一种 数据结构 ,它是由 n(n≥1 )个有限节点组成一个具有层次关系的 集合 。把它叫做“树”是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。

我简单手绘了一张图,为大家表示树的数据结构的形态:

这张图上的每一个圆圈都代表一个节点,在最中间的节点1代表着这棵树的根节点,可以看到在节点1下面有三个箭头分别指向了三个节点,这三个元素就是根节点的三个子节点,对于子节点来说节点一就是它们的父节点,每个节点都拥有自己的子节点和父节点,不同节点的子节点之间不会重合。

每个节点也可以独立成为一棵子树,比如节点3就是一棵子树,它下面分别有5和6两个子节点,节点2和4也可以说是一个子树,但是它们没有子节点。

接下来还有几个关于树的名词需要了解:

  1. 高度 / 深度 :树的高度是其根结点到最深节点的长度,比如我上面的例图中的树的高度就是3,你可以将其理解为3层。
  2. 叶子节点 :没有下级节点的节点被称为叶子节点,比如节点2、4、5和6。
  3. 非叶子节点 :有下级节点的节点被称为非叶子节点,比如节点3。
  4. 节点的度 :其直接子节点的个数,比如节点3只拥有两个子节点,其度就为2。
  5. 树的度 :该树中所有节点拥有的最大度,在例图中树的度就为3,因为1号节点有三个子节点,没有其他节点拥有更多的子节点,所以树的度为3。

了解完关于树的基础概念,就要讲讲树一般怎么进行构造了,由于树的直接子节点的数量的是不固定的,所以我们可以采用链表的方式来构造一个子节点,就像这样:

1
2
3
4
5
6
7
8
java复制代码public class DiyTree<T extends Comparable> {

private Node<T> root;

private static class Node<T extends Comparable> {
private LinkedList<Node<T>> item;
}
}

这相当于每一个节点下面都包含着一个链表,如果用画图的方式表述的话就是这样的:

在上图中我用矩形代替链表,圆形代替里面的一个个的元素,每一个元素都可以延展出一个链表来。

以上,我介绍的树都是N树,除了N树之外还有一种叫做二叉树的树,我们先来看看它的介绍:

  • N树 :即一个子节点允许拥有N个子节点,例图中的树就是N树,我们所熟知的B树就是N树的一种。
  • 二叉树 :即一个子节点只允许拥有2个子节点,这两个子节点往往被称为左节点和右节点。

上图中就是一个二叉树,二叉树的代码构造也比N树要好理解,因为它只有两个子节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码public class DiyTree<T extends Comparable> {

private Node<T> root;

private static class Node<T extends Comparable> {
private T item;
private Node<T> left;
private Node<T> right;
public Node(T t) {
item = t;
}
}
}

N树与二叉树最大的区别就是子节点所允许的数量不同,数量不同带来的另一层差异就是树的高度不同。

比如我有7个节点,放在二叉树上需要3层才能摆下,在N树中两层就够了,节点越多,二叉树所需要的树高就越多,而N树却可以以极低的树高维持上千万的数据(MySQL中的B+索引的树高是3层),这在需要访问磁盘的程序中非常有用,当然了,今天本文的重点还是会围绕二叉树展开,N树暂时不讲。

  1. 二叉搜索树

上一节中简单说了二叉树的概念,那么它有什么用呢?

一个东西的引入必然有其作用,二叉树往往被用以二叉搜索树中,二叉搜索树是二叉树的变形,它以O(logN)的时间复杂度进行数据查找,在一个有2147483647个数字(21亿)的数组中,它最多只需要进行31次查找就能查找任何元素。

虽然它这么快,但是通过二叉树你可以很容易的变形到二叉搜索树去,因为它需要的条件非常简单:

  1. 二叉树。
  2. 左节点小于它的父节点,右节点大于它的父节点。

在上图中,白色背景的树就不是一颗二叉搜索树,而蓝色背景的树则是二叉搜索树,因为白色背景的树不满足我上面所说的第二个条件。

通过二叉搜索树可以保证你的树是有序的,所以在查找某个元素的过程中,每一次比较就可以缩小一半的查找范围,所以它的时间复杂度是O(logN)。

比如我们要查找1这个元素,在上图中只需要对根节点进行比较就能轻松的知道1是在这棵树左边的链路之中,这样我们就不必再去右边的链路中查找了,利用递归的方式不断重复这一动作,从而就能很快找到我们需要的元素。

当然,二叉搜索树它也有缺点,比如我们依次插入1,0,2,3,4这五个元素,你就会发现它形成了一个链表,你的查找速度就会变成O(N):

有没有办法解决这个问题呢?

有,那就是在其链路有点长的时候对它重新调整一次,这种自平衡的二叉搜索树叫做二叉平衡树。

  1. 二叉平衡树(AVL)

本节是本篇文章中最重要也是最难的部分,请读者们注意。

上节说到了二叉平衡树可以解决二叉搜索树一遍链路过长的问题,这一节我们就来实现一个二叉平衡树,实现方式将用最经典的AVL,这是一种比较苛刻的二叉平衡树方案,它要求树两个子节点的层高相差不能大于1,也是比较麻烦的一种方案,业界中更常用的红黑树则没有这么严格的平衡要求。

之所以选用这种方案来讲解,一是它经典,二是经典算法书—算法第四版居然没有讲这个AVL实现,所以我想补充一下。

先来看上面这张图,这张图其实是一个比较正常的二叉树,根节点的左边层高为1,右边的层高为2,它俩相差没有大于1,只是等于1。

如果我此时在插入一个4呢?

这下很明显的就能看出这棵树两边不平衡了,根节点的左边层高为1,右边层高为3,相差大于1了。

但是我们不能这样的来调整根节点,应该从插入处不断往上寻找不平衡点,在第一个不平衡的地方进行平衡操作。

图中的新插入点为4,它的父节点是3,父节点的左节点没有元素,可以看作层高为0,右节点有一个4,可以看作层高为1,所以它俩相差为1,不需要调整。

紧接着网上走到2处,2的左节点没有元素看作层高为0,右节点层高则为2,相差为2,所以真正引起不平衡的节点是2节点,需要调整此处让这个树重新平衡。

那么,怎么调整呢?

经典之所以是经典,是因为有大师已经发明过之后经历了时间的洗礼而经久不衰。

在AVL中,前辈们已经总结了四种树的不平衡情况,并通过一种名为旋转的方式对树进行再平衡。

为了更直观的理解,我不会直接列出这四种情况,而是通过配图的方式逐个讲解。

3.1 左旋转

像上图这种明显节点2处的右链路要比左链路长,那么我们需要进行一次左旋转:

将其变成上图中右边的样子,这样整棵树又恢复了平衡,左旋转看上去非常简单:

就是将不平衡的节点,放到其子节点的左节点。

因为节点2是小于节点3的,所以它放在节点3的左节点正正好。

当然,有人可能有疑问了,如果节点3的左节点有值怎么办?

原来的例子中不会出现这种情况,但是如果你在上图中右树再增加一个节点5,就会出现这种情况:

可以明显的看出,根节点1产生了不平衡,我们照例使用左旋转进行修正:

我们照例将根节点放在了节点3 的左节点上面,并将原来节点3的左节点放在了根节点的右节点上 ,因为节点3下的所有节点都是大于根节点的。

所以左旋大概是以下两步:

  1. 将不平衡节点,放到其子节点的左节点。
  2. 将其子节点原来的左节点,放到不平衡节点的右节点。

3.2 右旋转

右旋转是左旋转的镜像问题:

左树明显是节点9不平衡,经过右旋转调整之后变成右边那样。

其步骤和左旋转也是相对应的:

  1. 将不平衡节点,放到其子节点的右节点。
  2. 将其子节点原来的右节点,放到不平衡节点的左节点。

理解左旋转,自然理解右旋转,如果这里有点蒙的话,可以再看一遍左旋转,然后自行推演一下。

3.3 双旋转

双旋转是说在某种情况下,一次旋转无法解决问题,所以需要两次旋转才能解决问题,双旋转可以分为两种:

  1. 左右旋转 :先左旋转再右旋转。
  2. 右左旋转 :先右旋转再左旋转。

可以先来看下这个例子:

在这个例子中,很明显是节点9失衡,它的左子树层高和右子树相差为2,但是如果按照我们上面的经验对它直接进行右旋转是行不通的:

直接右旋转会变成这样,整棵树依然是失衡的。

因为它的失衡形式其实和我上面举出的右旋转的例子有些不一样:

左树是我们可以通过右旋转解决的失衡情形,而右树则不一样,它的节点5处是右层高大于左边,而非左层高大于右边。

由此可见,当失衡节点的所有子节点都偏向一侧时,才可以使用但旋转解决。

比如上图中的左树的节点9和节点5其实都是偏向左侧的,所以可以右旋转解决。

上图中的节点2和节点3都偏向右侧,所以可以使用左旋转解决。

但是上图这种并非都偏向一侧的则需要两次旋转,在上图中失衡的节点是节点5,它先偏向左边,再偏向右边,所以需要先左旋转再右旋转:

不过在第一次左旋转时需要对失衡节点的子节点进行左旋转,然后再对失衡节点进行右旋转。

在这张图上,我将第一次旋转的节点标为红色,将第二次旋转的节点标为黄色,可以很轻易的看出它的运行步骤。

与之相对应的还有它的镜像问题,需要进行一次右左旋转解决。

3.4 代码实现

AVL理论上的东西不难,但是上手实现时遇见了很多问题,重新看了好几遍书上的代码后才将其实现出来,读者们可以直接copy运行。

AVL的基本结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码public class DiyTree<T extends Comparable> {

private Node<T> root;

private static class Node<T extends Comparable> {
private T item;
private Node<T> left;
private Node<T> right;
private Integer height = 0;
public Node(T t) {
item = t;
}
}

}

AVL的基本结构比上文中的二叉搜索树的结构多了一个高度。

然后进行数据插入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码    public void add(T t) {
Node<T> node = new Node<>(t);
root = insertTree(root, node);
}

private Node<T> insertTree(Node<T> root, Node<T> node) {
if (root == null) {
return node;
}
int i = node.item.compareTo(root.item);
if (i > 0) {
root.right = insertTree(root.right, node);
} else if (i < 0){
root.left = insertTree(root.left, node);
} else {
// 节点相同暂不处理
}
// 插入之后进行平衡 会平衡从根节点往下的所有节点
return balance(root);
}

数据插入就是正常的寻找,然后插入,插入之后才进行平衡:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
java复制代码    private Node<T> balance(Node<T> root) {
// 平衡条件是计算左右子节点是否相差大于1
if (height(root.left) - height(root.right) > 1) {
if (height(root.left.left) >= height(root.left.right)) {
// 左左 - 使用右旋转
root = rightRotate(root);
} else {
// 左右 - 使用左右双旋转 - 先对root.left进行左旋转,再对root进行右旋转
root = leftRightRotate(root);
}

}

if (height(root.right) - height(root.left) > 1) {
if (height(root.right.right) >= height(root.right.left)) {
// 右右 - 使用左旋转
root = leftRotate(root);
} else {
// 右左 - 使用右左双旋转 - 先对root.right进行右旋转,再对root进行左旋转
root = rightLeftRotate(root);
}
}

root.height = Math.max(height(root.left), height(root.right)) + 1;

return root;
}

平衡时是从最深层的节点往上进行平衡,因为插入方法是一个递归方法,插入时会出现四种情况,分别用四种旋转解决。

还有一个求高度的辅助方法:

1
2
3
java复制代码    private int height(Node<T> node) {
return node == null ? -1 : node.height;
}

右旋转方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码    private Node<T> rightRotate(Node<T> node) {
// 先拿到节点的左边
Node<T> newNode = node.left;

// 去掉这个新节点的右边
node.left = newNode.right;

// 新节点的右边给予原来的root
newNode.right = node;

// 处理高度
node.height = Math.max(height(node.left), height(node.right)) + 1;
newNode.height = Math.max(height(newNode.left), height(newNode.right)) + 1;
return newNode;
}

右左旋转方法:

1
2
3
4
java复制代码    private Node<T> rightLeftRotate(Node<T> node) {
node.right = rightRotate(node.right);
return leftRotate(node);
}

最后,你可以根据我的右旋转方法和右左旋转方法推导出左旋转和左右旋转方法。
代码编写完成后,我在visualgo这个网站输入和我本地测试用例一样的数据,用这种方法来测试代码的正确性。

我测试了两组数据:1, 2, 3, 4, 5, 6, 7, 8, 9 和 50, 45, 40, 55, 60, 47, 57, 35, 38 ,这两组数据涉及所有的旋转情况,本地测试结果和线上网站的执行结果是一致的。

  1. AVL的遍历

说完了AVL的插入和平衡,再来说说它的遍历,二叉树的遍历可以分为以下几类:

  1. 先序遍历 :先打印root节点。
  2. 中序遍历 :打印完左节点再打印root节点。
  3. 后序遍历 :打印完左节点和右节点,最后打印root节点。
  4. 层序遍历 :从root开始,一层一层的从左往右进行打印。

前三种遍历中的先、中、后都是相对于root节点来说的,理解了这点后你可以将任意一种遍历代码方便的变形为其他遍历的代码。

4.1 先序遍历

拿这张图来举例,先序遍历的话,它的结果就应该是:3, 1, 0, 2, 4, 5 。

代码也很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码    public void preorder() {
if (root == null) {
return;
}

order(root);
}

public void order(Node<T> root) {
if (root == null) {
return;
}
System.out.println(root.item);
order(root.left);
order(root.right);
}

4.2 中序遍历

拿这张图来举例,中序遍历的话,它的结果就应该是:0, 1, 2, 3, 4, 5 。

代码也很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码    public void preorder() {
if (root == null) {
return;
}

order(root);
}

public void order(Node<T> root) {
if (root == null) {
return;
}
order(root.left);
System.out.println(root.item);
order(root.right);
}

4.3 后序遍历

拿这张图来举例,后序遍历的话,它的结果就应该是:0, 2, 1, 5, 4, 3 。

代码也很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码    public void preorder() {
if (root == null) {
return;
}

order(root);
}

public void order(Node<T> root) {
if (root == null) {
return;
}
order(root.left);
order(root.right);
System.out.println(root.item);
}

可以看到这三种遍历方式代码几乎没差,至于最后的一种层序遍历就留给大家实现了~

  1. 结语

到这里,树相关的知识点就告一段落了。

今天讲了树的基础概念、二叉搜索树和AVL的实现,学习完这章你应该会对树这一数据结构有一个不错的掌握。

二叉平衡树也是一个在现代编程语言中都会内置的一个数据类型,比如Java中的TreeMap就是二叉树的典型代表,并且Java中的HashMap也会在冲突过多的时候转换成TreeMap,它们都是Java中极其重要的工具类。

截止到本文,我一共讲了五种数据结构,其中二叉平衡树是最快数据结构,它的查找和插入的速度都已经到达指数级别,但是在数据结构中还有一种能将查找和插入速度达到常数级别,它就是Hash。

Hash,又称哈希表,是一种查找和插入都达到常数级别的数据结构,我将会在下一篇中仔细讨论它,请大家拭目以待。


参考书目 :

  1. 算法第四版
  2. 数据结构与算法分析
  3. 数据结构与算法图解
  4. 计算机科学导论

推荐阅读

  1. 数组、链表、队列和栈,四大基础数据结构详解
  2. 归约、分组与分区,深入讲解JavaStream终结操作

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

spring data jpa(概述、快速入门、内部原理剖析

发表于 2021-08-24

一、概述

1.1 Spring Data JPA概述

111.png

Spring Data JPA 是 Spring 基于 ORM 框架、JPA 规范的基础上封装的一套JPA应用框架,可使开发者用极简的代码即可实现对数据库的访问和操作。

它提供了包括增删改查等在内的常用功能,且易于扩展!学习并使用 Spring Data JPA 可以极大提高开发效率!

Spring Data JPA 让我们解脱了DAO层的操作,基本上所有CRUD都可以依赖于它来实现

在实际的工作工程中,推荐使用Spring Data JPA + ORM(如:hibernate)完成操作,这样在切换不同的ORM框架时提供了极大的方便,同时也使数据库层操作更加简单,方便解耦

1.2 Spring Data JPA的特性

111.png

SpringData Jpa 极大简化了数据库访问层代码。 如何简化的呢?

使用了SpringDataJpa,我们的dao层中只需要写接口,就自动具有了增删改查、分页查询等方法。

1.3 Spring Data JPA 与 JPA和hibernate之间的关系

JPA是一套规范,内部是有接口和抽象类组成的。

hibernate是一套成熟的ORM框架,而且Hibernate实现了JPA规范,所以也可以称hibernate为JPA的一种实现方式。我们使用JPA的API编程,意味着站在更高的角度上看待问题(面向接口编程)

Spring Data JPA是Spring提供的一套对JPA操作更加高级的封装,是在JPA规范下的专门用来进行数据持久化的解决方案。

二、Spring Data JPA的快速入门

2.1 使用JPA注解配置映射关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
java复制代码
package cn.itcast.entity;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;

/**
*
* * 所有的注解都是使用JPA的规范提供的注解,
* * 所以在导入注解包的时候,一定要导入javax.persistence下的
*/
@Entity //声明实体类
@Table(name="cst_customer") //建立实体类和表的映射关系
public class Customer {

@Id//声明当前私有属性为主键
@GeneratedValue(strategy=GenerationType.IDENTITY) //配置主键的生成策略
@Column(name="cust_id") //指定和表中cust_id字段的映射关系
private Long custId;

@Column(name="cust_name") //指定和表中cust_name字段的映射关系
private String custName;

@Column(name="cust_source")//指定和表中cust_source字段的映射关系
private String custSource;

@Column(name="cust_industry")//指定和表中cust_industry字段的映射关系
private String custIndustry;
}

2.2 编写符合Spring Data JPA规范的Dao层接口

Spring Data JPA是spring提供的一款对于数据访问层(Dao层)的框架,使用Spring Data JPA,只需要按照框架的规范提供dao接口,不需要实现类就可以完成数据库的增删改查、分页查询等方法的定义,极大的简化了我们的开发过程。

在Spring Data JPA中,对于定义符合规范的Dao层接口,我们只需要遵循以下几点就可以了:

  1. 创建一个Dao层接口,并实现JpaRepository和JpaSpecificationExecutor
  2. 提供相应的泛型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码package cn.itcast.dao;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;

import cn.itcast.entity.Customer;

/**
* JpaRepository<实体类类型,主键类型>:用来完成基本CRUD操作
* JpaSpecificationExecutor<实体类类型>:用于复杂查询(分页等查询操作)
*/
public interface CustomerDao extends JpaRepository<Customer, Long>,
JpaSpecificationExecutor<Customer> {
}

2.3 完成基本CRUD操作

完成了Spring Data JPA的环境搭建,并且编写了符合Spring Data JPA 规范的Dao层接口之后,就可以使用定义好的Dao层接口进行客户的基本CRUD操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
java复制代码public class CustomerDaoTest {

@Autowired
private CustomerDao customerDao;

/**
* 保存客户:调用save(obj)方法
*/
@Test
public void testSave() {
Customer c = new Customer();
c.setCustName("111");
customerDao.save(c);
}

/**
* 修改客户:调用save(obj)方法
* 对于save方法的解释:如果执行此方法是对象中存在id属性,
即为更新操作会先根据id查询,再更新
* 如果执行此方法中对象中不存在id属性,
即为保存操作
*
*/
@Test
public void testUpdate() {
//根据id查询id为1的客户
Customer customer = customerDao.findOne(1l);
//修改客户名称
customer.setCustName("222");
//更新
customerDao.save(customer);
}

/**
* 根据id删除:调用delete(id)方法
*/
@Test
public void testDelete() {
customerDao.delete(1l);
}

/**
* 根据id查询:调用findOne(id)方法
*/
@Test
public void testFindById() {
Customer customer = customerDao.findOne(2l);
System.out.println(customer);
}
}

三、Spring Data JPA的内部原理剖析

3.1 Spring Data JPA的常用接口分析

111.png

在使用Spring Data JPA时,一般实现JpaRepository和JpaSpecificationExecutor接口,这样就可以使用这些接口中定义的方法。

但是这些方法都只是一些声明,没有具体的实现方式,那么在 Spring Data JPA中它又是怎么实现的呢?

3.2 Spring Data JPA的实现过程

我们以findOne方法为例进行分析

111.png

断点执行到方法上时,我们可以发现注入的customerDao对象,本质上是通过JdkDynamicAopProxy生成的一个代理对象

当程序执行的时候,会通过JdkDynamicAopProxy的invoke方法,对customerDao对象生成动态代理对象。

根据对Spring Data JPA介绍而知,要想进行findOne查询方法,最终还是会出现JPA规范的API完成操作,那么这些底层代码存在于何处呢?答案很简单,都隐藏在通过JdkDynamicAopProxy生成的动态代理对象当中,而这个动态代理对象就是SimpleJpaRepository

111.png

通过SimpleJpaRepository的源码分析,定位到了findOne方法,在此方法中,返回em.find()的返回结果,那么em又是什么呢?

111.png

带着问题继续查找em对象,我们发现em就是EntityManager对象,而他是JPA原生的实现方式,所以我们得到结论Spring Data JPA只是对标准JPA操作进行了进一步封装,简化了Dao层代码的开发

3.3 Spring Data JPA完整的调用过程分析

111.png

四、Spring Data JPA的查询方式

4.1 使用Spring Data JPA中接口定义的方法进行查询

在继承JpaRepository,和JpaRepository接口后,我们就可以使用接口中定义的方法进行查询

l 继承 JpaRepository 后的方法列表

111.png

l 继承 JpaSpecificationExecutor 的方法列表

111.png

4.2 使用JPQL的方式查询

使用Spring Data JPA提供的查询方法已经可以解决大部分的应用场景,但是对于某些业务来说,我们还需要灵活的构造查询条件,这时就可以使用@Query注解,结合JPQL的语句方式完成查询

@Query 注解的使用非常简单,只需在方法上面标注该注解,同时提供一个JPQL查询语句即可

1
2
3
4
5
6
7
8
9
java复制代码public interface CustomerDao extends JpaRepository<Customer,Long>,JpaSpecificationExecutor<Customer>{    
//@Query 使用jpql的方式查询。
@Query(value="from Customer")
public List<Customer> findAllCustomer();

//@Query 使用jpql的方式查询。?1代表参数的占位符,其中1对应方法中的参数索引
@Query(value="from Customer where custName = ?1")
public Customer findCustomer(String custName);
}

此外,也可以通过使用 @Query 来执行一个更新操作,为此,我们需要在使用 @Query 的同时,用 @Modifying 来将该操作标识为修改查询,这样框架最终会生成一个更新的操作,而非查询

1
2
3
java复制代码@Query(value="update Customer set custName = ?1 where custId = ?2")
@Modifying
public void updateCustomer(String custName,Long custId);

4.3 使用SQL语句查询

Spring Data JPA同样也支持sql语句的查询,如下:

1
2
3
4
5
java复制代码    /**
* nativeQuery : 使用本地sql的方式查询
*/
@Query(value="select * from cst_customer",nativeQuery=true)
public void findSql();

4.4 方法命名规则查询

方法命名规则查询就是根据方法的名字,就能创建查询。只需要按照Spring Data JPA提供的方法命名规则定义方法的名称,就可以完成查询工作。Spring Data JPA在程序执行的时候会根据方法名称进行解析,并自动生成查询语句进行查询

按照Spring Data JPA 定义的规则,查询方法以findBy开头,涉及条件查询时,条件的属性用条件关键字连接,要注意的是:条件属性首字母需大写。框架在进行方法名解析时,会先把方法名多余的前缀截取掉,然后对剩下部分进行解析。

1
2
java复制代码    //方法命名方式查询(根据客户名称查询客户)
public Customer findByCustName(String custName);

具体的关键字,使用方法和生产成SQL如下表所示

Keyword Sample JPQL
And findByLastnameAndFirstname … where x.lastname = ?1 and x.firstname = ?2
Or findByLastnameOrFirstname … where x.lastname = ?1 or x.firstname = ?2
Is,Equals findByFirstnameIs,findByFirstnameEquals … where x.firstname = ?1
Between findByStartDateBetween … where x.startDate between ?1 and ?2
LessThan findByAgeLessThan … where x.age < ?1
LessThanEqual findByAgeLessThanEqual … where x.age ⇐ ?1
GreaterThan findByAgeGreaterThan … where x.age > ?1
GreaterThanEqual findByAgeGreaterThanEqual … where x.age >= ?1
After findByStartDateAfter … where x.startDate > ?1
Before findByStartDateBefore … where x.startDate < ?1
IsNull findByAgeIsNull … where x.age is null
IsNotNull,NotNull findByAge(Is)NotNull … where x.age not null
Like findByFirstnameLike … where x.firstname like ?1
NotLike findByFirstnameNotLike … where x.firstname not like ?1
StartingWith findByFirstnameStartingWith … where x.firstname like ?1 (parameter bound with appended %)
EndingWith findByFirstnameEndingWith … where x.firstname like ?1 (parameter bound with prepended %)
Containing findByFirstnameContaining … where x.firstname like ?1 (parameter bound wrapped in %)
OrderBy findByAgeOrderByLastnameDesc … where x.age = ?1 order by x.lastname desc
Not findByLastnameNot … where x.lastname <> ?1
In findByAgeIn(Collection ages) … where x.age in ?1
NotIn findByAgeNotIn(Collection age) … where x.age not in ?1
TRUE findByActiveTrue() … where x.active = true
FALSE findByActiveFalse() … where x.active = false
IgnoreCase findByFirstnameIgnoreCase … where UPPER(x.firstame) = UPPER(?1)

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

后端服务慢成狗?试试这 7 招!

发表于 2021-08-23

​这是我参与8月更文挑战的第 23 天,活动详情查看:8月更文挑战

本文简单介绍下后端服务开发中常用的一些性能优化策略。​

1、代码

优化代码实现是第一位的,特别是一些不合理的复杂实现。如果结合需求能从代码实现的角度,使用更高效的算法或方案实现,进而解决问题,那是最简单有效的。

2、数据库

数据库的优化,总体上有3个方面:

1) SQL调优:除了掌握SQL基本的优化手段,使用慢日志定位到具体问题SQL,使用explain、profile等工具来逐步调优。

2) 连接池调优:选择高效适用的连接池,结合当前使用连接池的原理、具体的连接池监控数据和当前的业务量作一个综合的判断,通过反复的几次调试得到最终的调优参数。

3) 架构层面:包括读写分离、主从库负载均衡、水平和垂直分库分表等方面,一般需要的改动较大,需要从整体架构方面综合考虑。

3、缓存

分类

本地缓存(HashMap/ConcurrentHashMap、Ehcache、RocksDB、Guava Cache等)。

缓存服务(Redis/Tair/Memcache等)。系列缓存教程请关注公众号Java技术栈阅读,都是实战干货。

设计关键点

1、什么时候更新缓存?如何保障更新的可靠性和实时性?

更新缓存的策略,需要具体问题具体分析。基本的更新策略有两个:

1) 接收变更的消息,准实时更新。

2) 给每一个缓存数据设置5分钟的过期时间,过期后从DB加载再回设到DB。这个策略是对第一个策略的有力补充,解决了手动变更DB不发消息、接收消息更新程序临时出错等问题导致的第一个策略失效的问题。通过这种双保险机制,有效地保证了缓存数据的可靠性和实时性。

2、缓存是否会满,缓存满了怎么办?

对于一个缓存服务,理论上来说,随着缓存数据的日益增多,在容量有限的情况下,缓存肯定有一天会满的。如何应对?

1) 给缓存服务,选择合适的缓存逐出算法,比如最常见的LRU。

2) 针对当前设置的容量,设置适当的警戒值,比如10G的缓存,当缓存数据达到8G的时候,就开始发出报警,提前排查问题或者扩容。

3) 给一些没有必要长期保存的key,尽量设置过期时间。

3、缓存是否允许丢失?丢失了怎么办?

根据业务场景判断,是否允许丢失。如果不允许,就需要带持久化功能的缓存服务来支持,比如Redis或者Tair。更细节的话,可以根据业务对丢失时间的容忍度,还可以选择更具体的持久化策略,比如Redis的RDB或者AOF。

缓存问题

1、缓存穿透

描述:缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为id为“-1”的数据或id为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。

解决方案:

1) 接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截;

2) 从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-null,缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击。

2、缓存击穿

描述:缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。

解决方案:

1) 设置热点数据永远不过期。

2) 加互斥锁,业界比较常用的做法,是使用mutex。简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去load db,而是先使用缓存工具的某些带成功操作返回值的操作(比如Redis的SETNX或者Memcache的ADD)去set一个mutex key,当操作返回成功时,再进行load db的操作并回设缓存;否则,就重试整个get缓存的方法。类似下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
scss复制代码public String get(key) {
String value = redis.get(key);
if (value == null) { //代表缓存值过期
//设置3min的超时,防止del操作失败的时候,下次缓存过期一直不能load db
if (redis.setnx(key_mutex, 1, 3 * 60) == 1) { //代表设置成功
value = db.get(key);
redis.set(key, value, expire_secs);
redis.del(key_mutex);
} else { //这个时候代表同时候的其他线程已经load db并回设到缓存了,这时候重试获取缓存值即可
sleep(50);
get(key); //重试
}
} else {
return value;
}
}

3、缓存雪崩

描述:缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至down机。

和缓存击穿不同的是,缓存击穿是并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。

解决方案:

1)缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。

2)如果缓存系统是分布式部署,将热点数据均匀分布在不同的缓存节点中。

3)设置热点数据永远不过期。

4、缓存更新

Cache Aside 模式:这是最常用最常用的pattern了。其具体逻辑如下:

失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。

命中:应用程序从cache中取数据,取到后返回。

更新:先把数据存到数据库中,成功后,再让缓存失效。

4、异步

使用场景

针对某些客户端的请求,在服务端可能需要针对这些请求做一些附属额外的事情,这些事情其实用户并不关心或者不需要立即拿到这些事情的处理结果,这种情况就比较适合用异步的方式去处理。

作用

异步处理的好处:

1) 缩短接口响应时间,使用户的请求快速返回,用户体验更好。

2) 避免线程长时间处于运行状态,这样会引起服务线程池的可用线程长时间不够用,进而引起线程池任务队列长度增大,从而阻塞更多请求任务,使得更多请求得不到及时处理。

3) 提升服务的处理性能。

实现方式

1、线程(线程池)

采用额外开辟一个线程或者使用线程池的做法,在IO线程(处理请求响应)之外的线程来处理相应的任务,在IO线程中让response先返回。

如果异步线程处理的任务设计的数据量非常大,那么可以引入阻塞队列BlockingQueue作进一步的优化。具体做法是让一批异步线程不断地往阻塞队列里添加要处理的数据,然后额外起一个或一批处理线程,循环批量从队列里拿预设大小的数据,来进行批处理,这样进一步提高了性能。

2、消息队列(MQ)

使用消息队列(MQ)中间件服务,MQ天生就是异步的。一些额外的任务,可能不需要这个系统来处理,但是需要其他系统来处理。这个时候可以先把它封装成一个消息,扔到消息队列里面,通过消息中间件的可靠性保证把消息投递到关心它的系统,然后让其他系统来做相应的处理。

5、NoSQL

和缓存的区别

这里介绍的NoSQL和缓存不一样,虽然可能会使用一样的数据存储方案(比如Redis或者Tair),但是使用的方式不一样,这一节介绍的是把它作为DB来用。如果当作DB来用,需要有效保证数据存储方案的可用性、可靠性。

使用场景

需要结合具体的业务场景,看这块业务涉及的数据是否适合用NoSQL来存储,对数据的操作方式是否适合用NoSQL的方式来操作,或者是否需要用到NoSQL的一些额外特性(比如原子加减等)。

如果业务数据不需要和其他数据作关联,不需要事务或者外键之类的支持,而且有可能写入会异常频繁,这个时候就比较适合用NoSQL(比如HBase)。监控类、日志类系统通常会采集大量的时序数据,这类时序指标数据往往都是“读少写多”的类型,可以使用Elasticsearch、OpenTSDB等。

6、多线程与分布式

使用场景

离线任务、异步任务、大数据任务、耗时较长任务的运行,适当地利用,可达到加速的效果。系列多线程教程请关注公众号Java技术栈阅读,都是实战干货。

注意:线上对响应时间要求较高的场合,尽量少用多线程,尤其是服务线程需要等待任务线程的场合(很多重大事故就是和这个息息相关),如果一定要用,可以对服务线程设置一个最大等待时间。

常见做法

如果单机的处理能力可以满足实际业务的需求,那么尽可能地使用单机多线程的处理方式,减少复杂性;反之,则需要使用多机多线程的方式。

对于单机多线程,可以引入线程池的机制,作用有二:

1) 提高性能,节省线程创建和销毁的开销。

2) 限流,给线程池一个固定的容量,达到这个容量值后再有任务进来,就进入队列进行排队,保障机器极限压力下的稳定处理能力在使用JDK自带的线程池时,一定要仔细理解构造方法的各个参数的含义,如core pool size、max pool size、keepAliveTime、worker queue等,在理解的基础上通过不断地测试调整这些参数值达到最优效果。

如果单机的处理能力不能满足需求,这个时候需要使用多机多线程的方式。这个时候就需要一些分布式系统的知识了,可以选用一些开源成熟的分布式任务调度系统如xxl-job。

7、JVM优化

个人主要的后端语言是JAVA,对JVM进行优化也能一定程度上的提升JAVA程序的性能。JVM通常能够在软件开发后期进行,如在开发完毕或者是软件开发的某一里程碑阶段,JVM的各项參数将会直接影响JAVA程序的性能。

性能指标

关注以下指标:CPU使用率、CPU load、GC count、GC time、GC日志

查看java进程GC状态:jstat -gcutil {pid} 1000

查看java进程CPU高原因:

1) 获取java进程pid:ps –ef|grep java

2) 分析是哪个线程占用率过高:top -H -p ‘PID’

3) 线程id转换为16进制:printf “%x\n” ‘NID’

4) Jstack查看线程堆栈:jstack PID | grep ‘NID’ -C行数 –color

推荐2个java工具:

1)show-busy-java-threads

github.com/oldratlee/u…

2)arthas

alibaba.github.io/arthas/inde…

优化方向

比如,JVM的堆大小(Xms、Xmx),垃圾回收策略等。

要进行JVM层面的调优,需要对JVM的执行原理有一定的了解,如内存的结构,GC的种类等,然后根据应用程序的特点设置合理的JVM參数,但是GC tuning is the last task to be done.大家关注公众号Java技术栈回复jvm46可以获取一份JVM调优指南。

参考:

  • tech.meituan.com/2016/12/02/…
  • blog.csdn.net/qq\_4289489…
  • www.cnblogs.com/java-chen-h…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

现代IM架构研究笔记(一):瓜子IM和OpenIM

发表于 2021-08-23

这是我参与8月更文挑战的第3天,活动详情查看:8月更文挑战

背景

在上一篇文章中:Golang中如何正确的使用sarama包操作Kafka?,我介绍了Kafka在golang中的使用,以及常见的丢消息、乱序、重复消费等问题和在IM中的解决思路。

那么在这一篇中,我们从更高的层面来关注一下,模块之间应该怎么使用Kafka。

这里的思路以 传统架构(TeamTalk) -> 瓜子IM(生产级,无代码) -> OpenIM(开源新秀)来介绍。

何谓“传统”:单进程或者多进场思路,用户量在十万级别以内。

何谓“现代”:使用微服务分布式架构,用户量在百万级别以上。

传统架构

传统的架构(十万级用户量)还是基于多进程思想,这里以TeamTalk为例,TeamTalk是蘑菇街5年前(2015年)开源的内部企业通讯软件,当时还火爆了一下,很多人纷纷研究,各种分析文章满天飞。它的架构如图所示:

AE8D53CC-3AE9-40BB-A140-83FB4B7E0544.png

简单介绍一下工作原理:

  • login:客户端先通过http发到login(这里应该叫rebanlancer,负载均衡),获取一个低负载(登录用户数量,即tcp连接数)的msg IP地址
  • msg:然后通过tcp连接msg,进行登录认证、查询会话、收发消息等。msg在整个架构中就是一个网关的角色,主要负责管理客户端的tcp连接和与客户端通信,业务实现和消息存储都在dbproxy中。因为msg是无状态设计,所以是可以水平复制的。技术实现上主要使用epoll I/O复用,是单进程方式。这样一台支持3-5万连接没啥问题,部署2-3台就可以实现10万的用户在线了。
  • route:msg之间通过一个路由服务进行通信,以解决在不同的msg上用户消息传递的问题。
  • dbproxy:业务实现和消息存储,也是无状态设计,所以可以部署2个,一个用来处理登录业务,一个做负责消息业务。
  • 还有其他一些模块,这里就不展开讲了,比如httpmsg提供http接口给外部进行调用,推送服务,文件服务等等。

这个架构,在10万用户级别是完全没问题的,适合企业内部通讯这种场景,但是如果在百万的用户级别下,就有一些问题:

  1. 单点故障。route,所有msg都需要连接到一台route上,进行路由分发。那么这个服务挂了,就完蛋了。当然,也可以在运维层面通过Haproxy和KeepAlive等解决。
  2. msg直连dbproxy,如果某一段时间请求很密集,势必造成dbproxy压力陡增。虽然dbproxy使用了线程池和局部任务队列排队处理,但是因为是FCFS(先来先处理),请求积压了,会造成后面的请求失败、超时等问题。
  3. dbproxy是1个大模块,改动代价很高。如果增加一个功能没测试好,发布上线后崩溃或者有BUG整个服务将无法使用。另外一点就是,各种业务的频率也是不一样的,比如登录因为用户不停打开APP,所以最频繁,应该拆分开。使用不同的mysql,降低压力。

所以,势必对架构进行升级,才能扛得住更大的量。route的问题先放一放,msg和dbproxy这一层,我们可以通过引入Kafka解决。

这也是我认为,现代IM架构都会使用MQ的原因之一,引入Kafka的好处:

  • 流量削峰:Kafka QPS是百万级的,mysql QPS 不太好计算,但是差不多几千到几万,肯定比Kafka慢很多,所以消息在存储进mysql之前,让Kafka这个大肚子挡一下,以避免一下子把Mysql打死。
  • 解耦合:服务的可用性毋庸置疑的重要,通过引入Kafka,我们把系统间的强依赖变成了基于数据的弱依赖,原来修改一个模块可能其他模块也会受影响。现在,由于Kafka不同消费者之间可以同时消费消息,所以我完全可以把大模块拆成一个个小的模块,处理自己感兴趣的消息。

上面2点是我觉得从十万跨越到百万最重要的点。

现代架构

我最近主要在研究瓜子IM(瓜子二手车业务)和openIM,其中瓜子IM的作者封宇大神分享了一系列文章,里面有详尽的时序图、协议设计、分库分表、TimeLine模型同步等等(文章最后有地址),看完之后会有一个大概的认识。Open-IM-Server是前微信技术专家创业的开源项目,使用go语言开发,使用的收件箱模型,能很好的结合瓜子IM的设计文档,加深理解。

瓜子IM

(图片来源:公众号-普通程序员 作者-封宇)

4B97B894-FE2F-418E-AA00-5C1C832106A0.webp

作者分享的架构,一开始其实并没有Kafka这一层,具体可以参考:

  • 一个海量在线用户即时通讯系统(IM)的完整设计Plus mp.weixin.qq.com/s/TYUNPgf_3…

看完之后,你可能和我一样有所疑惑:这里面Kafka到底要怎么用🤔?

Opem-IM-Server

(图片来源:github.com/OpenIMSDK/O…)

682FA6E5-080F-4BFF-B2D9-C6131A9C35B6.jpg

这个作者好像是微信团队出来的,所以架构的设计上天然就是基于收件箱+写扩散模式,目前还在完善中,已经实现了基本的单聊和群里功能。

我们以上图为例,你可能我和一样,第一眼以为发消息是这个流程:

  1. gate网关生产2个消息到kafka(上图步骤2和3),然后回复发送者发送成功(步骤4)。
  2. 通过不同的consume name,实现多个服务同时消费这个消息。在config.yaml中可以看到针对Kafka的配置:2个topic,3个消费组
1
2
3
4
5
6
7
8
9
10
kafka:复制代码  ws2mschat:
    addr: [ 127.0.0.1:9092 ]
    topic: "ws2ms_chat"
  ms2pschat:
    addr: [ 127.0.0.1:9092 ]
    topic: "ms2ps_chat"
  consumergroupid:
    msgToMongo: mongo
    msgToMySql: mysql
    msgToPush: push
  1. 其中transfer服务通过2个不同的消费组,同时把消息持久化到mysql和mongodb中
  2. 因为pusher和transfer是不同的消费组,所以pusher并发的把这个消息通过rpc调用gate,再转发给对方(扩散写,from发件箱和to收件箱各写一份)。如果对方处于离线状态,则通过第三方推送,否则,在线的情况下直接走WebSocket长连接。

看完之后,我不禁有2个疑问:

  • 消息序号(乱序)在哪个环节生成的,我怎么没看到?不然客户端如何做消息排序?
  • 消息都没有入mysql就判定成功,给客户端返回了是不是有问题?如果Kafka丢消息了,可能会出现数据不一致?

第2个疑问,我已经在文章:Golang中如何正确的使用sarama包操作Kafka?中进行了说明,只要producer和comsumer使用正确,最终数据会一致。为什么这里不要求强一致性?我的理解是,对于在线的用户,其实已经通过push进行了推送,客户端自己会对数据进行对齐(本地存储)。离线的客户端上线拉取的时候再对齐,这中间是有充分的时间让我们服务处理的,当然出现了BUG(消费者挂了)另说。

所以,下面主要来分析一下第一个问题。

细究Open-IM-Server中使用Kafka的流程

我画了一个简化版的图:

A43CB01D-944B-4EC4-B407-806E361CFC17.png

经过梳理和翻源码,我更正了几个误区:

  1. 并不是msg-gateway把消息投递到Kafka,而是通过gRPC调用Chat服务,Chat中会生成2个消息发到Kafka(发件箱和收件箱)
  2. chat本地只生成MsgID(字符串,去重)和发送时间,然后发给给客户端,客户端拿服务端生成的msgID去重和发送时间进行排序。
  3. 消息序号的生成是在transfer的消费者中。一开始我以为没有seq,后面才发现是在写mongodb之前分配了一个seq。这是不是有点问题?应该在chat中生成seq啊,不然客户端pull的时候拿到seq,发送的时候拿到sendTime以那个字段为主呢,莫不是自己构造一个seq?
  4. 在Kafka中,只要消费组名不一样,就可以多次消费,所以上面有3个消费组,分别进行mysql的持久化,离线消息存储mongodb以及推送任务。
  5. transfer的mongo消费者,写入mongodb后(如果客户端设置了 isHistory 字段),会先尝试gRPC直接调用push给客户端推消息,否则通过Kafka再绕一圈。

chat服务send_msg.go的关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
go复制代码func GetMsgID(sendID string) string {
   t := time.Now().Format("2006-01-02 15:04:05")
   return t + "-" + sendID + "-" + strconv.Itoa(rand.Int())
}

func (rpc *rpcChat) UserSendMsg(_ context.Context, pb *pbChat.UserSendMsgReq) (*pbChat.UserSendMsgResp, error) {
    log.InfoByKv("sendMsg", pb.OperationID, "args", pb.String())

    // 这里获取一个消息ID,见上面的实现,通过时间戳+随机的一串数字,还不是定长的,实现上有点缺陷。
    serverMsgID := GetMsgID(pb.SendID)
    pbData := pbChat.WSToMsgSvrChatMsg{}
    pbData.MsgFrom = pb.MsgFrom
    pbData.SessionType = pb.SessionType

    // 发送时间以服务的接收到的时间为主,因为客户端的时间不可靠
    // 这里OpenIMServer没有使用单独的字段来进行排序,而是使用的发送时间,个人感觉有点缺陷
    // 部署多台机器,时间可能会出现不一致,使用ntpd校时也不能保证百分百一样
    pbData.SendTime = utils.GetCurrentTimestampByNano()
    // .. 
    switch pbData.SessionType {
        case constant.SingleChatType:
            // 发到kafka,扩散写的方式
            // 自己的发件箱,对方的收件箱
            err1 := rpc.sendMsgToKafka(&pbData, pbData.RecvID)
            err2 := rpc.sendMsgToKafka(&pbData, pbData.SendID)
            if err1 != nil || err2 != nil {
                return returnMsg(&replay, pb, 201, "kafka send msg err", "", 0)
            }
            return returnMsg(&replay, pb, 0, "", serverMsgID, pbData.SendTime)
        case constant.GroupChatType:
            // ...
            return returnMsg(&replay, pb, 0, "", serverMsgID, pbData.SendTime)
        default:
    }
    // ...
}

transfer的mongo消费者关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
go复制代码func (mc *HistoryConsumerHandler) handleChatWs2Mongo(msg []byte, msgKey string) {
   log.InfoByKv("chat come mongo!!!", "", "chat", string(msg))
   pbData := pbMsg.WSToMsgSvrChatMsg{}
   err := proto.Unmarshal(msg, &pbData)

   if err != nil {
      log.ErrorByKv("msg_transfer Unmarshal chat err", "", "chat", string(msg), "err", err.Error())
      return
   }

   pbSaveData := pbMsg.MsgSvrToPushSvrChatMsg{}
   pbSaveData.SendID = pbData.SendID
   pbSaveData.SenderNickName = pbData.SenderNickName
   pbSaveData.SenderFaceURL = pbData.SenderFaceURL
   pbSaveData.ClientMsgID = pbData.ClientMsgID
   pbSaveData.SendTime = pbData.SendTime
   pbSaveData.Content = pbData.Content
   pbSaveData.MsgFrom = pbData.MsgFrom
   pbSaveData.ContentType = pbData.ContentType
   pbSaveData.SessionType = pbData.SessionType
   pbSaveData.MsgID = pbData.MsgID
   pbSaveData.OperationID = pbData.OperationID
   pbSaveData.RecvID = pbData.RecvID
   pbSaveData.PlatformID = pbData.PlatformID
   Options := utils.JsonStringToMap(pbData.Options)
   //Control whether to store offline messages (mongo)
   isHistory := utils.GetSwitchFromOptions(Options, "history")
   //Control whether to store history messages (mysql)

   isPersist := utils.GetSwitchFromOptions(Options, "persistent")

   if pbData.SessionType == constant.SingleChatType {
      log.Info("", "", "msg_transfer chat type = SingleChatType", isHistory, isPersist)

      if isHistory { // 客户端启用了存储消息
         if msgKey == pbSaveData.RecvID {
            err := saveUserChat(pbData.RecvID, &pbSaveData)
            if err != nil {
               log.ErrorByKv("data insert to mongo err", pbSaveData.OperationID, "data", pbSaveData.String(), "err", err.Error())
            }
         } else if msgKey == pbSaveData.SendID {
            err := saveUserChat(pbData.SendID, &pbSaveData)
            if err != nil {
               log.ErrorByKv("data insert to mongo err", pbSaveData.OperationID, "data", pbSaveData.String(), "err", err.Error())
            }
         }
      }

      if msgKey == pbSaveData.RecvID {
         pbSaveData.Options = pbData.Options
         pbSaveData.OfflineInfo = pbData.OfflineInfo
         sendMessageToPush(&pbSaveData)
      }
      log.InfoByKv("msg_transfer handle topic success...", "", "")

   } else if pbData.SessionType == constant.GroupChatType {
      log.Info("", "", "msg_transfer chat type = GroupChatType")

      if isHistory {
         uidAndGroupID := strings.Split(pbData.RecvID, " ")
         saveUserChat(uidAndGroupID[0], &pbSaveData)
      }

      pbSaveData.Options = pbData.Options
      pbSaveData.OfflineInfo = pbData.OfflineInfo
      sendMessageToPush(&pbSaveData)
      log.InfoByKv("msg_transfer handle topic success...", "", "")
   } else {
      log.Error("", "", "msg_transfer recv chat err, chat.MsgFrom = %d", pbData.SessionType)
   }
}

func sendMessageToPush(message *pbMsg.MsgSvrToPushSvrChatMsg) {
   log.InfoByKv("msg_transfer send message to push", message.OperationID, "message", message.String())

   msg := pbPush.PushMsgReq{}
   msg.OperationID = message.OperationID
   msg.PlatformID = message.PlatformID
   msg.Content = message.Content
   msg.ContentType = message.ContentType
   msg.SessionType = message.SessionType
   msg.RecvID = message.RecvID
   msg.SendID = message.SendID
   msg.SenderNickName = message.SenderNickName
   msg.SenderFaceURL = message.SenderFaceURL
   msg.ClientMsgID = message.ClientMsgID
   msg.MsgFrom = message.MsgFrom
   msg.Options = message.Options
   msg.RecvSeq = message.RecvSeq
   msg.SendTime = message.SendTime
   msg.MsgID = message.MsgID
   msg.OfflineInfo = message.OfflineInfo

   // 先尝试gRPC,否则直接发到Kafka,让push从Kafka消费然后推送
   grpcConn := getcdv3.GetConn(config.Config.Etcd.EtcdSchema, strings.Join(config.Config.Etcd.EtcdAddr, ","), config.Config.RpcRegisterName.OpenImPushName)

   if grpcConn == nil {
      log.ErrorByKv("rpc dial failed", msg.OperationID, "push data", msg.String())
      pid, offset, err := producer.SendMessage(message)
      if err != nil {
         log.ErrorByKv("kafka send failed", msg.OperationID, "send data", message.String(), "pid", pid, "offset", offset, "err", err.Error())
      }
      return
   }

   msgClient := pbPush.NewPushMsgServiceClient(grpcConn)
   _, err := msgClient.PushMsg(context.Background(), &msg)
   if err != nil {
      log.ErrorByKv("rpc send failed", msg.OperationID, "push data", msg.String(), "err", err.Error())
      pid, offset, err := producer.SendMessage(message)
      if err != nil {
         log.ErrorByKv("kafka send failed", msg.OperationID, "send data", message.String(), "pid", pid, "offset", offset, "err", err.Error())
      }
   } else {
      log.InfoByKv("rpc send success", msg.OperationID, "push data", msg.String())
   }
}

用时序图梳理一下整个过程:

openimserver_seq.png

  1. 客户端通过webSocket发送消息到msg_gateway
  2. msg_gateway通过gRPC调用chat的 UserSendMsg() 发送消息
  3. chat服务主要是本地生成唯一消息ID(去重)和发送时间
  4. 然后投递到Kafka,等待所有Kafka的Slave都收到消息后判断发送成功
  5. gRPC返回
  6. 给客户端回复ACK,携带错误码和服务端生成的MsgID等
  7. transfer中的消费组mysql消费到2条消息(发送者的发件箱、接收者的收件箱)
  8. 持久化到mysql中全量存储,主要是应对后台分析、审计等需求,客户端是从mongodb中拉取的(拉取后删除),这里和微信逻辑类似。微信号称不在服务器存储数据,所以你用微信登录PC端时,你会发现刚刚手机上的消息在PC上怎么看不到?要么是PC端没有pull的过程,要么是离线消息只针对APP端,PC端拉不到。
  9. 同理,transfer中消费组mongodb消费到2条消息
  10. 调用redis的incr,递增用户的消息序号,key格式为:”REDIS_USER_INCR_SEQ: “ + UserID,所以是用户范围内递增,因为本身用户只有一个收件箱,没毛病。
  11. 插入mongodb中的chat collection
  12. 优先通过gRPC调用pusher进行推送,否则走Kafka,通过Pusher消费的方式推送
  13. pusher也同样通过gRPC调用msg_gateway的MsgToUser推送消息
  14. 通过websocket推送
  15. 用户b上线的时候,通过pull从mongodb中拉取离线消息(成功后会从mongodb中删除)
  16. …

尾语

至此,在IM中如何使用Kafka已经相对清晰了,更多的一些细节读者可以通过代码来进一步了解。

参考

公众号——普通程序员:

  • 一个海量在线用户即时通讯系统(IM)的完整设计Plus
  • 一个海量在线用户即时通讯系统(IM)的完整设计
  • IM系统的SESSION结构
  • IM移动端怎么搜索本地聊天记录
  • 基于TimeLine模型的消息同步机制
  • TimeLine模型下确保消息有序不丢

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…552553554…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%