├── .idea ├── .gitignore ├── checkstyle-idea.xml ├── dataSources.xml ├── inspectionProfiles │ └── Project_Default.xml ├── misc.xml ├── modules.xml ├── susanSayJava.iml └── vcs.xml ├── README.md └── docs ├── mybatis └── mybatis的日志功能是如何设计的?.md ├── spring ├── @Autowired的这些新姿势,你学会了.md ├── @Value竟然能玩出这么多花样.md ├── spring aop聊点不一样的东西.md ├── spring中那些让你爱不释手的代码技巧.md ├── spring中那些让你爱不释手的代码技巧(续集).md ├── spring:我是如何解决循环依赖的?.md ├── 使用spring cache让我的接口性能瞬间提升了100倍.md ├── 厉害了,spring中竟然有12种定义bean的方法.md ├── 消除if...else是9条锦囊妙计.md ├── 聊聊spring事务失效的12种场景,太坑了.md └── 让人头痛的大事务问题要如何解决?.md ├── 其他 ├── 强烈推荐 | 阿里开源的这10个神级项目.md ├── 微信一面,过了.md ├── 我的公众号万粉之路.md └── 我的第一个10万.md ├── 基础 ├── java中那些让你傻傻分不清楚的小细节.md ├── 单例模式,真不简单.md └── 迷茫了,我们到底该不该用lombok?.md ├── 多线程 ├── 深入剖析ThreadLocal.md ├── 线程池最佳线程到底要如何配置?.md └── 这8种保证线程安全的技术你都知道吗?.md ├── 学习路线 └── Java后端学习路线.png ├── 工具 ├── 使用了这个神器,让我的代码bug少了一半.md ├── 求你别再用swagger了,给你推荐一个在线文档神器.md ├── 这11款chrome神器,用起来爽到爆.md └── 这样写代码,真是帅到没有朋友.md ├── 数据库 ├── innodb是如何存数据的?yyds.md ├── 卧槽,sql注入竟然把我们系统搞挂了.md ├── 盘点一下数据库误操作有哪些后悔药?.md ├── 聊聊sql优化的15个小技巧.md ├── 聊聊索引失效的10种场景,太坑了.md └── 阿里二面:我们为什么要做分库分表?.md ├── 线上问题 ├── 一个地区问题,竟然让我们大战了三百回合.md └── 线上一次诡异的NPE问题,竟然反转了4次.md └── 高并发 ├── 我用kafka两年踩过的一些非比寻常的坑.md ├── 聊聊redis分布式锁的8大坑.md ├── 面试必考:如何设计秒杀系统?.md ├── 面霸:mq的6大关键问题.md └── 高并发下如何保证接口幂等性?.md /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Default ignored files 3 | /workspace.xml 4 | # Datasource local storage ignored files 5 | /dataSources.local.xml 6 | /dataSources/ -------------------------------------------------------------------------------- /.idea/checkstyle-idea.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 15 | 16 | -------------------------------------------------------------------------------- /.idea/dataSources.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | mysql.8 6 | true 7 | 本地测试 8 | com.mysql.cj.jdbc.Driver 9 | jdbc:mysql://localhost:3306/sue 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 36 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/susanSayJava.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /docs/mybatis/mybatis的日志功能是如何设计的?.md: -------------------------------------------------------------------------------- 1 | ## 引言 2 | 我们在使用mybatis时,如果出现sql问题,一般会把mybatis配置文件中的logging.level参数改成debug,这样就能在日志中看到某个mapper最终执行sql、入参和影响数据行数。我们拿到sql和入参,手动拼接成完整的sql,然后将该sql在数据库中执行一下,就基本能定位到问题原因。mybatis的日志功能使用起来还是非常方便的,大家有没有想过它是如何设计的呢? 3 | 4 | ## 从logging目录开始 5 | 我们先看一下mybatis的logging目录,该目录的功能决定了mybatis使用什么日志工具打印日志。 6 | 7 | logging目录结构如下: 8 | 9 | ![](https://pic.imgdb.cn/item/610fe9705132923bf81bc1a7.jpg) 10 | 11 | 它里面除了jdbc目录,还包含了7个子目录,每一个子目录代表一种日志打印工具,目前支持6种日志打印工具和1种非日志打印工具。我们用一张图来总结一下 12 | 13 | ![](https://pic.imgdb.cn/item/610fe9835132923bf81bf1c9.jpg) 14 | 15 | 除了上面的8种日志工具之外,它还抽象出一个Log接口,所有的日志打印工具必须实现该接口,后面可以面向接口编程。定义了LogException异常,该异常是日志功能的专属异常,如果你有看过mybatis其他源码的话,不难发现,其他功能也定义专属异常,比如:DataSourceException等,这是mybatis的惯用手法,主要是为了将异常细粒度的划分,以便更快定位问题。 16 | 17 | 此外,它还定义了LogFactory日志工厂,以便于屏蔽日志工具实例的创建细节,让用户使用起来更简单。 18 | 19 | ### 如果是你该如何设计这个功能? 20 | 我们按照上面目录结构的介绍其实已经有一些思路: 21 | 22 | 1. 定义一个Log接口,以便于统一抽象日志功能,这8种日志功能都实现Log接口,并且重写日志打印方法。 23 | 2. 定义一个LogFactory日志工厂,它会根据我们项目中引入的某个日志打印工具jar包,创建一个具体的日志打印工具实例。 24 | 25 | 看起来,不错。但是,再仔细想想,LogFactory中如何判断项目中引入了某个日志打印工具jar包才创建相应的实例呢?我们第一个想到的可能是用if...else判断不就行了,再想想感觉用if...else不好,7种条件判断太多了,并非优雅的编程。这时候,你会想一些避免太长if...else判断的方法,当然如果你看过我之前写的文章《消除if...else的9条锦囊妙计》,可能已经学到了几招,但是mybatis却用了一个新的办法。 26 | 27 | ### mybatis是如何设计这个功能的? 28 | ### 1.从Log接口开始 29 | ![](https://pic.imgdb.cn/item/610fea0b5132923bf81d0b7f.jpg) 30 | 31 | 它里面抽象了日志打印的5种方法和2种判断方法。 32 | 33 | ### 2.再分析LogFactory的代码 34 | ![](https://pic.imgdb.cn/item/610fea3d5132923bf81d66ab.jpg) 35 | 36 | 它里面定义了一个静态的构造器logConstructor,没有用if...else判断,在static代码块中调用了6个tryImplementation方法,该方法会启动一个执行任务去调用了useXXXLogging方法,创建日志打印工具实例。 37 | 38 | ![](https://pic.imgdb.cn/item/610fea545132923bf81d8c56.jpg) 39 | 40 | 当然tryImplementation方法在执行前会判断构造器logConstructor为空才允许执行任务中的run方法。下一步看看useXXXLogging方法: 41 | ![](https://pic.imgdb.cn/item/610fea915132923bf81e1ab5.jpg) 42 | 43 | 看到这里,聪明的你可能会有这样的疑问,从上图可以看出mybatis定义了8种useXXXLogging方法,但是在前面的static静态代码块中却只调用了6种,这是为什么? 44 | 45 | 对比后发现:useCustomLogging 和 useStdOutLogging 前面是没调用的。useStdOutLogging它里面使用了StdOutImpl类 46 | 47 | ![](https://pic.imgdb.cn/item/610feb005132923bf81f19f4.jpg) 48 | 49 | 该类其实就是通过JDK自带的System类的方法打印日志的,无需引入额外的jar包,所以不参与static代码块中的判断。 50 | 51 | 而useCustomLogging方法需要传入一个实现了Log接口的类,如果mybatis默认提供的6种日志打印工具不满足要求,以便于用户自己扩展。 52 | 53 | 而这个方法是在Configuration类中调用的,如果用户有自定义logImpl参数的话。 54 | ![](https://pic.imgdb.cn/item/610feb1a5132923bf81f5338.jpg) 55 | 56 | ![](https://pic.imgdb.cn/item/610feb295132923bf81f7093.jpg) 57 | 58 | 具体是在XMLConfigBuilder类的settingsElement方法中调用 59 | ![](https://pic.imgdb.cn/item/610feb3e5132923bf81f9d03.jpg) 60 | 61 | 再回到前面LogFactory的setImplementation方法 62 | ![](https://pic.imgdb.cn/item/610feb505132923bf81fc540.jpg) 63 | 64 | 它会先找到实现了Log接口的类的构造器,返回将该构造器赋值给全局的logConstructor。 65 | 66 | 这样一来,就可以通过getLog方法获取到Log实例。 67 | ![](https://pic.imgdb.cn/item/610feb645132923bf81feec5.jpg) 68 | 69 | 然后在业务代码中通过下面这种方式获取Log对象,调用它的方法打印日志了。 70 | ![](https://pic.imgdb.cn/item/610feb785132923bf8201be7.jpg) 71 | 72 | 梳理一下LogFactory的流程: 73 | 74 | - 在static代码块中根据逐个引入日志打印工具jar包中的日志类,先判断如果全局变量logConstructor为空,则加载并获取相应的构造器,如果可以获取到则赋值给全局变量logConstructor。 75 | - 如果全局变量logConstructor不为空,则不继续获取构造器。 76 | - 根据getLog方法获取Log实例 77 | - 通过Log实例的具体日志方法打印日志 78 | 79 | 在这里还分享一个知识点,如果某个工具类里面都是静态方法,那么要把该工具类的构造方法定义成private的,防止被疑问调用,LogFactory就是这么做的。 80 | ![](https://pic.imgdb.cn/item/610feba35132923bf8207819.jpg) 81 | 82 | ### 3.适配器模式 83 | 日志模块除了使用工厂模式之外,还是有了适配器模式。 84 | 85 | > 适配器模式会将所需要适配的类转换成调用者能够使用的目标接口 86 | 87 | 涉及以下几个角色: 88 | 89 | - 目标接口( Target ) 90 | - 需要适配的类( Adaptee ) 91 | - 适配器( Adapter) 92 | ![](https://pic.imgdb.cn/item/610fec0a5132923bf8216ede.jpg) 93 | 94 | mybatis是怎么用适配器模式的? 95 | ![](https://pic.imgdb.cn/item/610fec2f5132923bf821c297.jpg) 96 | 97 | 上图中标红的类对应的是Adapter角色,Log是Target角色。 98 | ![](https://pic.imgdb.cn/item/610fec3f5132923bf821e500.jpg) 99 | 100 | 而LogFactory就是Adaptee,它里面的getLog方法里面包含是需要适配的对象。 101 | 102 | ## sql执行日志打印原理 103 | 从上面已经能够确定使用哪种日志打印工具,但在sql执行的过程中是如何打印日志的呢?这就需要进一步分析logging目录下的jdbc目录了。 104 | 105 | ![](https://pic.imgdb.cn/item/610fec6b5132923bf8224afc.jpg) 106 | 107 | 看看这几个类的关系图: 108 | 109 | ![](https://pic.imgdb.cn/item/610fec7a5132923bf8226b75.jpg) 110 | 111 | ConnectionLogger、PreparedStatementLogger、ResultSetLogger和StatementLogger都继承了BaseJdbcLogger类,并且实现了InvocationHandler接口。从类名非常直观的看出,这4种类对应的数据库jdbc功能。 112 | 113 | ![](https://pic.imgdb.cn/item/610fecbf5132923bf822fe95.jpg) 114 | 115 | 它们实现了InvocationHandler接口意味着它用到了动态代理,真正起作用的是invoke方法,我们以ConnectionLogger为例: 116 | ![](https://pic.imgdb.cn/item/610feceb5132923bf8235cf0.jpg) 117 | 118 | 如果调用了prepareStatement方法,则会打印debug日志。 119 | ![](https://pic.imgdb.cn/item/610fed045132923bf8239103.jpg) 120 | 121 | 上图中传入的original参数里面包含了\n\t等分隔符,需要将分隔符替换成空格,拼接成一行sql。 122 | 123 | 最终会在日志中打印sql、入参和影响行数: 124 | ![](https://pic.imgdb.cn/item/610fed1c5132923bf823c49e.jpg) 125 | 126 | 上图中的sql语句是在ConnectionLogger类中打印的 127 | 128 | 那么入参和影响行数呢? 129 | 130 | 入参在PreparedStatementLogger类中打印的 131 | ![](https://pic.imgdb.cn/item/610fed3e5132923bf82409e6.jpg) 132 | 133 | 影响行数在ResultSetLogger类中打印的 134 | ![](https://pic.imgdb.cn/item/610fed4f5132923bf8242d91.jpg) 135 | 136 | 大家需要注意的一个地方是:sql、入参和影响行数只打印了debug级别的日志,其他级别并没打印。所以需要在mybatis配置文件中的logging.level参数配置成debug,才能打印日志。 137 | 138 | ## 彩蛋 139 | 不知道大家有没有发现这样一个问题: 140 | 141 | 在LogFactory的代码中定义了很多匿名的任务执行器 142 | ![](https://pic.imgdb.cn/item/610fed715132923bf824756a.jpg) 143 | 144 | 但是在实际调用时,却没有在线程中执行,而是直接调用的,这是为什么? 145 | 146 | ![](https://pic.imgdb.cn/item/610fed825132923bf82498c3.jpg) 147 | 148 | 答案是为了保证顺序执行,如果所有的日志工具jar包都有,加载优先级是:slf4j 》commonsLog 》log4j2 》log4j 》jdkLog 》NoLog 149 | 150 | 还有个问题,顺序执行就可以了,为什么要把匿名内部类定义成Runnable的呢? 151 | 152 | 这里非常有迷惑性,因为它没创建Thread类,并不会多线程执行。我个人认为,这里是mybatis的开发者的一种偷懒,不然需要定义一个新类代替这种执行任务的含义,还不如就用已有的。 -------------------------------------------------------------------------------- /docs/spring/@Autowired的这些新姿势,你学会了.md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 最近review别人代码的时候,看到了一些`@Autowired`不一样的用法,觉得有些意思,特定花时间研究了一下,收获了不少东西,现在分享给大家。 3 | 4 | 也许`@Autowired`比你想象中更强大。 5 | 6 | ![](https://pic.imgdb.cn/item/610e8adb5132923bf8ddb066.jpg) 7 | 8 | 9 | ## 1. @Autowired的默认装配 10 | 我们都知道在spring中@Autowired注解,是用来自动装配对象的。通常,我们在项目中是这样用的: 11 | ```java 12 | package com.sue.cache.service; 13 | 14 | import org.springframework.stereotype.Service; 15 | 16 | @Service 17 | public class TestService1 { 18 | public void test1() { 19 | } 20 | } 21 | ``` 22 | ```java 23 | package com.sue.cache.service; 24 | 25 | import org.springframework.stereotype.Service; 26 | 27 | @Service 28 | public class TestService2 { 29 | 30 | @Autowired 31 | private TestService1 testService1; 32 | 33 | public void test2() { 34 | } 35 | } 36 | ``` 37 | 没错,这样是能够装配成功的,因为默认情况下spring是按照类型装配的,也就是我们所说的`byType`方式。 38 | 39 | 此外,@Autowired注解的`required`参数默认是true,表示开启自动装配,有些时候我们不想使用自动装配功能,可以将该参数设置成false。 40 | 41 | 42 | ## 2. 相同类型的对象不只一个时 43 | 上面`byType`方式主要针对相同类型的对象只有一个的情况,此时对象类型是唯一的,可以找到正确的对象。 44 | 45 | 但如果相同类型的对象不只一个时,会发生什么? 46 | 47 | 在项目的test目录下,建了一个同名的类TestService1: 48 | ```java 49 | package com.sue.cache.service.test; 50 | 51 | import org.springframework.stereotype.Service; 52 | 53 | @Service 54 | public class TestService1 { 55 | 56 | public void test1() { 57 | } 58 | } 59 | ``` 60 | 重新启动项目时: 61 | ```java 62 | Caused by: org.springframework.context.annotation.ConflictingBeanDefinitionException: Annotation-specified bean name 'testService1' for bean class [com.sue.cache.service.test.TestService1] conflicts with existing, non-compatible bean definition of same name and class [com.sue.cache.service.TestService1] 63 | 64 | ``` 65 | 结果报错了,报类类名称有冲突,直接导致项目启动不来。 66 | 67 | > 注意,这种情况不是相同类型的对象在Autowired时有两个导致的,非常容易产生混淆。这种情况是因为spring的@Service方法不允许出现相同的类名,因为spring会将类名的第一个字母转换成小写,作为bean的名称,比如:testService1,而默认情况下bean名称必须是唯一的。 68 | 69 | 下面看看如何产生两个相同的类型bean: 70 | ```java 71 | public class TestService1 { 72 | 73 | public void test1() { 74 | } 75 | } 76 | ``` 77 | ```java 78 | @Service 79 | public class TestService2 { 80 | 81 | @Autowired 82 | private TestService1 testService1; 83 | 84 | public void test2() { 85 | } 86 | } 87 | ``` 88 | ```java 89 | @Configuration 90 | public class TestConfig { 91 | 92 | @Bean("test1") 93 | public TestService1 test1() { 94 | return new TestService1(); 95 | } 96 | 97 | @Bean("test2") 98 | public TestService1 test2() { 99 | return new TestService1(); 100 | } 101 | } 102 | ``` 103 | 在TestConfig类中手动创建TestService1实例,并且去掉TestService1类上原有的@Service注解。 104 | 105 | 重新启动项目: 106 | ![](https://pic.imgdb.cn/item/610e8af45132923bf8ddd9bf.jpg) 107 | 108 | 果然报错了,提示testService1是单例的,却找到两个对象。 109 | 110 | 其实还有一个情况会产生两个相同的类型bean: 111 | ```java 112 | public interface IUser { 113 | void say(); 114 | } 115 | ``` 116 | 117 | ```java 118 | @Service 119 | public class User1 implements IUser{ 120 | @Override 121 | public void say() { 122 | } 123 | } 124 | ``` 125 | ```java 126 | @Service 127 | public class User2 implements IUser{ 128 | @Override 129 | public void say() { 130 | } 131 | } 132 | ``` 133 | ```java 134 | @Service 135 | public class UserService { 136 | 137 | @Autowired 138 | private IUser user; 139 | } 140 | ``` 141 | 项目重新启动时: 142 | 143 | ![](https://pic.imgdb.cn/item/610e8b105132923bf8de0700.jpg) 144 | 145 | 报错了,提示跟上面一样,testService1是单例的,却找到两个对象。 146 | 147 | 第二种情况在实际的项目中出现得更多一些,后面的例子,我们主要针对第二种情况。 148 | 149 | ## 3. @Qualifier和@Primary 150 | 显然在spring中,按照Autowired默认的装配方式:byType,是无法解决上面的问题的,这时可以改用按名称装配:byName。 151 | 152 | 只需在代码上加上`@Qualifier`注解即可: 153 | ```java 154 | @Service 155 | public class UserService { 156 | 157 | @Autowired 158 | @Qualifier("user1") 159 | private IUser user; 160 | } 161 | ``` 162 | 只需这样调整之后,项目就能正常启动了。 163 | 164 | > Qualifier意思是合格者,一般跟Autowired配合使用,需要指定一个bean的名称,通过bean名称就能找到需要装配的bean。 165 | 166 | 除了上面的`@Qualifier`注解之外,还能使用`@Primary`注解解决上面的问题。在User1上面加上@Primary注解: 167 | ```java 168 | @Primary 169 | @Service 170 | public class User1 implements IUser{ 171 | @Override 172 | public void say() { 173 | } 174 | } 175 | ``` 176 | 去掉UserService上的@Qualifier注解: 177 | ```java 178 | @Service 179 | public class UserService { 180 | 181 | @Autowired 182 | private IUser user; 183 | } 184 | ``` 185 | 重新启动项目,一样能正常运行。 186 | 187 | > 当我们使用自动配置的方式装配Bean时,如果这个Bean有多个候选者,假如其中一个候选者具有@Primary注解修饰,该候选者会被选中,作为自动配置的值。 188 | 189 | ## 4. @Autowired的使用范围 190 | 上面的实例中@Autowired注解,都是使用在成员变量上,但@Autowired的强大之处,远非如此。 191 | 192 | 先看看@Autowired注解的定义: 193 | ![](https://pic.imgdb.cn/item/610e8adb5132923bf8ddb066.jpg) 194 | 195 | 从图中可以看出该注解能够使用在5种目标类型上,下面用一张图总结一下: 196 | 197 | ![](https://files.mdnice.com/user/5303/28abc125-7d38-4580-adba-d26c2c96c7c4.png) 198 | 199 | 该注解我们平常使用最多的地方可能是在成员变量上。 200 | 201 | 接下来,我们重点看看在其他地方该怎么用? 202 | 203 | ### 4.1 成员变量 204 | 在成员变量上使用Autowired注解: 205 | ```java 206 | @Service 207 | public class UserService { 208 | 209 | @Autowired 210 | private IUser user; 211 | } 212 | ``` 213 | 这种方式可能是平时用得最多的。 214 | 215 | ### 4.2 构造器 216 | 在构造器上使用Autowired注解: 217 | ```java 218 | @Service 219 | public class UserService { 220 | 221 | private IUser user; 222 | 223 | @Autowired 224 | public UserService(IUser user) { 225 | this.user = user; 226 | System.out.println("user:" + user); 227 | } 228 | } 229 | ``` 230 | > 注意,在构造器上加Autowired注解,实际上还是使用了Autowired装配方式,并非构造器装配。 231 | 232 | ### 4.3 方法 233 | 在普通方法上加Autowired注解: 234 | ```java 235 | @Service 236 | public class UserService { 237 | 238 | @Autowired 239 | public void test(IUser user) { 240 | user.say(); 241 | } 242 | } 243 | ``` 244 | spring会在项目启动的过程中,自动调用一次加了@Autowired注解的方法,我们可以在该方法做一些初始化的工作。 245 | 246 | 也可以在setter方法上Autowired注解: 247 | ```java 248 | @Service 249 | public class UserService { 250 | 251 | private IUser user; 252 | 253 | @Autowired 254 | public void setUser(IUser user) { 255 | this.user = user; 256 | } 257 | } 258 | ``` 259 | 260 | ### 4.4 参数 261 | 可以在构造器的入参上加Autowired注解: 262 | ```java 263 | @Service 264 | public class UserService { 265 | 266 | private IUser user; 267 | 268 | public UserService(@Autowired IUser user) { 269 | this.user = user; 270 | System.out.println("user:" + user); 271 | } 272 | } 273 | ``` 274 | 也可以在非静态方法的入参上加Autowired注解: 275 | ```java 276 | @Service 277 | public class UserService { 278 | 279 | public void test(@Autowired IUser user) { 280 | user.say(); 281 | } 282 | } 283 | ``` 284 | ### 4.5 注解 285 | 这种方式其实用得不多,我就不过多介绍了。 286 | 287 | ## 5. @Autowired的高端玩法 288 | 其实上面举的例子都是通过@Autowired自动装配单个实例,但这里我会告诉你,它也能自动装配多个实例,怎么回事呢? 289 | 290 | 将UserService方法调整一下,用一个List集合接收IUser类型的参数: 291 | ```java 292 | @Service 293 | public class UserService { 294 | 295 | @Autowired 296 | private List userList; 297 | 298 | @Autowired 299 | private Set userSet; 300 | 301 | @Autowired 302 | private Map userMap; 303 | 304 | public void test() { 305 | System.out.println("userList:" + userList); 306 | System.out.println("userSet:" + userSet); 307 | System.out.println("userMap:" + userMap); 308 | } 309 | } 310 | ``` 311 | 增加一个controller: 312 | ```java 313 | @RequestMapping("/u") 314 | @RestController 315 | public class UController { 316 | 317 | @Autowired 318 | private UserService userService; 319 | 320 | @RequestMapping("/test") 321 | public String test() { 322 | userService.test(); 323 | return "success"; 324 | } 325 | } 326 | ``` 327 | 328 | 调用该接口后: 329 | 330 | ![](https://pic.imgdb.cn/item/610e8b435132923bf8de58c5.jpg) 331 | 332 | 从上图中看出:userList、userSet和userMap都打印出了两个元素,说明@Autowired会自动把相同类型的IUser对象收集到集合中。 333 | 334 | 意不意外,惊不惊喜? 335 | 336 | ## 6. @Autowired一定能装配成功? 337 | 前面介绍了@Autowired注解这么多牛逼之处,其实有些情况下,即使使用了@Autowired装配的对象还是null,到底是什么原因呢? 338 | 339 | ### 6.1 没有加@Service注解 340 | 在类上面忘了加@Controller、@Service、@Component、@Repository等注解,spring就无法完成自动装配的功能,例如: 341 | ```java 342 | public class UserService { 343 | 344 | @Autowired 345 | private IUser user; 346 | 347 | public void test() { 348 | user.say(); 349 | } 350 | } 351 | ``` 352 | 这种情况应该是最常见的错误了,不会因为你长得帅,就不会犯这种低级的错误。 353 | 354 | ### 6.2 注入Filter或Listener 355 | 356 | web应用启动的顺序是:`listener`->`filter`->`servlet`。 357 | 358 | ![](https://pic.imgdb.cn/item/610e8b855132923bf8ded60a.jpg) 359 | 360 | 接下来看看这个案例: 361 | ```java 362 | public class UserFilter implements Filter { 363 | 364 | @Autowired 365 | private IUser user; 366 | 367 | @Override 368 | public void init(FilterConfig filterConfig) throws ServletException { 369 | user.say(); 370 | } 371 | 372 | @Override 373 | public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { 374 | 375 | } 376 | 377 | @Override 378 | public void destroy() { 379 | } 380 | } 381 | ``` 382 | ```java 383 | @Configuration 384 | public class FilterConfig { 385 | 386 | @Bean 387 | public FilterRegistrationBean filterRegistrationBean() { 388 | FilterRegistrationBean bean = new FilterRegistrationBean(); 389 | bean.setFilter(new UserFilter()); 390 | bean.addUrlPatterns("/*"); 391 | return bean; 392 | } 393 | } 394 | ``` 395 | 程序启动会报错: 396 | 397 | ![](https://pic.imgdb.cn/item/610e8b9f5132923bf8df0917.jpg) 398 | 399 | tomcat无法正常启动。 400 | 401 | 什么原因呢? 402 | 403 | 众所周知,springmvc的启动是在DisptachServlet里面做的,而它是在listener和filter之后执行。如果我们想在listener和filter里面@Autowired某个bean,肯定是不行的,因为filter初始化的时候,此时bean还没有初始化,无法自动装配。 404 | 405 | 如果工作当中真的需要这样做,我们该如何解决这个问题呢? 406 | 407 | ```java 408 | public class UserFilter implements Filter { 409 | 410 | private IUser user; 411 | 412 | @Override 413 | public void init(FilterConfig filterConfig) throws ServletException { 414 | ApplicationContext applicationContext = WebApplicationContextUtils.getWebApplicationContext(filterConfig.getServletContext()); 415 | this.user = ((IUser)(applicationContext.getBean("user1"))); 416 | user.say(); 417 | } 418 | 419 | @Override 420 | public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { 421 | 422 | } 423 | 424 | @Override 425 | public void destroy() { 426 | 427 | } 428 | } 429 | ``` 430 | 答案是使用WebApplicationContextUtils.getWebApplicationContext获取当前的ApplicationContext,再通过它获取到bean实例。 431 | 432 | ### 6.3 注解未被@ComponentScan扫描 433 | 通常情况下,@Controller、@Service、@Component、@Repository、@Configuration等注解,是需要通过@ComponentScan注解扫描,收集元数据的。 434 | 435 | 但是,如果没有加@ComponentScan注解,或者@ComponentScan注解扫描的路径不对,或者路径范围太小,会导致有些注解无法收集,到后面无法使用@Autowired完成自动装配的功能。 436 | 437 | 有个好消息是,在springboot项目中,如果使用了`@SpringBootApplication`注解,它里面内置了@ComponentScan注解的功能。 438 | 439 | ### 6.4 循环依赖问题 440 | 如果A依赖于B,B依赖于C,C又依赖于A,这样就形成了一个死循环。 441 | 442 | ![](https://pic.imgdb.cn/item/610e8bb85132923bf8df36f1.jpg) 443 | 444 | spring的bean默认是单例的,如果单例bean使用@Autowired自动装配,大多数情况,能解决循环依赖问题。 445 | 446 | 但是如果bean是多例的,会出现循环依赖问题,导致bean自动装配不了。 447 | 448 | 还有有些情况下,如果创建了代理对象,即使bean是单例的,依然会出现循环依赖问题。 449 | 450 | 如果你对循环依赖问题比较感兴趣,也可以看一下我的另一篇专题《》,里面介绍的非常详细。 451 | 452 | 453 | ## 7. @Autowired和@Resouce的区别 454 | 455 | @Autowired功能虽说非常强大,但是也有些不足之处。比如:比如它跟spring强耦合了,如果换成了JFinal等其他框架,功能就会失效。而@Resource是JSR-250提供的,它是Java标准,绝大部分框架都支持。 456 | 457 | 除此之外,有些场景使用@Autowired无法满足的要求,改成@Resource却能解决问题。接下来,我们重点看看@Autowired和@Resource的区别。 458 | 459 | - @Autowired默认按byType自动装配,而@Resource默认byName自动装配。 460 | - @Autowired只包含一个参数:required,表示是否开启自动准入,默认是true。而@Resource包含七个参数,其中最重要的两个参数是:name 和 type。 461 | - @Autowired如果要使用byName,需要使用@Qualifier一起配合。而@Resource如果指定了name,则用byName自动装配,如果指定了type,则用byType自动装配。 462 | - @Autowired能够用在:构造器、方法、参数、成员变量和注解上,而@Resource能用在:类、成员变量和方法上。 463 | - @Autowired是spring定义的注解,而@Resource是JSR-250定义的注解。 464 | 465 | 此外,它们的装配顺序不同。 466 | 467 | **@Autowired的装配顺序如下:** 468 | 469 | ![](https://pic.imgdb.cn/item/610e8bcb5132923bf8df5b83.jpg) 470 | 471 | 472 | **@Resource的装配顺序如下:** 473 | 1. 如果同时指定了name和type: 474 | ![](https://pic.imgdb.cn/item/610e8be55132923bf8df8991.jpg) 475 | 476 | 477 | 2. 如果指定了name: 478 | ![](https://pic.imgdb.cn/item/610e8bf75132923bf8dfa887.jpg) 479 | 480 | 481 | 3. 如果指定了type: 482 | ![](https://pic.imgdb.cn/item/610e8c095132923bf8dfc886.jpg) 483 | 484 | 485 | 4. 如果既没有指定name,也没有指定type: 486 | 487 | ![](https://pic.imgdb.cn/item/610e8c195132923bf8dfe656.jpg) 488 | 489 | ## 后记 490 | 我原本打算接下来写@Autowired原理分析和源码解读的,但是由于篇幅太长了,不适合放在一起,后面打算开个专题。如果有兴趣的朋友,可以持续关注我后续的文章,相信你读完必定会有些收获。 491 | 492 | 493 | -------------------------------------------------------------------------------- /docs/spring/spring:我是如何解决循环依赖的?.md: -------------------------------------------------------------------------------- 1 | ## 1.由同事抛的一个问题开始 2 | 最近项目组的一个同事遇到了一个问题,问我的意见,一下子引起的我的兴趣,因为这个问题我也是第一次遇到。平时自认为对spring循环依赖问题还是比较了解的,直到遇到这个和后面的几个问题后,重新刷新了我的认识。 3 | 4 | 我们先看看当时出问题的代码片段: 5 | ```java 6 | @Service 7 | public class TestService1 { 8 | 9 | @Autowired 10 | private TestService2 testService2; 11 | 12 | @Async 13 | public void test1() { 14 | } 15 | } 16 | @Service 17 | public class TestService2 { 18 | 19 | @Autowired 20 | private TestService1 testService1; 21 | 22 | public void test2() { 23 | } 24 | } 25 | ``` 26 | 这两段代码中定义了两个Service类:TestService1和TestService2,在TestService1中注入了TestService2的实例,同时在TestService2中注入了TestService1的实例,这里构成了循环依赖。 27 | 28 | 只不过,这不是普通的循环依赖,因为TestService1的test1方法上加了一个`@Async`注解。 29 | 30 | 大家猜猜程序启动后运行结果会怎样? 31 | ```java 32 | org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'testService1': Bean with name 'testService1' has been injected into other beans [testService2] in its raw version as part of a circular reference, but has eventually been wrapped. This means that said other beans do not use the final version of the bean. This is often the result of over-eager type matching - consider using 'getBeanNamesOfType' with the 'allowEagerInit' flag turned off, for example. 33 | ``` 34 | 报错了。。。原因是出现了循环依赖。 35 | 36 | 「不科学呀,spring不是号称能解决循环依赖问题吗,怎么还会出现?」 37 | 38 | 如果把上面的代码稍微调整一下: 39 | ```java 40 | @Service 41 | public class TestService1 { 42 | 43 | @Autowired 44 | private TestService2 testService2; 45 | 46 | public void test1() { 47 | } 48 | } 49 | ``` 50 | 把TestService1的test1方法上的@Async注解去掉,TestService1和TestService2都需要注入对方的实例,同样构成了循环依赖。 51 | 52 | 但是重新启动项目,发现它能够正常运行。这又是为什么? 53 | 54 | 带着这两个问题,让我们一起开始spring循环依赖的探秘之旅。 55 | 56 | ## 2.什么是循环依赖? 57 | 循环依赖:说白是一个或多个对象实例之间存在直接或间接的依赖关系,这种依赖关系构成了构成一个环形调用。 58 | 59 | 第一种情况:自己依赖自己的直接依赖 60 | ![](https://pic.imgdb.cn/item/610f54865132923bf8d50480.jpg) 61 | 62 | 第二种情况:两个对象之间的直接依赖 63 | ![](https://pic.imgdb.cn/item/610f54bf5132923bf8d54cb5.jpg) 64 | 65 | 第三种情况:多个对象之间的间接依赖图片 66 | ![](https://pic.imgdb.cn/item/610e8bb85132923bf8df36f1.jpg) 67 | 68 | 前面两种情况的直接循环依赖比较直观,非常好识别,但是第三种间接循环依赖的情况有时候因为业务代码调用层级很深,不容易识别出来。 69 | 70 | ## 3.循环依赖的N种场景 71 | spring中出现循环依赖主要有以下场景: 72 | ![](https://pic.imgdb.cn/item/610f54ec5132923bf8d58882.jpg) 73 | 74 | ### 单例的setter注入 75 | 这种注入方式应该是spring用的最多的,代码如下: 76 | ```java 77 | @Service 78 | public class TestService1 { 79 | 80 | @Autowired 81 | private TestService2 testService2; 82 | 83 | public void test1() { 84 | } 85 | } 86 | @Service 87 | public class TestService2 { 88 | 89 | @Autowired 90 | private TestService1 testService1; 91 | 92 | public void test2() { 93 | } 94 | } 95 | ``` 96 | 这是一个经典的循环依赖,但是它能正常运行,得益于spring的内部机制,让我们根本无法感知它有问题,因为spring默默帮我们解决了。 97 | 98 | spring内部有三级缓存: 99 | - singletonObjects 一级缓存,用于保存实例化、注入、初始化完成的bean实例 100 | - earlySingletonObjects 二级缓存,用于保存实例化完成的bean实例 101 | - singletonFactories 三级缓存,用于保存bean创建工厂,以便于后面扩展有机会创建代理对象。 102 | 103 | 下面用一张图告诉你,spring是如何解决循环依赖的: 104 | ![](https://pic.imgdb.cn/item/610f55295132923bf8d5d5be.jpg) 105 | 106 | 细心的朋友可能会发现在这种场景中第二级缓存作用不大。 107 | 108 | 那么问题来了,为什么要用第二级缓存呢? 109 | 110 | 试想一下,如果出现以下这种情况,我们要如何处理? 111 | ```java 112 | @Service 113 | public class TestService1 { 114 | 115 | @Autowired 116 | private TestService2 testService2; 117 | @Autowired 118 | private TestService3 testService3; 119 | 120 | public void test1() { 121 | } 122 | } 123 | @Service 124 | public class TestService2 { 125 | 126 | @Autowired 127 | private TestService1 testService1; 128 | 129 | public void test2() { 130 | } 131 | } 132 | @Service 133 | public class TestService3 { 134 | 135 | @Autowired 136 | private TestService1 testService1; 137 | 138 | public void test3() { 139 | } 140 | } 141 | ``` 142 | TestService1依赖于TestService2和TestService3,而TestService2依赖于TestService1,同时TestService3也依赖于TestService1。 143 | 144 | 按照上图的流程可以把TestService1注入到TestService2,并且TestService1的实例是从第三级缓存中获取的。 145 | 146 | 假设不用第二级缓存,TestService1注入到TestService3的流程如图: 147 | 148 | ![](https://pic.imgdb.cn/item/610f556b5132923bf8d6287f.jpg) 149 | 150 | TestService1注入到TestService3又需要从第三级缓存中获取实例,而第三级缓存里保存的并非真正的实例对象,而是ObjectFactory对象。说白了,两次从三级缓存中获取都是ObjectFactory对象,而通过它创建的实例对象每次可能都不一样的。 151 | 152 | 这样不是有问题? 153 | 154 | 为了解决这个问题,spring引入的第二级缓存。上面图1其实TestService1对象的实例已经被添加到第二级缓存中了,而在TestService1注入到TestService3时,只用从第二级缓存中获取该对象即可。 155 | 156 | ![](https://pic.imgdb.cn/item/610f55f55132923bf8d6e248.jpg) 157 | 158 | 159 | 还有个问题,第三级缓存中为什么要添加ObjectFactory对象,直接保存实例对象不行吗? 160 | 161 | 答:不行,因为假如你想对添加到三级缓存中的实例对象进行增强,直接用实例对象是行不通的。 162 | 163 | 针对这种场景spring是怎么做的呢? 164 | 165 | 答案就在AbstractAutowireCapableBeanFactory类doCreateBean方法的这段代码中: 166 | ![](https://pic.imgdb.cn/item/610f56e65132923bf8d82a84.jpg) 167 | 168 | 它定义了一个匿名内部类,通过getEarlyBeanReference方法获取代理对象,其实底层是通过AbstractAutoProxyCreator类的getEarlyBeanReference生成代理对象。 169 | 170 | ### 多例的setter注入 171 | 这种注入方法偶然会有,特别是在多线程的场景下,具体代码如下: 172 | ```java 173 | @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) 174 | @Service 175 | public class TestService1 { 176 | 177 | @Autowired 178 | private TestService2 testService2; 179 | 180 | public void test1() { 181 | } 182 | } 183 | @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) 184 | @Service 185 | public class TestService2 { 186 | 187 | @Autowired 188 | private TestService1 testService1; 189 | 190 | public void test2() { 191 | } 192 | } 193 | ``` 194 | 很多人说这种情况spring容器启动会报错,其实是不对的,我非常负责任的告诉你程序能够正常启动。 195 | 196 | 为什么呢? 197 | 198 | 其实在AbstractApplicationContext类的refresh方法中告诉了我们答案,它会调用finishBeanFactoryInitialization方法,该方法的作用是为了spring容器启动的时候提前初始化一些bean。该方法的内部又调用了preInstantiateSingletons方法 199 | ![](https://pic.imgdb.cn/item/610f57325132923bf8d89860.jpg) 200 | 201 | 标红的地方明显能够看出:非抽象、单例 并且非懒加载的类才能被提前初始bean。 202 | 203 | 而多例即SCOPE_PROTOTYPE类型的类,非单例,不会被提前初始化bean,所以程序能够正常启动。 204 | 205 | 如何让他提前初始化bean呢? 206 | 207 | 只需要再定义一个单例的类,在它里面注入TestService1 208 | ```java 209 | @Service 210 | public class TestService3 { 211 | 212 | @Autowired 213 | private TestService1 testService1; 214 | } 215 | ``` 216 | 重新启动程序,执行结果: 217 | ```java 218 | Requested bean is currently in creation: Is there an unresolvable circular reference? 219 | ``` 220 | 果然出现了循环依赖。 221 | 222 | 注意:这种循环依赖问题是无法解决的,因为它没有用缓存,每次都会生成一个新对象。 223 | 224 | ### 构造器注入 225 | 这种注入方式现在其实用的已经非常少了,但是我们还是有必要了解一下,看看如下代码: 226 | ```java 227 | @Service 228 | public class TestService1 { 229 | 230 | public TestService1(TestService2 testService2) { 231 | } 232 | } 233 | @Service 234 | public class TestService2 { 235 | 236 | public TestService2(TestService1 testService1) { 237 | } 238 | } 239 | ``` 240 | 运行结果: 241 | ```java 242 | Requested bean is currently in creation: Is there an unresolvable circular reference? 243 | ``` 244 | 出现了循环依赖,为什么呢? 245 | 246 | ![](https://pic.imgdb.cn/item/610f577e5132923bf8d9026a.jpg) 247 | 248 | 从图中的流程看出构造器注入没能添加到三级缓存,也没有使用缓存,所以也无法解决循环依赖问题。 249 | 250 | ### 单例的代理对象setter注入 251 | 这种注入方式其实也比较常用,比如平时使用:@Async注解的场景,会通过AOP自动生成代理对象。 252 | 253 | 我那位同事的问题也是这种情况。 254 | ```java 255 | @Service 256 | public class TestService1 { 257 | 258 | @Autowired 259 | private TestService2 testService2; 260 | 261 | @Async 262 | public void test1() { 263 | } 264 | } 265 | @Service 266 | public class TestService2 { 267 | 268 | @Autowired 269 | private TestService1 testService1; 270 | 271 | public void test2() { 272 | } 273 | } 274 | ``` 275 | 从前面得知程序启动会报错,出现了循环依赖: 276 | ```java 277 | org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'testService1': Bean with name 'testService1' has been injected into other beans [testService2] in its raw version as part of a circular reference, but has eventually been wrapped. This means that said other beans do not use the final version of the bean. This is often the result of over-eager type matching - consider using 'getBeanNamesOfType' with the 'allowEagerInit' flag turned off, for example. 278 | ``` 279 | 为什么会循环依赖呢? 280 | 281 | 答案就在下面这张图中: 282 | 283 | ![](https://pic.imgdb.cn/item/610f57f45132923bf8d99c6b.jpg) 284 | 285 | 说白了,bean初始化完成之后,后面还有一步去检查:第二级缓存 和 原始对象 是否相等。由于它对前面流程来说无关紧要,所以前面的流程图中省略了,但是在这里是关键点,我们重点说说: 286 | 287 | ![](https://pic.imgdb.cn/item/610f58055132923bf8d9afd2.jpg) 288 | 289 | 那位同事的问题正好是走到这段代码,发现第二级缓存 和 原始对象不相等,所以抛出了循环依赖的异常。 290 | 291 | 如果这时候把TestService1改个名字,改成:TestService6,其他的都不变。 292 | ```java 293 | @Service 294 | public class TestService6 { 295 | 296 | @Autowired 297 | private TestService2 testService2; 298 | 299 | @Async 300 | public void test1() { 301 | } 302 | } 303 | ``` 304 | 再重新启动一下程序,神奇般的好了。 305 | 306 | what? 这又是为什么? 307 | 308 | 这就要从spring的bean加载顺序说起了,默认情况下,spring是按照文件完整路径递归查找的,按路径+文件名排序,排在前面的先加载。所以TestService1比TestService2先加载,而改了文件名称之后,TestService2比TestService6先加载。 309 | 310 | 为什么TestService2比TestService6先加载就没问题呢? 311 | 312 | 答案在下面这张图中: 313 | 314 | ![](https://pic.imgdb.cn/item/610f58915132923bf8da4c64.jpg) 315 | 316 | 这种情况testService6中其实第二级缓存是空的,不需要跟原始对象判断,所以不会抛出循环依赖。 317 | 318 | 319 | ### DependsOn循环依赖 320 | 还有一种有些特殊的场景,比如我们需要在实例化Bean A之前,先实例化Bean B,这个时候就可以使用@DependsOn注解。 321 | ```java 322 | @DependsOn(value = "testService2") 323 | @Service 324 | public class TestService1 { 325 | 326 | @Autowired 327 | private TestService2 testService2; 328 | 329 | public void test1() { 330 | } 331 | } 332 | @DependsOn(value = "testService1") 333 | @Service 334 | public class TestService2 { 335 | 336 | @Autowired 337 | private TestService1 testService1; 338 | 339 | public void test2() { 340 | } 341 | } 342 | ``` 343 | 程序启动之后,执行结果: 344 | ```java 345 | Circular depends-on relationship between 'testService2' and 'testService1' 346 | ``` 347 | 这个例子中本来如果TestService1和TestService2都没有加`@DependsOn`注解是没问题的,反而加了这个注解会出现循环依赖问题。 348 | 349 | 这又是为什么? 350 | 351 | 答案在AbstractBeanFactory类的doGetBean方法的这段代码中: 352 | ![](https://pic.imgdb.cn/item/610f58fa5132923bf8dadfcf.jpg) 353 | 354 | 它会检查dependsOn的实例有没有循环依赖,如果有循环依赖则抛异常。 355 | 356 | ## 4.出现循环依赖如何解决? 357 | 项目中如果出现循环依赖问题,说明是spring默认无法解决的循环依赖,要看项目的打印日志,属于哪种循环依赖。目前包含下面几种情况: 358 | 359 | ![](https://pic.imgdb.cn/item/610f591f5132923bf8db0eaa.jpg) 360 | 361 | ### 生成代理对象产生的循环依赖 362 | 这类循环依赖问题解决方法很多,主要有: 363 | 364 | 1. 使用@Lazy注解,延迟加载 365 | 2. 使用@DependsOn注解,指定加载先后关系 366 | 367 | 修改文件名称,改变循环依赖类的加载顺序 368 | 369 | ### 使用@DependsOn产生的循环依赖 370 | 这类循环依赖问题要找到@DependsOn注解循环依赖的地方,迫使它不循环依赖就可以解决问题。 371 | 372 | ### 多例循环依赖 373 | 这类循环依赖问题可以通过把bean改成单例的解决。 374 | 375 | ### 构造器循环依赖 376 | 这类循环依赖问题可以通过使用@Lazy注解解决。 -------------------------------------------------------------------------------- /docs/spring/让人头痛的大事务问题要如何解决?.md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 最近有个网友问了我一个问题:系统中大事务问题要如何处理? 3 | 4 | 正好前段时间我在公司处理过这个问题,我们当时由于项目初期时间比较紧张,为了快速完成业务功能,忽略了系统部分性能问题。项目顺利上线后,专门抽了一个迭代的时间去解决大事务问题,目前已经优化完成,并且顺利上线。现给大家总结了一下,我们当时使用的一些解决办法,以便大家被相同问题困扰时,可以参考一下。 5 | 6 | ## 大事务引发的问题 7 | 在分享解决办法之前,先看看系统中如果出现大事务可能会引发哪些问题 8 | 9 | ![](https://pic.imgdb.cn/item/610f44e55132923bf8c0d246.jpg) 10 | 11 | 从上图可以看出如果系统中出现大事务时,问题还不小,所以我们在实际项目开发中应该尽量避免大事务的情况。如果我们已有系统中存在大事务问题,该如何解决呢? 12 | 13 | ## 解决办法 14 | ### 1.少用@Transactional注解 15 | 大家在实际项目开发中,我们在业务方法加上@Transactional注解开启事务功能,这是非常普遍的做法,它被称为声明式事务。 16 | 17 | 部分代码如下: 18 | ```java 19 | @Transactional(rollbackFor=Exception.class) 20 | public void save(User user) { 21 | doSameThing... 22 | } 23 | ``` 24 | 然而,我要说的第一条是:少用@Transactional注解。 25 | 26 | 为什么? 27 | 28 | 1. 我们知道@Transactional注解是通过spring的aop起作用的,但是如果使用不当,事务功能可能会失效。如果恰巧你经验不足,这种问题不太好排查。至于事务哪些情况下会失效,可以参考我之前写的《spring事务的这10种坑,你稍不注意可能就会踩中!!!》这篇文章。 29 | 30 | 2. @Transactional注解一般加在某个业务方法上,会导致整个业务方法都在同一个事务中,粒度太粗,不好控制事务范围,是出现大事务问题的最常见的原因。 31 | 那我们该怎么办呢? 32 | 33 | 可以使用编程式事务,在spring项目中使用TransactionTemplate类的对象,手动执行事务。 34 | 35 | 部分代码如下: 36 | 37 | ```java 38 | @Autowired 39 | private TransactionTemplate transactionTemplate; 40 | 41 | ... 42 | 43 | public void save(final User user) { 44 | transactionTemplate.execute((status) => { 45 | doSameThing... 46 | return Boolean.TRUE; 47 | }) 48 | } 49 | ``` 50 | 从上面的代码中可以看出,使用TransactionTemplate的编程式事务功能自己灵活控制事务的范围,是避免大事务问题的首选办法。 51 | 52 | 当然,我说少使用@Transactional注解开启事务,并不是说一定不能用它,如果项目中有些业务逻辑比较简单,而且不经常变动,使用@Transactional注解开启事务开启事务也无妨,因为它更简单,开发效率更高,但是千万要小心事务失效的问题。 53 | 54 | ### 2. 将查询(select)方法放到事务外 55 | 如果出现大事务,可以将查询(select)方法放到事务外,也是比较常用的做法,因为一般情况下这类方法是不需要事务的。 56 | 57 | 比如出现如下代码: 58 | ```java 59 | @Transactional(rollbackFor=Exception.class) 60 | public void save(User user) { 61 | queryData1(); 62 | queryData2(); 63 | addData1(); 64 | updateData2(); 65 | } 66 | ``` 67 | 可以将queryData1和queryData2两个查询方法放在事务外执行,将真正需要事务执行的代码才放到事务中,比如:addData1和updateData2方法,这样就能有效的减少事务的粒度。 68 | 69 | 如果使用TransactionTemplate的编程式事务这里就非常好修改。 70 | 71 | ```java 72 | @Autowired 73 | private TransactionTemplate transactionTemplate; 74 | 75 | ... 76 | 77 | public void save(final User user) { 78 | queryData1(); 79 | queryData2(); 80 | transactionTemplate.execute((status) => { 81 | addData1(); 82 | updateData2(); 83 | return Boolean.TRUE; 84 | }) 85 | } 86 | ``` 87 | 但是如果你实在还是想用@Transactional注解,该怎么拆分呢? 88 | ```java 89 | public void save(User user) { 90 | queryData1(); 91 | queryData2(); 92 | doSave(); 93 | } 94 | 95 | @Transactional(rollbackFor=Exception.class) 96 | public void doSave(User user) { 97 | addData1(); 98 | updateData2(); 99 | } 100 | ``` 101 | 这个例子是非常经典的错误,这种直接方法调用的做法事务不会生效,给正在坑中的朋友提个醒。因为@Transactional注解的声明式事务是通过spring aop起作用的,而spring aop需要生成代理对象,直接方法调用使用的还是原始对象,所以事务不会生效。 102 | 103 | 有没有办法解决这个问题呢? 104 | 105 | 1.新加一个Service方法 106 | 107 | 这个方法非常简单,只需要新加一个Service方法,把@Transactional注解加到新Service方法上,把需要事务执行的代码移到新方法中。具体代码如下: 108 | ```java 109 | @Servcie 110 | public class ServiceA { 111 | @Autowired 112 | prvate ServiceB serviceB; 113 | 114 | public void save(User user) { 115 | queryData1(); 116 | queryData2(); 117 | serviceB.doSave(user); 118 | } 119 | } 120 | 121 | @Servcie 122 | public class ServiceB { 123 | 124 | @Transactional(rollbackFor=Exception.class) 125 | public void doSave(User user) { 126 | addData1(); 127 | updateData2(); 128 | } 129 | 130 | } 131 | ``` 132 | 2.在该Service类中注入自己 133 | 134 | 如果不想再新加一个Service类,在该Service类中注入自己也是一种选择。具体代码如下: 135 | ```java 136 | @Servcie 137 | public class ServiceA { 138 | @Autowired 139 | prvate ServiceA serviceA; 140 | 141 | public void save(User user) { 142 | queryData1(); 143 | queryData2(); 144 | serviceA.doSave(user); 145 | } 146 | 147 | @Transactional(rollbackFor=Exception.class) 148 | public void doSave(User user) { 149 | addData1(); 150 | updateData2(); 151 | } 152 | } 153 | ``` 154 | 可能有些人可能会有这样的疑问:这种做法会不会出现循环依赖问题? 155 | 156 | 其实spring ioc内部的三级缓存保证了它,不会出现循环依赖问题。如果你想进一步了解循环依赖问题,可以看看我之前文章《spring解决循环依赖为什么要用三级缓存?》。 157 | 158 | 3.在该Service类中使用AopContext.currentProxy()获取代理对象 159 | 160 | 上面的方法2确实可以解决问题,但是代码看起来并不直观,还可以通过在该Service类中使用AOPProxy获取代理对象,实现相同的功能。具体代码如下: 161 | ```java 162 | @Servcie 163 | public class ServiceA { 164 | 165 | public void save(User user) { 166 | queryData1(); 167 | queryData2(); 168 | ((ServiceA)AopContext.currentProxy()).doSave(user); 169 | } 170 | 171 | @Transactional(rollbackFor=Exception.class) 172 | public void doSave(User user) { 173 | addData1(); 174 | updateData2(); 175 | } 176 | } 177 | ``` 178 | 179 | ### 4. 事务中避免远程调用 180 | 我们在接口中调用其他系统的接口是不能避免的,由于网络不稳定,这种远程调的响应时间可能比较长,如果远程调用的代码放在某个事物中,这个事物就可能是大事务。当然,远程调用不仅仅是指调用接口,还有包括:发MQ消息,或者连接redis、mongodb保存数据等。 181 | ```java 182 | @Transactional(rollbackFor=Exception.class) 183 | public void save(User user) { 184 | callRemoteApi(); 185 | addData1(); 186 | } 187 | ``` 188 | 远程调用的代码可能耗时较长,切记一定要放在事务之外。 189 | ```java 190 | @Autowired 191 | private TransactionTemplate transactionTemplate; 192 | 193 | ... 194 | 195 | public void save(final User user) { 196 | callRemoteApi(); 197 | transactionTemplate.execute((status) => { 198 | addData1(); 199 | return Boolean.TRUE; 200 | }) 201 | } 202 | ``` 203 | 有些朋友可能会问,远程调用的代码不放在事务中如何保证数据一致性呢?这就需要建立:重试+补偿机制,达到数据最终一致性了。 204 | 205 | ### 5. 事务中避免一次性处理太多数据 206 | 如果一个事务中需要处理的数据太多,也会造成大事务问题。比如为了操作方便,你可能会一次批量更新1000条数据,这样会导致大量数据锁等待,特别在高并发的系统中问题尤为明显。 207 | 208 | 解决办法是分页处理,1000条数据,分50页,一次只处理20条数据,这样可以大大减少大事务的出现。 209 | 210 | ### 6. 非事务执行 211 | 在使用事务之前,我们都应该思考一下,是不是所有的数据库操作都需要在事务中执行? 212 | 213 | ```java 214 | @Autowired 215 | private TransactionTemplate transactionTemplate; 216 | 217 | ... 218 | 219 | public void save(final User user) { 220 | transactionTemplate.execute((status) => { 221 | addData(); 222 | addLog(); 223 | updateCount(); 224 | return Boolean.TRUE; 225 | }) 226 | } 227 | ``` 228 | 上面的例子中,其实addLog增加操作日志方法 和 updateCount更新统计数量方法,是可以不在事务中执行的,因为操作日志和统计数量这种业务允许少量数据不一致的情况。 229 | 230 | ```java 231 | @Autowired 232 | private TransactionTemplate transactionTemplate; 233 | 234 | ... 235 | 236 | public void save(final User user) { 237 | transactionTemplate.execute((status) => { 238 | addData(); 239 | return Boolean.TRUE; 240 | }) 241 | addLog(); 242 | updateCount(); 243 | } 244 | ``` 245 | 当然大事务中要鉴别出哪些方法可以非事务执行,其实没那么容易,需要对整个业务梳理一遍,才能找出最合理的答案。 246 | 247 | ### 6. 异步处理 248 | 还有一点也非常重要,是不是事务中的所有方法都需要同步执行?我们都知道,方法同步执行需要等待方法返回,如果一个事务中同步执行的方法太多了,势必会造成等待时间过长,出现大事务问题。 249 | 250 | 看看下面这个列子: 251 | 252 | ```java 253 | @Autowired 254 | private TransactionTemplate transactionTemplate; 255 | 256 | ... 257 | 258 | public void save(final User user) { 259 | transactionTemplate.execute((status) => { 260 | order(); 261 | delivery(); 262 | return Boolean.TRUE; 263 | }) 264 | } 265 | ``` 266 | order方法用于下单,delivery方法用于发货,是不是下单后就一定要马上发货呢? 267 | 268 | 答案是否定的。 269 | 270 | 这里发货功能其实可以走mq异步处理逻辑。 271 | 272 | ```java 273 | @Autowired 274 | private TransactionTemplate transactionTemplate; 275 | 276 | ... 277 | 278 | public void save(final User user) { 279 | transactionTemplate.execute((status) => { 280 | order(); 281 | return Boolean.TRUE; 282 | }) 283 | sendMq(); 284 | } 285 | ``` 286 | ## 总结 287 | 本人从网友的一个问题出发,结合自己实际的工作经验分享了处理大事务的6种办法: 288 | 289 | 1. 少用@Transactional注解 290 | 2. 将查询(select)方法放到事务外 291 | 3. 事务中避免远程调用 292 | 4. 事务中避免一次性处理太多数据 293 | 5. 非事务执行 294 | 6. 异步处理 -------------------------------------------------------------------------------- /docs/其他/强烈推荐 | 阿里开源的这10个神级项目.md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 最近趁着国庆节放假休息,特地整理了一下,阿里巴巴开源的10款神级项目。 3 | 4 | 这些开源项目中的绝大多数,我都在实际工作中用过,或者有同事用过。确实挺不错,挺有价值的,现在推荐给大家。 5 | 6 | ## 1. Druid 7 | 8 | Druid自称是Java语言中最好的数据库连接池,它能够提供强大的监控和扩展功能。监控后台如下图所示: 9 | 10 | ![](https://pic.imgdb.cn/item/615d0c902ab3f51d91b8fddf.jpg) 11 | 12 | Druid的主要优点如下: 13 | - 它能监控数据库访问性能。 14 | - 它提供了WallFilter,它是基于SQL语义分析来实现防御SQL注入攻击的。 15 | - 它提供了多种监测连接泄漏的手段。 16 | - 它提供了数据库密码加密的功能。 17 | - 它能打印SQL执行日志。 18 | 19 | > github地址: https://github.com/alibaba/druid 20 | 21 | > maven中央仓库: https://mvnrepository.com/artifact/com.alibaba/druid 22 | 23 | 配置maven依赖: 24 | ```java 25 | 26 | com.alibaba 27 | druid 28 | ${druid-version} 29 | 30 | ``` 31 | 32 | ## 2. fastjson 33 | `fastjson`是阿里巴巴的开源JSON解析库,它可以解析JSON格式的字符串,支持将Java Bean序列化为JSON字符串,也可以从JSON字符串反序列化到JavaBean。 34 | 35 | fastjson的主要优点如下: 36 | - 速度快,fastjson相对其他JSON库的特点是快,从2011年fastjson发布1.1.x版本之后,其性能从未被其他Java实现的JSON库超越。 37 | - 使用广泛,fastjson在阿里巴巴大规模使用,在数万台服务器上部署,fastjson在业界被广泛接受。在2012年被开源中国评选为最受欢迎的国产开源软件之一。 38 | - 使用简单,fastjson的API十分简洁。 39 | ```java 40 | //序列化 41 | String text = JSON.toJSONString(obj); 42 | //反序列化 43 | VO vo = JSON.parseObject("{...}", VO.class); 44 | ``` 45 | - 功能完备,支持泛型,支持流处理超大文本,支持枚举,支持序列化和反序列化扩展。 46 | 47 | > github地址: https://github.com/alibaba/fastjson 48 | 49 | > maven中央仓库: https://mvnrepository.com/artifact/com.alibaba/fastjson 50 | 51 | 配置maven依赖: 52 | ```java 53 | 54 | com.alibaba 55 | fastjson 56 | 1.2.76 57 | 58 | ``` 59 | 60 | ## 3. Dubbo 61 | 62 | Apache Dubbo 是一款微服务开发框架,它提供了 RPC通信 与 微服务治理 两大关键能力。这意味着,使用 Dubbo 开发的微服务,将具备相互之间的远程发现与通信能力, 同时利用 Dubbo 提供的丰富服务治理能力,可以实现诸如服务发现、负载均衡、流量调度等服务治理诉求。 63 | 64 | 同时 Dubbo 是高度可扩展的,用户几乎可以在任意功能点去定制自己的实现,以改变框架的默认行为来满足自己的业务需求。它目前已交给Apache管理和维护。 65 | 66 | 架构图如下: 67 | ![](https://pic.imgdb.cn/item/615d0ca92ab3f51d91b92cf0.jpg) 68 | 69 | Dubbo的主要优点如下: 70 | - 基于透明接口的RPC 71 | - 智能负载均衡 72 | - 自动服务注册和发现 73 | - 高扩展性 74 | - 运行时流量路由 75 | - 可视化服务治理 76 | - 云原生友好 77 | 78 | > github地址: https://github.com/apache/dubbo 79 | 80 | > maven中央仓库: https://mvnrepository.com/artifact/com.alibaba/dubbo/ 81 | 82 | 配置maven依赖: 83 | ```java 84 | 85 | 3.0.3 86 | 87 | 88 | 89 | 90 | org.apache.dubbo 91 | dubbo 92 | ${dubbo.version} 93 | 94 | 95 | org.apache.dubbo 96 | dubbo-dependencies-zookeeper 97 | ${dubbo.version} 98 | pom 99 | 100 | 101 | ``` 102 | 103 | ## 4. Rocketmq 104 | Apache RocketMQ是一个分布式消息和流媒体平台,具有低延迟、高性能和可靠性、万亿级容量和灵活的可扩展性。 105 | 106 | 它提供了多种功能: 107 | 108 | - 消息传递模式,包括发布/订阅、请求/回复和流媒体 109 | - 金融级交易消息 110 | - 基于DLedger的内置容错和高可用配置选项 111 | - 多种跨语言客户端,如Java、C/C++、Python、Go 112 | - 可插拔传输协议,例如 TCP、SSL、AIO 113 | - 内置消息追踪能力,也支持opentracing 114 | - 多功能大数据和流媒体生态系统集成 115 | - 按时间或偏移量的消息追溯 116 | - 可靠的 FIFO 和同一队列中的严格有序消息传递 117 | - 高效的拉推式消费模式 118 | - 单个队列百万级消息累积能力 119 | - 多种消息传递协议,如 JMS 和 OpenMessaging 120 | - 灵活的分布式横向扩展部署架构 121 | - 闪电般的批量消息交换系统 122 | - 各种消息过滤机制,例如 SQL 和 Tag 123 | - 用于隔离测试和云隔离集群的 Docker 镜像 124 | - 用于配置、指标和监控的功能丰富的管理仪表板 125 | - 认证和授权 126 | - 免费的开源连接器,用于源和接收器 127 | 128 | rocketmq后台管理界面: 129 | ![](https://pic.imgdb.cn/item/615d0cc02ab3f51d91b95942.jpg) 130 | 131 | > github地址: https://github.com/apache/rocketmq 132 | 133 | > maven中央仓库: https://mvnrepository.com/artifact/org.apache.rocketmq/rocketmq-spring-boot-starter 134 | 135 | rocketmq包含:服务端和客户端,在我们的项目中主要关注客户端的代码即可。 136 | 137 | 配置maven依赖: 138 | ```java 139 | 140 | org.apache.rocketmq 141 | rocketmq-client 142 | 4.3.0 143 | 144 | ``` 145 | 146 | ## 4. Arthas 147 | 148 | Arthas 是Alibaba开源的Java诊断工具,深受开发者喜爱。 149 | 150 | 当你遇到以下类似问题而束手无策时,Arthas可以帮助你解决: 151 | 152 | - 这个类从哪个 jar 包加载的?为什么会报各种类相关的 Exception? 153 | - 我改的代码为什么没有执行到?难道是我没 commit?分支搞错了? 154 | - 遇到问题无法在线上 debug,难道只能通过加日志再重新发布吗? 155 | - 线上遇到某个用户的数据处理有问题,但线上同样无法 debug,线下无法重现! 156 | - 是否有一个全局视角来查看系统的运行状况? 157 | - 有什么办法可以监控到JVM的实时运行状态? 158 | - 怎么快速定位应用的热点,生成火焰图? 159 | - 怎样直接从JVM内查找某个类的实例? 160 | 161 | 分析代码消耗时间: 162 | ![](https://pic.imgdb.cn/item/615d0ce02ab3f51d91b99d3c.jpg) 163 | Arthas支持JDK 6+,能够运行在多种操作系统上,比如:Linux/Mac/Windows,采用命令行交互模式,同时提供丰富的 Tab 自动补全功能,进一步方便进行问题的定位和诊断。 164 | 165 | > github地址: https://alibaba.github.io/arthas/ 166 | 167 | > maven中央仓库: https://mvnrepository.com/artifact/com.taobao.arthas/arthas-spring-boot-starter 168 | 169 | 在目标机器执行如下命令即可启动arthas: 170 | ```java 171 | curl -O https://arthas.aliyun.com/arthas-boot.jar 172 | java -jar arthas-boot.jar 173 | ``` 174 | 175 | ## 5. Nacos 176 | 177 | Nacos是一个易于使用的平台,专为动态服务发现和配置以及服务管理而设计。它可以帮助您轻松构建云原生应用程序和微服务平台。 178 | 179 | 服务是Nacos的一等公民。Nacos 支持几乎所有类型的服务,例如Dubbo/gRPC 服务、Spring Cloud RESTFul 服务或Kubernetes 服务。 180 | 181 | Nacos 提供了四大功能。 182 | 183 | - `服务发现和服务健康检查`。Nacos 使服务通过 DNS 或 HTTP 接口注册自己和发现其他服务变得简单。Nacos 还提供服务的实时健康检查,以防止向不健康的主机或服务实例发送请求。 184 | - `动态配置管理`。动态配置服务允许您在所有环境中以集中和动态的方式管理所有服务的配置。Nacos 无需在更新配置时重新部署应用程序和服务,这使得配置更改更加高效和敏捷。 185 | - `动态 DNS 服务`。Nacos 支持加权路由,让您更容易在数据中心内的生产环境中实现中层负载均衡、灵活的路由策略、流量控制和简单的 DNS 解析服务。它可以帮助您轻松实现基于 DNS 的服务发现,并防止应用程序耦合到特定于供应商的服务发现 API。 186 | - `服务和元数据管理`。Nacos 提供了一个易于使用的服务仪表板,帮助您管理您的服务元数据、配置、kubernetes DNS、服务健康和指标统计。 187 | 188 | Nacos 地图: 189 | ![](https://pic.imgdb.cn/item/615d0cf42ab3f51d91b9caf9.jpg) 190 | 191 | Nacos 生态图: 192 | ![](https://pic.imgdb.cn/item/615d0d042ab3f51d91b9ebd2.jpg) 193 | 194 | > github地址: https://github.com/alibaba/nacos 195 | 196 | > maven中央仓库: https://mvnrepository.com/artifact/com.alibaba.cloud/spring-cloud-starter-alibaba-nacos-discovery 197 | 198 | ## 6. easyexcel 199 | Java解析、生成Excel比较有名的框架有Apache poi、jxl。但他们都存在一个严重的问题就是非常的耗内存,poi有一套SAX模式的API可以一定程度的解决一些内存溢出的问题,但POI还是有一些缺陷,比如07版Excel解压缩以及解压后存储都是在内存中完成的,内存消耗依然很大。 200 | 201 | easyexcel重写了poi对07版Excel的解析,一个3M的excel用POI sax解析依然需要100M左右内存,改用easyexcel可以降低到几M,并且再大的excel也不会出现内存溢出;03版依赖POI的sax模式,在上层做了模型转换的封装,让使用者更加简单方便。 202 | 203 | 64M内存1分钟内读取75M(46W行25列)的Excel 204 | 205 | ![](https://pic.imgdb.cn/item/615d0d132ab3f51d91ba0e43.jpg) 206 | 207 | > github地址: https://github.com/alibaba/easyexcel 208 | 209 | > maven中央仓库: https://mvnrepository.com/artifact/com.alibaba/easyexcel 210 | 211 | 配置maven依赖: 212 | ```java 213 | 214 | com.alibaba 215 | easyexcel 216 | 2.2.6 217 | 218 | ``` 219 | ## 7. Sentinel 220 | 随着分布式系统变得越来越流行,服务之间的可靠性变得比以往任何时候都更加重要。 221 | 222 | Sentinel以“流量”为切入点,在流量控制、 流量整形、熔断、系统自适应保护等多个领域开展工作,保障微服务的可靠性和弹性。 223 | 224 | Sentinel具有以下特点: 225 | 226 | - `丰富的适用场景`:Sentinel在阿里巴巴得到了广泛的应用,几乎覆盖了近10年双11(11.11)购物节的所有核心场景,比如需要限制突发流量的“秒杀”满足系统容量、消息削峰填谷、下游不可靠业务断路、集群流量控制等。 227 | - `实时监控`:Sentinel 还提供实时监控能力。可以实时查看单台机器的运行时信息,以及500个节点以下集群的运行时信息汇总。 228 | - `广泛的开源生态系统`:Sentinel 提供与 Spring Cloud、Dubbo 和 gRPC 等常用框架和库的开箱即用集成。您只需将适配器依赖项添加到您的服务即可轻松使用 Sentinel。 229 | - `多语言支持`:Sentinel 为 Java、Go和C++提供了本机支持。 230 | - `丰富的SPI扩展`:Sentinel提供简单易用的SPI扩展接口,可以让您快速自定义逻辑,例如自定义规则管理、适配数据源等。 231 | 232 | 功能概述: 233 | ![](https://pic.imgdb.cn/item/615d0d242ab3f51d91ba339c.jpg) 234 | 235 | 生态系统景观: 236 | ![](https://pic.imgdb.cn/item/615d0d322ab3f51d91ba525b.jpg) 237 | 238 | > github地址: https://github.com/alibaba/Sentinel 239 | 240 | > maven中央仓库: https://mvnrepository.com/artifact/com.alibaba.csp/sentinel-core 241 | 242 | 配置maven依赖: 243 | ```java 244 | 245 | com.alibaba.csp 246 | sentinel-core 247 | 1.8.2 248 | 249 | ``` 250 | ## 8. otter 251 | 阿里巴巴B2B公司,因为业务的特性,卖家主要集中在国内,买家主要集中在国外,所以衍生出了杭州和美国异地机房的需求,同时为了提升用户体验,整个机房的架构为双A,两边均可写,由此诞生了otter这样一个产品。 252 | 253 | otter第一版本可追溯到04~05年,此次外部开源的版本为第4版,开发时间从2011年7月份一直持续到现在,目前阿里巴巴B2B内部的本地/异地机房的同步需求基本全上了otte4。 254 | 255 | 目前同步规模: 256 | - 同步数据量6亿 257 | - 文件同步1.5TB(2000w张图片) 258 | - 涉及200+个数据库实例之间的同步 259 | - 80+台机器的集群规模 260 | 261 | otter能解决什么? 262 | - `异构库同步`。 mysql -> mysql/oracle. (目前开源版本只支持mysql增量,目标库可以是mysql或者oracle,取决于canal的功能) 263 | 264 | - `单机房同步` (数据库之间RTT < 1ms) 265 | 266 | a. 数据库版本升级 267 | 268 | b. 数据表迁移 269 | 270 | c. 异步二级索引 271 | 272 | - `异地机房同步` (比如阿里巴巴国际站就是杭州和美国机房的数据库同步,RTT > 200ms,亮点) 273 | 274 | a. 机房容灾 275 | 276 | - `双向同步` 277 | 278 | a. 避免回环算法 (通用的解决方案,支持大部分关系型数据库) 279 | 280 | b. 数据一致性算法 (保证双A机房模式下,数据保证最终一致性,亮点) 281 | 282 | - `文件同步` 283 | 站点镜像 (进行数据复制的同时,复制关联的图片,比如复制产品数据,同时复制产品图片). 284 | 285 | 工作原理图: 286 | ![](https://pic.imgdb.cn/item/615d0d442ab3f51d91ba7ca8.jpg) 287 | 288 | 单机房复制示意图: 289 | ![](https://pic.imgdb.cn/item/615d0d512ab3f51d91ba9a07.jpg) 290 | 291 | 异地机房复制示意图: 292 | ![](https://pic.imgdb.cn/item/615d0d612ab3f51d91baba8d.jpg) 293 | 294 | 295 | > github地址: https://github.com/alibaba/otter 296 | 297 | > maven中央仓库: https://mvnrepository.com/artifact/com.alibaba.otter/canal.client 298 | 299 | ## 9. P3C 300 | P3C插件呈现了阿里巴巴 Java 编码指南,它整合了阿里巴巴集团技术团队多年来的最佳编程实践。由于我们鼓励重用和更好地理解彼此的程序,因此大量 Java 编程团队对跨项目的代码质量提出了苛刻的要求。 301 | 302 | 阿里巴巴过去见过很多编程问题。例如,有缺陷的数据库表结构和索引设计可能会导致软件架构缺陷和性能风险。另一个例子是混乱的代码结构难以维护。此外,未经身份验证的易受攻击的代码容易受到黑客的攻击。为了解决这些问题,我们为阿里巴巴的Java开发人员编写了这份文档。 303 | 304 | 更多信息请参考阿里巴巴Java编码指南: 305 | 306 | - 中文版:阿里巴巴Java开发手册 307 | - 英文版:Alibaba Java Coding Guidelines 308 | 309 | 该项目由3部分组成: 310 | - PMD 实现 311 | - IntelliJ IDEA 插件 312 | - Eclipse 插件 313 | 314 | 四十九条规则是基于PMD实现的,更多详细信息请参考P3C-PMD文档。IDE 插件(IDEA 和 Eclipse)中实现的四个规则如下: 315 | 316 | - [Mandatory]禁止使用已弃用的类或方法。 317 | 注意:例如,应该使用 decode(String source, String encode) 而不是不推荐使用的方法 decode(String encodeStr)。一旦接口被弃用,接口提供者就有义务提供一个新的接口。同时,客户端程序员有义务检查它的新实现是什么。 318 | 319 | - [Mandatory]来自接口或抽象类的重写方法必须用 @Override 注释标记。反例:对于 getObject() 和 get0bject(),第一个是字母“O”,第二个是数字“0”。为了准确判断覆盖是否成功,需要一个@Override注解。同时,一旦抽象类中的方法签名发生变化,实现类将立即报告编译时错误。 320 | 321 | - [Mandatory] 静态字段或方法应直接通过其类名而不是其对应的对象名来引用。 322 | 323 | - [Mandatory] hashCode 和 equals 的用法应该遵循: 324 | 325 | 1. 如果 equals 被覆盖,则覆盖 hashCode。 326 | 2. 这两个方法必须为 Set 重写,因为它们用于确保不会在 Set 中插入重复的对象。 327 | 3. 如果使用自定义对象作为 Map 的键,则必须覆盖这两个方法。注意:String 可以用作 Map 的键,因为这两个方法已经被重写。 328 | 329 | 使用p3c插件的效果: 330 | 331 | ![](https://pic.imgdb.cn/item/615d0d742ab3f51d91bae1df.jpg) 332 | 333 | 最新版阿里巴巴Java开发手册下载地址: 334 | https://github.com/alibaba/p3c/blob/master/Java开发手册(嵩山版).pdf 335 | 336 | 337 | > github地址:https://github.com/alibaba/p3c/tree/master/idea-plugin 338 | 339 | 340 | ## 10. Spring Cloud Alibaba 341 | 342 | Spring Cloud Alibaba 为分布式应用开发提供一站式解决方案。它包含开发分布式应用程序所需的所有组件,使您可以轻松地使用 Spring Cloud 开发应用程序。 343 | 344 | 使用Spring Cloud Alibaba,您只需添加一些注解和少量配置,即可将Spring Cloud应用连接到阿里巴巴的分布式解决方案,并通过阿里巴巴中间件构建分布式应用系统。 345 | 346 | 主要功能如下: 347 | - `流量控制和服务降级`:默认支持 HTTP 服务的流量控制。您还可以使用注释自定义流量控制和服务降级规则。规则可以动态更改。 348 | - `服务注册和发现`:可以注册服务,客户端可以使用 Spring 管理的 bean,自动集成 Ribbon 来发现实例。 349 | - `分布式配置`:支持分布式系统中的外化配置,配置变化时自动刷新。 350 | - `事件驱动`:支持构建与共享消息系统连接的高度可扩展的事件驱动微服务。 351 | - `分布式事务`:支持高性能、易用的分布式事务解决方案。 352 | - `阿里云对象存储`:海量、安全、低成本、高可靠的云存储服务。支持随时随地在任何应用程序中存储和访问任何类型的数据。 353 | - `阿里云SchedulerX`:精准、高可靠、高可用的定时作业调度服务,响应时间秒级。 354 | - `阿里云短信`:覆盖全球的短信服务,阿里短信提供便捷、高效、智能的通讯能力,帮助企业快速联系客户。 355 | 356 | 主要包含如下组件: 357 | - `Sentinel`:把流量作为切入点,从流量控制、熔断降级、系统负载保护等多个维度保护服务的稳定性。 358 | - `Nacos`:一个更易于构建云原生应用的动态服务发现、配置管理和服务管理平台。 359 | - `RocketMQ`:一款开源的分布式消息系统,基于高可用分布式集群技术,提供低延时的、高可靠的消息发布与订阅服务。 360 | - `Dubbo`:Apache Dubbo™ 是一款高性能 Java RPC 框架。 361 | - `Seata`:阿里巴巴开源产品,一个易于使用的高性能微服务分布式事务解决方案。 362 | - `Alibaba Cloud OSS`: 阿里云对象存储服务(Object Storage Service,简称 OSS),是阿里云提供的海量、安全、低成本、高可靠的云存储服务。您可以在任何应用、任何时间、任何地点存储和访问任意类型的数据。 363 | - `Alibaba Cloud SchedulerX`: 阿里中间件团队开发的一款分布式任务调度产品,提供秒级、精准、高可靠、高可用的定时(基于 Cron 表达式)任务调度服务。 364 | - `Alibaba Cloud SMS`: 覆盖全球的短信服务,友好、高效、智能的互联化通讯能力,帮助企业迅速搭建客户触达通道。 365 | 366 | > github地址: https://github.com/alibaba/spring-cloud-alibaba 367 | 368 | 配置maven依赖: 369 | ```java 370 | 371 | 372 | 373 | com.alibaba.cloud 374 | spring-cloud-alibaba-dependencies 375 | 2.2.6.RELEASE 376 | pom 377 | import 378 | 379 | 380 | 381 | ``` -------------------------------------------------------------------------------- /docs/其他/微信一面,过了.md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 最近经常有小伙伴,问我面试相关的问题。尤其是近期正在找工作的小伙伴,希望我能够分享一些大厂的面试题,让他们面试前有个参考,不打无准备之仗。今天有个好消息是,我整理了一份微信、VIVO和货拉拉的真实面试题,其中涉及的知识点很有代表性,非常有参考价值。想进大厂的小伙伴,赶紧看过来,这是一份开卷考试,你准备好了吗? 3 | 4 | ## 微信支付1面 5 | 1. http1.0、http2.0、http3.0的区别? 6 | 2. http是如何采用UDP的? 7 | 3. http3.0为什么采用UDP? 8 | 4. TCP怎么保证可靠性的 (TCP拥塞控制讲一讲) 9 | 5. TimeWait状态知道吗? 怎么造成的? 怎么避免和解决? 10 | 6. JAVA的AOP怎么实现的? 11 | 7. 多态的原理是什么? 12 | 8. WEB安全谈一下 13 | 9. 一个接口的性能要提升怎么做? 14 | 10. 索引讲一下,为什么不能用红黑树? 15 | 11. 红黑树应用场景有哪些? 16 | - 我答的epoll里面, hashmap里面, linux的进程调用3个地方。 17 | 12. 什么时候分库?什么时候分表? 有什么区别? 读写分离 主要解决的什么? 18 | 13. redis除了缓存还能拿来干什么?(我答锁..忘记说bitmap了...) 19 | 14. redis的主从复制原理,两个机制是什么? 半同步机制,你详细讲一下。 20 | 15. redis故障转移过程细节说一下 21 | 16. 聊聊缓存击穿、穿透、雪崩的区别 22 | 17. 一致性hash原理是什么? 23 | 18. redis有哪些数据类型? 底层实现是什么? 24 | 19. 跳表到底是一个什么样的数据结构? 你解释下干什么用?有什么好处? 25 | 20. 看了哪些书? 26 | 21. 一个线程的生命周期的过程中,能不能换到不同的CPU核心上执行? 27 | 22. 操作系统的调度算法知道吗? 28 | 23. cpu执行高位和低位的过程 (题目没听懂..不明白什么玩意) 29 | 24. redis为什么那么快? 30 | 25. https是什么? 有什么用? 怎么实现的? 31 | 26. 你说下https加密的过程 32 | 27. 为什么既要非对称加密又要对称加密? 33 | 28. 你们消息队列用的什么? 为什么用kafka? 其他几个不行吗? 怎么选型的? 34 | 29. mysql主从同步延时怎么解决? 35 | 30. 你们mysql怎么保证的数据不丢失 比如机房地震 36 | 31. http状态码301和403代表什么含义? 37 | 32. sql注入是什么? 怎么避免? 原理是什么? 38 | 33. 类设计的5个原则 39 | 34. hash冲突有哪些解决方案? 40 | 35. 如果项目要做升级,要考虑哪些方面? 41 | 36. 缓存与数据一致性,假设先操作数据库后写缓存, 两者都成功的情况下还存在什么问题吗? 42 | 43 | 44 | ## VIVO-2面 45 | 1. 线上jvm环境, 哪个命令可以查到每个类有多少个?占用多大? 46 | 2. dump文件用mat工具分析的时候,排在前面占用最多的不是自定义类, 而是jdk自带的常用来,比如java.lang.string, 该怎么办? 47 | 3. ReentrenLock能不能替代Synchronized? 48 | 4. 什么时候用乐观锁? 什么时候用悲观锁? 数据库的锁有哪些有了解吗? 49 | 5. Mysql分库以后,多个库的分页排序查询怎么做? 50 | 6. SpringCloud Alibaba 里面的nacos注册中心怎么做的高可用? 51 | 7. Redis集群为什么选Redis Cluster,而不选代理+哨兵+主从,选型的时候怎么考虑的? 52 | 8. G1收集器说一说,然后他的缺点是什么?什么时候选CMS什么时候选G1? 53 | 9. 分布式事务怎么搞的,有没有落地?怎么落地的?具体方案是什么? 54 | 10. 线上一共有多少服务? 服务直接调用链路说一说, 多少台机器? QPS有多少? 有没有压测? 怎么做的? 55 | 11. 有没有做自动化测试?怎么做的? 56 | 57 | ## 货拉拉2面 58 | 1. 聊聊间隙锁怎么样最低降低间隙锁的范围?因为间隙锁太大可能会导致死锁,虽然不能完全避免, 那怎么尽最大力度避免? 59 | 2. 什么时候用索引比不用索引慢? 哪些场景? 60 | 3. mysql执行计划出来了以后,那你说下你的调优思路, 除了索引之外, 说其他的思路? 哪些方面? 怎么看? 怎么弄? 61 | 4. 那怎么在不改变sql语句的情况下,如果改变你sql的执行计划? 62 | 5. 项目中遇到jvm内存泄露或者cpu机器飙高没? 怎么解决的? 63 | 6. jvm调优总体说一下思路。 64 | 7. 除了mat这些可视化的工具, 你还用什么其他的调优工具没? 除了那些原始的jdk命令 65 | 8. 你们用的G1,为什么不用CMS+PN? 66 | 9. 说说对象的分配,那有了栈上分配 还搞一个TLAB?两者什么区别 栈不就是在线程里吗 又搞一个线程本地buffer? 67 | 10. kafka多线程消费: 假设总共有10条数据,其中2条数据 有问题,8条数据成功了, 你怎么处理? 68 | 11. 设计模式你们在项目中怎么用的? 除了单例和工厂模式 这两个不谈, 说其他的结合你的项目说。 69 | 12. redis中热key你怎么处理?单节点可能爆,打到多个节点?怎么打? 前面怎么查? 我这是一个key哦?说说 70 | 13. redis分布式锁和zk分布式锁怎么选型的?项目中用到了没有? 71 | 14. 那你说说用redlock的分布式锁有什么问题没?如果5个节点, abcde中abc加了锁, 万一a宕机了, 另外一个机器还能不能拿到分布式锁?为什么? 72 | 15. 你们用的redis cluster?为什么不用代理+哨兵, 为什么选型redis cluster? 73 | 16. 为什么用kafka? 不用rocketmq 或者rabbitmq? 怎么选型的? 74 | 17. 数据库和redis双写保证一致性? 有没有什么问题? 怎么解决? 75 | 18. 降低数据库的压力你可以搞读写分离, 加从节点啊, 为什么直接上redis? 76 | 19. 缓存并发竞争有没有在项目中遇到过?怎么解决的? 77 | 20. 分布式事务用的哪一种? 为什么?优点和缺点是什么? 78 | 21. 消息队列满了怎么办?你用kafka怎么消息的幂等?怎么保证消息不丢? 79 | 22. 你说kafka你设置ack-1, 为什么是-1? -1的时候存在什么问题? 怎么解决? -------------------------------------------------------------------------------- /docs/其他/我的公众号万粉之路.md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 3 | 大家好,我是苏三,又跟大家见面了。今天要告诉大家一个好消息,我的原创技术公众号:「苏三说技术」, 粉丝破万了。 4 | 5 | ![](https://files.mdnice.com/user/5303/50e8ed48-83c0-4c75-ab87-11b0c36610d5.png) 6 | 7 | 这是我在写作道路上,真正意义的一个里程碑(在某个平台有1万粉丝)。它会鞭策着我,继续努力低调前行,输出更多原创高质量的文章,回馈给一路以来一直支持我的粉丝们。❤️❤️❤️ 8 | 9 | ## 1. 写公众号的初衷 10 | 可能很多小伙伴都比较好奇,想问我最初为什么写公众号? 11 | 12 | - 为了赚钱? 13 | 14 | - 为了提升影响力? 15 | 16 | - 为了提升技术? 17 | 18 | - 为了装逼? 19 | 20 | 其实都不对,我最初的想法是通过文字输出,锻炼一下自己的表达能力。因为做技术的,写代码可以,但是写文档其实并不在行。 21 | 22 | 还有就是把以前工作当中踩过的一些坑,记录下来,避免以后再踩一遍。也给遇到相同问题的小伙伴一个参考,毕竟我使用过的很多方案都是自己实践过的,有一定的参考价值。 23 | 24 | 25 | ## 2. 注册公众号 26 | 「苏三说技术」 这个公众号注册时间是2018年08月01日,之后被冻结了很长一段时间(有一年多的时间),后来,又通过申诉恢复了。 27 | ![](https://files.mdnice.com/user/5303/77756999-08ab-4880-9c19-a88e1f821342.png) 28 | 为什么会被冻结? 29 | 30 | 因为我注册之后,一篇文章都没写过,也一直没有登录过。其实,我以前的想法是申请个公众号写读书感想的,但最后发现这一年多的时间内,总共加起来也没读过几本书。账号被系统自动回收了,白白浪费了这一年多的大好光阴,现在想想真的好可惜。如果早点写公众号,当时内卷没那么严重,也许我会发展得更好一些,但很多事情都没有如果。 31 | 32 | 为什么又申诉恢复? 33 | 34 | 我当时想写点东西,记录一下自己的工作经验,遇到过的问题,和踩过的坑。此外,想通过文字输出,锻炼一下自己的表达能力。非常偶然的一次,看到朋友圈有个同事发的技术文章,竟然是在他自己的公众号中写的。我当时觉得有点意思,想想自己不也有公众号吗,不如也整一下。但后来发现公众号已经被冻结了,然后自然而然就跑去申请恢复,整个过程比较顺利。 35 | 36 | 37 | 38 | ## 3. 第一篇文章 39 | 之后,抱着先试一下水的心态,很快我的第一篇文章发表了,叫做《如果你不知道spring中的那些初始化方法,你就out了》,发表时间是:2020-05-17。这篇文章主要介绍的spring的三种初始化方法,该文章目前已经被我删了。 40 | 41 | 由于我之前没写过这类文章,什么都不知道,什么都需要自己一点点的摸索。当时排版成了我最头痛的问题,写文章花了1天时间,但排版却占了大部分的时间,所以写作效率极低。 42 | ![](https://files.mdnice.com/user/5303/1cf06bc0-d96d-4230-b3c9-76e0847a996b.png) 43 | 我当时为了能够引用读者的注意,想了一个响亮的名字。但发完推文之后,阅读量只有1个,因为粉丝只有1个,也就是我自己。 44 | 45 | 46 | ## 4. 如何推广? 47 | 梦想很性感,现实很骨感。来看光写文章还不够,需要把文章推广出去,让更多的读者看到,吸引更多粉丝关注,才能有更多的阅读量。 48 | 49 | 那么如何推广呢? 50 | 51 | ### 4.1 朋友圈 52 | 发朋友圈推广自己的文章,是最简单最有效的办法,因为里面有很多熟人,马上就能吸引粉丝的关注。但有个问题就是,我当时的朋友圈只有500个左右,真正通过技术文章关注的人极少。后来发现,涨了几个粉丝,还是因为亲戚和好朋友的捧场。 53 | 54 | 现在回头想想,以后必须要多加点好友,学会经营朋友圈,以备不时之需,这点我之前做的不好。 55 | 56 | ### 4.2 给帖子灌水 57 | 既然朋友圈的路走不通,得想想其他法子。于是,我花了很长一段时间,在各大技术论坛,比如:CSDN、掘金、思否、博客园等,找排名靠前的技术帖子,在里面疯狂的留言,给自己打广告。 58 | 59 | 后来发现,这种方式效果微乎其微。 60 | 61 | ### 4.3 社交群 62 | 前面都无功而返了,我抱着试一下的心态,在网上搜索了其他的推广方式,发现了另一条路:社交群。 63 | 64 | 但当时我的微信群都是熟人群,技术群极少,有些还是我们公司拉的群。直接从微信群入手,几乎是不可能。于是,我选择了qq群。 65 | 66 | 那段时间,我加了N个qq技术群。每天在群里推广自己的公众号文章一次,每天都被很多群踢了。被踢了又再加回去,有些群都把我直接拉黑了。 67 | 68 | 说实话,那段时间真的挺累,但同样收获很少。唯一的收获是有些群里会发微信群的二维码,趁机可以加一个微信群。 69 | 70 | 我通过这种方式加了几个微信群,然后在群里认识了一些朋友,为以后的发展奠定了一些基础。然后找人互换微信群,这样加入了更多的微信群。通过在这些微信群里发我的技术推文,涨了一些粉丝。 71 | 72 | ### 4.4 投稿 73 | 通过在微信群发推文,可以涨粉,但相同的文章不能天天发,容易引起群主的反感。而且通过这种方式涨粉,有点不稳定。我很长一段时间是保持一周一篇文章的节奏,中间有很长的空档期,这段时间是没有粉丝增加的。这样会导致粉丝增加缓慢,必须要寻找其他突破口。 74 | 75 | 后来,从有些号主朋友那边得知要向大号投稿,他们转载之后,会给你带来一些粉丝。但是问题来了,要向哪些大佬投稿呢? 76 | 77 | 有些大号接收投稿,有些大号不接受投稿,如何选择? 78 | 79 | 里面还是有些门道的。 80 | 81 | ### 4.5 各大平台引流 82 | 为了快速增加粉丝,我还在各大平台,比如:CSDN、知乎、掘金、开发者头条、博客园、今日头条、开源中国、腾讯云社区、开发者头条等,也同步发表自己的文章,同时增加少量信息,引流关注公众号。 83 | 84 | 事实证明这些平台确实可以带来一些粉丝,很多大佬都在上面引流。我摸索了这么久,目前知乎和CSDN的引流效果是最好的,但是他们都不允许直接引流。 85 | 86 | 不过需要花大量的精力,去摸索各个平台的玩法。 87 | 88 | ### 4.6 礼物车 89 | 有时偶然会在群里看到,300或500阅读礼物车的消息。一般是6-10个号主一起组队,搞的抽奖活动,比如:抽奖送手机,目的是为了吸引对方粉丝的关注。这种活动一般是真实的,奖品的费用是号主AA制,共同承担。 90 | 91 | 不过也有些出版社提供了一些技术类书籍,于是会看到一些抽奖免费送书的活动。这类活动也是真实的,目的也是希望吸引对方粉丝的关注。 92 | 93 | 这两类活动可以快速涨粉,但涨的粉丝活跃度不高,我在前期参加过几次,后面极少参加。 94 | 95 | ### 4.7 号主互推 96 | 当你粉丝达到一定数量之后,可以找差不多量级的号主互推,包括:互推技术文 和 硬推。 97 | 98 | 我更倾向于互推技术文,利用在自己的空档期,互推对方的硬核文章,不仅可以保存粉丝的活跃度,而且还能通过对方给自己涨些粉丝,一举两得,何乐而不为呢? 99 | 100 | 说一个小秘密,通过技术文章互推,涨的粉丝数量,可能比投稿更多。不过前提是,你写的文章质量一定要过硬。 101 | 102 | 硬推的推文说白了就是相互吹嘘的,底部一般会带点引流资料,这种方式涨的粉丝数量更多,但有点伤自己的粉,不建议用太多次。 103 | 104 | ## 5. 被喷子骂了怎么办? 105 | 由于我的公众号注册的太晚(其实只晚了半年,有点小遗憾),没有留言的功能。所以,我经常到其他转载我文章的公众号上去,看他们粉丝的评价。我当时特别在意这个,很希望得到好评。 106 | 107 | 我投稿之后,真正意义上第一个转载我文章的大号是:java笔记虾。当时它转载了我一篇《如何消除又臭又长的if...else,更优雅的编程》,文章当中有个粉丝留言,让我无法平静。他说:傻逼,不懂就不要出来丢人。 108 | 109 | 我没想到该留言被精选了,而且有很多人点赞,我一下子有点生气。 110 | 111 | 后来,联系找到了java笔记虾的作者小知大佬,想让他删除该文章。 112 | 113 | 他问我:还没有习惯这些呀? 114 | 115 | 我说:没有。 116 | 117 | 他也是原创作者写了很多篇文章,对于网络上的一些谩骂、质疑已经见怪不怪了。 118 | 119 | 他接着说:是不是你理解错了?该留言是骂的一楼的那个人,不是你。 120 | 121 | 我默默不做声。 122 | 123 | 后来,小知大佬问了该留言的作者骂的是谁,作者果然说骂的一楼的那个人。 124 | 125 | 我顿时想找个地洞钻下去了。 126 | 127 | 从这个事情之后,我开始不像以前那样关注网络上的骂声了。因为网上的喷子太多了,不管你做得多好,都会有人骂你。其实没必要放在心上,把更多时间花在那些关心你的身上不是更好? 128 | 129 | ## 6. 如何被认可的? 130 | 说实话,我初期涨粉非常难,每天按个位数的速度增长,主要的粉丝来源是微信群的推广。给很多大号投稿,也一直没有被转载过。有些大佬好心提醒我说,文章内容有些浅显。 131 | 132 | 很显然这个阶段,写的文章没有被认可。 133 | 134 | 我后来仔细分析了一下原因,主要有两方面:一方面原因是自己的文章质量不高,另一方面原因是不懂得写文章的技巧。 135 | 136 | 首先要改善的地方是文章的内容要有价值,不能随着自己的性子,像以前一样想写啥就写啥,我们要写读者关系的内容。 137 | 138 | 之后,写了一篇工具类的文章《求你别再用swagger了,给你推荐一个在线文档神器》,这篇文章很有价值,发表之后,我发现有大号主动开始转载了。 139 | 140 | 但是,也不能只写工具类文章呀? 141 | 142 | 于是我打算写几篇关于spring有点深度的文章,可是由于文章内容比较长,排版成了大问题。后来通过圈子里的朋友得知,可以使用排版工具:`mdnice`。 143 | 144 | 简直一下子解放了我的双手,我从此以后,就不再为排版问题花费太多的时间了,而把这些时间可以更多的花在文章质量上。 145 | 146 | 但是光有排版工具还不够,还需要一个好的画图工具,优质的文章中必须配置高质量的图片。我刚开始使用的:processon,但是上面有数量限制,后来通过圈子里的朋友得知,画图可以使用:`draw.io`。 147 | 148 | 用draw.io画出来的流程图,给人一种耳目一新的感觉。 149 | 150 | 有了这两个神器,再加上我之前的构思,之后写了spring相关的《spring:我是如何解决循环依赖的?》和《消除if...else的9条锦囊妙计》, 151 | 152 | 这两篇文章发表之后,开始陆续被头部大号转载了,我的文章开始受到关注。 153 | 154 | 之后发表的《卧槽,sql注入把我们系统搞挂了》,一发不可收拾,同一天被转载了N次。 155 | 156 | 再到后来的《我用kafka两年踩过的一些非比寻常的坑》和《高并发下如何保证接口幂等性》发表之后,一下子受到了越来越多号主和读者的认可。很多公众号号主私信给我,想转载我的文章。 157 | 158 | ## 7. 这一年有什么收获? 159 | 这一年多的时间说长也长,说短也短。 160 | 161 | 为了写好文章,我利用了大量下班和周末休息时间。总感觉时间不够用,一天时间一下子就过去了。如果有好的点子,就马上用手机记录下来。 162 | 163 | 经历了重重困难: 164 | 165 | 公众号粉丝破万了。 166 | 167 | 掘金上连续两个月得了上榜证书,并且有多篇文章入选周榜。 168 | 169 | 腾讯云得了年度潜力作者证书。 170 | 171 | 知乎上粉丝已经破千了,开了赞赏功能。 172 | 173 | CSDN参加了原力计划,粉丝有6千多。 174 | 175 | 开发者头条中利用推荐的积分,成功了兑换了一本技术书。 176 | 177 | 收到了很多读者的感谢,有些读者特地加了我的微信,就为了跑过来跟我说一声谢谢,说我的文章对他帮助大。 178 | 179 | 收到了很多读者或者号主朋友的打赏,具体金额就不公布了,非常感谢。 180 | 181 | 甚至有些读者想把我内推到阿里,被我委婉的拒绝了。 182 | 183 | 让人惊喜的是有多家出版社的人找到我,想跟我一起合作出本书。 184 | 185 | 当然,还赚了点广告费,真的不多。 186 | 。。。 187 | 188 | 这一年,这一切的内容都是从0-1,收获真的很多很多,但也很累很累。 189 | 190 | -------------------------------------------------------------------------------- /docs/其他/我的第一个10万.md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 大家看到这个标题的时候,可能会想: 3 | - 是不是赚了10万块钱了? 4 | - 是不是有10万粉丝了? 5 | 6 | 答:都不是,是我的单篇微信公众号文章阅读量破10万了。 7 | 8 | 我们都知道微信公众号是属于私域流量领域,更多的流量是靠粉丝阅读和其他公众号的转载。虽说它也有推荐,但推荐的流量比知乎、csdn、今日头条等差远了。 9 | 10 | 说实话,在技术类公众号中,一周左右的时间,单篇阅读量破10万还是挺难得的。(至少对我来说是挺难的,有少部分大佬除外) 11 | 12 | 这算是我的一个里程碑吧。 13 | 14 | 那么,到底是怎么回事呢? 15 | 16 | ## 我的这篇文章火了 17 | 最近一周的时间,大家可能在我的公众号,或者其他大号上看过这篇文章《》。 18 | 19 | 没错,这篇文章是我原创的,最近真的火起来了。 20 | 21 | 一周时间已经被转载`28`次: 22 | ![](https://files.mdnice.com/user/5303/7cd747c4-27a0-4adb-a99e-62ed2bb5fd25.png) 23 | 24 | 转载列表中有很多头部大号的身影: 25 | ![](https://files.mdnice.com/user/5303/251ecb1f-036a-4b1f-b886-8a4d3474372c.png) 26 | 27 | ![](https://files.mdnice.com/user/5303/ff56062d-eb58-4974-a065-44371ea95e18.png) 28 | 29 | ![](https://files.mdnice.com/user/5303/323363f9-c2ae-4fb9-8377-d683acbf9976.png) 30 | 31 | ![](https://files.mdnice.com/user/5303/3b9d2f9b-85d0-40e5-b73e-8beb29673eba.png) 32 | 33 | ![](https://files.mdnice.com/user/5303/a39fa237-8c1c-48b7-b4be-2b881a970a7a.png) 34 | 在这里非常感谢各位号主大佬的转载,以及对我文章的肯定。 35 | 36 | ## 为什么写这篇文章? 37 | 最近有很多读者私信给我,问我文章中的图是用什么工具画的。 38 | 39 | 很多人觉得我画的图,有种小清新的感觉,让人眼前一亮。 40 | 41 | 刚开始的时候,我逐一回复读者的问题。 42 | 43 | 但后来,文章在多个平台,包括:公众号、掘金、csdn、知乎、开源中国、博客园等平台发表之后。我已经没办法逐一回复所有的读者了。 44 | 45 | 此时,我心里在想,问我的人多,恰恰说明了这个画图工具的价值。我何不写一篇介绍这个画图工具的文章,给那些真正有需要的朋友一个参考呢? 46 | 47 | 其实我当时知道,可能很多读者已经用过这个工具了,写这篇文章可能会遭到一些读者的吐槽。 48 | 49 | 但任然坚持写这篇文章,因为我当时的想法是,把我的实际画图经验分享给大家,哪怕有一个读者朋友看了这篇文章有些收获,也是值得的。 50 | 51 | 后来,我花了两天的时间把文章写完了。 52 | 53 | ## 怎么火起来的? 54 | 为了让文章标题显得与众不同,我特意起了`干掉xxx`。文章封面选了一个我画过的图,更突显主题一些。 55 | 56 | 这篇文章当时在公众号上发表之后,阅读量一下子涨起来了。 57 | 58 | 是我平时阅读量的两倍,让我很兴奋。 59 | 60 | 第二天,开始陆续有大佬开始转载我的文章。 61 | 62 | java后端技术大佬是最早转载的,当时差不多1万的阅读量。 63 | 64 | 后来,菜鸟教程大佬转载之后,当天就有2万多的阅读量。 65 | 66 | 自从菜鸟教程转载,达到惊人的阅读量之后,越来越多的大佬开始关注这篇文章,并且头条转载了这篇文章。 67 | 68 | 69 | ## 我的思考 70 | 这篇介绍画图工具的文章,能够火起来,给我了很多惊喜。 71 | 72 | 同时我也在思考,为什么这篇文章能火。 73 | 74 | 我总结了几个原因: 75 | 1. 画图工具是很多人的痛点,因为市面上大多数画图工具是收费的。免费并且好用的画图工具比较少,说明这篇文章有一定的话题性。 76 | 77 | 2. 文章起了一个非常不错的标题:《干掉visio,这个画图神器真的绝了!!!》。标题中包含了几个吸引人的关键字:干掉、神器、绝了。 78 | 79 | 3. 文章用了一个不错的封面图片,这个图片就是我画图的真实截图,看起来特别真实。事实证明,这张图非常有用,有些大佬转载时换了其他图片,阅读量就被打了大大的折扣。 80 | 81 | 4. 文章内容有用,这篇文章发表之后,点赞量和收藏量挺多的,说明还是挺有价值的。正是我发自内心的分享,才引起了很多读者的共鸣。 82 | 83 | 说实话,写文章不难。难的是写一篇受欢迎,并且对读者有价值,同时得到同行认可的文章。 84 | 85 | 希望下次公众号上破10万阅读的是我的一篇纯技术文章。 86 | 87 | 目前这两篇文章被转载挺多的,强烈推荐一下《》《》。 88 | 89 | 好了,先聊到这里,下期再见。 -------------------------------------------------------------------------------- /docs/基础/java中那些让你傻傻分不清楚的小细节.md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 最近我们通过sonar静态代码检测,同时配合人工代码review,发现了项目中很多代码问题。除了常规的bug和安全漏洞之外,还有几处方法用法错误,引起了我极大的兴趣。 3 | 4 | 我为什么会对这几个方法这么感兴趣呢?因为它们极具迷惑性,可能会让我们傻傻分不清楚。 5 | 6 | ## 1. replace会替换所有字符? 7 | 很多时候我们在使用字符串时,想把字符串比如:ATYSDFA*Y中的字符A替换成字符B,第一个想到的可能是使用replace方法。 8 | 9 | 如果想把所有的A都替换成B,很显然可以用replaceAll方法,因为非常直观,光从方法名就能猜出它的用途。 10 | 11 | 那么问题来了:replace方法会替换所有匹配字符吗? 12 | 13 | jdk的官方给出了答案。 14 | 15 | ![](https://pic.imgdb.cn/item/610fe6655132923bf81517b5.jpg) 16 | 17 | 该方法会替换每一个匹配的字符串。 18 | 19 | 既然replace和replaceAll都能替换所有匹配字符,那么他们有啥区别呢? 20 | 21 | replace有两个重载的方法。 22 | 其中一个方法的参数:char oldChar 和 char newChar,支持字符的替换。 23 | ```java 24 | source.replace('A', 'B') 25 | ``` 26 | 另一个方法的参数是:CharSequence target 和 CharSequence replacement,支持字符串的替换。 27 | ```java 28 | source.replace("A", "B") 29 | ``` 30 | replaceAll方法的参数是:String regex 和 String replacement,基于正则表达式的替换。普通字符串替换: 31 | ```java 32 | source.replaceAll("A", "B") 33 | ``` 34 | 正则表达替换(将*替换成C): 35 | ```java 36 | source.replaceAll("\\*", "C") 37 | ``` 38 | 顺便说一下,将*替换成C使用replace方法也可以实现: 39 | ```java 40 | source.replace("*", "C") 41 | ``` 42 | 无需对特殊字符进行转义。 43 | 44 | 不过,千万注意,切勿使用如下写法: 45 | ```java 46 | source.replace("\\*", "C") 47 | ``` 48 | 这种写法会导致字符串无法替换。 49 | 50 | 还有个小问题,如果我只想替换第一个匹配的字符串该怎么办? 51 | 52 | 这时可以使用replaceFirst方法: 53 | ```java 54 | source.replaceFirst("A", "B") 55 | ``` 56 | 57 | ## 2. Integer不能用==判断相等? 58 | 不知道你在项目中有没有见过,有些同事对Integer类型的两个参数使用==比较是否相等? 59 | 60 | 反正我见过的,那么这种用法对吗? 61 | 62 | 我的回答是看具体场景,不能说一定对,或不对。 63 | 64 | 有些状态字段,比如:orderStatus有:-1(未下单),0(已下单),1(已支付),2(已完成),3(取消),5种状态。 65 | 66 | 这时如果用==判断是否相等: 67 | ```java 68 | Integer orderStatus1 = new Integer(1); 69 | Integer orderStatus2 = new Integer(1); 70 | System.out.println(orderStatus1 == orderStatus2); 71 | ``` 72 | 返回结果会是true吗? 73 | 74 | 答案:是false。 75 | 76 | 有些同学可能会反驳,Integer中不是有范围是:-128-127的缓存吗? 77 | 78 | 为什么是false? 79 | 80 | 先看看Integer的构造方法: 81 | ![](https://pic.imgdb.cn/item/610fe6d05132923bf81605cd.jpg) 82 | 83 | 它其实并没有用到缓存。 84 | 85 | 那么缓存是在哪里用的? 86 | 87 | 答案在valueOf方法中: 88 | 89 | ![](https://pic.imgdb.cn/item/610fe6f15132923bf8165237.jpg) 90 | 91 | 如果上面的判断改成这样: 92 | ```java 93 | String orderStatus1 = new String("1"); 94 | String orderStatus2 = new String("1"); 95 | System.out.println(Integer.valueOf(orderStatus1) == Integer.valueOf(orderStatus2)); 96 | ``` 97 | 返回结果会是true吗? 98 | 99 | 答案:还真是true。 100 | 101 | 我们要养成良好编码习惯,尽量少用==判断两个Integer类型数据是否相等,只有在上述非常特殊的场景下才相等。 102 | 103 | 而应该改成使用equals方法判断: 104 | ```java 105 | Integer orderStatus1 = new Integer(1); 106 | Integer orderStatus2 = new Integer(1); 107 | System.out.println(orderStatus1.equals(orderStatus2)); 108 | ``` 109 | 110 | ## 3. 使用BigDecimal就不丢失精度? 111 | 通常我们会把一些小数类型的字段(比如:金额),定义成BigDecimal,而不是Double,避免丢失精度问题。 112 | 113 | 使用Double时可能会有这种场景: 114 | ```java 115 | double amount1 = 0.02; 116 | double amount2 = 0.03; 117 | System.out.println(amount2 - amount1); 118 | ``` 119 | 正常情况下预计amount2 - amount1应该等于0.01 120 | 121 | 但是执行结果,却为: 122 | ```java 123 | 0.009999999999999998 124 | ``` 125 | 实际结果小于预计结果。 126 | 127 | Double类型的两个参数相减会转换成二进制,因为Double有效位数为16位这就会出现存储小数位数不够的情况,这种情况下就会出现误差。 128 | 129 | 常识告诉我们使用BigDecimal能避免丢失精度。 130 | 131 | 但是使用BigDecimal能避免丢失精度吗? 132 | 133 | 答案是否定的。 134 | 135 | 为什么? 136 | ```java 137 | BigDecimal amount1 = new BigDecimal(0.02); 138 | BigDecimal amount2 = new BigDecimal(0.03); 139 | System.out.println(amount2.subtract(amount1)); 140 | ``` 141 | 这个例子中定义了两个BigDecimal类型参数,使用构造函数初始化数据,然后打印两个参数相减后的值。 142 | 143 | 结果: 144 | ```java 145 | 0.0099999999999999984734433411404097569175064563751220703125 146 | ``` 147 | 不科学呀,为啥还是丢失精度了? 148 | 149 | jdk中BigDecimal的构造方法上有这样一段描述: 150 | 151 | ![](https://pic.imgdb.cn/item/610fe72f5132923bf816deee.jpg) 152 | 153 | 大致的意思是此构造函数的结果可能不可预测,可能会出现创建时为0.1,但实际是0.1000000000000000055511151231257827021181583404541015625的情况。 154 | 155 | 由此可见,使用BigDecimal构造函数初始化对象,也会丢失精度。 156 | 157 | 那么,如何才能不丢失精度呢? 158 | ```java 159 | BigDecimal amount1 = new BigDecimal(Double.toString(0.02)); 160 | BigDecimal amount2 = new BigDecimal(Double.toString(0.03)); 161 | System.out.println(amount2.subtract(amount1)); 162 | ``` 163 | 使用Double.toString方法对double类型的小数进行转换,这样能保证精度不丢失。 164 | 165 | 其实,还有更好的办法: 166 | ```java 167 | BigDecimal amount1 = BigDecimal.valueOf(0.02); 168 | BigDecimal amount2 = BigDecimal.valueOf(0.03); 169 | System.out.println(amount2.subtract(amount1)); 170 | ``` 171 | 使用BigDecimal.valueOf方法初始化BigDecimal类型参数,也能保证精度不丢失。在新版的阿里巴巴开发手册中,也推荐使用这种方式创建BigDecimal参数。 172 | 173 | ## 4. 字符串拼接不能用String? 174 | String类型的字符串被称为不可变序列,也就是说该对象的数据被定义好后就不能修改了,如果要修改则需要创建新对象。 175 | ```java 176 | String a = "123"; 177 | String b = "456"; 178 | String c = a + b; 179 | System.out.println(c); 180 | ``` 181 | 在大量字符串拼接的场景中,如果对象被定义成String类型,会产生很多无用的中间对象,浪费内存空间,效率低。 182 | 183 | 这时,我们可以用更高效的可变字符序列:StringBuilder和StringBuffer,来定义对象。 184 | 185 | 那么,StringBuilder和StringBuffer有啥区别? 186 | 187 | StringBuffer对各主要方法加了synchronized关键字,而StringBuilder没有。所以,StringBuffer是线程安全的,而StringBuilder不是。 188 | 189 | 其实,我们很少会出现需要在多线程下拼接字符串的场景,所以StringBuffer实际上用得非常少。一般情况下,拼接字符串时我们推荐使用StringBuilder,通过它的append方法追加字符串,它只会产生一个对象,而且没有加锁,效率较高。 190 | ```java 191 | String a = "123"; 192 | String b = "456"; 193 | StringBuilder c = new StringBuilder(); 194 | c.append(a).append(b); 195 | System.out.println(c); 196 | ``` 197 | 接下来,关键问题来了:字符串拼接时使用String类型的对象,效率一定比StringBuilder类型的对象低? 198 | 199 | 答案是否定的。 200 | 201 | 为什么? 202 | 203 | 使用javap -c StringTest命令反编译: 204 | 205 | ![](https://pic.imgdb.cn/item/610fe7805132923bf81795cb.jpg) 206 | 207 | 从图中能看出定义了两个String类型的参数,又定义了一个StringBuilder类的参数,然后两次使用append方法追加字符串。 208 | 209 | 如果代码是这样的: 210 | ```java 211 | String a = "123"; 212 | String b = "789"; 213 | String c = a + b; 214 | System.out.println(c); 215 | ``` 216 | 使用javap -c StringTest命令反编译的结果会怎样呢? 217 | 218 | ![](https://pic.imgdb.cn/item/610fe7a75132923bf817ea2e.jpg) 219 | 220 | 我们会惊讶的发现,同样定义了两个String类型的参数,又定义了一个StringBuilder类的参数,然后两次使用append方法追加字符串。跟上面的结果是一样的。 221 | 222 | 其实从jdk5开始,java就对String类型的字符串的+操作做了优化,该操作编译成字节码文件后会被优化为StringBuilder的append操作。 223 | 224 | ## 5. isEmpty和isBlank的区别 225 | 我们在对字符串进行操作的时候,需要经常判断该字符串是否为空。如果没有借助任何工具,我们一般是这样判断的: 226 | ```java 227 | if (null != source && !"".equals(source)) { 228 | System.out.println("not empty"); 229 | } 230 | ``` 231 | 但是如果每次都这样判断,会有些麻烦,所以很多jar包都对字符串判空做了封装。目前市面上主流的工具有: 232 | 233 | - spring中的StringUtils 234 | - jdbc中的StringUtils 235 | - apache common3中的StringUtils 236 | 237 | 不过spring中的StringUtils类只有isEmpty方法,没有isNotEmpty方法。 238 | 239 | jdbc中的StringUtils类只有isNullOrEmpty方法,也没有isNotNullOrEmpty方法。 240 | 241 | 所以在这里强烈推荐一下apache common3中的StringUtils类,它里面包含了很多实用的判空方法:isEmpty、isBlank、isNotEmpty、isNotBlank等,还有其他字符串处理方法。 242 | 243 | 问题来了,isEmpty和isBlank有啥区别? 244 | 245 | 使用isEmpty方法判断: 246 | ```java 247 | StringUtils.isEmpty(null) = true 248 | StringUtils.isEmpty("") = true 249 | StringUtils.isEmpty(" ") = false 250 | StringUtils.isEmpty("bob") = false 251 | StringUtils.isEmpty(" bob ") = false 252 | ``` 253 | 使用isBlank方法判断: 254 | ```java 255 | StringUtils.isBlank(null) = true 256 | StringUtils.isBlank("") = true 257 | StringUtils.isBlank(" ") = true 258 | StringUtils.isBlank("bob") = false 259 | StringUtils.isBlank(" bob ") = false 260 | ``` 261 | 两个方法关键的区别在于这种" "空字符串的情况,isNotEmpty返回false,而isBlank返回true。 262 | 263 | ## 6. mapper查询结果要判空? 264 | 有次代码review的时候,当时有个同事说这里的判空可以去掉,让我记忆犹新: 265 | ```java 266 | List list = userMapper.query(search); 267 | if(CollectionUtils.isNotEmpty(list)) { 268 | List idList = list.stream().map(User::getId).collect(Collectors.toList()); 269 | } 270 | ``` 271 | 因为按常理,一般调用方法查询出来的集合,可能为null,需要判空的。但是,这里比较特殊,我查了一下mybatis的源码,这个判空的代码还真的可以去掉。 272 | 273 | 怎么回事呢? 274 | 275 | mybatis的查询方法最终都会调到DefaultResultSetHandler类的handleResultSets方法: 276 | ![](https://pic.imgdb.cn/item/610fe81c5132923bf818e568.jpg) 277 | 278 | ![](https://pic.imgdb.cn/item/610fe82a5132923bf81903d2.jpg) 279 | 280 | 该方法会返回一个multipleResultsList集合对象,在方法刚开始就new出来了,肯定是不会为空。 281 | 282 | 所以,如果你在项目的代码中看到有人直接使用查询出的结果,不判空也不要惊讶: 283 | ```java 284 | List list = userMapper.query(search); 285 | List idList = list.stream().map(User::getId).collect(Collectors.toList()); 286 | ``` 287 | 因为mapper底层已经处理过的,它不会出现空指针异常。 288 | 289 | ## 7. indexOf方法的正确用法 290 | 有次在review别人代码的时候,看到有个地方indexOf使用了这种写法,让我印象比较深刻: 291 | ```java 292 | String source = "#ATYSDFA*Y"; 293 | if(source.indexOf("#") > 0) { 294 | System.out.println("do something"); 295 | } 296 | ``` 297 | 你们说这段代码会打印出do something吗? 298 | 299 | 答案是否定的。 300 | 301 | 为什么呢? 302 | 303 | jdk官方说了不存在的情况会返回-1图片indexOf方法返回的是指定元素在字符串中的位置,从0开始。而上面的例子#在字符串的第一个位置,所以调用indexOf方法后的值其实是0。所以,条件是false,不会打印do something。 304 | 305 | 如果想通过indexOf判断某个元素是否存在时,要用: 306 | ```java 307 | if(source.indexOf("#") > -1) { 308 | System.out.println("do something"); 309 | } 310 | ``` 311 | 其实,还有更优雅的contains方法: 312 | ```java 313 | if(source.contains("#")) { 314 | System.out.println("do something"); 315 | } 316 | ``` 317 | 318 | -------------------------------------------------------------------------------- /docs/基础/迷茫了,我们到底该不该用lombok?.md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 最近上网查资料发现很多人对lombok褒贬不一,引起了我的兴趣,因为我们项目中也在大量使用lombok,大家不同的观点让我也困惑了几天,今天结合我实际的项目经验,说说我的个人建议。 3 | 4 | 随便搜搜就找到了这几篇文章:图片 5 | 6 | ![](https://pic.imgdb.cn/item/610fb7a95132923bf89fba46.jpg) 7 | 8 | ![](https://pic.imgdb.cn/item/610fb7bf5132923bf89feb8f.jpg) 9 | 10 | ![](https://pic.imgdb.cn/item/610fb7d35132923bf8a02239.jpg) 11 | 12 | 这些人建议使用 lombok,觉得它是一个神器,可以大大提高编码效率,并且让代码更优雅。 13 | 14 | 在搜索的过程中,有些文章却又不推荐使用:图片 15 | 16 | ![](https://pic.imgdb.cn/item/610fb7e25132923bf8a0480c.jpg) 17 | 18 | ![](https://pic.imgdb.cn/item/610fb7f35132923bf8a0743f.jpg) 19 | 20 | ![](https://pic.imgdb.cn/item/610fbb795132923bf8a971f0.jpg) 21 | 22 | 这些人觉得它有一些坑,容易给项目埋下隐患,我们到底该听谁的呢? 23 | 24 | ## 为什么建议使用lombok? 25 | ### 1.传统javabean 26 | 在没使用lombok之前,我们一般是这样定义javabean的: 27 | ```java 28 | public class User { 29 | 30 | private Long id; 31 | private String name; 32 | private Integer age; 33 | private String address; 34 | 35 | public User() { 36 | 37 | } 38 | 39 | public User(Long id, String name, Integer age, String address) { 40 | this.id = id; 41 | this.name = name; 42 | this.age = age; 43 | this.address = address; 44 | } 45 | 46 | public Long getId() { 47 | return id; 48 | } 49 | 50 | public String getName() { 51 | return name; 52 | } 53 | 54 | public Integer getAge() { 55 | return age; 56 | } 57 | 58 | public String getAddress() { 59 | return address; 60 | } 61 | 62 | 63 | public void setId(Long id) { 64 | this.id = id; 65 | } 66 | 67 | public void setName(String name) { 68 | this.name = name; 69 | } 70 | 71 | public void setAge(Integer age) { 72 | this.age = age; 73 | } 74 | 75 | public void setAddress(String address) { 76 | this.address = address; 77 | } 78 | 79 | @Override 80 | public boolean equals(Object o) { 81 | if (this == o) returntrue; 82 | if (o == null || getClass() != o.getClass()) returnfalse; 83 | User user = (User) o; 84 | return Objects.equals(id, user.id) && 85 | Objects.equals(name, user.name) && 86 | Objects.equals(age, user.age) && 87 | Objects.equals(address, user.address); 88 | } 89 | 90 | @Override 91 | public int hashCode() { 92 | return Objects.hash(id, name, age, address); 93 | } 94 | 95 | @Override 96 | public String toString() { 97 | return"User{" + 98 | "id=" + id + 99 | ", name='" + name + '\'' + 100 | ", age=" + age + 101 | ", address='" + address + '\'' + 102 | '}'; 103 | } 104 | } 105 | ``` 106 | 该User类中包含了:成员变量、getter/setter方法、构造方法、equals、hashCode方法。 107 | 108 | 咋一看,代码还是挺多的。而且还有个问题,如果User类中的代码修改了,比如:age字段改成字符串类型,或者name字段名称修改了,是不是需要同步修改相关的成员变量、getter/setter方法、构造方法、equals、hashCode方法全都修改一遍? 109 | 110 | 也许有些朋友会说:现在的idea非常智能,可以把修改一次性搞定。 111 | 112 | 没错,但是有更优雅的处理方法。 113 | 114 | ### 2.lombok的使用 115 | 第一步,引入jar包 116 | ```java 117 | 118 | org.projectlombok 119 | lombok 120 | 1.18.4 121 | provided 122 | 123 | ``` 124 | 第二步,在idea中安装插件 125 | 126 | ![](https://pic.imgdb.cn/item/610fbbc85132923bf8aa2c22.jpg) 127 | 128 | 注意:如果不按照插件idea中就无法编译使用lombok注解的代码。 129 | 130 | 第三步,在代码中使用lombok注解 131 | 132 | 上面的User类代码可以改成这样: 133 | ```java 134 | @ToString 135 | @EqualsAndHashCode 136 | @NoArgsConstructor 137 | @AllArgsConstructor 138 | @Getter 139 | @Setter 140 | publicclass User { 141 | 142 | private Long id; 143 | private String name; 144 | private Integer age; 145 | private String address; 146 | } 147 | ``` 148 | so good,代码可以优化到如此简单。User类的主体只用定义成员变量,其他的方法全都交给注解来完成。 149 | 150 | 如果修改了成员变量名称或者类型,怎么办呢? 151 | ```java 152 | @ToString 153 | @EqualsAndHashCode 154 | @NoArgsConstructor 155 | @AllArgsConstructor 156 | @Getter 157 | @Setter 158 | public class User { 159 | 160 | private Long id; 161 | private String userName; 162 | private String age; 163 | private String address; 164 | } 165 | ``` 166 | 你只用一心一意修改成员变量即可,其他的根本不用操心,简直太爽了。 167 | 168 | 更让人兴奋的是,还能进一步优化: 169 | ```java 170 | @NoArgsConstructor 171 | @AllArgsConstructor 172 | @Data 173 | public class User { 174 | 175 | private Long id; 176 | private String userName; 177 | private String age; 178 | private String address; 179 | } 180 | ``` 181 | @Data相当于@Getter、@Setter、@ToString、@EqualsAndHashCode、@RequiredArgsConstructor的集合。 182 | 183 | lombok注解整理如下: 184 | ![](https://pic.imgdb.cn/item/610fbbf95132923bf8aaa00f.jpg) 185 | 186 | 图片来源占小狼 187 | 188 | 从上面看出使用lombok给人最大的感受是代码量显著减少了,能够有效的提升开发效率,而代码看起来更优雅,确实是一个不可多得的神器。 189 | 190 | ## lombok工作原理 191 | java程序的解析分为:运行时解析 和 编译时解析。 192 | 193 | 通常我们通过反射获取类、方法、注解和成员变量就是运行时解析。但是这种方式效率其实不高,要在程序运行起来才能解析。 194 | 195 | 这时候编译时解析就体现出它的价值了。 196 | 197 | 编译时解析又分为:注解处理器(Annotation Processing Tool)和JSR 269 插入式注解处理器(Pluggable Annotation Processing API) 198 | 199 | 第一种处理器它最早是在 JDK 1.5 与注解(Annotation) 一起引入的,它是一个命令行工具,能够提供构建时基于源代码对程序结构的读取功能,能够通过运行注解处理器来生成新的中间文件,进而影响编译过程。 200 | 201 | 不过在JDK 1.8以后,第一种处理器被淘汰了,取而代之的是第二种处理器,我们一起看看它的处理流程: 202 | 203 | ![](https://pic.imgdb.cn/item/610fbc215132923bf8aafa79.jpg) 204 | 205 | Lombok的底层具体实现流程如下: 206 | 207 | 1. javac对源代码进行分析,生成了一棵抽象语法树(AST) 208 | 2. 编译过程中调用实现了“JSR 269 API”的Lombok程序 209 | 3. 此时Lombok就对第一步骤得到的AST进行处理,找到@Data注解所在类对应的语法树(AST),然后修改该语法树(AST),增加getter和setter方法定义的相应树节点 210 | 4. javac使用修改后的抽象语法树(AST)生成字节码文件,即给class增加新的节点(代码块) 211 | 212 | ## 为什么建议不用lombok? 213 | 即使lombok是一个神器,但是却有很多人不建议使用,这又是为什么呢? 214 | 215 | ![](https://pic.imgdb.cn/item/610fbc6e5132923bf8aba5d3.jpg) 216 | 217 | ### 1.强制要求队友安装idea插件 218 | 这点确实比较恶心,因为如果使用lombok注解编写代码,就要求参与开发的所有人都必须安装idea的lombok插件,否则代码编译出错。 219 | 220 | ### 2.代码可读性变差 221 | 使用lombok注解之后,最后生成的代码你其实是看不到的,你能看到的是代码被修改之前的样子。如果要想查看某个getter或setter方法的引用过程,是非常困难的。 222 | 223 | ### 3.升级JDK对功能有影响 224 | 有人把JDK从Java 8升级到Java 11时,后发现Lombok不能正常工作了。 225 | 226 | ### 4.有一些坑 227 | 使用@Data时会默认使用@EqualsAndHashCode(callSuper=false),这时候生成的equals()方法只会比较子类的属性,不会考虑从父类继承的属性,无论父类属性访问权限是否开放。 228 | 229 | 使用@Builder时要加上@AllArgsConstructor,否则可能会报错。 230 | 231 | ### 5.不便于调试 232 | 我们平时大部分人都喜欢用debug调试定位问题,但是使用lombok生成的代码不太好调试。 233 | 234 | ### 6.上下游系统强依赖 235 | 如果上游系统中提供的fegin client使用了lombok,那么下游系统必须也使用lombok,否则会报错,上下游系统构成了强依赖。 236 | 237 | ## 我们该如何选择? 238 | lombok有利有弊,我们该如何选择呢? 239 | 240 | 个人建议要结合项目的实际情况做最合理的选择。 241 | 242 | 1. 如果你参与的是一个新项目,上下游系统都是新的,这时候建议使用lombok,因为它可以显著提升开发效率。 243 | 2. 如果你参与的是一个老项目,并且以前没有使用过lombok,建议你后面也不要使用,因为代码改造成本较高。如果以前使用过lombok,建议你后面也使用,因为代码改造成本较高。 244 | 3. 其实只要引入jar包可能都有:强制要求队友安装idea插件、升级JDK对功能有影响、有一些坑 和 上下游系统强依赖 这几个问题,只要制定好规范,多总结使用经验这些问题不大。 245 | 4. 代码的可读性变差 和 不便于调试 这两个问题,我认为也不大,因为lombok一般被使用在javabean上,该类的逻辑相对来说比较简单,很多代码一眼就能看明白,即使不调试问题原因也能猜测7、8分。 -------------------------------------------------------------------------------- /docs/多线程/线程池最佳线程到底要如何配置?.md: -------------------------------------------------------------------------------- 1 | ## 一、前言 2 | 对于从事后端开发的同学来说,线程是必须要使用了,因为使用它可以提升系统的性能。 3 | 4 | 但是,创建线程和销毁线程都是比较耗时的操作,频繁的创建和销毁线程会浪费很多CPU的资源。此外,如果每个任务都创建一个线程去处理,这样线程会越来越多。我们知道每个线程默认情况下占1M的内存空间,如果线程非常多,内存资源将会被耗尽。 5 | 6 | 这时,我们需要线程池去管理线程,不会出现内存资源被耗尽的情况,也不会出现频繁创建和销毁线程的情况,因为它内部是可以复用线程的。 7 | 8 | ## 二、从实战开始 9 | 在介绍线程池之前,让我们先看个例子。 10 | ```java 11 | public class MyCallable implements Callable { 12 | 13 | @Override 14 | 15 | public String call() throws Exception { 16 | System.out.println("MyCallable call"); 17 | return "success"; 18 | } 19 | 20 | public static void main(String[] args) { 21 | 22 | ExecutorService threadPool = Executors.newSingleThreadExecutor(); 23 | try { 24 | 25 | Future future = threadPool.submit(new MyCallable()); 26 | System.out.println(future.get()); 27 | } catch (Exception e) { 28 | System.out.println(e); 29 | } finally { 30 | threadPool.shutdown(); 31 | } 32 | 33 | } 34 | 35 | } 36 | ``` 37 | 这个类的功能就是使用`Executors`类的`newSingleThreadExecutor`方法创建了的一个单线程池,他里面会执行Callable线程任务。 38 | 39 | ## 三、创建线程池的方法 40 | 我们仔细看看Executors类,会发现它里面给我们封装了不少创建线程池的静态方法,如下图所示: 41 | ![](https://pic.imgdb.cn/item/610f4a055132923bf8c73787.jpg) 42 | 43 | 其实,我们总结一下其实只有6种: 44 | 45 | ### 1.newCachedThreadPool可缓冲线程池 46 | ![](https://pic.imgdb.cn/item/610f4a1f5132923bf8c75b4c.jpg) 47 | 48 | 它的核心线程数是0,最大线程数是integer的最大值,每隔60秒回收一次空闲线程,使用SynchronousQueue队列。SynchronousQueue队列比较特殊,内部只包含一个元素,插入元素到队列的线程被阻塞,直到另一个线程从队列中获取了队列中存储的元素。同样,如果线程尝试获取元素并且当前不存在任何元素,则该线程将被阻塞,直到线程将元素插入队列。 49 | 50 | 51 | ### 2.newFixedThreadPool固定大小线程池 52 | ![](https://pic.imgdb.cn/item/610f4a495132923bf8c78d0b.jpg) 53 | 54 | 它的核心线程数 和 最大线程数是一样,都是nThreads变量的值,该变量由用户自己决定,所以说是固定大小线程池。此外,它的每隔0毫秒回收一次线程,换句话说就是不回收线程,因为它的核心线程数 和 最大线程数是一样,回收了没有任何意义。此外,使用了LinkedBlockingQueue队列,该队列其实是有界队列,很多人误解了,只是它的初始大小比较大是integer的最大值。 55 | 56 | 57 | 3.newScheduledThreadPool定时任务线程池 58 | ![](https://pic.imgdb.cn/item/610f4a705132923bf8c7bebf.jpg) 59 | 60 | 它的核心线程数是corePoolSize变量,需要用户自己决定,最大线程数是integer的最大值,同样,它的每隔0毫秒回收一次线程,换句话说就是不回收线程。使用了DelayedWorkQueue队列,该队列具有延时的功能。 61 | 62 | 63 | 64 | 4.newSingleThreadExecutor单个线程池 65 | ![](https://pic.imgdb.cn/item/610f4a875132923bf8c7dd37.jpg) 66 | 67 | 其实,跟上面的newFixedThreadPool是一样的,稍微有一点区别是核心线程数 和 最大线程数 都是1,这就是为什么说它是单线程池的原因。 68 | 69 | 5.newSingleThreadScheduledExecutor单线程定时任务线程池 70 | ![](https://pic.imgdb.cn/item/610f4aa95132923bf8c805bd.jpg) 71 | 72 | 该线程池是对上面介绍过的ScheduledThreadPoolExecutor定时任务线程池的简单封装,核心线程数固定是1,其他的功能一模一样。 73 | 74 | 6.newWorkStealingPool窃取线程池 75 | ![](https://pic.imgdb.cn/item/610f4ac05132923bf8c823c3.jpg) 76 | 77 | 它是JDK1.8增加的新线程池,跟其他的实现方式都不一样,它底层是通过ForkJoinPool类来实现的。会创建一个含有足够多线程的线程池,来维持相应的并行级别,它会通过工作窃取的方式,使得多核的 CPU 不会闲置,总会有活着的线程让 CPU 去运行。 78 | 79 | 讲了这么多,具体要怎么用呢? 80 | 81 | 其实newFixedThreadPool、newCachedThreadPool、newSingleThreadExecutor 和 newWorkStealingPool方法创建和使用线程池的方法是一样的。这四个方法创建线程池返回值是ExecutorService,通过它的execute方法执行线程。 82 | ```java 83 | public class MyWorker implements Runnable { 84 | 85 | 86 | @Override 87 | public void run() { 88 | System.out.println("MyWorker run"); 89 | } 90 | 91 | public static void main(String[] args) { 92 | ExecutorService threadPool = Executors.newFixedThreadPool(8); 93 | try { 94 | threadPool.execute(new MyWorker()); 95 | } catch (Exception e) { 96 | System.out.println(e); 97 | } finally { 98 | threadPool.shutdown(); 99 | } 100 | 101 | } 102 | } 103 | ``` 104 | newScheduledThreadPool 和 newSingleThreadScheduledExecutor 方法创建和使用线程池的方法也是一样的 105 | ```java 106 | public class MyTask implements Runnable { 107 | 108 | @Override 109 | public void run() { 110 | System.out.println("MyTask call"); 111 | } 112 | 113 | public static void main(String[] args) { 114 | 115 | ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(8); 116 | try { 117 | scheduledExecutorService.schedule(new MyRunnable(), 60, TimeUnit.SECONDS); 118 | } finally { 119 | scheduledExecutorService.shutdown(); 120 | } 121 | } 122 | } 123 | ``` 124 | 以上两个方法创建的线程池返回值是ScheduledExecutorService,通过它的schedule提交线程,并且可以配置延迟执行的时间。 125 | 126 | ## 四、自定义线程池 127 | Executors类有这么多方法可以创建线程池,但是阿里巴巴开发规范中却明确规定不要使用Executors类创建线程池,这是为什么呢? 128 | 129 | newCachedThreadPool可缓冲线程池,它的最大线程数是integer的最大值,意味着使用它创建的线程池,可以创建非常多的线程,我们都知道一个线程默认情况下占用内存1M,如果创建的线程太多,占用内存太大,最后肯定会出现内存溢出的问题。 130 | 131 | newFixedThreadPool和newSingleThreadExecutor在这里都称为固定大小线程池,它的队列使用的LinkedBlockingQueue,我们都知道这个队列默认大小是integer的最大值,意味着可以往该队列中加非常多的任务,每个任务也是要内存空间的,如果任务太多,最后肯定也会出现内存溢出的问题。 132 | 133 | 134 | 135 | 阿里建议使用ThreadPoolExecutor类创建线程池,其实从刚刚看到的Executors类创建线程池的newFixedThreadPool等方法可以看出,它也是使用ThreadPoolExecutor类创建线程池的。 136 | 137 | ![](https://pic.imgdb.cn/item/610f4d605132923bf8cbb738.jpg) 138 | 139 | ![](https://pic.imgdb.cn/item/610f4d9b5132923bf8cc08bb.jpg) 140 | 141 | 从上图可以看出ThreadPoolExecutor类的构造方法有4个,里面包含了很多参数,让我们先一起认识一下: 142 | 143 | - corePoolSize:核心线程数 144 | - maximumPoolSize:最大线程数 145 | - keepAliveTime:空闲线程回收时间间隔 146 | - unit:空闲线程回收时间间隔单位 147 | - workQueue:提交任务的队列,当线程数量超过核心线程数时,可以将任务提交到任务队列中。比较常用的有:ArrayBlockingQueue; LinkedBlockingQueue; SynchronousQueue; 148 | - threadFactory:线程工厂,可以自定义线程的一些属性,比如:名称或者守护线程等 149 | - handler:表示当拒绝处理任务时的策略 150 | > AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。 151 | DiscardPolicy:也是丢弃任务,但是不抛出异常。 152 | DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复此过程) 153 | CallerRunsPolicy:由调用线程处理该任务 154 | 155 | 我们根据上面的内容自定义一个线程池: 156 | ```java 157 | public class MyThreadPool implements Runnable { 158 | 159 | private static final ExecutorService executorService = new ThreadPoolExecutor( 160 | 8, 161 | 10, 162 | 30, 163 | TimeUnit.SECONDS, 164 | new ArrayBlockingQueue<>(500), 165 | new ThreadPoolExecutor.AbortPolicy()); 166 | 167 | @Override 168 | public void run() { 169 | System.out.println("MyThreadPool run"); 170 | } 171 | 172 | public static void main(String[] args) { 173 | int availableProcessors = Runtime.getRuntime().availableProcessors(); 174 | try { 175 | executorService.execute(new MyThreadPool()); 176 | } catch (Exception e) { 177 | System.out.println(e); 178 | } finally { 179 | executorService.shutdown(); 180 | } 181 | } 182 | } 183 | ``` 184 | 从上面可以看到,我们使用ThreadPoolExecutor类自定义了一个线程池,它的核心线程数是8,最大线程数是 10,空闲线程回收时间是30,单位是秒,存放任务的队列用的ArrayBlockingQueue,而队列满的处理策略用的AbortPolicy。使用这个队列,基本可以保持线程在系统的可控范围之内,不会出现内存溢出的问题。但是也不是绝对的,只是出现内存溢出的概率比较小。 185 | 186 | 当然,阿里巴巴开发规范建议不使用Executors类创建线程池,并不表示它完全没用,在一些低并发的业务场景照样可以使用。 187 | 188 | 189 | ## 五、最佳线程数 190 | 在使用线程池时,很多同学都有这样的疑问,不知道如何配置线程数量,今天我们一起探讨一下这个问题。 191 | 192 | ### 1.经验值 193 | 194 | 配置线程数量之前,首先要看任务的类型是 IO密集型,还是CPU密集型? 195 | 196 | **什么是IO密集型?** 197 | 198 | 比如:频繁读取磁盘上的数据,或者需要通过网络远程调用接口。 199 | 200 | **什么是CPU密集型?** 201 | 202 | 比如:非常复杂的调用,循环次数很多,或者递归调用层次很深等。 203 | 204 | IO密集型配置线程数经验值是:`2N`,其中N代表CPU核数。 205 | 206 | CPU密集型配置线程数经验值是:`N + 1`,其中N代表CPU核数。 207 | 208 | 如果获取N的值? 209 | ```java 210 | int availableProcessors = Runtime.getRuntime().availableProcessors(); 211 | ``` 212 | 213 | 那么问题来了,混合型(既包含IO密集型,又包含CPU密集型)的如何配置线程数? 214 | 215 | 混合型如果IO密集型,和CPU密集型的执行时间相差不太大,可以拆分开,以便于更好配置。如果执行时间相差太大,优化的意义不大,比如IO密集型耗时60s,CPU密集型耗时1s。 216 | 217 | 218 | 219 | ### 2.最佳线程数目算法 220 | 221 | 除了上面介绍是经验值之外,其实还提供了计算公式: 222 | 223 | > 最佳线程数目 = ((线程等待时间+线程CPU时间)/线程CPU时间 )* CPU数目 224 | 225 | 很显然线程等待时间所占比例越高,需要越多线程。线程CPU时间所占比例越高,需要越少线程。 226 | 227 | 虽说最佳线程数目算法更准确,但是线程等待时间和线程CPU时间不好测量,实际情况使用得比较少,一般用经验值就差不多了。再配合系统压测,基本可以确定最适合的线程数。 228 | 229 | -------------------------------------------------------------------------------- /docs/多线程/这8种保证线程安全的技术你都知道吗?.md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 并发情况下如何保证数据安全,一直都是开发人员每天都要面对的问题,稍不注意就会出现数据异常,造成不可挽回的结果。笔者根据自己的实际开发经验,总结了下面几种保证数据安全的技术手段: 3 | 1. 无状态 4 | 2. 不可变 5 | 3. 安全的发布 6 | 4. volatile 7 | 5. synchronized 8 | 6. lock 9 | 7. cas 10 | 8. threadlocal 11 | 12 | ## 一.无状态 13 | 我们都知道只有多个线程访问公共资源的时候,才可能出现数据安全问题,那么如果我们没有公共资源,是不是就没有这个问题呢? 14 | ```java 15 | public class NoStatusService { 16 | 17 | public void add(String status) { 18 | System.out.println("add status:" + status); 19 | } 20 | 21 | public void update(String status) { 22 | System.out.println("update status:" + status); 23 | } 24 | } 25 | ``` 26 | 27 | ## 二.不可变 28 | 如果多个线程访问公共资源是不可变的,也不会出现数据的安全性问题。 29 | ```java 30 | public class NoChangeService { 31 | public static final String DEFAULT_NAME = "abc"; 32 | 33 | public void add(String status) { 34 | System.out.println("add status:" + status); 35 | } 36 | } 37 | ``` 38 | ## 三.安全的发布 39 | 如果类中有公共资源,但是没有对外开放访问权限,即对外安全发布,也没有线程安全问题 40 | ```java 41 | public class SafePublishService { 42 | private String name; 43 | 44 | public String getName() { 45 | return name; 46 | } 47 | 48 | public void add(String status) { 49 | System.out.println("add status:" + status); 50 | } 51 | } 52 | ``` 53 | 54 | ## 四.volatile 55 | 如果有些公共资源只是一个开关,只要求可见性,不要求原子性,这样可以用volidate关键字定义来解决问题。 56 | ```java 57 | public class FlagService { 58 | public volatile boolean flag = false; 59 | 60 | public void change() { 61 | if (flag) { 62 | System.out.println("return"); 63 | return; 64 | } 65 | flag = true; 66 | System.out.println("change"); 67 | } 68 | } 69 | ``` 70 | 71 | ## 五.synchronized 72 | 使用JDK内部提供的同步机制,这也是使用比较多的手段,分为:方法同步 和 代码块同步,我们优先使用代码块同步,因为方法同步的范围更大,更消耗性能。每个对象内部都又一把锁,只有抢答那把锁的线程,才能进入代码块里,代码块执行完之后,会自动释放锁。 73 | ```java 74 | public class SyncService { 75 | private int age = 1; 76 | 77 | public synchronized void add(int i) { 78 | age = age + i; 79 | System.out.println("age:" + age); 80 | } 81 | 82 | public void update(int i) { 83 | synchronized (this) { 84 | age = age + i; 85 | System.out.println("age:" + age); 86 | } 87 | } 88 | } 89 | ``` 90 | 91 | ## 六.lock 92 | 除了使用synchronized关键字实现同步功能之外,JDK还提供了lock显示锁的方式。它包含:可重入锁、读写锁 等更多更强大的功能,有个小问题就是需要手动释放锁,不过在编码时提供了更多的灵活性。 93 | ```java 94 | public class LockService { 95 | private ReentrantLock reentrantLock = new ReentrantLock(); 96 | public int age = 1; 97 | 98 | public void add(int i) { 99 | try { 100 | reentrantLock.lock(); 101 | age = age + i; 102 | System.out.println("age:" + age); 103 | } finally { 104 | reentrantLock.unlock(); 105 | } 106 | } 107 | } 108 | ``` 109 | 110 | ## 七.cas 111 | JDK除了使用锁的机制解决多线程情况下数据安全问题之外,还提供了cas机制。这种机制是使用CPU中比较和交换指令的原子性,JDK里面是通过Unsafe类实现的。cas需要四个值:旧数据、期望数据、新数据 和 地址,比较旧数据 和 期望的数据如果一样的话,就把旧数据改成新数据,当前线程不断自旋,一直到成功为止。 112 | 113 | 不过可能会出现aba问题,需要使用AtomicStampedReference增加版本号解决。其实,实际工作中很少直接使用Unsafe类的,一般用atomic包下面的类即可。 114 | ```java 115 | public class AtomicService { 116 | private AtomicInteger atomicInteger = new AtomicInteger(); 117 | 118 | public int add(int i) { 119 | return atomicInteger.getAndAdd(i); 120 | } 121 | } 122 | ``` 123 | 124 | ## 八.threadlocal 125 | 除了上面几种解决思路之外,JDK还提供了另外一种用空间换时间的新思路:threadlocal。它的核心思想是:共享变量在每个线程都有一个副本,每个线程操作的都是自己的副本,对另外的线程没有影响。 126 | 127 | 特别注意,使用threadlocal时,使用完之后,要记得调用remove方法,不然可能会出现内存泄露问题。 128 | ```java 129 | public class ThreadLocalService { 130 | private ThreadLocal threadLocal = new ThreadLocal<>(); 131 | 132 | public void add(int i) { 133 | Integer integer = threadLocal.get(); 134 | threadLocal.set(integer == null ? 0 : integer + i); 135 | } 136 | } 137 | ``` 138 | 139 | ## 总结 140 | 本文介绍了8种多线程情况下保证数据安全的技术手段,当然实际工作中可能会有其他。技术没有好坏之分,主要是看使用的场景,需要在不同的场景下使用不同的技术。 -------------------------------------------------------------------------------- /docs/学习路线/Java后端学习路线.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvsusan/susanSayJava/95468beec0e55353e22187f93c09fdcc467cb4ee/docs/学习路线/Java后端学习路线.png -------------------------------------------------------------------------------- /docs/工具/使用了这个神器,让我的代码bug少了一半.md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 最近一段时间,我们团队在生产环境出现了几次线上问题,有部分比较严重,直接影响用户功能的使用,惹得领导不高兴了,让我想办法提升代码质量,这时候项目工程代码质量检测神器——SonarQube,出现在我们的视线当中。 3 | 4 | ## 一 sonarqube是做什么的 5 | SonarQube®是一种自动代码审查工具,用于检测代码中的错误,漏洞和代码味道。它可以与您现有的工作流程集成,以实现跨项目分支和提取请求的连续代码检查。通过插件形式,可以支持包括 java, C#, C/C++, PL/SQL, Cobol, JavaScrip, Groovy 等二十几种编程语言的代码质量管理与检测。sonarqube可以从以下7个维度检测代码质量,而作为开发人员至少需要处理前5种代码质量问题。 6 | 7 | ### 1.1 不遵循代码标准 8 | sonarqube可以通过CheckStyle等代码规则检测工具规范代码编写。 9 | 10 | ### 1.2 存在的缺陷漏洞 11 | sonarqube可以通过Findbugs等等代码规则检测工具检测出潜在的缺陷。 12 | 13 | ### 1.3 糟糕的复杂度分布 14 | 文件、类、方法等,如果复杂度过高将难以改变,这会使得开发人员 难以理解它们, 且如果没有自动化的单元测试,对于程序中的任何组件的改变都将可能导致需要全面的回归测试。 15 | 16 | ### 1.4 重复 17 | 显然程序中包含大量复制粘贴的代码是质量低下的,sonarqube可以展示源码中重复严重的地方。 18 | 19 | ### 1.5 注释不足或者过多 20 | 没有注释将使代码可读性变差,特别是当不可避免地出现人员变动 时,程序的可读性将大幅下降 而过多的注释又会使得开发人员将精力过多地花费在阅读注释上,亦违背初衷。 21 | 22 | ### 1.6 缺乏单元测试 23 | sonarqube可以很方便地统计并展示单元测试覆盖率。 24 | 25 | ### 1.7 糟糕的设计 26 | 通过sonarqube可以找出循环,展示包与包、类与类之间的相互依赖关系,可以检测自定义的架构规则 通过sonarqube可以管理第三方的jar包,可以利用LCOM4检测单个任务规则的应用情况, 检测耦合。sonarqube可以很方便地统计并展示单元测试覆盖率。 27 | 28 | 29 | 总览: 30 | 31 | ![](https://pic.imgdb.cn/item/611525015132923bf847c97e.jpg) 32 | 33 | 在典型的开发过程中: 34 | 35 | 1. 开发人员在IDE中开发和合并代码(最好使用SonarLint在编辑器中接收即时反馈),然后将其代码签入ALM。 36 | 37 | 2. 组织的持续集成(CI)工具可以检出,构建和运行单元测试,而集成的SonarQube扫描仪可以分析结果。 38 | 39 | 3. 扫描程序将结果发布到SonarQube服务器,该服务器通过SonarQube界面,电子邮件,IDE内通知(通过SonarLint)以及对拉取或合并请求的修饰(使用Developer Edition及更高版本时)向开发人员提供反馈。 40 | 41 | 42 | 43 | SonarQube实例包含三个组件: 44 | 45 | ![](https://pic.imgdb.cn/item/611525285132923bf8482b06.jpg) 46 | 47 | 1. SonarQube服务器运行以下过程: 48 | 49 | - 提供SonarQube用户界面的Web服务器。 50 | 51 | - 基于Elasticsearch的搜索服务器。 52 | 53 | - 计算引擎负责处理代码分析报告并将其保存在SonarQube数据库中。 54 | 55 | 2. 该数据库存储以下内容: 56 | 57 | - 代码扫描期间生成的代码质量和安全性的度量标准和问题。 58 | 59 | - SonarQube实例配置。 60 | 61 | 3. 在构建或连续集成服务器上运行的一台或多台扫描仪可以分析项目。 62 | 63 | ## 二 sonarqube如何搭建 64 | 官网地址:https://www.sonarqube.org/,选择“文档”菜单 65 | 66 | ![](https://pic.imgdb.cn/item/611525765132923bf848e681.jpg) 67 | 68 | 在出现的文档页面中可以选择版本,目前最新的版本是8.5。笔者尝试过三个版本: 69 | 70 | 8.5:它是目前最新的版本,需要安装JDK11,并且只支持oracle、sqlserver和PostgreSQL数据库 71 | 72 | 7.9:它是一个长期支持的版本,非常文档,也需要安装JDK11,并且只支持oracle、sqlserver和PostgreSQL数据库 。 73 | 74 | 7.6:它是一个老版本,只需安装JDK8,支持oracle、sqlserver和PostgreSQL数据库,以及mysql数据库。 75 | 76 | 刚开始我们为了省事,安装了 7.6的版本,因为mysql数据库我们已经在用了,无需额外安装其他数据库,并且JDK8也在使用,安装成本最小。但是后来发现,如果需要安装汉化版插件,或者mybatis插件,这些插件要求的SonarQube版本必须在7.9以上,并且需要运行在JDK11以上。经过权衡之后,我们决定安装最新版的。 77 | 78 | ### 2.1 安装JDK11和postgreSQL 79 | JDK下载地址:https://www.oracle.com/java/technologies/javase-jdk11-downloads.html 80 | 81 | JDK的安装比较简单,我在这里就不过多介绍了,网上有很多教程。 82 | 83 | PostgreSQL它自己号称自己是世界上最先进的开源数据库,具有许多功能,旨在帮助开发人员构建应用程序,管理员来保护数据完整性和构建容错环境,并帮助您管理数据,无论数据集的大小。除了免费和开源之外,PostgreSQL也是高度可扩展的。例如,您可以定义自己的数据类型,构建自定义函数,甚至可以使用不同的编程语言编写代码,而无需重新编译数据库。 84 | 85 | PostgreSQL的安装与使用可以参数:https://www.jianshu.com/p/7d133efccaa4 86 | 87 | 88 | 89 | ### 2.2 从zip文件安装sonarqube 90 | 91 | SonarQube无法在root基于Unix的系统上运行,因此,如有必要,请为SonarQube创建专用的用户帐户。 92 | 93 | $ SONARQUBE-HOME(下面)指的是SonarQube发行版已解压缩的目录的路径。 94 | 95 | 设置对数据库的访问 96 | 编辑$ SONARQUBE-HOME / conf / sonar.properties以配置数据库设置。模板可用于每个受支持的数据库。只需取消注释并配置所需的模板,然后注释掉专用于H2的行: 97 | ```java 98 | Example for PostgreSQL 99 | sonar.jdbc.username=sonarqube 100 | sonar.jdbc.password=mypassword 101 | sonar.jdbc.url=jdbc:postgresql://localhost/sonarqube 102 | ``` 103 | 配置Elasticsearch存储路径 104 | 默认情况下,Elasticsearch数据存储在$ SONARQUBE-HOME / data中,但不建议将其用于生产实例。相反,您应该将此数据存储在其他位置,最好是在具有快速I / O的专用卷中。除了保持可接受的性能外,这样做还可以简化SonarQube的升级。 105 | 106 | 编辑$ SONARQUBE-HOME / conf / sonar.properties以配置以下设置: 107 | ```jaa 108 | sonar.path.data=/var/sonarqube/data 109 | sonar.path.temp=/var/sonarqube/temp 110 | ``` 111 | 用于启动SonarQube的用户必须具有对这些目录的读写权限。 112 | 113 | 启动Web服务器 114 | 默认端口为“ 9000”,上下文路径为“ /”。这些值可以在$ SONARQUBE-HOME / conf / sonar.properties中进行更改: 115 | ```java 116 | sonar.web.host=192.0.0.1 117 | sonar.web.port=80 118 | sonar.web.context=/sonarqube 119 | ``` 120 | 执行以下脚本来启动服务器: 121 | 122 | - 在Linux上:bin / linux-x86-64 / sonar.sh start 123 | - 在macOS上:bin / macosx-universal-64 / sonar.sh start 124 | - 在Windows上:bin / windows-x86-64 / StartSonar.bat 125 | 126 | 调整Java安装 127 | 如果服务器上安装了多个Java版本,则可能需要明确定义使用哪个Java版本。 128 | 129 | 要更改SonarQube使用的Java JVM,请编辑$ SONARQUBE-HOME / conf / wrapper.conf并更新以下行: 130 | ```java 131 | wrapper.java.command=/path/to/my/jdk/bin/java 132 | ``` 133 | 您现在可以在http:// localhost:9000上浏览SonarQube (默认的系统管理员凭据为admin/ admin)。第一次访问这个地址比较会停留在这个页面一段时间,因为SonarQube会做一些初始化工作,包含往空数据库中建表 134 | ![](https://pic.imgdb.cn/item/6115261e5132923bf84a8b7a.jpg) 135 | 136 | 初始化成功后运行的页面: 137 | 138 | ![](https://pic.imgdb.cn/item/6115262d5132923bf84aac2b.jpg) 139 | 140 | 同时会生成20多张表: 141 | 142 | ![](https://pic.imgdb.cn/item/6115263a5132923bf84acb52.jpg) 143 | 144 | ### 2.3 安装插件 145 | 根据个人需要,可以安装汉化插件,sonarqube默认是英文界面。 146 | 147 | github地址:https://github.com/SonarQubeCommunity/sonar-l10n-zh 148 | 149 | 将项目下载编译打包后,将jar放到$SONARQUBE-HOME\extensions\plugins 150 | 151 | 目录下即可,然后执行:./sonar.sh restart命令重启sonarqube服务。 152 | 153 | 此外,还有mybatis插件 154 | 155 | gitee地址:https://gitee.com/mirrors/sonar-mybatis 156 | 157 | 我个人用过,觉得作用不大,不过可以基于这个代码扩展自己需要的功能。 158 | 159 | ## 三 sonarqube如何使用 160 | ### 3.1 在maven项目中集成sonarqube 161 | 先在maven的settings.xml文件中增加如下配置: 162 | ```java 163 | 164 | org.sonarsource.scanner.maven 165 | 166 | 167 | 168 | sonar 169 | 170 | true 171 | 172 | 173 | 174 | 175 | http://localhost:9000 176 | 177 | 178 | 179 | 180 | ``` 181 | 然后在pom.xml文件中增加配置: 182 | ```java 183 | 184 | org.sonarsource.scanner.maven 185 | sonar-maven-plugin 186 | 3.3.0.603 187 | 188 | ``` 189 | 在项目目录下运行代码检测命令: 190 | ```java 191 | mvn clean complie -U -Dmaven.test.skip=true sonar:sonar 192 | ``` 193 | 194 | 看到这几句话,就表示检测成功了 195 | 196 | ![](https://pic.imgdb.cn/item/611526815132923bf84b708c.jpg) 197 | 198 | 然后在sonar后台查看检测报告 199 | 200 | ![](https://pic.imgdb.cn/item/611526915132923bf84b9a8e.jpg) 201 | 202 | 报告里面包含:bug、漏洞、异味、安全热点、覆盖、重复率等,对有问题的代码能够快速定位。 203 | 204 | 点击某个bug可以查看具体有问题代码: 205 | 206 | 没有关闭输入流问题: 207 | 208 | ![](https://pic.imgdb.cn/item/611526b65132923bf84bf736.jpg) 209 | 210 | 空指针问题: 211 | 212 | ![](https://pic.imgdb.cn/item/611526c35132923bf84c1750.jpg) 213 | 214 | 错误的用法: 215 | 216 | ![](https://pic.imgdb.cn/item/611526d05132923bf84c35b5.jpg) 217 | 218 | SimpleDateFormat不应该被定义成static的。 219 | 220 | 检测出的代码问题类型太多,这里就不一一列举了。总之,记住一句话:sonar很牛逼。它不光可以检测出代码问题,还对一些不好的代码写法和用法有更好的建议。 221 | 222 | ## 彩蛋 223 | sonarqube非常强大,上面只介绍了它的基本用法。一般情况下,我们可以使用jenkins配置需要代码检测的项目,从gitlab上下载代码,执行maven编译打包代码测试命令,可直接生成报告。jenkins触发执行代码检测的时机是:1.有代码提交,或者指定比如test分支有代码提交,项目数量少可以这样做。2.定时执行,我们公司就是配置在凌晨定时执行,因为jenkins部署的项目太多了,为了不影响正常的项目部署。 224 | 225 | 此外,我们可以自定义代码检测的执行规则,根据实际的项目需求自己开发插件,比如:我们自己开发了mybatis插件,扫描mapper和xml文件名称不一致的情况。 226 | 227 | ![](https://pic.imgdb.cn/item/611526e65132923bf84c6c03.jpg) 228 | 229 | 230 | > 总之,sonar的功能非常强大,强烈建议大家在项目中使用,真的可以减少很多隐藏的bug,提高代码质量,如果你用过就会发现它的好处。如果想了解更多sonar的用法,可以在公众号中回复:sonar,可以获取更详细的用法。 -------------------------------------------------------------------------------- /docs/工具/求你别再用swagger了,给你推荐一个在线文档神器.md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 最近公司打算做一个openapi开放平台,让我找一款好用的在线文档生成工具,具体要求如下: 3 | 4 | 1. 必须是开源的 5 | 2. 能够实时生成在线文档 6 | 3. 支持全文搜索 7 | 4. 支持在线调试功能 8 | 5. 界面优美 9 | 10 | 说实话,这个需求看起来简单,但是实际上一点的都不简单。 11 | 12 | 我花了几天时间到处百度,谷歌,技术博客 和 论坛查资料,先后调研了如下文档生成工具: 13 | 14 | ## gitbook 15 | github地址:https://github.com/GitbookIO/gitbook 16 | 17 | 开源协议:Apache-2.0 License 18 | 19 | Star: 22.9k 20 | 21 | 开发语言:javascript 22 | 23 | 用户:50万+ 24 | 25 | 推荐指数:★★★ 26 | 27 | 示例地址:https://www.servicemesher.com/envoy/intro/arch_overview/dynamic_configuration.html 28 | 29 | ![](https://pic.imgdb.cn/item/610e9d6b5132923bf8fa0ae9.jpg) 30 | gitBook是一款文档编辑工具。它的功能类似金山WPS中的word或者微软office中的word的文档编辑工具。它可以用来写文档、建表格、插图片、生成pdf。当然,以上的功能WPS、office可能做得更好,但是,gitBook还有更最强大的功能:它可以用文档建立一个网站,让更多人了解你写的书。 31 | 32 | 另外,最最核心的是,他支持Git,也就意味着,它是一个分布式的文档编辑工具。你可以随时随地来编写你的文档,也可以多人共同编写文档,哪怕多人编写同一页文档,它也能记录每个人的内容,然后告诉你他们之间的区别,也能记录你的每一次改动,你可以查看每一次的书写记录和变化,哪怕你将文档都删除了,它也能找回来!这就是它继承git后的厉害之处! 33 | 34 | 优点:使用起来非常简单,支持全文搜索,可以跟git完美集成,对代码无任何嵌入性,支持markdown格式的文档编写。 35 | 36 | 缺点:需要单独维护一个文档项目,如果接口修改了,需要手动去修改这个文档项目,不然可能会出现接口和文档不一致的情况。并且,不支持在线调试功能。 37 | 38 | 个人建议:如果对外的接口比较少,或者编写之后不会经常变动可以用这个。 39 | 40 | ## smartdoc 41 | gitee地址:https://gitee.com/smart-doc-team/smart-doc 42 | 43 | 开源协议:Apache-2.0 License 44 | 45 | Star: 758 46 | 47 | 开发语言:html、javascript 48 | 49 | 用户:小米、科大讯飞、1加 50 | 51 | 推荐指数:★★★★ 52 | 53 | 示例地址:https://gitee.com/smart-doc-team/smart-doc/wikis/文档效果图?sort_id=1652819 54 | 55 | ![](https://pic.imgdb.cn/item/610e9da65132923bf8fa63f6.jpg) 56 | 57 | smart-doc是一个java restful api文档生成工具,smart-doc颠覆了传统类似swagger这种大量采用注解侵入来生成文档的实现方法。smart-doc完全基于接口源码分析来生成接口文档,完全做到零注解侵入,只需要按照java标准注释的写就能得到一个标准的markdown接口文档。 58 | 59 | 优点:基于接口源码分析生成接口文档,零注解侵入,支持html、pdf、markdown格式的文件导出。 60 | 61 | 缺点:需要引入额外的jar包,不支持在线调试 62 | 63 | 个人建议:如果实时生成文档,但是又不想打一些额外的注解,比如:使用swagger时需要打上@Api、@ApiModel等注解,就可以使用这个。 64 | 65 | ## redoc 66 | github地址:https://github.com/Redocly/redoc 67 | 68 | 开源协议:MIT License 69 | 70 | Star: 10.7K 71 | 72 | 开发语言:typescript、javascript 73 | 74 | 用户:docker、redocly 75 | 76 | 推荐指数:★★★☆ 77 | 78 | 示例地址:https://docs.docker.com/engine/api/v1.40/ 79 | 80 | ![](https://pic.imgdb.cn/item/610e9dd35132923bf8faa4a3.jpg) 81 | 82 | redoc自己号称是一个最好的在线文档工具。它支持swagger接口数据,提供了多种生成文档的方式,非常容易部署。使用redoc-cli能够将您的文档捆绑到零依赖的 HTML文件中,响应式三面板设计,具有菜单/滚动同步。 83 | 84 | 优点:非常方便生成文档,三面板设计 85 | 86 | 缺点:不支持中文搜索,分为:普通版本 和 付费版本,普通版本不支持在线调试。另外UI交互个人感觉不适合国内大多数程序员的操作习惯。 87 | 88 | 个人建议:如果想快速搭建一个基于swagger的文档,并且不要求在线调试功能,可以使用这个。 89 | 90 | ## knife4j 91 | gitee地址:https://gitee.com/xiaoym/knife4j 92 | 93 | 开源协议:Apache-2.0 License 94 | 95 | Star: 3k 96 | 97 | 开发语言:java、javascript 98 | 99 | 用户:未知 100 | 101 | 推荐指数:★★★★ 102 | 103 | 示例地址:http://swagger-bootstrap-ui.xiaominfo.com/doc.html 104 | 105 | ![](https://pic.imgdb.cn/item/610e9df45132923bf8fada62.jpg) 106 | 107 | knife4j是为Java MVC框架集成Swagger生成Api文档的增强解决方案,前身是swagger-bootstrap-ui,取名kni4j是希望她能像一把匕首一样小巧,轻量,并且功能强悍。 108 | 109 | 优点:基于swagger生成实时在线文档,支持在线调试,全局参数、国际化、访问权限控制等,功能非常强大。 110 | 111 | 缺点:界面有一点点丑,需要依赖额外的jar包 112 | 113 | 个人建议:如果公司对ui要求不太高,可以使用这个文档生成工具,比较功能还是比较强大的。 114 | 115 | ## yapi 116 | github地址:https://github.com/YMFE/yapi 117 | 118 | 开源协议:Apache-2.0 License 119 | 120 | Star: 17.8k 121 | 122 | 开发语言:javascript 123 | 124 | 用户:腾讯、阿里、百度、京东等大厂 125 | 126 | 推荐指数:★★★★★ 127 | 128 | 示例地址:http://swagger-bootstrap-ui.xiaominfo.com/doc.html 129 | 130 | ![](https://pic.imgdb.cn/item/610e9e225132923bf8fb2729.jpg) 131 | 132 | yapi是去哪儿前端团队自主研发并开源的,主要支持以下功能: 133 | 134 | - 可视化接口管理 135 | - 数据mock 136 | - 自动化接口测试 137 | - 数据导入(包括swagger、har、postman、json、命令行) 138 | - 权限管理 139 | - 支持本地化部署 140 | - 支持插件 141 | - 支持二次开发 142 | 143 | 优点:功能非常强大,支持权限管理、在线调试、接口自动化测试、插件开发等,BAT等大厂等在使用,说明功能很好。 144 | 145 | 缺点:在线调试功能需要安装插件,用户体检稍微有点不好,主要是为了解决跨域问题,可能有安全性问题。不过要解决这个问题,可以自己实现一个插件,应该不难。 146 | 147 | 个人建议:如果不考虑插件安全的安全性问题,这个在线文档工具还是非常好用的,可以说是一个神器,笔者在这里强烈推荐一下。 148 | 149 | ## apidoc 150 | github地址:https://github.com/apidoc/apidoc 151 | 152 | 开源协议:MIT License 153 | 154 | Star: 8.7k 155 | 156 | 开发语言:javascript 157 | 158 | 用户:未知 159 | 160 | 推荐指数:★★★★☆ 161 | 162 | 示例地址:https://apidocjs.com/example/#api-User 163 | 164 | ![](https://pic.imgdb.cn/item/610e9e505132923bf8fb6c0f.jpg) 165 | 166 | apidoc 是一个简单的 RESTful API 文档生成工具,它从代码注释中提取特定格式的内容生成文档。支持诸如 Go、Java、C++、Rust 等大部分开发语言,具体可使用 apidoc lang 命令行查看所有的支持列表。 167 | 168 | apidoc 拥有以下特点: 169 | 170 | - 跨平台,linux、windows、macOS 等都支持; 171 | - 支持语言广泛,即使是不支持,也很方便扩展; 172 | - 支持多个不同语言的多个项目生成一份文档; 173 | - 输出模板可自定义; 174 | - 根据文档生成 mock 数据; 175 | 176 | 优点:基于代码注释生成在线文档,对代码的嵌入性比较小,支持多种语言,跨平台,也可自定义模板。支持搜索和在线调试功能。 177 | 178 | 缺点:需要在注释中增加指定注解,如果代码参数或类型有修改,需要同步修改注解相关内容,有一定的维护工作量。 179 | 180 | 个人建议:这种在线文档生成工具提供了另外一种思路,swagger是在代码中加注解,而apidoc是在注解中加数据,代码嵌入性更小,推荐使用。 181 | 182 | 183 | 184 | ## showdoc 185 | github地址:https://github.com/star7th/showdoc 186 | 187 | 开源协议:Apache Licence 188 | 189 | Star: 8.1k 190 | 191 | 开发语言:javascript、php 192 | 193 | 用户:超过10000+互联网团队正在使用 194 | 195 | 推荐指数:★★★★☆ 196 | 197 | 示例地址:https://www.showdoc.com.cn/demo?page_id=9 198 | 199 | ![](https://pic.imgdb.cn/item/610e9e7a5132923bf8fbae56.jpg) 200 | 201 | ShowDoc就是一个非常适合IT团队的在线文档分享工具,它可以加快团队之间沟通的效率。 202 | 203 | 它都有些什么功能: 204 | 205 | 1. 响应式网页设计,可将项目文档分享到电脑或移动设备查看。同时也可以将项目导出成word文件,以便离线浏览。 206 | 2. 权限管理,ShowDoc上的项目有公开项目和私密项目两种。公开项目可供任何登录与非登录的用户访问,而私密项目则需要输入密码验证访问。密码由项目创建者设置。 207 | 3. ShowDoc采用markdown编辑器,点击编辑器上方的按钮可方便地插入API接口模板和数据字典模板。 208 | 4. ShowDoc为页面提供历史版本功能,你可以方便地把页面恢复到之前的版本。 209 | 5. 支持文件导入,文件可以是postman的json文件、swagger的json文件、showdoc的markdown压缩包,系统会自动识别文件类型。 210 | 211 | 优点:支持项目权限管理,多种格式文件导入,全文搜索等功能,使用起来还是非常方便的。并且既支持部署自己的服务器,也支持在线托管两种方式。 212 | 213 | 缺点:不支持在线调试功能 214 | 215 | 个人建议:如果不要求在线调试功能,这个在线文档工具值得使用。 -------------------------------------------------------------------------------- /docs/工具/这11款chrome神器,用起来爽到爆.md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 对于从事IT行业的我们来说,几乎无时无刻都在用chrome浏览器,因为它给我们的工作和生活带来了极大的便利。今天给大家分享我用过的11款牛逼的chrome插件,你看完前3个可能就会忍不住想点赞了。 3 | 4 | ## 1. 谷歌翻译 5 | 很多小伙伴,英语不太好,包括我自己,英语刚过四级。从事软件相关工作时,有时有些吃力,因为很多优秀的技术网站、书籍或者文章都是老外写的,如果因为看不懂就放弃阅读,我们将会少了很多学习和进步的机会。 6 | 7 | 今天分享的第一个神器就是:`谷歌翻译`。 8 | 9 | 在没使用谷歌翻译之前,访问https://docs.mongodb.com/drivers/java/,看到的页面是这样的,有可能一脸懵逼。 10 | 11 | ![](https://pic.imgdb.cn/item/610ea1445132923bf800181a.jpg) 12 | 13 | 使用谷歌翻译插件(其实现在已经是chrome浏览器的内置工具): 14 | 15 | ![](https://pic.imgdb.cn/item/610ea1755132923bf800640f.jpg) 16 | 17 | 之后看到的页面,变得毫无违和感: 18 | ![](https://pic.imgdb.cn/item/610ea18c5132923bf80085ff.jpg) 19 | 20 | 页面内容一下子变成了全中文,幸福来得太突然了,哈哈哈。 21 | 22 | ## 2. github加速器 23 | github号称是全球第一大同性交友网站,它是程序员的乐园,里面有各种好玩的开源项目。很多编程爱好者,秉承share精神,喜欢把自己优秀代码提交到github上,能够让更多的人看到,帮助更多的人。 24 | 25 | 但是,在国内对github访问非常慢,是很多程序员非常头疼的一件事。 26 | ![](https://pic.imgdb.cn/item/610ea1a85132923bf800b1d0.jpg) 27 | 28 | 这时可以用github加速器插件:![](https://pic.imgdb.cn/item/610ea1c25132923bf800d99e.jpg) 29 | 30 | 开启插件功能后,再访问该页面: 31 | ![](https://pic.imgdb.cn/item/610ea1e05132923bf8010a55.jpg) 32 | 33 | 2秒后就能非常愉快的访问github了。 34 | 35 | 36 | ## 3. Octotree 37 | github上默认的项目展示页面,对用户不太友好。如果想要查找某个文件,必须一层层点击往下找,默认是平铺展示内容,无法用树形结构展示。 38 | 39 | 默认的页面是长这样子: 40 | 41 | ![](https://pic.imgdb.cn/item/610ea21a5132923bf8016561.jpg) 42 | 43 | 44 | 如果安装了Octotree插件: 45 | ![](https://pic.imgdb.cn/item/610ea22d5132923bf80185a0.jpg) 46 | 47 | 48 | 之后,多了一个树状目录: 49 | 50 | ![](https://pic.imgdb.cn/item/610ea23e5132923bf801a30b.jpg) 51 | 52 | 53 | 可以非常方便的查找某个文件。 54 | 55 | ## 4. Infinity 56 | 不知道你有没有过这种想法: 57 | 58 | - 觉得自己的浏览器首页太low,想换个背景,不知道怎么换。 59 | - 想把自己收藏起来的网站,放在显目的地方,让自己方便访问。 60 | - 想把搜索引擎放在首页一下子就能看到。 61 | 62 | 63 | 恭喜你,这些需求chrome神器:Infinity都能帮你实现。 64 | 65 | 66 | 需要安装Infinity插件: 67 | 68 | ![](https://pic.imgdb.cn/item/610ea27d5132923bf8020949.jpg) 69 | 70 | 之后打开浏览器时,就会自动弹出如下页面: 71 | 72 | ![](https://pic.imgdb.cn/item/610ea29b5132923bf802404e.jpg) 73 | 74 | 可以自定义你喜欢的任何网站: 75 | ![](https://pic.imgdb.cn/item/610ea2b45132923bf8026ccd.jpg) 76 | 77 | 此外,首页右下角的小风车,点击一下就能自动切换一张漂亮的背景图片。据说每天点一下小风车,每天都会拥有好心情。 78 | 79 | 80 | 81 | ## 5. vue.js devtools 82 | 83 | 如果有接触过前端的朋友会发现,现在基本上是:react 和 vue 的天下。我本身是做后端开发的,相较而言,使用更多的是 vue,它配合Element UI一起使用,大大提升了开发页面的效率。 84 | 85 | 如果代码运行时出问题了,想调试怎么办?使用传统的debugger模式,不是说不行,不过我在这里推荐一个更牛逼的调试方法。 86 | 87 | 需要安装vue.js devtools插件: 88 | ![](https://pic.imgdb.cn/item/610ea2d75132923bf802a2d6.jpg) 89 | 90 | 之后,访问页面时,就能调试了: 91 | ![](https://pic.imgdb.cn/item/610ea3055132923bf802de03.jpg) 92 | 93 | 你想看到的大部分内容,这里都有。是不是很神奇? 94 | 95 | ## 6. ImageAssistant 96 | 97 | 在平时的工作或生活当中,我们经常需要上传和下载图片。如果在浏览网页的时,喜欢某些图片,我们需要一张张手动下载,非常不方便。 98 | 99 | 有时候需要对上传的图片进行编辑,调整文字大小、样式,加一些水印效果等。我们一般需要先使用专业的图片工具,把图片编辑好,再重新上传,很麻烦。 100 | 101 | 有没有一款软件,可以帮我们解决这些问题呢? 102 | 103 | 答案是肯定的,这时可以使用google的ImageAssistant插件: 104 | ![](https://pic.imgdb.cn/item/610ea3275132923bf8030bb1.jpg) 105 | 106 | 之后,访问网页时选择 提取本页图片: 107 | ![](https://pic.imgdb.cn/item/610ea3525132923bf8034178.jpg) 108 | 109 | 就会出现如下网页: 110 | ![](https://pic.imgdb.cn/item/610ea3665132923bf80359c6.jpg) 111 | 112 | 该网页中展示了之前页面中的所有图片,包括尺寸等信息,可以批量下载。 113 | 114 | 此外,还能对上传的图片进行编辑: 115 | ![](https://pic.imgdb.cn/item/610ea3765132923bf803777d.jpg) 116 | 117 | 看到这里,你爱上它了没?反正我是爱不释手。 118 | 119 | 120 | ## 7. LastPass 121 | 122 | 随着互联网的蓬勃发展,出现了越来越多的网站,其中大部分网站为了保持用户的粘性,需要用户自己注册和登录。为了安全起见,用户密码一般要求包含:数字、字母、特殊字符、还要区分大小写等,并且要求密码长度少则8位,多则十几位。 123 | 124 | 为了方便,你可以将所有密码设置成一样,但是如果一旦泄露,所有网站上你相关信息都可能会被泄露,风险太高。所以,还是把密码设置成不一样吧,这样我们睡觉也安心一点。 125 | 126 | 如果你只注册了一两个网站还好,但如果你过注册过几十个,甚至上百个网站,那么多密码你都能记得住? 127 | 128 | 答案是否定的,这时就急需一种安全管理密码的工具。 129 | 130 | 这时又一个chrome神器:LastPass出现了。 131 | 132 | ![](https://pic.imgdb.cn/item/610ea3a05132923bf803af6c.jpg) 133 | 134 | 它需要先注册,这一步很容易完成。然后添加想访问的网站地址、用户名和密码,以后想访问该网站,直接点击一下即可,就不用再重复输入用户名和密码,方便快速访问,并且自动登录。 135 | ![](https://pic.imgdb.cn/item/610ea3c35132923bf803df63.jpg) 136 | 137 | LastPass的功能非常强大,其他类似的插件还有:1Password、Bitwarden等。 138 | 139 | 140 | 141 | ## 8. adblock 142 | 143 | 我们在浏览网页的时候,经常会发现广告像狗皮膏药一样,无处不在,如影随形。有些页面甚至广告就占了整个屏幕的一半,真正的有价值的信息,我们每次都需要用肉眼一一分辨出来,相当让人抓狂,大大增加了搜索资料的时间。这也是很多人喜欢用google搜索资料,不喜欢用baidu的主要原因。 144 | 145 | 有没有办法,屏蔽掉一些多余的广告,让我们能看到净化后的页面内容呢? 146 | 147 | 答案是有的,可以使用AdBlock,它是目前世界排名第一的免费广告拦截程序。 148 | 149 | 需要安装AdBlock插件: 150 | ![](https://pic.imgdb.cn/item/610ea3ea5132923bf8041788.jpg) 151 | 152 | 之后,就能使用AdBlock拦截广告了。一种方式是自定义拦截规则,不过有些复杂,需要仔细研究一下。还有另外一种手动的方式,这种方式相对来说更简单,在需要屏蔽的广告上右键,在弹窗的工具窗口中选择AdBlock中的选项即可。 153 | ![](https://pic.imgdb.cn/item/610ea4075132923bf8044725.jpg) 154 | 155 | 好了,世界终于清净了。 156 | 157 | ## 9. markdown nice 158 | 159 | 对于有些写公众号的朋友来说,在文章排版上花费的时间,有可能比写一篇文章的时间还多。为了解决写文章时的排版问题,一些强大的排版工具应运而生,比如:md2all 和 markdown nice ,能够解放他们的双手,让他们可以把更多的时间花在文章内容上。 160 | 161 | 需要安装markdown nice插件: 162 | 163 | ![](https://pic.imgdb.cn/item/610ea42e5132923bf80480c5.jpg) 164 | 165 | 之后,在公众号后台写文章时,你只用专注于写markdown语法的文章即可,然后选择一种主题和代码主题,其他的交给插件。 166 | ![](https://pic.imgdb.cn/item/610ea4485132923bf804a80a.jpg) 167 | 168 | 最终文章会自动生成左半部分的样式,是不是很nice? 169 | 170 | 171 | ## 10. 掘金 172 | 在国内程序员平时喜欢逛的技术网站,比如:CSDN、博客园、开源中国、思否、51CTO、掘金等。尤其是最近几年,掘金越来越受到广大程序员的喜爱,它是一个帮助程序员成长的网站。 173 | 174 | 以前需要访问地址:https://juejin.cn/,才能访问技术文章,访问其他技术网站也类似,必须手动输入网站地址。 175 | 176 | ![](https://pic.imgdb.cn/item/610ea4635132923bf804d9f6.jpg) 177 | 178 | 如果安装了掘金插件: 179 | ![](https://pic.imgdb.cn/item/610ea4835132923bf8050b67.jpg) 180 | 181 | 之后,在打开chrome浏览器的时候,首页默认访问就是掘金,给我们节省了非常多的时间。 182 | ![](https://pic.imgdb.cn/item/610ea4935132923bf805238d.jpg) 183 | 它里面包含两大块:掘金 和 github。 184 | 185 | 掘金可以按类型,比如:后端、前端、IOS、人工智能等,还有热门和最新 过滤文章。 186 | 187 | github可以按 热门 和 新生,以及时间维度:今日、本周、本月,查询排名靠前的开源项目。 188 | 189 | 这些不都是我们一直都想要的吗?简直太给力了。 190 | 191 | ## 11. JSONView 192 | JSON数据格式简单,结构化,层次分明,是开发人员最常用的数据格式,目前是大部分接口返回值的首先。 193 | 194 | 有时我们在浏览器中访问get请求数据,由于接口返回值太多,一眼根本无法看出数据的层次和结构,顿时有点懵逼。针对这种情况,很多人可能会想到,将数据复制到一些在线的Json工具,或者使用postman发送请求,这样就能非常愉快的浏览格式化的数据。 195 | 196 | 这样不是不行,我想说的是,其实不用这么麻烦,还有更简单的方式。只用安装一款chrome插件,在浏览器中,就能轻松访问浏览格式化的数据。这款插件的名字是:JSONView。 197 | 198 | ![](https://pic.imgdb.cn/item/610ea4f15132923bf805a8e1.jpg) 199 | 200 | 之后,再访问接口时,就能看到更人性化的数据了: 201 | 202 | ![](https://pic.imgdb.cn/item/610ea50d5132923bf805cd9f.jpg) 203 | 204 | 不说了,这就是我想要的。 205 | 206 | 其实在我实际工作和生活中,使用过的插件远不止这11种。由于众所众知的原因,很多好插件不便于分享,如果有感兴趣的朋友,可以关注我的公众号,找我私聊,绝对不虚此行。 207 | 208 | -------------------------------------------------------------------------------- /docs/工具/这样写代码,真是帅到没有朋友.md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 对于如何提高开发效率,是每一个程序员都非常关心的问题,本文总结了开发工具idea中提升开发效率的10个小技巧。纯干货分享,个个都非常实用,希望小伙伴们会喜欢,记得给我打call喔。 3 | 4 | ## 1.快速生成main方法并打印 5 | - 用psvm命令能快速生成main方法。 6 | - 用sout命令能快速生成打印方法System.out.println。 7 | 8 | 9 | ## 2.给new出来的对象快速赋值 10 | 在new出来的对象后面加上.var,就能实现快速赋值 11 | 12 | 13 | ## 3.快速for循环 14 | ### 1.基本变量 15 | 16 | 比如:int,long,byte等,在需要进行for循环遍历的变量后加上.for,就能快速实现for循环功能 17 | 18 | ### 2.集合 19 | 20 | 在需要进行forEach循环遍历的集合后加上.for,就能快速实现forEach循环功能 21 | 22 | ## 4.快速判断 23 | 判断条件在开发过程中使用频率非常高,如何快速的写出判断条件呢? 24 | 25 | - boolean.if 可以生成if(boolean) 26 | - boolean.else 可以生成if(!boolean) 27 | - string.null 可以生成if(string==null) 28 | - string.nn 可以生成if(string!=null) 29 | 30 | 31 | 此外.switch也有类似的功能。 32 | 33 | ## 5.快速try...catch 34 | 有时候我们有异常需要捕获,手动写try...catch比较麻烦,这时快速try...catch可以给我们节省不少时间,只需加.try即可。 35 | 36 | 37 | ## 6.快速类型转换 38 | 有时候我们需要做类型转换,必须手写括号和赋值参数,同样有些麻烦,这时快速类型转换,可以帮我们搞定,只需加.castvar即可。 39 | 图片 40 | 41 | ## 7.快速抽取变量 42 | 有时候我们需要把方法中的局部变量,抽取成成员变量,或者全局变量,快速抽取变量可以帮你搞定,只需加.field即可。 43 | 44 | 45 | ## 8.快速定义Optional 46 | 有时候我们想把某个对象转换成Optional,避免出现空指针问题,只需加.opt即可。 47 | 48 | ## 9.快速生成lambda语句 49 | 如果你在用jdk1.8以上的版本,那么lambda表达式必不可少,因为用它可以极大的提高开发效率,少写很多代码。 50 | 51 | 使用.lambda就能快速生成lambda语句 52 | 53 | ## 10.快速迁移代码到新方法 54 | 在代码重构时,经常需要把某段代码迁移到一个新方法中,这时使用快捷键ctrl + alt + m。 55 | 56 | ## 后续 57 | 我在这里只是抛砖引玉,其实idea中非常有趣且实用的小技巧有很多,欢迎大家跟我一起交流学习,共同进步。 58 | 59 | 需要特别说明一下,由于很多动图在博客中无法正常展示,所以暂时去掉了。如果想看实际效果,可以去我微信公众号上,查看《[这样写代码,真是帅到没有朋友](https://mp.weixin.qq.com/s?__biz=MzUxODkzNTQ3Nw==&mid=2247486256&idx=1&sn=6f7c6e90a2dd9745a2ea0479d6867569&chksm=f9800deacef784fc6098a703a4b4cd0a9270b2d4a230c983230d8453d1c65fd7e0e8f6af6e9d&token=2142272128&lang=zh_CN#rd)》这篇文章。 60 | -------------------------------------------------------------------------------- /docs/数据库/innodb是如何存数据的?yyds.md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 如果你使用过mysql数据库,对它的存储引擎:`innodb`,一定不会感到陌生。 3 | 4 | 众所周知,在mysql5.5以前,默认的存储引擎是:myslam。但mysql5.5之后,默认的存储引擎已经变成了:innodb,它是我们建表的首选存储引擎。 5 | 6 | 那么,问题来了: 7 | 1. innodb的底层是如何存储数据的? 8 | 2. 表中有哪些隐藏列? 9 | 3. 用户记录之间是如何关联起来的? 10 | 11 | 如果你想知道上面三个问题的答案,那么,请继续往下面看。 12 | 13 | 本文主要包含如下内容: 14 | 15 | ![](https://pic.imgdb.cn/item/61210c384907e2d39c3babf6.jpg) 16 | 17 | ## 1.磁盘or内存? 18 | 19 | ### 1.1 磁盘 20 | 数据对系统来说是非常重要的东西,比如:用户的身份证、手机号、银行号、会员过期时间、积分等等。一旦丢失,会对用户造成很大的影响。 21 | 22 | 那么问题来了,如何才能保证这些重要的数据不丢呢? 23 | 24 | **答案:把数据存在磁盘上。** 25 | 26 | 当然有人会说,如果磁盘坏了怎么办? 27 | 28 | 那就需要备份,或者做主从了。。。 29 | 30 | 好了,打住,这不是今天的重点。 31 | 32 | 言归正传。 33 | 34 | 大家都知道,从磁盘上读写数据,至少需要两次IO请求才能完成。一次是读IO,另一次是写IO。 35 | 36 | 而IO请求是比较耗时的操作,如果频繁的进行IO请求势必会影响数据库的性能。 37 | 38 | 那么,如何才能解决数据库的性能问题呢? 39 | 40 | ### 1.2 内存 41 | 把数据存在寄存器? 42 | 43 | 没错,操作系统从寄存器中读取数据是最快的,因为它离CPU最近。 44 | 45 | 但是寄存器有个非常致命的问题是:它只能存储非常少量的数据,设计它的目的主要是用来暂存指令和地址,并非存储大量用户数据的。 46 | 47 | **这样看来,只能把数据存在内存中了。** 48 | 49 | 因为内存同样能满足我们,快速读取和写入数据的需求,而且性能是非常可观的,只是比较寄存器稍稍慢了一丢丢而已。 50 | 51 | 不过有个让人讨厌的地方是,内存相对于磁盘来说,是更加昂贵的资源。通常情况下,500G或者1T的磁盘,是很常见的。但你有听说过有500G的内存吗?别人会以为你疯了。内存大小讨论的数量级一般是16G或32G。 52 | 53 | 内存可以存储一些用户数据,但无法存储所有的用户数据,因为如果数据量太大了,它可能还是存不下。 54 | 55 | 此外,即使用户数据能刚好存在内存,以后万一有一天,数据库服务器或者部署节点挂了,或者重启了,数据不就丢了? 56 | 57 | 怎么做,才能不会因为异常情况,而丢数据。同时,又能保证数据的读写速度呢? 58 | 59 | ## 2.数据页 60 | 我们可以把一批数据放在一起。 61 | 62 | 写操作时,先将数据写到内存的某个批次中,然后再将该批次的数据一次性刷到磁盘上。如下图所示: 63 | ![](https://pic.imgdb.cn/item/611bcf264907e2d39cded578.jpg) 64 | 65 | 读操作时,从磁盘上一次读一批数据,然后加载到内存当中,以后就在内存中操作。如下图所示: 66 | ![](https://pic.imgdb.cn/item/611bcf864907e2d39ce099c3.jpg) 67 | 68 | 将内存中的数据刷到磁盘,或者将磁盘中的数据加载到内存,都是以批次为单位,这个批次就是我们常说的:`数据页`。 69 | 70 | 当然innodb中存在多种不同类型的页,数据页只是其中一种,我们在这里重点介绍一下数据页。 71 | 72 | 那么问题来了,什么是数据页? 73 | 74 | 数据页主要是用来存储表中记录的,它在磁盘中是用双向链表相连的,方便查找,能够非常快速得从一个数据页,定位到另一个数据页。 75 | 76 | 很多时候,由于我们表中的数据比较多,在磁盘中可能存放在多个数据页当中。 77 | 78 | 有一天,我们要根据某个条件查询数据时,需要从一个数据页找到另一个数据页,这时候的双向链表就派上大用场了。磁盘中各数据页的整体结构如下图所示: 79 | ![](https://pic.imgdb.cn/item/611bdada4907e2d39c2844a3.jpg) 80 | 通常情况下,单个数据页默认的大小是`16kb`。当然,我们也可以通过参数:`innodb_page_size`,来重新设置大小。不过,一般情况下,用它的默认值就够了。 81 | 82 | 好吧,数据页的整体结构已经搞明白了。 83 | 84 | 那么,单个数据页包含哪些内容呢? 85 | 86 | ![](https://pic.imgdb.cn/item/611bdf054907e2d39c588b7b.jpg) 87 | 从上图中可以看出,数据页主要包含如下几个部分: 88 | - 文件头部 89 | - 页头部 90 | - 最大和最小记录 91 | - 用户记录 92 | - 空闲空间 93 | - 页目录 94 | - 文件尾部 95 | 96 | ## 3.用户记录 97 | 对于新申请的数据页,用户记录是空的。当插入数据时,innodb会将一部分`空闲空间`分配给用户记录。 98 | 99 | 用户记录是innodb的重中之重,我们平时保存到数据库中的数据,就存储在它里面。那么,它里面又包含哪些内容呢?你不好奇吗? 100 | 101 | 其实在innodb支持的数据行格式有四种: 102 | 1. compact行格式 103 | 2. redundant行格式 104 | 3. dynamic行格式 105 | 4. compressed行格式 106 | 107 | 我们以compact行格式为例: 108 | ![](https://pic.imgdb.cn/item/611e46eb4907e2d39c3900a9.jpg) 109 | 一条用户记录主要包含三部分内容: 110 | 1. 记录额外信息,它包含了变长字段、null值列表和记录头信息。 111 | 2. 隐藏列,它包含了行id、事务id和回滚点。 112 | 3. 真正的数据列,包含真正的用户数据,可以有很多列。 113 | 114 | 下面让我们一起了解一下这些内容。 115 | 116 | ### 3.1 额外信息 117 | 额外信息并非真正的用户数据,它是为了辅助存数据用的。 118 | 119 | #### 3.1.1 变长字段列表 120 | 有些数据如果直接存会有问题,比如:如果某个字段是varchar或text类型,它的长度不固定,可以根据存入数据的长度不同,而随之变化。 121 | 122 | 如果不在一个地方记录数据真正的长度,innodb很可能不知道要分配多少空间。假如都按某个固定长度分配空间,但实际数据又没占多少空间,岂不是会浪费? 123 | 124 | 所以,需要在变长字段中记录某个变长字段占用的字节数,方便按需分配空间。 125 | 126 | #### 3.1.2 null值列表 127 | 数据库中有些字段的值允许为null,如果把每个字段的null值,都保存到用户记录中,显然有些浪费存储空间。 128 | 129 | 有没有办法只简单的标记一下,不存储实际的null值呢? 130 | 131 | 答案:将为null的字段保存到null值列表。 132 | 133 | 在列表中用二进制的值1,表示该字段允许为null,用0表示不允许为null。它只占用了1位,就能表示某个字符是否为null,确实可以节省很多存储空间。 134 | 135 | 136 | #### 3.1.3 记录头信息 137 | 记录头信息用于描述一些特殊的属性。 138 | 139 | ![](https://pic.imgdb.cn/item/611e55754907e2d39c58e9b6.jpg) 140 | 它主要包含: 141 | - deleted_flag: 即删除标记,用于标记该记录是否被删除了。 142 | - min_rec_flag: 即最小目录标记,它是非叶子节点中的最小目录标记。 143 | - n_owned:即拥有的记录数,记录该组索引记录的条数。 144 | - heap_no:即堆上的位置,它表示当前记录在堆上的位置。 145 | - record_type:即记录类型,其中:0表示普通记录,1表示非叶子节点,2表示Infrimum记录, 3表示Supremum记录。 146 | - next_record:即下一条记录的位置。 147 | 148 | ### 3.2 隐藏列 149 | 数据库在保存一条用户记录时,会自动创建一些隐藏列。如下图所示:![](https://pic.imgdb.cn/item/61210c814907e2d39c3c45a0.jpg) 150 | 目前innodb自动创建的隐藏列有三种: 151 | - db_row_id,即行id,它是一条记录的唯一标识。 152 | - db_trx_id,即事务id,它是事务的唯一标识。 153 | - db_roll_ptr,即回滚点,它用于事务回滚。 154 | 155 | 如果表中有主键,则用主键做行id,无需额外创建。如果表中没有主键,假如有不为null的unique唯一键,则用它做为行id,同样无需额外创建。 156 | 157 | 如果表中既没有主键,又没有唯一键,则数据库会自动创建行id。 158 | 159 | 也就是说在innodb中,隐藏列中`事务id`和`回滚点`是一定会被创建的,但行id要根据实际情况决定。 160 | 161 | ### 3.3 真正数据列 162 | 真正的数据列中存储了用户的真实数据,它可以包含很多列的数据。这个比较简单,没有什么好多说的。 163 | 164 | ### 3.4 用户记录是如何相连的? 165 | 通过上面介绍的内容,大家对一条用户记录是如何存储的,应该有了一定的认识。 166 | 167 | 但问题来了,一条用户记录和另一条用户记录是如何相连的,innodb是怎么知道,某条记录的下一条记录是谁? 168 | 169 | 答案是:用前面提到过的, 记录额外信息 》 记录头信息 》下一条记录的位置。 170 | 171 | ![](https://pic.imgdb.cn/item/61210c9b4907e2d39c3c7eda.jpg) 172 | 多条用户记录之间通过`下一条记录的位置`,组成了一个单向链表。这样就能从前往后,找到所有的记录了。 173 | 174 | ## 4.最大和最小记录 175 | 从上面可以得知,在一个数据页当中,如果存在多条用户记录,它们是通过`下一条记录的位置`相连的。 176 | 177 | 不过有个问题:如果才能快速找到最大的记录和最小的记录呢? 178 | 179 | 这就需要在保存用户记录的同时,也保存最大和最小记录了。 180 | 181 | 最大记录保存到Supremum记录中。 182 | 183 | 最小记录保存在Infimum记录中。 184 | 185 | 在保存用户记录时,数据库会自动创建两条额外的记录:Supremum 和 Infimum。它们之间的关系,如下图所示: 186 | 187 | ![](https://pic.imgdb.cn/item/61210cad4907e2d39c3ca568.jpg) 188 | 从图中可以看出用户数据是从最小记录开始,通过下一条记录的位置,从小到大,一步步查找,最后找到最大记录为止。 189 | 190 | ## 5.页目录 191 | 从上面可以看出,如果我们要查询某条记录的话,数据库会从最小记录开始,一条条查找所有记录。如果中途找到了,则直接返回该记录。如果一直找到最大记录,还没有找到想要的记录,则返回空。 192 | 193 | 咋一看,没有问题。 194 | 195 | 但如果仔细想想。 196 | 197 | 效率会不会有点低? 198 | 199 | 这不是要对整页用户数据进行扫描吗? 200 | 201 | 有没有更高效的方法? 202 | 203 | 这就需要使用`页目录`了。 204 | 205 | 说白了,就是把一页用户记录分为若干组,每一组的最大记录都保存到一个地方,这个地方就是`页目录`。每一组的最大记录叫做`槽`。 206 | 207 | 由此可见,页目录是有多个槽组成的。所下图所示: 208 | 209 | ![](https://pic.imgdb.cn/item/61210cdf4907e2d39c3d1295.jpg) 210 | 假设一页的数据分为4组,这样在页目录中,就对应了4个槽,每个槽中都保存了该组数据的最大值。 211 | 212 | 这样就能通过二分查找,比较槽中的记录跟需要找到的记录的大小。如果用户需要查找的记录,小于当前槽中的记录,则向上查找上一个槽。如果用户需要查找的记录,大于当前槽中的记录,则向下查找下一个槽。 213 | 214 | 如此一来,就能通过二分查找,快速的定位需要查找的记录了。 215 | 216 | so easy 217 | 218 | ## 6.文件头部和尾部 219 | 220 | ### 6.1 文件头部 221 | 通过前面介绍的行记录中`下一条记录的位置`和`页目录`,innodb能非常快速的定位某一条记录。但有个前提条件,就是用户记录必须在同一个数据页当中。 222 | 223 | 如果用户记录非常多,在第一个数据页找不到我们想要的数据,需要到另外一页找该怎么办呢? 224 | 225 | 这时就需要使用`文件头部`了。 226 | 227 | 它里面包含了多个信息,但我只列出了其中4个最关键的信息: 228 | 1. 页号 229 | 2. 上一页页号 230 | 3. 下一页页号 231 | 4. 页类型 232 | 233 | 顾名思义,innodb是通过页号、上一页页号和下一页页号来串联不同数据页的。如下图所示: 234 | ![](https://pic.imgdb.cn/item/61210cf04907e2d39c3d3bff.jpg) 235 | 不同的数据页之间,通过上一页页号和下一页页号构成了双向链表。这样就能从前向后,一页页查找所有的数据了。 236 | 237 | 此外,页类型也是一个非常重要的字段,它包含了多种类型,其中比较出名的有:数据页、索引页(目录项页)、溢出页、undo日志页等。 238 | 239 | 240 | ### 6.2 文件尾部 241 | 我之前提过,数据库的数据是以数据页为单位,加载到内存中,如果数据有更新的话,需要刷新到磁盘上。 242 | 243 | 但如果某一天比较倒霉,程序在刷新到磁盘的过程中,出现了异常,比如:进程被kill掉了,或者服务器被重启了。 244 | 245 | 这时候数据可能只刷新了一部分,如何判断上次刷盘的数据是完整的呢? 246 | 247 | 这就需要用到`文件尾部`。 248 | 249 | 它里面记录了页面的`校验和`。 250 | 251 | 在数据刷新到磁盘之前,会先计算一个页面的校验和。后面如果数据有更新的话,会计算一个新值。文件头部中也会记录这个校验和,由于文件头部在前面,会先被刷新到磁盘上。 252 | 253 | 接下来,刷新用户记录到磁盘的时候,假设刷新了一部分,恰好程序出现异常了。这时,文件尾部的校验和,还是一个旧值。数据库会去校验,文件尾部的校验和,不等于文件头部的新值,说明该数据页的数据是不完整的。 254 | 255 | 256 | ## 7.页头部 257 | 通过上面介绍的内容,数据页之间能够轻松访问了,但剩下还有个比较重要的问题,就是记录的状态信息。 258 | 259 | 比如一页数据到底保存了多条记录,或者页目录到底使用了多个槽等。这些信息是实时统计,还是事先统计好了,保存到某个地方? 260 | 261 | 为了性能考虑,上面的这些统计数据,当然是先统计好,保存到一个地方。后面需要用到该数据时,再读取出来会更好。这个保存统计数据的地方,就是`页头部`。 262 | 263 | 当然页头部不仅仅只保存:槽的数量、记录条数等信息。 264 | 265 | 它还记录了: 266 | - 已删除记录所占的字节数 267 | - 最后插入记录的位置 268 | - 最大事务id 269 | - 索引id 270 | - 索引层级 271 | 272 | 其实还有很多,在这里就不一一列举了,有兴趣的朋友可以找我私聊。 273 | 274 | ## 总结 275 | 276 | 多个数据页之间通过`页号`构成了双向链表。而每一个数据页的行数据之间,又通过`下一条记录的位置`构成了单项链表。整体架构图如下: 277 | 278 | ![](https://pic.imgdb.cn/item/61210d074907e2d39c3d6e08.jpg) 279 | 好了,本文内容先到这里。如果小伙伴们有任何疑问的话,欢迎找我私聊。 280 | 281 | 顺便预告一下,在innodb的存储结构中,还有一个非常重要的内容没讲,它就是:`索引`。敬请期待,我们下期见。 282 | -------------------------------------------------------------------------------- /docs/数据库/卧槽,sql注入竟然把我们系统搞挂了.md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 最近我在整理安全漏洞相关问题,准备在公司做一次分享。恰好,这段时间团队发现了一个sql注入漏洞:在一个公共的分页功能中,排序字段作为入参,前端页面可以自定义。在分页sql的mybatis mapper.xml中,order by字段后面使用$符号动态接收计算后的排序参数,这样可以实现动态排序的功能。 3 | 4 | 但是,如果入参传入: 5 | ```sql 6 | id; select 1 -- 7 | ``` 8 | 最终执行的sql会变成: 9 | ```sql 10 | select * from user order by id; select 1 -- limit 1,20 11 | ``` 12 | `--`会把后面的`limit`语句注释掉,导致分页条件失效,返回了所有数据。攻击者可以通过这个漏洞一次性获取所有数据。 13 | 14 | 动态排序这个功能原本的想法是好的,但是却有sql注入的风险。值得庆幸的是,这次我们及时发现了问题,并且及时解决了,没有造成什么损失。 15 | 16 | 但是,几年前在老东家的时候,就没那么幸运了。 17 | 18 | **一次sql注入直接把我们支付服务搞挂了。** 19 | 20 | ## 1. 还原事故现场 21 | 有一天运营小姐姐跑过来跟我说,有很多用户支付不了。这个支付服务是一个老系统,转手了3个人了,一直很稳定没有出过啥问题。 22 | 23 | 我二话不说开始定位问题了,先看服务器日志,发现了很多报数据库连接过多的异常。因为支付功能太重要了,当时为了保证支付功能快速恢复,先找运维把支付服务2个节点重启了。 24 | 25 | 5分钟后暂时恢复了正常。 26 | 27 | 我再继续定位原因,据我当时的经验判断一般出现数据库连接过多,可能是因为`连接忘了关闭导致`。但是仔细排查代码没有发现问题,我们当时用的数据库连接池,它会自动回收空闲连接的,`排除了这种可能`。 28 | 29 | 过了会儿,又有一个节点出现了数据库连接过多的问题。 30 | 31 | 但此时,还没查到原因,逼于无奈,只能让运维再重启服务,不过这次把数据库`最大连接数`调大了,默认是100,我们当时设置的500,后面调成了`1000`。(其实现在大部分公司会将这个参数设置成1000) 32 | 33 | 使用命令: 34 | ```sql 35 | setGLOBAL max_connections=500; 36 | ``` 37 | 能及时生效,不需要重启mysql服务。 38 | 39 | 这次给我争取了更多的时间,找dba帮忙一起排查原因。 40 | 41 | 使用`show processlist;` 42 | 43 | 命令查看当前线程执行情况: 44 | ![](https://pic.imgdb.cn/item/610e95d85132923bf8eeb72d.jpg) 45 | 46 | 还可以查看当前的连接状态帮助识别出有问题的查询语句。(需要特别说明的是上图只是我给的一个例子,线上真实的结果不是这样的) 47 | 48 | - id 线程id 49 | - User 执行sql的账号 50 | - Host 执行sql的数据库的ip和端号 51 | - db 数据库名称 52 | - Command 执行命令,包括:Daemon、Query、Sleep等。 53 | - Time 执行sql所消耗的时间 54 | - State 执行状态 55 | - info 执行信息,里面可能包含sql信息。 56 | 果然,发现了一条不寻常的查询sql,执行了差不多1个小时还没有执行完。 57 | 58 | dba把那条sql复制出来,发给我了。然后`kill -9` 杀掉了那条执行耗时非常长的sql线程。 59 | 60 | 后面,数据库连接过多的问题就没再出现了。 61 | 62 | 我拿到那条sql仔细分析了一下,发现一条订单查询语句被攻击者注入了很长的一段sql,肯定是高手写的,有些语法我都没见过。 63 | 64 | **但可以确认无误,被人sql注入了。** 65 | 66 | 通过那条sql中的信息,我很快找到了相关代码,查询数据时入参竟然用的`Statment`,而非`PrepareStatement`预编译机制。 67 | 68 | 知道原因就好处理了,将查询数据的地方改成preparestatement预编译机制后问题得以最终解决。 69 | 70 | ## 2.为什么会导致数据库连接过多? 71 | 我相信很多同学看到这里,都会有一个疑问:sql注入为何会导致数据库连接过多? 72 | 73 | 我下面用一张图,给大家解释一下: 74 | ![](https://pic.imgdb.cn/item/610e968d5132923bf8efb7b2.jpg) 75 | 76 | 1. 攻击者sql注入了类似这样的参数:-1;锁表语句--。 77 | 2. 其中;前面的查询语句先执行了。 78 | 3. 由于--后面的语句会被注释,接下来只会执行锁表语句,把表锁住。 79 | 4. 正常业务请求从数据库连接池成功获取连接后,需要操作表的时候,尝试获取表锁,但一直获取不到,直到超时。注意,这里可能会累计大量的数据库连接被占用,没有及时归还。 80 | 5. 数据库连接池不够用,没有空闲连接。 81 | 新的业务请求从数据库连接池获取不到连接,报数据库连接过多异常。 82 | 6. sql注入导致数据库连接过多问题,最根本的原因是长时间锁表。 83 | 84 | ## 3.预编译为什么能防sql注入? 85 | preparestatement预编译机制会在sql语句执行前,对其进行语法分析、编译和优化,其中参数位置使用占位符?代替了。 86 | 87 | 当真正运行时,传过来的参数会被看作是一个纯文本,不会重新编译,不会被当做sql指令。 88 | 89 | 这样,即使入参传入sql注入指令如: 90 | ```sql 91 | id; select 1 -- 92 | ``` 93 | 最终执行的sql会变成: 94 | ```sql 95 | select * from user order by 'id; select 1 --' limit 1,20 96 | ``` 97 | 这样就不会出现sql注入问题了。 98 | 99 | ## 4.预编译就一定安全? 100 | 不知道你在查询数据时有没有用过like语句,比如:查询名字中带有“苏”字的用户,就可能会用类似这样的语句查询: 101 | ```sql 102 | select * from user where name like '%苏%'; 103 | ``` 104 | 正常情况下是没有问题的。 105 | 106 | 但有些场景下要求传入的条件是必填的,比如:name是必填的,如果注入了:`%`,最后执行的sql会变成这样的: 107 | ```sql 108 | select * from user where name like '%%%'; 109 | ``` 110 | 这种情况预编译机制是正常通过的,但sql的执行结果不会返回包含%的用户,而是返回了所有用户。 111 | 112 | name字段必填变得没啥用了,攻击者同样可以获取用户表所有数据。 113 | 114 | 为什么会出现这个问题呢? 115 | 116 | `%`在mysql中是关键字,如果使用`like '%%%'``,该like条件会失效。 117 | 118 | 如何解决呢? 119 | 120 | 需要对`%`进行转义:`\%`。 121 | 122 | 转义后的sql变成: 123 | ```sql 124 | select * from user where name like '%\%%'; 125 | ``` 126 | 只会返回包含%的用户。 127 | 128 | ## 5.有些特殊的场景怎么办? 129 | 在java中如果使用`mybatis`作为持久化框架,在mapper.xml文件中,如果入参使用#传值,会使用预编译机制。 130 | 131 | 一般我们是这样用的: 132 | ```java 133 | 134 | select * fromuser 135 | 136 | name = #{name} 137 | 138 | 139 | ``` 140 | 绝大多数情况下,鼓励大家使用`#`这种方式传参,更安全,效率更高。 141 | 142 | 但是有时有些特殊情况,比如: 143 | ```java 144 | 145 | order by ${sortString} 146 | 147 | ``` 148 | sortString字段的内容是一个方法中动态计算出来的,这种情况是没法用#,代替$的,这样程序会报错。 149 | 150 | 使用$的情况就有sql注入的风险。 151 | 152 | 那么这种情况该怎办呢? 153 | 154 | 1. 自己写个util工具过滤掉所有的注入关键字,动态计算时调用该工具。 155 | 2. 如果数据源用的阿里的druid的话,可以开启filter中的wall(防火墙),它包含了防止sql注入的功能。但是有个问题,就是它默认不允许多语句同时操作,对批量更新操作也会拦截,这就需要我们自定义filter了。 156 | 157 | ## 6.表信息是如何泄露的? 158 | 有些细心的同学,可能会提出一个问题:在上面锁表的例子中,攻击者是如何拿到表信息的? 159 | 160 | ### 方法1:盲猜 161 | 就是攻击者根据常识猜测可能存在的表名称。 162 | 163 | 假设我们有这样的查询条件: 164 | ```sql 165 | select * from t_order where id = ${id}; 166 | ``` 167 | 传入参数:`-1;select * from user` 168 | 169 | 最终执行sql变成: 170 | ```sql 171 | select * from t_order where id = -1; select * from user; 172 | ``` 173 | 如果该sql有数据返回,说明user表存在,被猜中了。 174 | 175 | 建议表名不要起得过于简单,可以带上适当的前缀,比如:t_user。这样可以增加盲猜的难度。 176 | 177 | ### 方法2:通过系统表 178 | 其实mysql有些系统表,可以查到我们自定义的数据库和表的信息。 179 | 180 | 假设我们还是以这条sql为例: 181 | ```sql 182 | select code,name from t_order where id = ${id}; 183 | ``` 184 | 第一步,获取数据库和账号名。 185 | 186 | 传参为:`-1 union select database(),user()#` 187 | 188 | 最终执行sql变成: 189 | ``` 190 | select code,name from t_order where id = -1 union select database(),user()# 191 | ``` 192 | 会返回当前 数据库名称:sue 和 账号名称:root@localhost。 193 | ![](https://pic.imgdb.cn/item/610e97dc5132923bf8f1b941.jpg) 194 | 195 | 第二步,获取表名。 196 | 197 | 传参改成:`-1 union select table_name,table_schema from information_schema.tables where table_schema='sue'#`最终执行sql变成: 198 | ```sql 199 | select code,name from t_order where id = -1 union select table_name,table_schema from information_schema.tables where table_schema='sue'# 200 | ``` 201 | 会返回数据库sue下面所有表名。 202 | ![](https://pic.imgdb.cn/item/610e98135132923bf8f2155d.jpg) 203 | 204 | 建议在生成环境程序访问的数据库账号,要跟管理员账号分开,一定要控制权限,不能访问系统表。 205 | 206 | ## 7.sql注入到底有哪些危害? 207 | ### 1. 核心数据泄露 208 | 大部分攻击者的目的是为了赚钱,说白了就是获取到有价值的信息拿出去卖钱,比如:用户账号、密码、手机号、身份证信息、银行卡号、地址等敏感信息。 209 | 210 | 他们可以注入类似这样的语句: 211 | ```sql 212 | -1; select * from user; -- 213 | ``` 214 | 就能轻松把用户表中所有信息都获取到。 215 | 216 | 所以,建议大家对这些敏感信息加密存储,可以使用AES对称加密。 217 | 218 | ### 2. 删库跑路 219 | 也不乏有些攻击者不按常理出牌,sql注入后直接把系统的表或者数据库都删了。 220 | 221 | 他们可以注入类似这样的语句: 222 | ```sql 223 | -1; delete from user; -- 224 | ``` 225 | 以上语句会删掉user表中所有数据。 226 | ``` 227 | -1; drop database test; -- 228 | ``` 229 | 以上语句会把整个test数据库所有内容都删掉。 230 | 231 | 正常情况下,我们需要控制线上账号的权限,只允许DML(data manipulation language)数据操纵语言语句,包括:select、update、insert、delete等。 232 | 233 | 不允许DDL(data definition language)数据库定义语言语句,包含:create、alter、drop等。 234 | 235 | 也不允许DCL(Data Control Language)数据库控制语言语句,包含:grant,deny,revoke等。 236 | 237 | DDL和DCL语句只有dba的管理员账号才能操作。 238 | 239 | 顺便提一句:如果被删表或删库了,其实还有补救措施,就是从备份文件中恢复,可能只会丢失少量实时的数据,所以一定有备份机制。 240 | 241 | 3. 把系统搞挂 242 | 有些攻击者甚至可以直接把我们的服务搞挂了,在老东家的时候就是这种情况。 243 | 244 | 他们可以注入类似这样的语句: 245 | ``` 246 | -1;锁表语句;-- 247 | ``` 248 | 把表长时间锁住后,可能会导致数据库连接耗尽。 249 | 250 | 这时,我们需要对数据库线程做监控,如果某条sql执行时间太长,要邮件预警。此外,合理设置数据库连接的超时时间,也能稍微缓解一下这类问题。 251 | 252 | 从上面三个方面,能看出sql注入问题的危害真的挺大的,我们一定要避免该类问题的发生,不要存着侥幸的心理。如果遇到一些不按常理出票的攻击者,一旦被攻击了,你可能会损失惨重。 253 | 254 | ## 8. 如何防止sql注入? 255 | ### 1. 使用预编译机制 256 | 尽量用预编译机制,少用字符串拼接的方式传参,它是sql注入问题的根源。 257 | 258 | ### 2. 要对特殊字符转义 259 | 有些特殊字符,比如:%作为like语句中的参数时,要对其进行转义处理。 260 | 261 | ### 3. 要捕获异常 262 | 需要对所有的异常情况进行捕获,切记接口直接返回异常信息,因为有些异常信息中包含了sql信息,包括:库名,表名,字段名等。攻击者拿着这些信息,就能通过sql注入随心所欲的攻击你的数据库了。 263 | 264 | 目前比较主流的做法是,有个专门的网关服务,它统一暴露对外接口。用户请求接口时先经过它,再由它将请求转发给业务服务。这样做的好处是:能统一封装返回数据的返回体,并且如果出现异常,能返回统一的异常信息,隐藏敏感信息。此外还能做限流和权限控制。 265 | 266 | ### 4. 使用代码检测工具 267 | 使用sqlMap等代码检测工具,它能检测sql注入漏洞。 268 | 269 | ### 5. 要有监控 270 | 需要对数据库sql的执行情况进行监控,有异常情况,及时邮件或短信提醒。 271 | 272 | ### 6. 数据库账号需控制权限 273 | 对生产环境的数据库建立单独的账号,只分配DML相关权限,且不能访问系统表。切勿在程序中直接使用管理员账号。 274 | 275 | ### 7. 代码review 276 | 建立代码review机制,能找出部分隐藏的问题,提升代码质量。 277 | 278 | ### 8. 使用其他手段处理 279 | 对于不能使用预编译传参时,要么开启druid的filter防火墙,要么自己写代码逻辑过滤掉所有可能的注入关键字。 280 | 281 | -------------------------------------------------------------------------------- /docs/数据库/盘点一下数据库误操作有哪些后悔药?.md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 无论是开发、测试,还是DBA,都难免会涉及到数据库的操作,比如:创建某张表,添加某个字段、添加数据、更新数据、删除数据、查询数据等等。 3 | 4 | 正常情况下还好,但如果操作数据库时出现失误,比如: 5 | 6 | 1. 删除订单数据时where条件写错了,导致多删了很多用户订单。 7 | 2. 更新会员有效时间时,一次性把所有会员的有效时间都更新了。 8 | 3. 修复线上数据时,改错了,想还原。 9 | 10 | 还有很多很多场景,我就不一一列举了。 11 | 12 | 如果出现线上环境数据库误操作怎么办?有没有后悔药? 13 | 14 | 答案是有的,请各位看官仔细往下看。 15 | 16 | ## 1.不要用聊天工具发sql语句 17 | 通常开发人员写好sql语句之后,习惯通过聊天工具,比如:qq、钉钉、或者腾讯通等,发给团队老大或者DBA在线上环境执行。但由于有些聊天工具,对部分特殊字符会自动转义,而且有些消息由于内容太长,会被自动分成多条消息。 18 | 19 | 这样会导致团队老大或者DBA复制出来的sql不一定是正确的。 20 | 21 | 他们需要手动拼接成一条完整的sql,有时甚至需要把转义后的字符替换回以前的特殊字符,无形之中会浪费很多额外的时间。即使最终sql拼接好了,真正执行sql的人,心里一定很虚。 22 | 23 | 所以,强烈建议你把要在线上执行的sql语句用邮件发过去,可以避免使用聊天工具的一些弊端,减少一些误操作的机会。而且有个存档,方便今后有问题的时候回溯原因。很多聊天工具只保留最近7天的历史记录,邮件会保留更久一些。 24 | 25 | **别用聊天工具发sql语句!** 26 | 27 | **别用聊天工具发sql语句!** 28 | 29 | **别用聊天工具发sql语句!** 30 | 31 | 重要的事情说三遍,它真的能减少一些误操作。 32 | 33 | ## 2.把sql语句压缩成一行 34 | 有些时候,开发人员写的sql语句很长,使用了各种join和union,而且使用美化工具,将一条sql变成了多行。在复制sql的时候,自己都无法确定sql是否完整。(为了装逼,把自己也坑了,哈哈哈) 35 | 36 | 线上环境有时候需要通过命令行连接数据库,比如:mysql,你把sql语句复制过来后,在命令行界面执行,由于屏幕滚动太快,这时根本无法确定sql是否都执行成功。 37 | 38 | 针对这类问题,强烈建议把sql语句压缩成一行,去掉多余的换行符和空格,可以有效的减少一些误操作。 39 | 40 | sql压缩工具推荐使用:https://tool.lu/sql/ 41 | 42 | ## 3.操作数据之前先select一下 43 | 需要特别说明的是:本文的操作数据主要指修改和删除数据。 44 | 45 | 很多时候,由于我们人为失误,把where条件写错了。但没有怎么仔细检查,就把sql语句直接执行了。影响范围小还好,如果影响几万、几十万,甚至几百万行数据,我们可能要哭了。 46 | 47 | 针对这种情况,在操作数据之前,把sql先改成select count(*)语句,比如: 48 | ```sql 49 | update order set status=1 where status=0; 50 | ``` 51 | 改成: 52 | ```sql 53 | select count(*) from order where status=0; 54 | ``` 55 | 查一下该sql执行后影响的记录行数,做到自己心中有数。也给自己一次测试sql是否正确,确认是否执行的机会。 56 | 57 | ## 4.操作数据sql加limit 58 | 即使通过上面的select语句确认了sql语句没有问题,执行后影响的记录行数是对的。 59 | 60 | 也建议你不要立刻执行,建议在正在执行的时候,加上limit + select出的记录行数。例如: 61 | ```sql 62 | update order set status=1 where status=0 limit 1000; 63 | ``` 64 | 假设有一次性更新的数据太多,所有相关记录行都会被锁住,造成长时间的锁等待,而造成用户请求超时。 65 | 66 | 此外,加limit可以避免一次性操作太多数据,对服务器的cpu造成影响。 67 | 68 | 还有一个最重要的原因:加limit后,操作数据的影响范围是完全可控的。 69 | 70 | ## 5.update时更新修改人和修改时间 71 | 很多人写update语句时,如果要修改状态,就只更新状态,不管其他的字段。比如: 72 | ```sql 73 | update order set status=1 where status=0; 74 | ``` 75 | 这条sql会把status等于0的数据,全部更新成1。 76 | 77 | 后来发现业务逻辑有问题,不应该这么更新,需要把status状态回滚。 78 | 79 | 这时你可能会很自然想到这条sql: 80 | ``` 81 | update order set status=0 where status=1; 82 | ``` 83 | 但仔细想想又有些不对。 84 | 85 | 这样不是会把有部分以前status就是1的数据更新成0? 86 | 87 | 这回真的要哭了,呜呜呜。 88 | 89 | 这时,送你一个好习惯:在更新数据的时候,同时更新修改人和修改时间字段。 90 | ``` 91 | update order set status=1,edit_date=now(),edit_user='admin' where status=0; 92 | ``` 93 | 这样在恢复数据时就能通过修改人和修改时间字段过滤数据了。 94 | 后面需要用到的修改时间通过这条sql语句可以轻松找到: 95 | ``` 96 | select edit_user ,edit_date from `order` order by edit_date desc limit 50; 97 | ``` 98 | > 当然,如果是高并发系统不建议这种批量更新方式,可能会锁表一定时间,造成请求超时。 99 | 100 | 有些同学可能会问:为什么要同时更新修改人,只更新修改时间不行吗? 101 | 102 | 主要有如下的原因: 103 | 104 | 1. 为了标识非正常用户操作,方便后面统计和定位问题。 105 | 2. 有些情况下,在执行sql语句的过程中,正常用户产生数据的修改时间跟你的可能一模一样,导致回滚时数据查多了。 106 | 107 | ## 6.多用逻辑删除,少用物理删除 108 | 在业务开发中,删除数据是必不可少的一种业务场景。 109 | 110 | 有些人开发人员习惯将表设计成物理删除,根据主键只用一条delete语句就能轻松搞定。 111 | 112 | 他们给出的理由是:节省数据库的存储空间。 113 | 114 | 想法是好的,但是现实很残酷。 115 | 116 | 如果有条极重要的数据删错了,想恢复怎么办? 117 | 118 | 此时只剩八个字:没有数据,恢复不了。(PS:或许通过binlog二进制文件可以恢复) 119 | 120 | 如果之前设计表的时候用的逻辑删除,上面的问题就变得好办了。删除数据时,只需update删除状态即可,例如: 121 | ```sql 122 | update order set del_status=1,edit_date=now(),edit_user='admin' where id=123; 123 | ``` 124 | 假如出现异常,要恢复数据,把该id的删除状态还原即可,例如: 125 | ```sql 126 | update order set del_status=0,edit_date=now(),edit_user='admin' where id=123; 127 | 128 | ``` 129 | ## 7.操作数据之前先做备份 130 | 如果只是修改了少量的数据,或者只执行了一两条sql语句,通过上面的修改人和修改时间字段,在需要回滚时,能快速的定位到正确的数据。 131 | 132 | 但是如果修改的记录行数很多,并且执行了多条sql,产生了很多修改时间。这时,你可能就要犯难了,没法一次性找出哪些数据需要回滚。 133 | 134 | 为了解决这类问题,可以将表做备份。 135 | 136 | 可以使用如下sql备份: 137 | ```sql 138 | create table order_bak_2021031721 like`order`; 139 | insert into order_bak_2021031721 select * from`order`; 140 | ``` 141 | 先创建一张一模一样的表,然后把数据复制到新表中。 142 | 143 | 也可以简化成一条sql: 144 | ```sql 145 | create table order_bak_2021031722 select * from`order`; 146 | ``` 147 | 创建表的同时复制数据到新表中。 148 | 149 | > 此外,建议在表名中加上bak和时间,一方面是为了通过表名快速识别出哪些表是备份表,另一方面是为了备份多次时好做区分。因为有时需要执行多次sql才能把数据修复好,这种情况建议把表备份多次,如果出现异常,把数据回滚到最近的一次备份,可以节省很多重复操作的时间。 150 | 151 | 恢复数据时,把sql语句改成select语句,先在备份库找出相关数据,每条数据对应一条update语句,还原到老表中。 152 | 153 | ## 8.中间结果写入临时表 154 | 有时候,我们要先用一条sql查询出要更新的记录的id,然后通过这些id更新数据。 155 | 156 | 批量更新之后,发现不对,要回滚数据。但由于有些数据已更新,此时使用相同的sql相同的条件,却查不出上次相同的id了。 157 | 158 | 这时,我们开始慌了。 159 | 160 | 针对这种情况,我们可以先将第一次查询的id存入一张临时表,然后通过临时表中的id作为查询条件更新数据。 161 | 162 | 如果要恢复数据,只用通过临时表中的id作为查询条件更新数据即可。 163 | 164 | 修改完,3天之后,如果没有出现问题,就可以把临时表删掉了。 165 | 166 | ## 9.表名前面一定要带库名 167 | 我们在写sql时为了方便,习惯性不带数据库名称。比如: 168 | ```sql 169 | update order set status=1,edit_date=now(),edit_user='admin' where status=0; 170 | 假如有多个数据库中有相同的表order,表结构一模一样,只是数据不一样。 171 | 由于执行sql语句的人一个小失误,进错数据库了。 172 | 173 | use trade1; 174 | ``` 175 | 然后执行了这条sql语句,结果悲剧了。 176 | 177 | 有个非常有效的预防这类问题的方法是加数据库名: 178 | ```sql 179 | update `trade2`.`order` set status=1,edit_date=now(),edit_user='admin' where status=0; 180 | ``` 181 | 这样即使执行sql语句前进错数据库了,也没什么影响。 182 | 183 | ## 10.字段增删改的限制 184 | 很多时候,我们少不了对表字段的操作,比如:新加、修改、删除字段,但每种情况都不一样。 185 | 186 | ### 新加的字段一定要加默认值 187 | 新加的字段一定要加默认值。为什么要这样设计呢? 188 | 189 | 正常情况下,如果程序新加了字段,一般是先在数据库中加字段,然后再发程序的最新代码。 190 | 191 | 为什么是这种顺序? 192 | 193 | 因为如果先发程序,然后在数据库中加字段。在该程序刚部署成功,但数据库新字段还没来得及加的这段时间内,最新程序中,所有使用了新加字段的增删改查sql都会报字段不存在的异常。 194 | 195 | 好了,就按先在数据库中加字段,再发程序的顺序。 196 | 197 | 如果数据库中新加的字段非空,且没有默认值,最新的程序还没发,线上跑的还是老代码,这时如果有insert操作,就会报字段不能为空的异常。因为新加的非空字段,老代码是没法赋值的。 198 | 199 | 所以说新加的字段最好是不允许为空,并且加默认值。 200 | 201 | 除此之外,这种设计更多的考虑是为了程序发布失败时的回滚操作。如果新加的字段加了默认值,则可以不用回滚数据库,只需回滚代码即可,是不是很方便? 202 | 203 | ### 不允许删除字段 204 | 删除字段是不允许的,特别是必填字段一定不能删除。 205 | 206 | 为什么这么说? 207 | 208 | 假设开发人员已经把程序改成不使用删除字段了,接下来如何部署呢? 209 | 210 | 如果先把程序部署好了,还没来得及删除数据库相关表字段。当有insert请求时,由于数据库中该字段是必填的,会报必填字段不能为空的异常。 211 | 如果先把数据库中相关表字段删了,程序还没来得及发。这时所有涉及该删除字段的增删改查,都会报字段不存在的异常。 212 | 所以,线上环境必填字段一定不能删除的。 213 | 214 | ### 根据实际情况修改字段 215 | 修改字段要分为这三种情况: 216 | 217 | #### 1.修改字段名称 218 | 修改字段名称也不允许,跟删除必填字段的问题差不多。 219 | 220 | 如果把程序部署好了,还没来得及修改数据库中表字段名称。这时所有涉及该字段的增删改查,都会报字段不存在的异常。 221 | 如果先把数据库中字段名称改了,程序还没来得及发。这时所有涉及该字段的增删改查,同样也会报字段不存在的异常。 222 | 所以,线上环境字段名称一定不要修改。 223 | 224 | #### 2.修改字段类型 225 | 修改字段类型时一定要兼容之前的数据。例如: 226 | 227 | tinyint改成int可以,但int改成tinyint要仔细衡量一下。 228 | varchar改成text可以,但text改成varchar要仔细衡量一下。 229 | #### 3.修改字段长度 230 | 字段长度建议改大,通常情况下,不建议改小。如果一定要改小,要先确认该字段可能会出现的最大长度,避免insert操作时出现字段太长的异常。 231 | 232 | 此外,建议改大也需要设置一个合理的长度,避免数据库资源浪费。 233 | 234 | ## 总结 235 | 本文分享了10种减少数据库误操作的方法,并非所有场景都适合你。特别是在一些高并发,或者单表数据量非常大的场景,你需要根据实际情况酌情选择。但我敢肯定的是读完这篇文章,你一定会有一些收获,因为大部分方法对你来说是适用的,可能会让你少走很多弯路,强烈建议收藏。 -------------------------------------------------------------------------------- /docs/数据库/聊聊sql优化的15个小技巧.md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | sql优化是一个大家都比较关注的热门话题,无论你在面试,还是工作中,都很有可能会遇到。 3 | 4 | 如果某天你负责的某个线上接口,出现了性能问题,需要做优化。那么你首先想到的很有可能是优化sql语句,因为它的改造成本相对于代码来说也要小得多。 5 | 6 | 那么,如何优化sql语句呢? 7 | 8 | 这篇文章从15个方面,分享了sql优化的一些小技巧,希望对你有所帮助。 9 | 10 | ![](https://pic.imgdb.cn/item/618bcb5b2ab3f51d910ad8dc.jpg) 11 | 12 | ## 1 避免使用select * 13 | 很多时候,我们写sql语句时,为了方便,喜欢直接使用`select *`,一次性查出表中所有列的数据。 14 | 15 | **反例:** 16 | ```sql 17 | select * from user where id=1; 18 | ``` 19 | 在实际业务场景中,可能我们真正需要使用的只有其中一两列。查了很多数据,但是不用,白白浪费了数据库资源,比如:内存或者cpu。 20 | 21 | 此外,多查出来的数据,通过网络IO传输的过程中,也会增加数据传输的时间。 22 | 23 | 还有一个最重要的问题是:`select *`不会走`覆盖索引`,会出现大量的`回表`操作,而从导致查询sql的性能很低。 24 | 25 | 那么,如何优化呢? 26 | 27 | **正例:** 28 | ```sql 29 | select name,age from user where id=1; 30 | ``` 31 | sql语句查询时,只查需要用到的列,多余的列根本无需查出来。 32 | 33 | ## 2 用union all代替union 34 | 我们都知道sql语句使用`union`关键字后,可以获取排重后的数据。 35 | 36 | 而如果使用`union all`关键字,可以获取所有数据,包含重复的数据。 37 | 38 | **反例:** 39 | ```sql 40 | (select * from user where id=1) 41 | union 42 | (select * from user where id=2); 43 | ``` 44 | 45 | 排重的过程需要遍历、排序和比较,它更耗时,更消耗cpu资源。 46 | 47 | 所以如果能用union all的时候,尽量不用union。 48 | 49 | **正例:** 50 | ```sql 51 | (select * from user where id=1) 52 | union all 53 | (select * from user where id=2); 54 | ``` 55 | 56 | 除非是有些特殊的场景,比如union all之后,结果集中出现了重复数据,而业务场景中是不允许产生重复数据的,这时可以使用union。 57 | 58 | ## 3 小表驱动大表 59 | 小表驱动大表,也就是说用小表的数据集驱动大表的数据集。 60 | 61 | 假如有order和user两张表,其中order表有10000条数据,而user表有100条数据。 62 | 63 | 这时如果想查一下,所有有效的用户下过的订单列表。 64 | 65 | 可以使用`in`关键字实现: 66 | ```sql 67 | select * from order 68 | where user_id in (select id from user where status=1) 69 | ``` 70 | 也可以使用`exists`关键字实现: 71 | ```sql 72 | select * from order 73 | where exists (select 1 from user where order.user_id = user.id and status=1) 74 | ``` 75 | 前面提到的这种业务场景,使用in关键字去实现业务需求,更加合适。 76 | 77 | 为什么呢? 78 | 79 | 因为如果sql语句中包含了in关键字,则它会优先执行in里面的`子查询语句`,然后再执行in外面的语句。如果in里面的数据量很少,作为条件查询速度更快。 80 | 81 | 而如果sql语句中包含了exists关键字,它优先执行exists左边的语句(即主查询语句)。然后把它作为条件,去跟右边的语句匹配。如果匹配上,则可以查询出数据。如果匹配不上,数据就被过滤掉了。 82 | 83 | 这个需求中,order表有10000条数据,而user表有100条数据。order表是大表,user表是小表。如果order表在左边,则用in关键字性能更好。 84 | 85 | 总结一下: 86 | - `in` 适用于左边大表,右边小表。 87 | - `exists` 适用于左边小表,右边大表。 88 | 89 | 不管是用in,还是exists关键字,其核心思想都是用小表驱动大表。 90 | 91 | ## 4 批量操作 92 | 如果你有一批数据经过业务处理之后,需要插入数据,该怎么办? 93 | 94 | **反例:** 95 | ```java 96 | for(Order order: list){ 97 | orderMapper.insert(order): 98 | } 99 | ``` 100 | 在循环中逐条插入数据。 101 | ```sql 102 | insert into order(id,code,user_id) 103 | values(123,'001',100); 104 | ``` 105 | 该操作需要多次请求数据库,才能完成这批数据的插入。 106 | 107 | 但众所周知,我们在代码中,每次远程请求数据库,是会消耗一定性能的。而如果我们的代码需要请求多次数据库,才能完成本次业务功能,势必会消耗更多的性能。 108 | 109 | 那么如何优化呢? 110 | 111 | **正例:** 112 | ```java 113 | orderMapper.insertBatch(list): 114 | ``` 115 | 提供一个批量插入数据的方法。 116 | ```sql 117 | insert into order(id,code,user_id) 118 | values(123,'001',100),(124,'002',100),(125,'003',101); 119 | ``` 120 | 这样只需要远程请求一次数据库,sql性能会得到提升,数据量越多,提升越大。 121 | 122 | 但需要注意的是,不建议一次批量操作太多的数据,如果数据太多数据库响应也会很慢。批量操作需要把握一个度,建议每批数据尽量控制在500以内。如果数据多于500,则分多批次处理。 123 | 124 | 125 | ## 5 多用limit 126 | 有时候,我们需要查询某些数据中的第一条,比如:查询某个用户下的第一个订单,想看看他第一次的首单时间。 127 | 128 | **反例:** 129 | ```sql 130 | select id, create_date 131 | from order 132 | where user_id=123 133 | order by create_date asc; 134 | ``` 135 | 136 | 根据用户id查询订单,按下单时间排序,先查出该用户所有的订单数据,得到一个订单集合。 然后在代码中,获取第一个元素的数据,即首单的数据,就能获取首单时间。 137 | ```java 138 | List list = orderMapper.getOrderList(); 139 | Order order = list.get(0); 140 | ``` 141 | 虽说这种做法在功能上没有问题,但它的效率非常不高,需要先查询出所有的数据,有点浪费资源。 142 | 143 | 那么,如何优化呢? 144 | 145 | **正例:** 146 | ```sql 147 | select id, create_date 148 | from order 149 | where user_id=123 150 | order by create_date asc 151 | limit 1; 152 | ``` 153 | 使用`limit 1`,只返回该用户下单时间最小的那一条数据即可。 154 | 155 | > 此外,在删除或者修改数据时,为了防止误操作,导致删除或修改了不相干的数据,也可以在sql语句最后加上limit。 156 | 157 | 例如: 158 | ```sql 159 | update order set status=0,edit_time=now(3) 160 | where id>=100 and id<200 limit 100; 161 | ``` 162 | 这样即使误操作,比如把id搞错了,也不会对太多的数据造成影响。 163 | 164 | 165 | ## 6 in中值太多 166 | 对于批量查询接口,我们通常会使用`in`关键字过滤出数据。比如:想通过指定的一些id,批量查询出用户信息。 167 | 168 | sql语句如下: 169 | ```sql 170 | select id,name from category 171 | where id in (1,2,3...100000000); 172 | ``` 173 | 如果我们不做任何限制,该查询语句一次性可能会查询出非常多的数据,很容易导致接口超时。 174 | 175 | 这时该怎么办呢? 176 | 177 | ```sql 178 | select id,name from category 179 | where id in (1,2,3...100) 180 | limit 500; 181 | ``` 182 | 可以在sql中对数据用limit做限制。 183 | 184 | 不过我们更多的是要在业务代码中加限制,伪代码如下: 185 | ```java 186 | public List getCategory(List ids) { 187 | if(CollectionUtils.isEmpty(ids)) { 188 | return null; 189 | } 190 | if(ids.size() > 500) { 191 | throw new BusinessException("一次最多允许查询500条记录") 192 | } 193 | return mapper.getCategoryList(ids); 194 | } 195 | ``` 196 | 还有一个方案就是:如果ids超过500条记录,可以分批用多线程去查询数据。每批只查500条记录,最后把查询到的数据汇总到一起返回。 197 | 198 | 不过这只是一个临时方案,不适合于ids实在太多的场景。因为ids太多,即使能快速查出数据,但如果返回的数据量太大了,网络传输也是非常消耗性能的,接口性能始终好不到哪里去。 199 | 200 | 201 | ## 7 增量查询 202 | 有时候,我们需要通过远程接口查询数据,然后同步到另外一个数据库。 203 | 204 | **反例:** 205 | ```sql 206 | select * from user; 207 | ``` 208 | 如果直接获取所有的数据,然后同步过去。这样虽说非常方便,但是带来了一个非常大的问题,就是如果数据很多的话,查询性能会非常差。 209 | 210 | 这时该怎么办呢? 211 | 212 | **正例:** 213 | ```java 214 | select * from user 215 | where id>#{lastId} and create_time >= #{lastCreateTime} 216 | limit 100; 217 | ``` 218 | 按id和时间升序,每次只同步一批数据,这一批数据只有100条记录。每次同步完成之后,保存这100条数据中最大的id和时间,给同步下一批数据的时候用。 219 | 220 | 通过这种增量查询的方式,能够提升单次查询的效率。 221 | 222 | ## 8 高效的分页 223 | 有时候,列表页在查询数据时,为了避免一次性返回过多的数据影响接口性能,我们一般会对查询接口做分页处理。 224 | 225 | 在mysql中分页一般用的`limit`关键字: 226 | ```sql 227 | select id,name,age 228 | from user limit 10,20; 229 | ``` 230 | 如果表中数据量少,用limit关键字做分页,没啥问题。但如果表中数据量很多,用它就会出现性能问题。 231 | 232 | 比如现在分页参数变成了: 233 | ```sql 234 | select id,name,age 235 | from user limit 1000000,20; 236 | ``` 237 | mysql会查到1000020条数据,然后丢弃前面的1000000条,只查后面的20条数据,这个是非常浪费资源的。 238 | 239 | 那么,这种海量数据该怎么分页呢? 240 | 241 | 优化sql: 242 | ```sql 243 | select id,name,age 244 | from user where id > 1000000 limit 20; 245 | ``` 246 | 先找到上次分页最大的id,然后利用id上的索引查询。不过该方案,要求id是连续的,并且有序的。 247 | 248 | 还能使用`between`优化分页。 249 | 250 | ```sql 251 | select id,name,age 252 | from user where id between 1000000 and 1000020; 253 | ``` 254 | 需要注意的是between要在唯一索引上分页,不然会出现每页大小不一致的问题。 255 | 256 | ## 9 用连接查询代替子查询 257 | mysql中如果需要从两张以上的表中查询出数据的话,一般有两种实现方式:`子查询` 和 `连接查询`。 258 | 259 | 子查询的例子如下: 260 | ```sql 261 | select * from order 262 | where user_id in (select id from user where status=1) 263 | ``` 264 | 子查询语句可以通过`in`关键字实现,一个查询语句的条件落在另一个select语句的查询结果中。程序先运行在嵌套在最内层的语句,再运行外层的语句。 265 | 266 | 子查询语句的优点是简单,结构化,如果涉及的表数量不多的话。 267 | 268 | 但缺点是mysql执行子查询时,需要创建临时表,查询完毕后,需要再删除这些临时表,有一些额外的性能消耗。 269 | 270 | 这时可以改成连接查询。 具体例子如下: 271 | ```sql 272 | select o.* from order o 273 | inner join user u on o.user_id = u.id 274 | where u.status=1 275 | ``` 276 | 277 | ## 10 join的表不宜过多 278 | 根据阿里巴巴开发者手册的规定,join表的数量不应该超过`3`个。 279 | 280 | **反例:** 281 | ```sql 282 | select a.name,b.name.c.name,d.name 283 | from a 284 | inner join b on a.id = b.a_id 285 | inner join c on c.b_id = b.id 286 | inner join d on d.c_id = c.id 287 | inner join e on e.d_id = d.id 288 | inner join f on f.e_id = e.id 289 | inner join g on g.f_id = f.id 290 | ``` 291 | 292 | 如果join太多,mysql在选择索引的时候会非常复杂,很容易选错索引。 293 | 294 | 并且如果没有命中中,nested loop join 就是分别从两个表读一行数据进行两两对比,复杂度是 n^2。 295 | 296 | 所以我们应该尽量控制join表的数量。 297 | 298 | **正例:** 299 | ```sql 300 | select a.name,b.name.c.name,a.d_name 301 | from a 302 | inner join b on a.id = b.a_id 303 | inner join c on c.b_id = b.id 304 | ``` 305 | 如果实现业务场景中需要查询出另外几张表中的数据,可以在a、b、c表中`冗余专门的字段`,比如:在表a中冗余d_name字段,保存需要查询出的数据。 306 | 307 | 不过我之前也见过有些ERP系统,并发量不大,但业务比较复杂,需要join十几张表才能查询出数据。 308 | 309 | 所以join表的数量要根据系统的实际情况决定,不能一概而论,尽量越少越好。 310 | 311 | ## 11 join时要注意 312 | 我们在涉及到多张表联合查询的时候,一般会使用`join`关键字。 313 | 314 | 而join使用最多的是left join和inner join。 315 | - `left join`:求两个表的交集外加左表剩下的数据。 316 | - `inner join`:求两个表交集的数据。 317 | 318 | 使用inner join的示例如下: 319 | ```sql 320 | select o.id,o.code,u.name 321 | from order o 322 | inner join user u on o.user_id = u.id 323 | where u.status=1; 324 | ``` 325 | 如果两张表使用inner join关联,mysql会自动选择两张表中的小表,去驱动大表,所以性能上不会有太大的问题。 326 | 327 | 使用left join的示例如下: 328 | ```sql 329 | select o.id,o.code,u.name 330 | from order o 331 | left join user u on o.user_id = u.id 332 | where u.status=1; 333 | ``` 334 | 如果两张表使用left join关联,mysql会默认用left join关键字左边的表,去驱动它右边的表。如果左边的表数据很多时,就会出现性能问题。 335 | 336 | > 要特别注意的是在用left join关联查询时,左边要用小表,右边可以用大表。如果能用inner join的地方,尽量少用left join。 337 | 338 | ## 12 控制索引的数量 339 | 众所周知,索引能够显著的提升查询sql的性能,但索引数量并非越多越好。 340 | 341 | 因为表中新增数据时,需要同时为它创建索引,而索引是需要额外的存储空间的,而且还会有一定的性能消耗。 342 | 343 | 阿里巴巴的开发者手册中规定,单表的索引数量应该尽量控制在`5`个以内,并且单个索引中的字段数不超过`5`个。 344 | 345 | mysql使用的B+树的结构来保存索引的,在insert、update和delete操作时,需要更新B+树索引。如果索引过多,会消耗很多额外的性能。 346 | 347 | 那么,问题来了,如果表中的索引太多,超过了5个该怎么办? 348 | 349 | 这个问题要辩证的看,如果你的系统并发量不高,表中的数据量也不多,其实超过5个也可以,只要不要超过太多就行。 350 | 351 | 但对于一些高并发的系统,请务必遵守单表索引数量不要超过5的限制。 352 | 353 | 那么,高并发系统如何优化索引数量? 354 | 355 | 能够建联合索引,就别建单个索引,可以删除无用的单个索引。 356 | 357 | 将部分查询功能迁移到其他类型的数据库中,比如:Elastic Seach、HBase等,在业务表中只需要建几个关键索引即可。 358 | 359 | ## 13 选择合理的字段类型 360 | `char`表示固定字符串类型,该类型的字段存储空间的固定的,会浪费存储空间。 361 | 362 | ```sql 363 | alter table order 364 | add column code char(20) NOT NULL; 365 | ``` 366 | `varchar`表示变长字符串类型,该类型的字段存储空间会根据实际数据的长度调整,不会浪费存储空间。 367 | ```sql 368 | alter table order 369 | add column code varchar(20) NOT NULL; 370 | ``` 371 | 如果是长度固定的字段,比如用户手机号,一般都是11位的,可以定义成char类型,长度是11字节。 372 | 373 | 但如果是企业名称字段,假如定义成char类型,就有问题了。 374 | 375 | 如果长度定义得太长,比如定义成了200字节,而实际企业长度只有50字节,则会浪费150字节的存储空间。 376 | 377 | 如果长度定义得太短,比如定义成了50字节,但实际企业名称有100字节,就会存储不下,而抛出异常。 378 | 379 | 所以建议将企业名称改成varchar类型,变长字段存储空间小,可以节省存储空间,而且对于查询来说,在一个相对较小的字段内搜索效率显然要高些。 380 | 381 | 我们在选择字段类型时,应该遵循这样的原则: 382 | 1. 能用数字类型,就不用字符串,因为字符的处理往往比数字要慢。 383 | 2. 尽可能使用小的类型,比如:用bit存布尔值,用tinyint存枚举值等。 384 | 3. 长度固定的字符串字段,用char类型。 385 | 4. 长度可变的字符串字段,用varchar类型。 386 | 5. 金额字段用decimal,避免精度丢失问题。 387 | 388 | 还有很多原则,这里就不一一列举了。 389 | 390 | ## 14 提升group by的效率 391 | 我们有很多业务场景需要使用`group by`关键字,它主要的功能是去重和分组。 392 | 393 | 通常它会跟`having`一起配合使用,表示分组后再根据一定的条件过滤数据。 394 | 395 | **反例:** 396 | ```sql 397 | select user_id,user_name from order 398 | group by user_id 399 | having user_id <= 200; 400 | ``` 401 | 这种写法性能不好,它先把所有的订单根据用户id分组之后,再去过滤用户id大于等于200的用户。 402 | 403 | 分组是一个相对耗时的操作,为什么我们不先缩小数据的范围之后,再分组呢? 404 | 405 | **正例:** 406 | ```sql 407 | select user_id,user_name from order 408 | where user_id <= 200 409 | group by user_id 410 | ``` 411 | 使用where条件在分组前,就把多余的数据过滤掉了,这样分组时效率就会更高一些。 412 | 413 | > 其实这是一种思路,不仅限于group by的优化。我们的sql语句在做一些耗时的操作之前,应尽可能缩小数据范围,这样能提升sql整体的性能。 414 | 415 | 416 | ## 15 索引优化 417 | sql优化当中,有一个非常重要的内容就是:`索引优化`。 418 | 419 | 很多时候sql语句,走了索引,和没有走索引,执行效率差别很大。所以索引优化被作为sql优化的首选。 420 | 421 | 索引优化的第一步是:检查sql语句有没有走索引。 422 | 423 | 那么,如何查看sql走了索引没? 424 | 425 | 可以使用`explain`命令,查看mysql的执行计划。 426 | 427 | 例如: 428 | ```java 429 | explain select * from `order` where code='002'; 430 | ``` 431 | 结果: 432 | ![](https://pic.imgdb.cn/item/618bcbad2ab3f51d910af563.jpg) 433 | 通过这几列可以判断索引使用情况,执行计划包含列的含义如下图所示: 434 | ![](https://pic.imgdb.cn/item/618bcb752ab3f51d910ae306.jpg) 435 | 如果你想进一步了解explain的详细用法,可以看看我的另一篇文章《[explain | 索引优化的这把绝世好剑,你真的会用吗?](https://mp.weixin.qq.com/s?__biz=MzUxODkzNTQ3Nw==&mid=2247485392&idx=1&sn=a0a2728179e20ad09487f09e0a785ec2&chksm=f980010acef7881c0591beb4e90220de5c283a63c74e02910830f4cb99922c388ee0226e7c88&token=393604486&lang=zh_CN#rd)》 436 | 437 | 说实话,sql语句没有走索引,排除没有建索引之外,最大的可能性是索引失效了。 438 | 439 | 下面说说索引失效的常见原因: 440 | ![](https://pic.imgdb.cn/item/618bcb872ab3f51d910ae8ed.jpg) 441 | 如果不是上面的这些原因,则需要再进一步排查一下其他原因。 442 | 443 | 此外,你有没有遇到过这样一种情况:明明是同一条sql,只有入参不同而已。有的时候走的索引a,有的时候却走的索引b? 444 | 445 | 没错,有时候mysql会选错索引。 446 | 447 | 必要时可以使用`force index`来强制查询sql走某个索引。 448 | 449 | 至于为什么mysql会选错索引,后面有专门的文章介绍的,这里先留点悬念。 450 | -------------------------------------------------------------------------------- /docs/数据库/阿里二面:我们为什么要做分库分表?.md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 在高并发系统当中,分库分表是必不可少的技术手段之一,同时也是BAT等大厂面试时,经常考的热门考题。 3 | 4 | 你知道我们为什么要做分库分表吗? 5 | 6 | 这个问题要从两条线说起:`垂直方向` 和 `水平方向`。 7 | 8 | ## 1 垂直方向 9 | `垂直方向`主要针对的是`业务`,下面聊聊业务的发展跟分库分表有什么关系。 10 | 11 | ### 1.1 单库 12 | 在系统初期,业务功能相对来说比较简单,系统模块较少。 13 | 14 | 为了快速满足迭代需求,减少一些不必要的依赖。更重要的是减少系统的复杂度,保证开发速度,我们通常会使用`单库`来保存数据。 15 | 16 | 系统初期的数据库架构如下: 17 | ![](https://pic.imgdb.cn/item/617a7b922ab3f51d91fa2858.jpg) 18 | 此时,使用的数据库方案是:`一个数据库`包含`多张业务表`。 用户读数据请求和写数据请求,都是操作的同一个数据库。 19 | 20 | ### 1.2 分表 21 | 系统上线之后,随着业务的发展,不断的添加新功能。导致单表中的字段越来越多,开始变得有点不太好维护了。 22 | 23 | 一个用户表就包含了几十甚至上百个字段,管理起来有点混乱。 24 | 25 | 这时候该怎么办呢? 26 | 27 | 答:`分表`。 28 | 29 | 将`用户表`拆分为:`用户基本信息表` 和 `用户扩展表`。 30 | 31 | ![](https://pic.imgdb.cn/item/617a7ba32ab3f51d91fa3501.jpg) 32 | 用户基本信息表中存的是用户最主要的信息,比如:用户名、密码、别名、手机号、邮箱、年龄、性别等核心数据。 33 | 34 | 这些信息跟用户息息相关,查询的频次非常高。 35 | 36 | 而用户扩展表中存的是用户的扩展信息,比如:所属单位、户口所在地、所在城市等等,非核心数据。 37 | 38 | 这些信息只有在特定的业务场景才需要查询,而绝大数业务场景是不需要的。 39 | 40 | 所以通过分表把核心数据和非核心数据分开,让表的结构更清晰,职责更单一,更便于维护。 41 | 42 | 除了按实际业务分表之外,我们还有一个常用的分表原则是:把调用频次高的放在一张表,调用频次低的放在另一张表。 43 | 44 | 有个非常经典的例子就是:订单表和订单详情表。 45 | 46 | ### 1.3 分库 47 | 不知不觉,系统已经上线了一年多的时间了。经历了N个迭代的需求开发,功能已经非常完善。 48 | 49 | 系统功能完善,意味着系统各种关联关系,错综复杂。 50 | 51 | 此时,如果不赶快梳理业务逻辑,后面会带来很多隐藏问题,会把自己坑死。 52 | 53 | 这就需要按业务功能,划分不同领域了。把相同领域的表放到同一个数据库,不同领域的表,放在另外的数据库。 54 | 55 | 具体拆分过程如下: 56 | ![](https://pic.imgdb.cn/item/617a7bc12ab3f51d91fa495a.jpg) 57 | 58 | 将用户、产品、物流、订单相关的表,从原来一个数据库中,拆分成单独的用户库、产品库、物流库和订单库,一共四个数据库。 59 | 60 | > 在这里为了看起来更直观,每个库我只画了一张表,实际场景可能有多张表。 61 | 62 | 这样按领域拆分之后,每个领域只用关注自己相关的表,职责更单一了,一下子变得更好维护了。 63 | 64 | ### 1.4 分库分表 65 | 有时候按业务,只分库,或者只分表是不够的。比如:有些财务系统,需要按月份和年份汇总,所有用户的资金。 66 | 67 | 这就需要做:`分库分表`了。 68 | 69 | 每年都有个单独的数据库,每个数据库中,都有12张表,每张表存储一个月的用户资金数据。 70 | ![](https://pic.imgdb.cn/item/617a7bd42ab3f51d91fa5829.jpg) 71 | 这样分库分表之后,就能非常高效的查询出某个用户每个月,或者每年的资金了。 72 | 73 | 此外,还有些比较特殊的需求,比如需要按照地域分库,比如:华中、华北、华南等区,每个区都有一个单独的数据库。 74 | 75 | 甚至有些游戏平台,按接入的游戏厂商来做分库分表。 76 | 77 | ## 2 水平方向 78 | `水分方向`主要针对的是`数据`,下面聊聊数据跟分库分表又有什么关系。 79 | 80 | ### 2.1 单库 81 | 在系统初期,由于用户非常少,所以系统并发量很小。并且存在表中的数据量也非常少。 82 | 83 | 这时的数据库架构如下: 84 | ![](https://pic.imgdb.cn/item/617a7b922ab3f51d91fa2858.jpg) 85 | 此时,使用的数据库方案同样是:`一个master数据库`包含`多张业务表`。 86 | 87 | 用户读数据请求和写数据请求,都是操作的同一个数据库,该方案比较适合于并发量很低的业务场景。 88 | 89 | ### 2.2 主从读写分离 90 | 系统上线一段时间后,用户数量增加了。 91 | 92 | 此时,你会发现用户的请求当中,读数据的请求占据了大部分,真正写数据的请求占比很少。 93 | 94 | 众所周知,`数据库连接是有限的`,它是非常宝贵的资源。而每次数据库的读或写请求,都需要占用至少一个数据库连接。 95 | 96 | 如果写数据请求需要的数据库连接,被读数据请求占用完了,不就写不了数据了? 97 | 98 | 这样问题就严重了。 99 | 100 | 为了解决该问题,我们需要把`读库`和`写库`分开。 101 | 102 | 于是,就出现了主从读写分离架构: 103 | ![](https://pic.imgdb.cn/item/617a7bf42ab3f51d91fa6f9a.jpg) 104 | 考虑刚开始用户量还没那么大,选择的是`一主一从`的架构,也就是常说的一个master一个slave。 105 | 106 | 所有的写数据请求,都指向主库。一旦主库写完数据之后,立马异步同步给从库。这样所有的读数据请求,就能及时从从库中获取到数据了(除非网络有延迟)。 107 | 108 | 读写分离方案可以解决上面提到的单节点问题,相对于单库的方案,能够更好的保证系统的稳定性。 109 | 110 | 因为如果主库挂了,可以升级从库为主库,将所有读写请求都指向新主库,系统又能正常运行了。 111 | 112 | > 读写分离方案其实也是分库的一种,它相对于为数据做了备份,它已经成为了系统初期的首先方案。 113 | 114 | 但这里有个问题就是:如果用户量确实有些大,如果master挂了,升级slave为master,将所有读写请求都指向新master。 115 | 116 | 但此时,如果这个新master根本扛不住所有的读写请求,该怎么办? 117 | 118 | 这就需要`一主多从`的架构了: 119 | 120 | ![](https://pic.imgdb.cn/item/617a7c042ab3f51d91fa78bb.jpg) 121 | 上图中我列的是`一主两从`,如果master挂了,可以选择从库1或从库2中的一个,升级为新master。假如我们在这里升级从库1为新master,则原来的从库2就变成了新master的的slave了。 122 | 123 | 调整之后的架构图如下: 124 | ![](https://pic.imgdb.cn/item/617a7c142ab3f51d91fa83e8.jpg) 125 | 这样就能解决上面的问题了。 126 | 127 | 除此之外,如果查询请求量再增大,我们还可以将架构升级为一主三从、一主四从...一主N从等。 128 | 129 | ### 2.3 分库 130 | 上面的读写分离方案确实可以解决读请求大于写请求时,导致master节点扛不住的问题。但如果某个领域,比如:用户库。如果注册用户的请求量非常大,即写请求本身的请求量就很大,一个master库根本无法承受住这么大的压力。 131 | 132 | 这时该怎么办呢? 133 | 134 | 答:建立多个用户库。 135 | 136 | 用户库的拆分过程如下: 137 | ![](https://pic.imgdb.cn/item/617a7c262ab3f51d91fa907d.jpg) 138 | 在这里我将用户库拆分成了三个库(真实场景不一定是这样的),每个库的表结构是一模一样的,只有存储的数据不一样。 139 | 140 | 141 | ### 2.4 分表 142 | 用户请求量上来了,带来的势必是数据量的成本上升。即使做了分库,但有可能单个库,比如:用户库,出现了5000万的数据。 143 | 144 | 根据经验值,单表的数据量应该尽量控制在1000万以内,性能是最佳的。如果有几千万级的数据量,用单表来存,性能会变得很差。 145 | 146 | 如果数据量太大了,需要建立的索引也会很大,从小到大检索一次数据,会非常耗时,而且非常消耗cpu资源。 147 | 148 | 这时该怎么办呢? 149 | 150 | 答:`分表`,这样可以控制每张表的数据量,和索引大小。 151 | 152 | 表拆分过程如下: 153 | 154 | ![](https://pic.imgdb.cn/item/617a7c3a2ab3f51d91fa9de7.jpg) 155 | 我在这里将用户库中的用户表,拆分成了四张表(真实场景不一定是这样的),每张表的表结构是一模一样的,只是存储的数据不一样。 156 | 157 | 如果以后用户数据量越来越大,只需再多分几张用户表即可。 158 | 159 | ### 2.5 分库分表 160 | 当系统发展到一定的阶段,用户并发量大,而且需要存储的数据量也很多。这时该怎么办呢? 161 | 162 | 答:需要做`分库分表`。 163 | 164 | 如下图所示: 165 | ![](https://pic.imgdb.cn/item/617a7c492ab3f51d91faa683.jpg) 166 | 图中将用户库拆分成了三个库,每个库都包含了四张用户表。 167 | 168 | 如果有用户请求过来的时候,先根据用户id路由到其中一个用户库,然后再定位到某张表。 169 | 170 | 路由的算法挺多的: 171 | - `根据id取模`,比如:id=7,有4张表,则7%4=3,模为3,路由到用户表3。 172 | - `给id指定一个区间范围`,比如:id的值是0-10万,则数据存在用户表0,id的值是10-20万,则数据存在用户表1。 173 | - `一致性hash算法` 174 | 175 | 这篇文章就不过多介绍了,后面会有文章专门介绍这些路由算法的。 176 | 177 | 178 | ## 3 真实案例 179 | 接下来,废话不多说,给大家分享三个我参与过的分库分表项目经历,给有需要的朋友一个参考。 180 | 181 | ### 3.1 分库 182 | 我之前待过一家公司,我们团队是做游戏运营的,我们公司提供平台,游戏厂商接入我们平台,推广他们的游戏。 183 | 184 | 游戏玩家通过我们平台登录,成功之后跳转到游戏厂商的指定游戏页面,该玩家就能正常玩游戏了,还可以充值游戏币。 185 | 186 | 这就需要建立我们的账号体系和游戏厂商的账号的映射关系,游戏玩家通过登录我们平台的游戏账号,成功之后转换成游戏厂商自己平台的账号。 187 | 188 | 这里有两个问题: 189 | 1. 每个游戏厂商的接入方式可能都不一样,账号体系映射关系也有差异。 190 | 2. 用户都从我们平台登录,成功之后跳转到游戏厂商的游戏页面。当时有N个游戏厂商接入了,活跃的游戏玩家比较多,登录接口的并发量不容小觑。 191 | 192 | 为了解决这两个问题,我们当时采用的方案是:`分库`。即针对每一个游戏都单独建一个数据库,数据库中的表结构允许存在差异。 193 | ![](https://pic.imgdb.cn/item/617a7c5e2ab3f51d91fab400.jpg) 194 | 我们当时没有进一步分表,是因为当时考虑每种游戏的用户量,还没到大到离谱的地步。不像王者荣耀这种现象级的游戏,有上亿的玩家。 195 | 196 | 其中有个比较关键的地方是:登录接口中需要传入游戏id字段,通过该字段,系统就知道要操作哪个库,因为库名中就包含了游戏id的信息。 197 | 198 | ### 3.2 分表 199 | 还是在那家游戏平台公司,我们还有另外一个业务就是:`金钻会员`。 200 | 201 | 说白了就是打造了一套跟游戏相关的会员体系,为了保持用户的活跃度,开通会员有很多福利,比如:送游戏币、充值有折扣、积分兑换、抽奖、专属客服等等。 202 | 203 | 在这套会员体系当中,有个非常重要的功能就是:`积分`。 204 | 205 | 用户有很多种途径可以获取积分,比如:签到、充值、玩游戏、抽奖、推广、参加活动等等。 206 | 207 | 积分用什么用途呢? 208 | 1. 退换实物礼物 209 | 2. 兑换游戏币 210 | 3. 抽奖 211 | 212 | 说了这么多,其实就是想说,一个用户一天当中,获取积分或消费积分都可能有很多次,那么,一个用户一天就可能会产生几十条记录。 213 | 214 | 如果用户多了的话,积分相关的数据量其实挺惊人的。 215 | 216 | 我们当时考虑了,水平方向的数据量可能会很大,但是用户并发量并不大,不像登录接口那样。 217 | 218 | 所以采用的方案是:`分表`。 219 | 220 | 当时使用一个积分数据库就够了,但是分了128张表。然后根据用户id,进行hash除以128取模。 221 | 222 | ![](https://pic.imgdb.cn/item/617a7c732ab3f51d91fac067.jpg) 223 | > 需要特别注意的是,分表的数量最好是2的幂次方,方便以后扩容。 224 | 225 | ### 3.3 分库分表 226 | 后来我去了一家从事餐饮软件开发的公司。这个公司有个特点是在每天的中午和晚上的就餐高峰期,用户的并发量很大。 227 | 228 | 用户吃饭前需要通过我们系统点餐,然后下单,然后结账。当时点餐和下单的并发量挺大的。 229 | 230 | 餐厅可能会有很多人,每个人都可能下多个订单。这样就会导致用户的并发量高,并且数据量也很大。 231 | 232 | 所以,综合考虑了一下,当时我们采用的技术方案是:`分库分表`。 233 | 234 | 经过调研之后,觉得使用了当当网开源的基于jdbc的中间件框架:`sharding-jdbc`。 235 | 236 | 当时分了4个库,每个库有32张表。 237 | 238 | ![](https://pic.imgdb.cn/item/617a7c822ab3f51d91faca75.jpg) 239 | 240 | ## 4 总结 241 | 上面主要从:垂直和水平,两个方向介绍了我们的系统为什么要分库分表。 242 | 243 | 说实话垂直方向(即业务方向)更简单。 244 | 245 | 在水平方向(即数据方向)上,`分库`和`分表`的作用,其实是有区别的,不能混为一谈。 246 | 247 | - `分库`:是为了解决数据库连接资源不足问题,和磁盘IO的性能瓶颈问题。 248 | - `分表`:是为了解决单表数据量太大,sql语句查询数据时,即使走了索引也非常耗时问题。此外还可以解决消耗cpu资源问题。 249 | - `分库分表`:可以解决 数据库连接资源不足、磁盘IO的性能瓶颈、检索数据耗时 和 消耗cpu资源等问题。 250 | 251 | 如果在有些业务场景中,用户并发量很大,但是需要保存的数据量很少,这时可以只分库,不分表。 252 | 253 | 如果在有些业务场景中,用户并发量不大,但是需要保存的数量很多,这时可以只分表,不分库。 254 | 255 | 如果在有些业务场景中,用户并发量大,并且需要保存的数量也很多时,可以分库分表。 256 | 257 | 好了,今天的内容就先到这里。 258 | 259 | 是不是有点意犹未尽? 260 | 261 | 没关系,其实分库分表相关内容挺多的,本文作为分库分表系列的第一弹,作为一个开胃小菜吧,分享给大家。 262 | 263 | 在文章末尾顺便提几个问题: 264 | 1. 分库分表的具体实现方案有哪些? 265 | 2. 分库分表后如何平滑扩容? 266 | 3. 分库分表后带来了哪些问题? 267 | 4. 如何在项目中实现分库分表功能? 268 | 269 | 欢迎关注,敬请期待我的下一篇文章。 270 | 271 | -------------------------------------------------------------------------------- /docs/线上问题/一个地区问题,竟然让我们大战了三百回合.md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 我最近参与了公司的一个新项目,需要通过openapi接口把`接入方`的数据,比如:企业、订单、合同、物流等,同步到我们平台,然后我们平台给他们提供金融能力。 3 | 4 | 由于`我方`跟`对接方`不在同一个城市,为了提高工作效率,双方进行了多次在线视频沟通。刚开始比较顺利,没想到在沟通企业信息上传接口时,接口文档中有个非常不起眼的`企业注册地id`字段,让我们一下子进入了僵局。 5 | 6 | 到底是怎么回事呢? 7 | 8 | 9 | ## 1.地区问题 10 | 在我们平台的`企业表`中有一个`企业注册地id`字段,是必填的,用户在注册企业的页面需要选择一个地区,作为该企业的注册地,实际上数据库保存的是地区的id。 11 | 12 | 如果该企业注册成功了,会在企业详情页面上展示该地区名称。当然我们系统的后台逻辑是先通过`地区id`到`地区表`反查出地区名称,然后在用户界面中展示出来。 13 | 14 | 为了跟`企业表`保持一致,我方在定义接口文档时,企业注册地id字段也做成必填了。 15 | 16 | 当时的情况是这样的:我方地区表中有id、地区名称、国标码、等级等字段,但这里的id,是我方数据库的主键,对接方系统中肯定是没有的。对接方系统中也有一套地区表,不过id是他们的数据库id,他们的表中也有地区名称、国标码、等级等字段。 17 | 18 | ![](https://pic.imgdb.cn/item/6113e73a5132923bf859dc9f.jpg) 19 | 20 | 所以他们系统内部需要经过一番转换,才能把我们所需的地区id传给我们。 21 | 22 | 23 | ### 1.1 持久化本地地区表 24 | 其实这个项目我是中途才加入的,之前在处理别的事情,我加入的时候接口文档已经定义好了。 25 | 26 | 我方跟对接方进行第二次在线沟通的时候,双方一起过接口文档的细节,包括:接口的作用、每个参数的含义,以及他们是否有值传过来等等。 27 | 28 | 其中过到企业信息上传接口时,接口文档中有个`企业注册地id`字段,对方没法传值过来。为了解决这个问题,我方第一版的方案是: 29 | 30 | ![](https://pic.imgdb.cn/item/6113e7505132923bf85a15fd.jpg) 31 | 32 | 1. 对接方调用我方地区查询接口,通过多次分页查询,最终能获取我方所有地区数据,落库到他们本地的地区表。 33 | 2. 他们在调用我方企业信息上传接口之前,先查询本地的地区表,转换成我方所需要的地区id。 34 | 35 | 在讨论的过程中,对接方觉得他们也是平台,不应该做这些额外的事情。所以在那次会议中,双方针对这个问题,谁也没有说服谁,最终也没能达成共识。 36 | 37 | 后来,我思考了一下,确实这个方案太过理想化了,没有真正站到用户的角度思考问题,忽略了很多细节。可能跟文档设计者不对地区表不太熟悉有关系。 38 | 39 | ### 1.2 按名称调用地区查询接口 40 | 那次会议当中,我们这边的几位同事,短暂的讨论了一下。既然对接方不愿意接受在他们本地持久化地区表,我们就退而求其次,不要求他们持久化了。这时我们这边有个同事提出,改成按名称调用地区查询接口,反查出地区id,具体方案如下: 41 | 42 | ![](https://pic.imgdb.cn/item/6113e7675132923bf85a5717.jpg) 43 | 44 | 这个方案表面上看起来没有问题,但我之前负责过区域相关功能,我知道,就怕出现如下情况: 45 | 46 | 1. 如果对接方传的地区名称不完整,比如:本来是`成都市`,实际上传的`成都`。这样,我们地区查询接口,需要做模糊匹配,如果并发调用接口可能影响接口性能。 47 | 2. 如果输入关键字`北京市`,在我们这边的地区表中,可以找到两条数据,一条是跟`省级别`一样的,另一条是跟`市级别`一样的。到底对应哪条数据呢? 48 | 49 | 所以我当时把这两个问题抛出来了,不建议使用地区名称查询。 50 | 51 | ### 1.3 按国标码调用地区查询接口 52 | 那个同事听完之后,也觉得用地区名称查询有点不靠谱。他马上修改方案,改成使用地区的国标码查询地区id,具体方案如下: 53 | 54 | ![](https://pic.imgdb.cn/item/6113e7ab5132923bf85b0874.jpg) 55 | 56 | 由于当时讨论时间非常短,我们没来得及考虑太多,暂且打算用这套方案。 57 | 58 | ### 1.4 企业上传接口入参传国标码 59 | 过了一会儿,双方继续过接口文档,重新讨论企业信息上传接口中`企业注册地id`字段传值问题。 60 | 61 | 他们在调企业信息上传接口之前,先调一下我们地区查询接口,查出地区id,入参是国标码。然后再将这个地区id,在企业信息上传接口中传过来。 62 | 63 | 对接方仔细听了我们的方案,犹豫了一下,他们觉得没有必要再调一次地区查询接口,双方都使用国标码不就行了? 64 | 65 | 他们的想法是:在企业信息上传接口中,入参由`企业注册地id`改成`企业注册地国标码`,由于国标码是国家统一的唯一编码,双方肯定是一样,能保证数据的一致性。 66 | 67 | 68 | ![](https://pic.imgdb.cn/item/6113e7bd5132923bf85b38aa.jpg) 69 | 70 | 71 | 72 | ## 2.想起了一个问题 73 | 说实话,如果你没接触过地区功能的话,大部分人可能会同意这套方案的。 74 | 75 | 但比较巧合的是我之前正好接触过类似的功能,当时我突然想起了一个问题:**双方数据的一致性如何保证?** 76 | 77 | 我们都知道,由于国家的发展,有些城市可能会改名,比如:`襄樊`改成了`襄阳`,另外有时候多个地级市合并成一个市,这样国标码会变化,所以国家统计网每年都会调整地区名称和国标码。 78 | 79 | 我方的地区表是两年之前创建的,数据初始化好之后没有就更新过。 80 | 81 | 而对接方不是跟我们在同一时刻初始化的数据,而且他们会定期更新地区数据,这样就导致了两边的数据不一致。如果对接方的业务表单中使用了新加的城市名和国标码,而这些信息在我方的地区表中没有,就无法查询出我方所需的地区id。 82 | 83 | 这种情况该怎么办? 84 | 85 | ### 2.1 双方同一时刻更新地区表 86 | 显然上面的问题是一个非常棘手的问题,这时候有些小伙伴可能会说:`双方使用job同一时刻更新地区表`,不就能解决问题了? 87 | 88 | ![](https://pic.imgdb.cn/item/6113e7d85132923bf85b7be4.jpg) 89 | 我不太赞成这种方案,主要原因如下: 90 | 91 | 1. 我方仅跟这个对接方有个同步执行的job,没问题。但如果还有其他的对接方,也需要调用企业信息上传接口,是不是也要整一个job,而且还要求大家都同一时刻执行,耦合性太大了。 92 | 2. 如果我方和对接方同时执行job,但万一有任意一方执行失败了,也会导致数据不一致的情况。如果恰好这时候对接方在调用企业信息上传接口,会不会出问题? 93 | 94 | ### 2.2 以一方的地区数据为准? 95 | 上面的双方同一时刻更新地区表的方案确实有点不靠谱,但有些读者可能会问,以一方的地区数据为准,另一方把数据同步过来不就行了。具体方案如下: 96 | 97 | ![](https://pic.imgdb.cn/item/6113e7f05132923bf85bbd9e.jpg) 98 | 这个方案其实跟之前我方给出的第一个方案很相似,已经被对接方拒绝了。站在他们的角度来说,确实没有必要因为上传企业信息,而保存我们的地区数据。 99 | 100 | 101 | 说实话,即使他们同意了,这种跨公司跨系统的数据一致性问题,也不好保证,因为如果对接方调用我们的地区接口失败了,此时,正好在上传企业信息,是不是也有问题? 102 | 103 | 104 | ## 3.其他解决方案 105 | 106 | 其实,我们当时为了解决问题,还穿插着讨论过这些方案。 107 | ## 3.1 上传的数据存快照 108 | 我当时提出既然是保存对接方的数据,为啥不能存快照呢?我们可以把数据写到mongodb,数据格式用json,简单又高效。我的方案是: 109 | 110 | ![](https://pic.imgdb.cn/item/6113e80a5132923bf85bfd03.jpg) 111 | 我们自己的业务数据存到mysql的业务表,而对接方的数据存在mongodb,互不干扰。 112 | 113 | 看起来,没有问题。 114 | 115 | 但是,当时产品说:银行那边规定,审查数据时只看我们mysql的业务表,其他的数据源不看。 116 | 117 | 好吧,不得不承认银行惹不起。 118 | 119 | ## 3.2 人工更新数据 120 | 另外一个同事的想法是,先让他们调用企业信息上传接口,如果发现有地区问题,我们手动帮他们调整地区表的数据。 121 | 122 | 具体方案如下: 123 | 124 | 125 | ![](https://pic.imgdb.cn/item/6113e81d5132923bf85c2e45.jpg) 126 | 127 | 如果调用企业信息上传接口时,出现地区不存在的情况,则发报警邮件给指定人员。然后,指定人员手动新增或修改相关的地区数据。 128 | 129 | 这套方案看起来也可以,不过有个比较坑爹的地方就是,就怕在下班或者周末的时候出问题,反正我是不愿意去做这个事情的,你愿意吗? 130 | 131 | 132 | ## 3.3 提供更新接口 133 | 除此之外,我们还相关这套方案:对接方在调我们企业信息上传接口之前,先调我们地区查询接口查一下数据是否存在,如果不存在,则保存地区接口(保存包括:新增和修改),如果存在,则正常上传数据。 134 | 135 | 具体方案如下: 136 | 137 | ![](https://pic.imgdb.cn/item/6113e82e5132923bf85c59d0.jpg) 138 | 139 | 这个方案还可以简化一下: 140 | 141 | ![](https://pic.imgdb.cn/item/6113e8425132923bf85c7c32.jpg) 142 | 将查询并保存地区的逻辑可以放到企业信息上传接口中,这样对接方肯定非常高兴,对他们来说是透明的,地区问题不存在了。 143 | 144 | 但产品觉得地区是我们的基础数据,处于安全考虑,不能提供入口给他们修改,不然以后可能会乱套的。 145 | 146 | 这样不行,那也不行。我们一下子进入了困境,但为了不影响整体进度,只能先记录一下问题,然后跳过这个问题,继续讨论其他字段了。 147 | 148 | ## 4.如何解决这个问题? 149 | 我当天晚上思考了良久,第二天早上,发现跟我们老大的想法不谋而合。得出的结论是,既然存在差异化,没办法避免,我们就要从系统设计上接受差异化。在企业信息上传接口中增加两个字段:`企业注册地国标码` 和 `地区名称`,对接方改成传入这两个字段,具体方案如下: 150 | 151 | ![](https://pic.imgdb.cn/item/6113e8575132923bf85cb09d.jpg) 152 | 1. 在我方的企业表中增加地区名称字段,是非必填的,同时把之前的地区id字段也改成非必填。 153 | 2. 对接方在调用我方企业信息上传接口时,同时传入地区国标码和地区名称。 154 | 3. 我方企业信息上传接口中判断,如果通过国标码能够找到地区id,则将地区id写入db,如果找不到,则将地区名称写入db。 155 | 156 | 我们评估了一下影响范围,在企业表中的地区字段,只做展示用,没有修改入口,所以上面的这套方案是可行的。 157 | 158 | 后来,再次跟对接方在线沟通时,把我们的这套方案告诉他们了,他们非常赞同。 159 | 160 | 161 | ## 5 总结 162 | 虽说这个地区问题,在众多技术问题中不值得一提。但是我仔细思考了一下,还是有一些宝贵的经验值得总结一下的,给有需要的小伙伴一个参考。 163 | 164 | ### 5.1 要从用户的角度设计接口 165 | 在设计接口文档时,要真正做到从用户的角度出发。 166 | 167 | 尤其是像这种openapi接口,定义的参数应该尽量选择通用的,大家都认可的参数,避免出现我方定制化的参数,比如:地区id。 168 | 169 | 尽量减少用户的复杂度,让他们调用接口时更简单一些。 170 | 171 | ### 5.2 技术方案要有包容性 172 | 技术方案要有包容性,不是非黑即白,需要有柔性的思想。在分布式环境中,如果去一味地追求数据的强一致性,不会有太好的结果。就像高并发下的商品秒杀系统,如果非要用同步方案去实现,系统最终可能会挂掉,更好的方案其实是改成异步队列处理。 173 | 174 | 我方和对接方都有地区表,数据很难保证完全一致,我们不要为了一致性而一致性,这样会适得其反。为了工作能够顺利进行下去,必然有一方要妥协,我的建议是openapi接口方做妥协,这种技术方案才够通用。 175 | 176 | ### 5.3 没有最好的方案,只有最适合的 177 | 我方最后的那个方案,其实并没有完全解决地区id找不到的问题,但是从业务的角度来看,即使没有地区id,有地区名称也是一样的。很显然,最后的方案是非常适合我们实际业务场景的。 178 | 179 | 所以没有最好的方案,只有最适合业务场景的。 180 | 181 | ### 5.4 进行有效的沟通 182 | 在跟对接方在线沟通时,不要因为某个问题卡壳了,而一直僵持下去。如果当时没有好的技术方案,可以先选择暂时跳过这个问题,而沟通其他的内容。后面我们再私下单独花时间,仔细思考当时的问题,从而能够提出更合理的方案。 183 | 184 | ### 5.5 技术是为业务服务的 185 | 本文的这个地区问题,咋一看比较简单。如果一细想,会发现里面有点东西。再加上各种外部因素的限制,你会发现分布式的环境中保证地区数据一致性,并不是那么好实现。 186 | 187 | 整个过程当中,我们提出了很多种技术方案,有些方案看似可以完美解决问题,但都被我们实际的业务场景给否定了。 188 | 189 | 技术是为业务服务的,技术虽说非常重要,但是如果离开了业务都是纸上谈兵。 -------------------------------------------------------------------------------- /docs/线上问题/线上一次诡异的NPE问题,竟然反转了4次.md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 公司为了保证系统的稳定性,加了很多监控,比如:接口响应时间、cpu使用率、内存使用率、错误日志等等。如果系统出现异常情况,会邮件通知相关人员,以便于大家能在第一时间解决隐藏的系统问题。此外,我们这边有个不成文的规定,就是线上问题最好能够当日解决,除非遇到那种非常棘手的问题。 3 | 4 | 5 | ## 1.起因 6 | 有个周一的早上,我去公司上班,查看邮件,收到我们老大转发的一封邮件,让我追查线上的一个NPE(NullPointException)问题。 7 | 8 | 邮件是通过`sentry`发出来的,我们通过点击邮件中的相关链接,可以直接跳转到sentry的详情页面。在这个页面中,展示了很多关键信息,比如:操作时间、请求的接口、出错的代码位置、报错信息、请求经过了哪些链路等等。真是居家旅行,查bug的良药,有了这些,小case一眼就能查到原因。 9 | 10 | 我当时没费吹灰之力,就访问到了NPE的sentry报错页面(其实只用鼠标双击一下就搞定)。果然上面有很多关键信息,我一眼就看到了NPE的具体代码位置: 11 | ```java 12 | notify.setName(CurrentUser.getCurrent().getUserName()); 13 | ``` 14 | 剧情发展得如此顺利,我都有点不好意思了。 15 | 16 | 根据类名和代码行号,我在idea中很快找到那行代码,不像是我写的,这下可以放心不用背锅了。于是接下来看了看那行的代码修改记录,最后修改人是XXX。 17 | 18 | 什么?是他? 19 | 20 | 他在一个月前已经离职了,看来这个无头公案已经无从问起,只能自己查原因。 21 | 22 | 我当时内心的OS是:`代码没做兼容处理`。 23 | 24 | **为什么这么说呢?** 25 | 26 | 这行代码其实很简单,就是从当前`用户上下文`中获取用户名称,然后设置到notify实体的inUserName字段上,最终notify的数据会保存到数据库。 27 | 28 | 该字段表示那条`推送通知`的添加人,正常情况下没啥卵用,主要是为了出现线上问题扯皮时,有个地方可以溯源。如果出现冤案,可以还你清白。 29 | 30 | > 顺便提一嘴,这里说的`推送通知`跟mq中的`消息`是两回事,前者指的是`websocket`长连接推送的实时通知,我们这边很多业务场景,在页面功能操作完之后,会实时推送通知给指定用户,以便用户能够及时处理相关单据,比如:您有一个审批单需要审批,请及时处理等。 31 | 32 | `CurrentUser`内部包含了一个`ThreadLocal`对象,它负责保存当前线程的用户上下文信息。当然为了保证在线程池中,也能从用户上下文中获取到正确的用户信息,这里用了阿里的`TransmittableThreadLocal`。伪代码如下: 33 | ```java 34 | @Data 35 | public class CurrentUser { 36 | private static final TransmittableThreadLocal THREA_LOCAL = new TransmittableThreadLocal<>(); 37 | 38 | private String id; 39 | private String userName; 40 | private String password; 41 | private String phone; 42 | ... 43 | 44 | public statis void set(CurrentUser user) { 45 | THREA_LOCAL.set(user); 46 | } 47 | 48 | public static void getCurrent() { 49 | return THREA_LOCAL.get(); 50 | } 51 | } 52 | ``` 53 | > 这里为什么用了阿里的`TransmittableThreadLocal`,而不是普通的`ThreadLocal`呢?在线程池中,由于线程会被多次复用,导致从普通的`ThreadLocal`中无法获取正确的用户信息。父线程中的参数,没法传递给子线程,而`TransmittableThreadLocal`很好解决了这个问题。 54 | 55 | 然后在项目中定义一个全局的`spring mvc拦截器`,专门设置用户上下文到ThreadLocal中。伪代码如下: 56 | ```java 57 | public class UserInterceptor extends HandlerInterceptorAdapter { 58 | 59 | @Override 60 | public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { 61 | CurrentUser user = getUser(request); 62 | if(Objects.nonNull(user)) { 63 | CurrentUser.set(user); 64 | } 65 | } 66 | } 67 | ``` 68 | 69 | 用户在请求我们接口时,会先触发该拦截器,它会根据用户cookie中的token,调用调用接口获取redis中的用户信息。如果能获取到,说明用户已经登录,则把用户信息设置到CurrentUser类的ThreadLocal中。 70 | 71 | 接下来,在api服务的下层,即business层的方法中,就能轻松通过`CurrentUser.getCurrent();`方法获取到想要的用户上下文信息了。 72 | ![](https://pic.imgdb.cn/item/610e92645132923bf8e9a302.jpg) 73 | 74 | 这套用户体系的想法是很good的,但深入使用后,发现了一个小插曲: 75 | 76 | api服务和mq消费者服务都引用了business层,business层中的方法两个服务都能直接调用。 77 | 78 | 我们都知道在api服务中用户是需要登录的,而mq消费者服务则不需要登录。 79 | ![](https://pic.imgdb.cn/item/610e92745132923bf8e9bfa3.jpg) 80 | 81 | 如果business中的某个方法刚开始是给api开发的,在方法深处使用了`CurrentUser.getCurrent();`获取用户上下文。但后来,某位新来的帅哥在mq消费者中也调用了那个方法,并未发觉这个小机关,就会中招,出现找不到用户上下文的问题。 82 | ![](https://pic.imgdb.cn/item/610e92935132923bf8e9f4d6.jpg) 83 | 84 | 所以我当时的第一个想法是:`代码没做兼容处理`,因为之前这类问题偶尔会发生一次。 85 | 86 | 想要解决这个问题,其实也很简单。只需先判断一下能否从CurrentUser中获取用户信息,如果不能,则取配置的系统用户信息。伪代码如下: 87 | ```java 88 | @Autowired 89 | private BusinessConfig businessConfig; 90 | 91 | CurrentUser user = CurrentUser.getCurrent(); 92 | if(Objects.nonNull(user)) { 93 | entity.setUserId(user.getUserId()); 94 | entity.setUserName(user.getUserName()); 95 | } else { 96 | entity.setUserId(businessConfig.getDefaultUserId()); 97 | entity.setUserName(businessConfig.getDefaultUserName()); 98 | } 99 | ``` 100 | 这种简单无公害的代码,如果只是在一两个地方加还OK。 101 | 102 | 但如果有多个地方都在获取用户信息,难道在每个地方都需要把相同的判断逻辑写一遍?对于有追求的程序员来说,这种简单的重复是写代码的大忌,如何更优雅的解决问题呢? 103 | 104 | 答案将会在文章后面揭晓。 105 | 106 | 这个NPE问题表面上,已经有答案了。根据以往的经验,由于在代码中没有做兼容处理,在mq消费者服务中获取到的用户信息为空,对一个空对象,调用它的方法,就会出现NPE。 107 | 108 | 109 | ## 2.第一次反转 110 | 但这个答案显得有点草率,会不会还有什么机关? 111 | 112 | 于是我在项目工程中全局搜索`CurrentUser.set`关键字,竟然真的找到了一个机关。 113 | 114 | **剧情出现第一次反转。** 115 | 116 | 有个地方写了一个`rocketmq`的`AOP拦截器`,伪代码如下: 117 | ```java 118 | @Aspect 119 | @Component 120 | public class RocketMqAspect { 121 | 122 | @Pointcut("execution(* onMessage(..)&&@within(org.apache.rocketmq.spring.annotation.RocketMQMessageListener))") 123 | public void pointcut() { 124 | 125 | } 126 | ... 127 | 128 | @Around(value="pointcut") 129 | public void around(ProceedingJoinPoint point) throws Throwable { 130 | if(point.getArgs().length == 1 && point.getArgs()[0] instanceof MessageExt) { 131 | Message message = (Message)point.getArgs()[0]; 132 | String userId = message.getUserProperty("userId"); 133 | String userName = message.getUserProperty("userName"); 134 | if(StringUtils.notEmpty(userId) && StringUtils.notEmpty(userName)) { 135 | CurrentUser user = new CurrentUser(); 136 | user.setUserId(userId); 137 | user.setUserName(userName); 138 | CurrentUser.set(user); 139 | } 140 | } 141 | 142 | ... 143 | } 144 | } 145 | ``` 146 | 它会拦截所有mq消费者中的`onMessage`方法,在该方法执行之前,从`userProperty`中获取用户信息,并且创建用户对象,设置到用户上下文中。 147 | 148 | > 温馨提醒一下,免得有些朋友依葫芦画瓢踩坑。上面的伪代码只给出了设置用户上下文的关键代码,用完后,删除用户上下文的代码没有给出,感兴趣的朋友可以找我私聊。 149 | 150 | 既然有获取用户信息的地方,我猜测必定有设置的地方。这时候突然发现自己有点当侦探的潜力,因为后面还真找到了。 151 | 152 | 意不意外,惊不惊喜? 153 | 154 | 另外一个同事自己自定义了一个`RocketMQTemplate`。伪代码如下: 155 | ```java 156 | public class MyRocketMQTemplate extends RocketMQTemplate { 157 | 158 | @Override 159 | public void asyncSend(String destnation, Meassage message, SendCallback sendCallback, long timeout, int delayLevel) { 160 | 161 | MessageBuilder builder = withPayload(message.getPayLoad()); 162 | CurrentUser user = CurrentUser.getCurrent(); 163 | builder.setHeader("userId", user.getUserId()); 164 | builder.setHeader("userName", user.getUserName()); 165 | 166 | super.asyncSend(destnation,message,sendCallback,timeout,delayLevel); 167 | } 168 | } 169 | ``` 170 | 这段代码的主要作用是在mq生产者在发送异步消息之前,先将当前用户上下文信息设置到mq消息的header中,这样在mq消费者中就能通过`userProperty`获取到,它的本质也是从header中获取到的。 171 | 172 | ![](https://pic.imgdb.cn/item/610e92ab5132923bf8ea1b7a.jpg) 173 | 174 | 这个设计比较巧妙,完美的解决了mq的消费者中通过`CurrentUser.getCurrent();`无法获取用户信息的问题。 175 | 176 | 此时线索一下子断了,没有任何进展。 177 | 178 | 我再去查了一下服务器的日志。确认了那条有问题的mq消息,它的header信息中确实没有userId和userName字段。 179 | 180 | 莫非是mq生产者没有往header中塞用户信息?这是需要重点怀疑的地方。 181 | 182 | 因为mq生产者是另外一个团队写的代码,在EOA(签报系统)回调他们系统时,会给我们发mq消息,通知我们签报状态。 183 | 184 | 而EOA是第三方的系统,用户体系没有跟我们打通。所以在另外一个团队的回调接口中,没法获取当前登录的用户信息,AOP的拦截器就没法自动往header中塞用户信息,这样在mq的消费者中自然就获取不到了。 185 | 186 | ![](https://pic.imgdb.cn/item/610e92c25132923bf8ea4142.jpg) 187 | 188 | 这样想来还真的是顺理成章。 189 | 190 | ## 3.第二次反转 191 | 192 | 但真的是这样的吗? 193 | 194 | 我们抱着很大的希望,给他们发了一封邮件,让他们帮忙查一下问题。 195 | 196 | 很快,他们回邮件了。 197 | 198 | 但他们说:已经本地测试过,功能正常。 199 | 200 | **就这样剧情第二次反转了。** 201 | 202 | 我此时有点好奇,他们是怎么往header中塞用户信息的。带着“学习的心态”,于是找他们一起查看了相关代码。 203 | 204 | 他们在发送mq消息之前,会调用一个UserUtil工具`注入用户`。该工具类的伪代码如下: 205 | ```java 206 | @Component 207 | public class UserUtil{ 208 | @Value("${susan.userId}") 209 | private String userId; 210 | 211 | @Value("${susan.userName}") 212 | private String userName; 213 | 214 | public void injectUser() { 215 | CurrentUser user = new CurrentUser(); 216 | user.setUserId(userId); 217 | user.setUserName(userName); 218 | CurrentUser.set(user); 219 | } 220 | } 221 | ``` 222 | 好吧,不得不承认,这样做确实可以解决`header`传入用户信息的问题,比之前需要手动判断用户信息是否为空要优雅得多,因为注入之后的用户信息肯定是不为空的。 223 | 224 | ![](https://pic.imgdb.cn/item/610e92dc5132923bf8ea6885.jpg) 225 | 226 | 折腾了半天,NPE问题还是没有着落。 227 | 228 | 我回头再仔细看了那个自定义的`RocketMQTemplate`类,发现里面重写的方法:`asyncSend`,它包含了5个参数。而他们在给我们推消息时,调用的`asyncSend`却只传了3个参数。 229 | 230 | 一下子,问题又有了新的进展,有没有可能是他们调错接口了? 231 | 232 | 原本应该调用5个参数的方法,但实际上他们调用了3个参数的方法。 233 | 234 | 这样就能解释通了。 235 | 236 | 237 | ## 4.第三次反转 238 | 终于有点思路,我带着一份喜悦,准备开始证明刚刚的猜测。 239 | 240 | 但事实证明,我真的高兴的太早了,马上被啪啪打脸。 241 | 242 | **这次是反转最快的一次。** 243 | 244 | 怎么回事呢? 245 | 246 | 原本我以为是另外一个团队的人,在发mq消息时调错方法了,应该调用5个参数的`asyncSend`方法,但他们的代码中实际上调用的是3个参数的同名方法。 247 | 248 | 为了防止出现冤枉同事的事情发生。我本着尽职尽责的态度,仔细看了看`RocketMQTemplate`类的所有方法,这个类是`rocketmq`框架提供的。 249 | 250 | 意外得发现了一些藕断丝连的关系,伪代码如下: 251 | ```java 252 | public void asyncSend(String destination, Message message, SendCallback sendCallback, long timeout, int delayLevel) { 253 | if (Objects.isNull(message) || Objects.isNull(message.getPayload())) { 254 | log.error("asyncSend failed. destination:{}, message is null ", destination); 255 | throw new IllegalArgumentException("`message` and `message.payload` cannot be null"); 256 | } 257 | 258 | try { 259 | org.apache.rocketmq.common.message.Message rocketMsg = RocketMQUtil.convertToRocketMessage(objectMapper, 260 | charset, destination, message); 261 | if (delayLevel > 0) { 262 | rocketMsg.setDelayTimeLevel(delayLevel); 263 | } 264 | producer.send(rocketMsg, sendCallback, timeout); 265 | } catch (Exception e) { 266 | log.info("asyncSend failed. destination:{}, message:{} ", destination, message); 267 | throw new MessagingException(e.getMessage(), e); 268 | } 269 | } 270 | 271 | 272 | public void asyncSend(String destination, Message message, SendCallback sendCallback, long timeout) { 273 | asyncSend(destination,message,sendCallback,timeout,0); 274 | } 275 | 276 | public void asyncSend(String destination, Message message, SendCallback sendCallback) { 277 | asyncSend(destination, message, sendCallback, producer.getSendMsgTimeout()); 278 | } 279 | 280 | public void asyncSend(String destination, Object payload, SendCallback sendCallback, long timeout) { 281 | Message message = this.doConvert(payload, null, null); 282 | asyncSend(destination, message, sendCallback, timeout); 283 | } 284 | 285 | public void asyncSend(String destination, Object payload, SendCallback sendCallback) { 286 | asyncSend(destination, payload, sendCallback, producer.getSendMsgTimeout()); 287 | } 288 | ``` 289 | 这个背后隐藏着一个天大的秘密,这些同名的方法殊途同归,竟然最终都会调用5个参数的asyncSend方法。 290 | 291 | 这样看来,如果在子类中重写了5个的asyncSend方法,相当于重写了所有的asyncSend方法。 292 | ![](https://pic.imgdb.cn/item/610e92f85132923bf8ea951b.jpg) 293 | 再次证明他们没错。 294 | > 温馨提醒一下,有些类的重载方法会相互调用,如果在子类中重新了最底层的那个重载方法,等于把所有的重载方法都重写了。 295 | 296 | 头疼,又要回到原点了。 297 | 298 | 299 | ## 5.第四次反转 300 | 此时,我有点迷茫了。 301 | 302 | 不过,有个好习惯是:遇到线上问题不知道怎办时,会多查一下日志。 303 | 304 | 本来不报啥希望的,但是没想到通过再查日志。 305 | 306 | **出现了第四次反转。** 307 | 308 | 这次抱着试一下的心态,根据messageID去查了mq生产者的日志,查到了一条消息的发送日志。 309 | 310 | 这次眼镜擦得雪亮,发现了一个小细节:`时间不对`。 311 | 312 | 这条日志显示的消息发送日期是2021-05-21,而实际上mq消费者处理的日期是2021-05-28。 313 | 314 | **这条消息一个星期才消费完?** 315 | 316 | 显然不是。 317 | 318 | 我有点肃然起敬了。再回去用那个messageID查了mq消费者的日志,发现里面其实消费了6次消息。前5次竟然是同一天,都在2021-05-21,而且都处理失败了。另一次是2021-05-28,处理成功了。 319 | 320 | **为什么同一条消息,会在同一天消费5次?** 321 | 322 | 如果你对rocketmq比较熟悉的话,肯定知道它支持重试机制。 323 | 324 | 如果mq消费者消息处理失败了,可以在业务代码中抛一个异常。然后框架层面捕获该异常返回ConsumeConcurrentlyStatus.RECONSUME_LATER,rocketmq会自动将该消息放到`重试队列`。 325 | ![](https://pic.imgdb.cn/item/610e93175132923bf8eac6da.jpg) 326 | 327 | 流程图如下: 328 | ![](https://pic.imgdb.cn/item/610e932f5132923bf8eae666.jpg) 329 | 330 | 这样mq消费者下次可以重新消费那条消息,直到达到一定次数(这里我们配置的5次),rocketmq会将那条消息发送到`死信队列`。![](https://pic.imgdb.cn/item/610e93425132923bf8eb01e1.jpg) 331 | 332 | 流程图如下: 333 | ![](https://pic.imgdb.cn/item/610e935d5132923bf8eb2ac6.jpg) 334 | 后面就不再消费了。 335 | 336 | 337 | **最后为什么会多消费一次?** 338 | 339 | 最后的那条消息不可能是其他的mq生产者发出的,因为messageID是唯一的,其他的生产者不可能产生一样的messageID。 340 | 341 | 那么接下来,只有一种可能,那就是`人为发了条消息`。 342 | 343 | > 查线上日志时,时间、messageID、traceID、记录条数 这几个维度至关重要。 344 | 345 | ## 6.真相 346 | 后来发现还真的是人为发的消息。 347 | 348 | 一周前,线上有个用户,由于EOA页面回调接口失败(重试也失败),导致审核状态变更失败。该审核单在EOA系统中审批通过了,但mq消费者去处理该审核单的时候,发现状态还是待审核,就直接返回了,没有走完后续的流程,从而导致该审核单数据数据异常。 349 | 350 | 为了修复这个问题,我们当时先修改了线上该审核单的状态。接下来,手动的在rocketmq后台发了条消息。由于当时在rocketmq后台看不到header信息,所以发消息时没有管header,直接往指定的topic中发消息了。 351 | 352 | > 千万注意,大家在手动发mq消息时,一定要注意header中是否也需要设置相关参数,尤其是rocketmq,不然就可能会出问题。 353 | 354 | mq消费者消费完那条消息之后,该审核单正常走完了流程,当时找测试一起测试过,数据库的状态都是正常的。 355 | 356 | 大家都以为没有问题了,但是所有人都忽略了一个小细节:就是在正常业务逻辑处理完之后,会发websocket通知给指定用户。 357 | 但这个功能是已经离职的那个同事加的新逻辑,其他人都不知道。站在手动发消息的那个人的角度来说,他没错,因为他根本不知道新功能的存在。 358 | 359 | 由于这行代码是最后一行代码,并且跟之前的代码不在同一个事物当中,即使出了问题也不会影响正常的业务逻辑。 360 | 361 | 所以这个NPE问题影响范围很小,只是那个商户没有收到某个通知而已。 362 | 363 | > 有个好习惯,就是把跟核心业务逻辑无关的代码,放在事务之外,防止出现问题时,影响主流程。 364 | 365 | 说实话,有时候遇到线上问题,对于我们来说未必是一件坏事。通过这次线上问题定位,让我熟悉了公司更多新功能,学习了其他同事的一些好的思想,总结了一些经验和教训,是一次难得的提升自己的好机会。 366 | 367 | 368 | -------------------------------------------------------------------------------- /docs/高并发/我用kafka两年踩过的一些非比寻常的坑.md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 我的上家公司是做餐饮系统的,每天中午和晚上用餐高峰期,系统的并发量不容小觑。为了保险起见,公司规定各部门都要在吃饭的时间轮流值班,防止出现线上问题时能够及时处理。 3 | 4 | 我当时在后厨显示系统团队,该系统属于订单的下游业务。用户点完菜下单后,订单系统会通过发kafka消息给我们系统,系统读取消息后,做业务逻辑处理,持久化订单和菜品数据,然后展示到划菜客户端。这样厨师就知道哪个订单要做哪些菜,有些菜做好了,就可以通过该系统出菜。系统自动通知服务员上菜,如果服务员上完菜,修改菜品上菜状态,用户就知道哪些菜已经上了,哪些还没有上。这个系统可以大大提高后厨到用户的效率。 5 | 6 | ![](https://pic.imgdb.cn/item/610ea6845132923bf807d96f.jpg) 7 | 8 | 事实证明,这一切的关键是消息中间件:kafka,如果它有问题,将会直接影响到后厨显示系统的功能。 9 | 10 | 接下来,我跟大家一起聊聊使用kafka两年时间踩过哪些坑? 11 | 12 | ## 顺序问题 13 | ### 1. 为什么要保证消息的顺序? 14 | 刚开始我们系统的商户很少,为了快速实现功能,我们没想太多。既然是走消息中间件kafka通信,订单系统发消息时将订单详细数据放在消息体,我们后厨显示系统只要订阅topic,就能获取相关消息数据,然后处理自己的业务即可。 15 | 16 | 不过这套方案有个关键因素:要保证消息的顺序。 17 | 18 | 为什么呢? 19 | 20 | 订单有很多状态,比如:下单、支付、完成、撤销等,不可能下单的消息都没读取到,就先读取支付或撤销的消息吧,如果真的这样,数据不是会产生错乱? 21 | 22 | 好吧,看来保证消息顺序是有必要的。 23 | 24 | ### 2.如何保证消息顺序? 25 | 我们都知道kafka的topic是无序的,但是一个topic包含多个partition,每个partition内部是有序的。 26 | ![](https://pic.imgdb.cn/item/610ea6b55132923bf80821b1.jpg) 27 | 28 | 如此一来,思路就变得清晰了:只要保证生产者写消息时,按照一定的规则写到同一个partition,不同的消费者读不同的partition的消息,就能保证生产和消费者消息的顺序。 29 | 30 | 我们刚开始就是这么做的,同一个商户编号的消息写到同一个partition,topic中创建了4个partition,然后部署了4个消费者节点,构成消费者组,一个partition对应一个消费者节点。从理论上说,这套方案是能够保证消息顺序的。![](https://pic.imgdb.cn/item/610ea7015132923bf8088b32.jpg) 31 | 32 | 一切规划得看似“天衣无缝”,我们就这样”顺利“上线了。 33 | 34 | ### 3.出现意外 35 | 该功能上线了一段时间,刚开始还是比较正常的。 36 | 37 | 但是,好景不长,很快就收到用户投诉,说在划菜客户端有些订单和菜品一直看不到,无法划菜。 38 | 39 | 我定位到了原因,公司在那段时间网络经常不稳定,业务接口时不时报超时,业务请求时不时会连不上数据库。 40 | 41 | 这种情况对顺序消息的打击,可以说是毁灭性的。 42 | 43 | 为什么这么说? 44 | 45 | 假设订单系统发了:”下单“、”支付“、”完成“ 三条消息。 46 | ![](https://pic.imgdb.cn/item/610e8fce5132923bf8e59485.jpg) 47 | 48 | 而”下单“消息由于网络原因我们系统处理失败了,而后面的两条消息的数据是无法入库的,因为只有”下单“消息的数据才是完整的数据,其他类型的消息只会更新状态。 49 | 50 | 加上,我们当时没有做失败重试机制,使得这个问题被放大了。问题变成:一旦”下单“消息的数据入库失败,用户就永远看不到这个订单和菜品了。 51 | 52 | 那么这个紧急的问题要如何解决呢? 53 | 54 | ### 4.解决过程 55 | 最开始我们的想法是:在消费者处理消息时,如果处理失败了,立马重试3-5次。但如果有些请求要第6次才能成功怎么办?不可能一直重试呀,这种同步重试机制,会阻塞其他商户订单消息的读取。 56 | 57 | 显然用上面的这种同步重试机制在出现异常的情况,会严重影响消息消费者的消费速度,降低它的吞吐量。 58 | 59 | 如此看来,我们不得不用异步重试机制了。 60 | 61 | 如果用异步重试机制,处理失败的消息就得保存到重试表下来。 62 | 63 | 但有个新问题立马出现:只存一条消息如何保证顺序? 64 | 65 | 存一条消息的确无法保证顺序,假如:”下单“消息失败了,还没来得及异步重试。此时,”支付“消息被消费了,它肯定是不能被正常消费的。 66 | 67 | 此时,”支付“消息该一直等着,每隔一段时间判断一次,它前面的消息都有没有被消费? 68 | 69 | 如果真的这么做,会出现两个问题: 70 | 71 | 1. ”支付“消息前面只有”下单“消息,这种情况比较简单。但如果某种类型的消息,前面有N多种消息,需要判断多少次呀,这种判断跟订单系统的耦合性太强了,相当于要把他们系统的逻辑搬一部分到我们系统。 72 | 2. 影响消费者的消费速度 73 | 74 | 这时有种更简单的方案浮出水面:消费者在处理消息时,先判断该订单号在重试表有没有数据,如果有则直接把当前消息保存到重试表。如果没有,则进行业务处理,如果出现异常,把该消息保存到重试表。 75 | 76 | 后来我们用elastic-job建立了失败重试机制,如果重试了7次后还是失败,则将该消息的状态标记为失败,发邮件通知开发人员。 77 | 78 | 终于由于网络不稳定,导致用户在划菜客户端有些订单和菜品一直看不到的问题被解决了。现在商户顶多偶尔延迟看到菜品,比一直看不菜品好太多。 79 | 80 | ## 消息积压 81 | 82 | 随着销售团队的市场推广,我们系统的商户越来越多。随之而来的是消息的数量越来越大,导致消费者处理不过来,经常出现消息积压的情况。对商户的影响非常直观,划菜客户端上的订单和菜品可能半个小时后才能看到。一两分钟还能忍,半个消息的延迟,对有些暴脾气的商户哪里忍得了,马上投诉过来了。我们那段时间经常接到商户投诉说订单和菜品有延迟。 83 | 84 | 虽说,加服务器节点就能解决问题,但是按照公司为了省钱的惯例,要先做系统优化,所以我们开始了消息积压问题解决之旅。 85 | 86 | ### 1. 消息体过大 87 | 虽说kafka号称支持百万级的TPS,但从producer发送消息到broker需要一次网络IO,broker写数据到磁盘需要一次磁盘IO(写操作),consumer从broker获取消息先经过一次磁盘IO(读操作),再经过一次网络IO。 88 | ![](https://pic.imgdb.cn/item/610ea79d5132923bf8097541.jpg) 89 | 90 | 一次简单的消息从生产到消费过程,需要经过2次网络IO和2次磁盘IO。如果消息体过大,势必会增加IO的耗时,进而影响kafka生产和消费的速度。消费者速度太慢的结果,就会出现消息积压情况。 91 | 92 | 除了上面的问题之外,消息体过大,还会浪费服务器的磁盘空间,稍不注意,可能会出现磁盘空间不足的情况。 93 | 94 | 此时,我们已经到了需要优化消息体过大问题的时候。 95 | 96 | 如何优化呢? 97 | 98 | 我们重新梳理了一下业务,没有必要知道订单的中间状态,只需知道一个最终状态就可以了。 99 | 100 | 如此甚好,我们就可以这样设计了: 101 | 102 | 1. 订单系统发送的消息体只用包含:id和状态等关键信息。 103 | 2. 后厨显示系统消费消息后,通过id调用订单系统的订单详情查询接口获取数据。 104 | 3. 后厨显示系统判断数据库中是否有该订单的数据,如果没有则入库,有则更新。 105 | ![](https://pic.imgdb.cn/item/610ea7ec5132923bf80a3171.jpg) 106 | 107 | 果然这样调整之后,消息积压问题很长一段时间都没再出现。 108 | 109 | ### 2. 路由规则不合理 110 | 还真别高兴的太早,有天中午又有商户投诉说订单和菜品有延迟。我们一查kafka的topic竟然又出现了消息积压。 111 | 112 | 但这次有点诡异,不是所有partition上的消息都有积压,而是只有一个。 113 | ![](https://pic.imgdb.cn/item/610ea80f5132923bf80a938b.jpg) 114 | 115 | 刚开始,我以为是消费那个partition消息的节点出了什么问题导致的。但是经过排查,没有发现任何异常。 116 | 117 | 这就奇怪了,到底哪里有问题呢? 118 | 119 | 后来,我查日志和数据库发现,有几个商户的订单量特别大,刚好这几个商户被分到同一个partition,使得该partition的消息量比其他partition要多很多。 120 | 121 | 这时我们才意识到,发消息时按商户编号路由partition的规则不合理,可能会导致有些partition消息太多,消费者处理不过来,而有些partition却因为消息太少,消费者出现空闲的情况。 122 | 123 | 为了避免出现这种分配不均匀的情况,我们需要对发消息的路由规则做一下调整。 124 | 125 | 我们思考了一下,用订单号做路由相对更均匀,不会出现单个订单发消息次数特别多的情况。除非是遇到某个人一直加菜的情况,但是加菜是需要花钱的,所以其实同一个订单的消息数量并不多。 126 | 127 | 调整后按订单号路由到不同的partition,同一个订单号的消息,每次到发到同一个partition。 128 | 129 | ![](https://pic.imgdb.cn/item/610ea82b5132923bf80ac18c.jpg) 130 | 131 | 调整后,消息积压的问题又有很长一段时间都没有再出现。我们的商户数量在这段时间,增长的非常快,越来越多了。 132 | 133 | ### 3. 批量操作引起的连锁反应 134 | 在高并发的场景中,消息积压问题,可以说如影随形,真的没办法从根本上解决。表面上看,已经解决了,但后面不知道什么时候,就会冒出一次,比如这次: 135 | 136 | 有天下午,产品过来说:有几个商户投诉过来了,他们说菜品有延迟,快查一下原因。 137 | 138 | 这次问题出现得有点奇怪。 139 | 140 | 为什么这么说? 141 | 142 | 首先这个时间点就有点奇怪,平常出问题,不都是中午或者晚上用餐高峰期吗?怎么这次问题出现在下午? 143 | 144 | 根据以往积累的经验,我直接看了kafka的topic的数据,果然上面消息有积压,但这次每个partition都积压了十几万的消息没有消费,比以往加压的消息数量增加了几百倍。这次消息积压得极不寻常。 145 | 146 | 我赶紧查服务监控看看消费者挂了没,还好没挂。又查服务日志没有发现异常。这时我有点迷茫,碰运气问了问订单组下午发生了什么事情没?他们说下午有个促销活动,跑了一个JOB批量更新过有些商户的订单信息。 147 | 148 | 这时,我一下子如梦初醒,是他们在JOB中批量发消息导致的问题。怎么没有通知我们呢?实在太坑了。 149 | 150 | 虽说知道问题的原因了,倒是眼前积压的这十几万的消息该如何处理呢? 151 | 152 | 此时,如果直接调大partition数量是不行的,历史消息已经存储到4个固定的partition,只有新增的消息才会到新的partition。我们重点需要处理的是已有的partition。 153 | 154 | 直接加服务节点也不行,因为kafka允许同组的多个partition被一个consumer消费,但不允许一个partition被同组的多个consumer消费,可能会造成资源浪费。 155 | 156 | 看来只有用多线程处理了。 157 | 158 | 为了紧急解决问题,我改成了用线程池处理消息,核心线程和最大线程数都配置成了50。 159 | 160 | 调整之后,果然,消息积压数量不断减少。 161 | 162 | 但此时有个更严重的问题出现:我收到了报警邮件,有两个订单系统的节点down机了。 163 | 164 | 不久,订单组的同事过来找我说,我们系统调用他们订单查询接口的并发量突增,超过了预计的好几倍,导致有2个服务节点挂了。他们把查询功能单独整成了一个服务,部署了6个节点,挂了2个节点,再不处理,另外4个节点也会挂。订单服务可以说是公司最核心的服务,它挂了公司损失会很大,情况万分紧急。 165 | 166 | 为了解决这个问题,只能先把线程数调小。 167 | 168 | 幸好,线程数是可以通过zookeeper动态调整的,我把核心线程数调成了8个,核心线程数改成了10个。 169 | 170 | 后面,运维把订单服务挂的2个节点重启后恢复正常了,以防万一,再多加了2个节点。为了确保订单服务不会出现问题,就保持目前的消费速度,后厨显示系统的消息积压问题,1小时候后也恢复正常了。![](https://pic.imgdb.cn/item/610ea8565132923bf80b0968.jpg) 171 | 172 | 后来,我们开了一次复盘会,得出的结论是: 173 | 174 | 1. 订单系统的批量操作一定提前通知下游系统团队。 175 | 2. 下游系统团队多线程调用订单查询接口一定要做压测。 176 | 3. 这次给订单查询服务敲响了警钟,它作为公司的核心服务,应4. 对高并发场景做的不够好,需要做优化。 177 | 5. 对消息积压情况加监控。 178 | 179 | 顺便说一下,对于要求严格保证消息顺序的场景,可以将线程池改成多个队列,每个队列用单线程处理。 180 | ### 4. 表过大 181 | 为了防止后面再次出现消息积压问题,消费者后面就一直用多线程处理消息。 182 | 183 | 但有天中午我们还是收到很多报警邮件,提醒我们kafka的topic消息有积压。我们正在查原因,此时产品跑过来说:又有商户投诉说菜品有延迟,赶紧看看。这次她看起来有些不耐烦,确实优化了很多次,还是出现了同样的问题。 184 | 185 | 在外行看来:为什么同一个问题一直解决不了? 186 | 187 | 其实技术心里的苦他们是不知道的。 188 | 189 | 表面上问题的症状是一样的,都是出现了菜品延迟,他们知道的是因为消息积压导致的。但是他们不知道深层次的原因,导致消息积压的原因其实有很多种。这也许是使用消息中间件的通病吧。 190 | 191 | 我沉默不语,只能硬着头皮定位原因了。 192 | 193 | 后来我查日志发现消费者消费一条消息的耗时长达2秒。以前是500毫秒,现在怎么会变成2秒呢? 194 | 195 | 奇怪了,消费者的代码也没有做大的调整,为什么会出现这种情况呢? 196 | 197 | 查了一下线上菜品表,单表数据量竟然到了几千万,其他的划菜表也是一样,现在单表保存的数据太多了。 198 | 199 | 我们组梳理了一下业务,其实菜品在客户端只展示最近3天的即可。 200 | 201 | 这就好办了,我们服务端存着多余的数据,不如把表中多余的数据归档。于是,DBA帮我们把数据做了归档,只保留最近7天的数据。 202 | 203 | 如此调整后,消息积压问题被解决了,又恢复了往日的平静。 204 | 205 | ## 主键冲突 206 | 别高兴得太早了,还有其他的问题,比如:报警邮件经常报出数据库异常: `Duplicate entry '6' for key 'PRIMARY'`,说主键冲突。 207 | 208 | 出现这种问题一般是由于有两个以上相同主键的sql,同时插入数据,第一个插入成功后,第二个插入的时候会报主键冲突。表的主键是唯一的,不允许重复。 209 | 210 | 我仔细检查了代码,发现代码逻辑会先根据主键从表中查询订单是否存在,如果存在则更新状态,不存在才插入数据,没得问题。 211 | 212 | 这种判断在并发量不大时,是有用的。但是如果在高并发的场景下,两个请求同一时刻都查到订单不存在,一个请求先插入数据,另一个请求再插入数据时就会出现主键冲突的异常。 213 | 214 | 解决这个问题最常规的做法是:`加锁`。 215 | 216 | 我刚开始也是这样想的,加数据库悲观锁肯定是不行的,太影响性能。加数据库乐观锁,基于版本号判断,一般用于更新操作,像这种插入操作基本上不会用。 217 | 218 | 剩下的只能用分布式锁了,我们系统在用redis,可以加基于redis的分布式锁,锁定订单号。 219 | 220 | 但后面仔细思考了一下: 221 | 222 | 加分布式锁也可能会影响消费者的消息处理速度。 223 | 消费者依赖于redis,如果redis出现网络超时,我们的服务就悲剧了。 224 | 所以,我也不打算用分布式锁。 225 | 226 | 而是选择使用mysql的`INSERT INTO ...ON DUPLICATE KEY UPDATE`语法: 227 | ```sql 228 | INSERTINTOtable (column_list) 229 | VALUES (value_list) 230 | ONDUPLICATEKEYUPDATE 231 | c1 = v1, 232 | c2 = v2, 233 | ...; 234 | ``` 235 | 它会先尝试把数据插入表,如果主键冲突的话那么更新字段。 236 | 237 | 把以前的insert语句改造之后,就没再出现过主键冲突问题。 238 | 239 | ## 数据库主从延迟 240 | 不久之后的某天,又收到商户投诉说下单后,在划菜客户端上看得到订单,但是看到的菜品不全,有时甚至订单和菜品数据都看不到。 241 | 242 | 这个问题跟以往的都不一样,根据以往的经验先看kafka的topic中消息有没有积压,但这次并没有积压。 243 | 244 | 再查了服务日志,发现订单系统接口返回的数据有些为空,有些只返回了订单数据,没返回菜品数据。 245 | 246 | 这就非常奇怪了,我直接过去找订单组的同事。他们仔细排查服务,没有发现问题。这时我们不约而同的想到,会不会是数据库出问题了,一起去找DBA。果然,DBA发现数据库的主库同步数据到从库,由于网络原因偶尔有延迟,有时延迟有3秒。 247 | 248 | 如果我们的业务流程从发消息到消费消息耗时小于3秒,调用订单详情查询接口时,可能会查不到数据,或者查到的不是最新的数据。 249 | 250 | 这个问题非常严重,会导致直接我们的数据错误。 251 | 252 | 为了解决这个问题,我们也加了重试机制。调用接口查询数据时,如果返回数据为空,或者只返回了订单没有菜品,则加入重试表。 253 | 254 | 调整后,商户投诉的问题被解决了。 255 | 256 | ## 重复消费 257 | kafka消费消息时支持三种模式: 258 | 259 | 1. at most once模式 最多一次。保证每一条消息commit成功之后,再进行消费处理。消息可能会丢失,但不会重复。 260 | 2. at least once模式 至少一次。保证每一条消息处理成功之后,再进行commit。消息不会丢失,但可能会重复。 261 | 3. exactly once模式 精确传递一次。将offset作为唯一id与消息同时处理,并且保证处理的原子性。消息只会处理一次,不丢失也不会重复。但这种方式很难做到。 262 | 263 | kafka默认的模式是`at least once`,但这种模式可能会产生重复消费的问题,所以我们的业务逻辑必须做幂等设计。 264 | 265 | 而我们的业务场景保存数据时使用了INSERT INTO ...ON DUPLICATE KEY UPDATE语法,不存在时插入,存在时更新,是天然支持幂等性的。 266 | 267 | ## 多环境消费问题 268 | 我们当时线上环境分为:pre(预发布环境) 和 prod(生产环境),两个环境共用同一个数据库,并且共用同一个kafka集群。 269 | 270 | 需要注意的是,在配置kafka的topic的时候,要加前缀用于区分不同环境。pre环境的以pre_开头,比如:pre_order,生产环境以prod_开头,比如:prod_order,防止消息在不同环境中串了。 271 | 272 | 但有次运维在pre环境切换节点,配置topic的时候,配错了,配成了prod的topic。刚好那天,我们有新功能上pre环境。结果悲剧了,prod的有些消息被pre环境的consumer消费了,而由于消息体做了调整,导致pre环境的consumer处理消息一直失败。 273 | 274 | 其结果是生产环境丢了部分消息。不过还好,最后生产环境消费者通过重置offset,重新读取了那一部分消息解决了问题,没有造成太大损失。 275 | 276 | ## 后记 277 | 除了上述问题之外,我还遇到过: 278 | 279 | 1. kafka的consumer使用自动确认机制,导致cpu使用率100%。 280 | 2. kafka集群中的一个broker节点挂了,重启后又一直挂。 281 | 282 | 这两个问题说起来有些复杂,我就不一一列举了,有兴趣的朋友可以关注我的公众号,加我的微信找我私聊。 283 | 284 | 非常感谢那两年使用消息中间件kafka的经历,虽说遇到过挺多问题,踩了很多坑,走了很多弯路,但是实打实的让我积累了很多宝贵的经验,快速成长了。 285 | 286 | 其实kafka是一个非常优秀的消息中间件,我所遇到的绝大多数问题,都并非kafka自身的问题(除了cpu使用率100%是它的一个bug导致的之外)。 -------------------------------------------------------------------------------- /docs/高并发/聊聊redis分布式锁的8大坑.md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 在分布式系统中,由于redis分布式锁相对于更简单和高效,成为了分布式锁的首先,被用到了很多业务场景当中。 3 | 4 | 尤其是分布式配置中心:apollo、nocos等的出现,让zookeeper的地位越来越低了。zookeeper分布式锁复杂度更高,想把它使用好并不容易。 5 | 6 | 所以我们还是好好使用redis分布式锁吧。 7 | 8 | 不是说用了redis分布式锁,就可以高枕无忧了,如果没有用好,也会引来一些意想不到的麻烦。 9 | 10 | 今天我们重点聊聊redis分布式锁的一些坑,给有需要的朋友一个参考。 11 | 12 | 13 | ![](https://pic.imgdb.cn/item/614ddbf12ab3f51d915f8fd7.jpg) 14 | 15 | 16 | ## 1 非原子操作 17 | 使用redis的分布式锁,我们首先想到的可能是`setNx`命令。 18 | ```java 19 | if (jedis.setnx(lockKey, val) == 1) { 20 | jedis.expire(lockKey, timeout); 21 | } 22 | ``` 23 | 容易,三下五除二,就可以把代码写好。 24 | 25 | 这段代码确实可以加锁成功,但你有没有发现什么问题? 26 | 27 | `加锁操作`和后面的`设置超时时间`是分开的,并`非原子操作`。 28 | 29 | 假如加锁成功,但是设置超时时间失败了,该lockKey就变成永不失效。假如在高并发场景中,有大量的lockKey加锁成功了,但不会失效,有可能直接导致redis内存空间不足。 30 | 31 | 那么,有没有保证原子性的加锁命令呢? 32 | 33 | 答案是:有,请看下面。 34 | 35 | ## 2 忘了释放锁 36 | 上面说到使用`setNx`命令加锁操作和设置超时时间是分开的,并非原子操作。 37 | 38 | 而在redis中还有`set`命令,该命令可以指定多个参数。 39 | ```java 40 | String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); 41 | if ("OK".equals(result)) { 42 | return true; 43 | } 44 | return false; 45 | ``` 46 | 其中: 47 | - `lockKey`:锁的标识 48 | - `requestId`:请求id 49 | - `NX`:只在键不存在时,才对键进行设置操作。 50 | - `PX`:设置键的过期时间为 millisecond 毫秒。 51 | - `expireTime`:过期时间 52 | 53 | `set`命令是原子操作,加锁和设置超时时间,一个命令就能轻松搞定。 54 | 55 | nice 56 | 57 | 使用`set`命令加锁,表面上看起来没有问题。但如果仔细想想,加锁之后,每次都要达到了超时时间才释放锁,会不会有点不合理?加锁后,如果不及时释放锁,会有很多问题。 58 | 59 | 分布式锁更合理的用法是: 60 | 1. 手动加锁 61 | 2. 业务操作 62 | 2. 手动释放锁 63 | 3. 如果手动释放锁失败了,则达到超时时间,redis会自动释放锁。 64 | 65 | 大致流程图如下: 66 | ![](https://pic.imgdb.cn/item/614ddc0f2ab3f51d915fc896.jpg) 67 | 那么问题来了,如何释放锁呢? 68 | 69 | ```java 70 | try{ 71 | String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); 72 | if ("OK".equals(result)) { 73 | return true; 74 | } 75 | return false; 76 | } finally { 77 | unlock(lockKey); 78 | } 79 | ``` 80 | 需要捕获业务代码的异常,然后在`finally`中释放锁。换句话说就是:无论代码执行成功或失败了,都需要释放锁。 81 | 82 | 此时,有些朋友可能会问:假如刚好在释放锁的时候,系统被重启了,或者网络断线了,或者机房断点了,不也会导致释放锁失败? 83 | 84 | 这是一个好问题,因为这种小概率问题确实存在。 85 | 86 | 但还记得前面我们给锁设置过超时时间吗?即使出现异常情况造成释放锁失败,但到了我们设定的超时时间,锁还是会被redis自动释放。 87 | 88 | 但只在finally中释放锁,就够了吗? 89 | 90 | ## 3 释放了别人的锁 91 | 做人要厚道,先回答上面的问题:只在finally中释放锁,当然是不够的,因为释放锁的姿势,还是不对。 92 | 93 | 哪里不对? 94 | 95 | 答:在多线程场景中,可能会出现释放了别人的锁的情况。 96 | 97 | 有些朋友可能会反驳:假设在多线程场景中,线程A获取到了锁,如果线程A没有释放锁,线程B是获取不到锁的,何来释放了别人锁之说? 98 | 99 | 答:假如线程A和线程B,都使用lockKey加锁。线程A加锁成功了,但是由于业务功能耗时时间很长,超过了设置的超时时间。这时候,redis会自动释放lockKey锁。此时,线程B就能给lockKey加锁成功了,接下来执行它的业务操作。恰好这个时候,线程A执行完了业务功能,释放了锁lockKey。这不就出问题了,线程B的锁,被线程A释放了。 100 | 101 | 我想这个时候,线程B肯定哭晕在厕所里,并且嘴里还振振有词。 102 | 103 | 那么,如何解决这个问题呢? 104 | 105 | 不知道你们注意到没?在使用`set`命令加锁时,除了使用lockKey锁标识,还多设置了一个参数:`requestId`,为什么要需要记录requestId呢? 106 | 107 | 答:requestId是在释放锁的时候用的。 108 | 109 | ```java 110 | if (jedis.get(lockKey).equals(requestId)) { 111 | jedis.del(lockKey); 112 | return true; 113 | } 114 | return false; 115 | ``` 116 | 在释放锁的时候,先获取到该锁的值(之前设置值就是requestId),然后判断跟之前设置的值是否相同,如果相同才允许删除锁,返回成功。如果不同,则直接返回失败。 117 | 118 | > 换句话说就是:自己只能释放自己加的锁,不允许释放别人加的锁。 119 | 120 | 这里为什么要用requestId,用userId不行吗? 121 | 122 | 答:如果用userId的话,对于请求来说并不唯一,多个不同的请求,可能使用同一个userId。而requestId是全局唯一的,不存在加锁和释放锁乱掉的情况。 123 | 124 | 此外,使用lua脚本,也能解决释放了别人的锁的问题: 125 | ```java 126 | if redis.call('get', KEYS[1]) == ARGV[1] then 127 | return redis.call('del', KEYS[1]) 128 | else 129 | return 0 130 | end 131 | ``` 132 | lua脚本能保证查询锁是否存在和删除锁是原子操作,用它来释放锁效果更好一些。 133 | 134 | 说到lua脚本,其实加锁操作也建议使用lua脚本: 135 | ```java 136 | if (redis.call('exists', KEYS[1]) == 0) then 137 | redis.call('hset', KEYS[1], ARGV[2], 1); 138 | redis.call('pexpire', KEYS[1], ARGV[1]); 139 | return nil; 140 | end 141 | if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) 142 | redis.call('hincrby', KEYS[1], ARGV[2], 1); 143 | redis.call('pexpire', KEYS[1], ARGV[1]); 144 | return nil; 145 | end; 146 | return redis.call('pttl', KEYS[1]); 147 | ``` 148 | 这是redisson框架的加锁代码,写的不错,大家可以借鉴一下。 149 | 150 | 151 | ## 4 大量失败请求 152 | 上面的加锁方法看起来好像没有问题,但如果你仔细想想,如果有1万的请求同时去竞争那把锁,可能只有一个请求是成功的,其余的9999个请求都会失败。 153 | 154 | 在秒杀场景下,会有什么问题? 155 | 156 | 答:每1万个请求,有1个成功。再1万个请求,有1个成功。如此下去,直到库存不足。这就变成均匀分布的秒杀了,跟我们想象中的不一样。 157 | 158 | 如何解决这个问题呢? 159 | 160 | 此外,还有一种场景: 161 | 162 | 比如,有两个线程同时上传文件到sftp,上传文件前先要创建目录。假设两个线程需要创建的目录名都是当天的日期,比如:20210920,如果不做如何控制,这样直接并发的创建,第二个线程会失败。 163 | 164 | 有同学会说:这还不容易,加一个redis分布式锁就能解决问题了,此外再判断一下,如果目录已经存在就不创建,只有目录不存在才需要创建。 165 | 166 | 伪代码如下: 167 | ```java 168 | try { 169 | String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); 170 | if ("OK".equals(result)) { 171 | if(!exists(path)) { 172 | mkdir(path); 173 | } 174 | return true; 175 | } 176 | } finally{ 177 | unlock(lockKey,requestId); 178 | } 179 | return false; 180 | ``` 181 | 答:只是加redis分布式锁是不够的,因为第二个请求如果加锁失败了,接下来,是返回失败呢?还是返回成功呢? 182 | 183 | ![](https://pic.imgdb.cn/item/614ddc2f2ab3f51d9160029c.jpg) 184 | 显然肯定是不能返回失败的,如果返回失败了,这个问题还是没有被解决。如果文件还没有上传成功,直接返回成功会有更大的问题。头疼,到底该如何解决呢? 185 | 186 | 答:使用`自旋锁`。 187 | 188 | ```java 189 | try { 190 | Long start = System.currentTimeMillis(); 191 | while(true) { 192 | String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); 193 | if ("OK".equals(result)) { 194 | if(!exists(path)) { 195 | mkdir(path); 196 | } 197 | return true; 198 | } 199 | 200 | long time = System.currentTimeMillis() - start; 201 | if (time>=timeout) { 202 | return false; 203 | } 204 | try { 205 | Thread.sleep(50); 206 | } catch (InterruptedException e) { 207 | e.printStackTrace(); 208 | } 209 | } 210 | } finally{ 211 | unlock(lockKey,requestId); 212 | } 213 | return false; 214 | ``` 215 | 在规定的时间,比如500毫秒内,自旋不断尝试加锁(说白了,就是在死循环中,不断尝试加锁),如果成功则直接返回。如果失败,则休眠50毫秒,再发起新一轮的尝试。如果到了超时时间,还未加锁成功,则直接返回失败。 216 | 217 | 218 | ## 5 锁重入问题 219 | 我们都知道redis分布式锁是互斥的。如果我们对某个key加锁了,如果该key对应的锁还没失效,再用相同key去加锁,大概率会失败。 220 | 221 | 没错,大部分场景是没问题的。 222 | 223 | 为什么说是大部分场景呢? 224 | 225 | 因为还有这样的场景: 226 | 227 | 假设在某个请求中,需要获取一颗满足条件的菜单树或者分类树。我们以菜单为例,这就需要在接口中从根节点开始,递归遍历出所有满足条件的子节点,然后组装成一颗菜单树。 228 | 229 | 需要注意的是菜单不是一成不变的,在后台系统中运营同学可以动态添加、修改和删除菜单。为了保证在并发的情况下,每次都可能获取最新的数据,这里可以加redis分布式锁。 230 | 231 | 加redis分布式锁的思路是对的。但接下来问题来了,在递归方法中递归遍历多次,每次都是加的同一把锁。递归第一层当然是可以加锁成功的,但递归第二层、第三层...第N层,不就会加锁失败了? 232 | 233 | 递归方法中加锁的伪代码如下: 234 | ```java 235 | private int expireTime = 1000; 236 | 237 | public void fun(int level,String lockKey,String requestId){ 238 | try{ 239 | String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); 240 | if ("OK".equals(result)) { 241 | if(level<=10){ 242 | this.fun(++level,lockKey,requestId); 243 | } else { 244 | return; 245 | } 246 | } 247 | return; 248 | } finally { 249 | unlock(lockKey,requestId); 250 | } 251 | } 252 | ``` 253 | 254 | 如果你直接这么用,看起来好像没有问题。但最终执行程序之后发现,等待你的结果只有一个:`出现异常`。 255 | 256 | 因为从根节点开始,第一层递归加锁成功,还没释放说,就直接进入第二层递归。因为requestId作为key的锁已经存在,所以第二层递归大概率会加锁失败,然后返回到第一层。第一层接下来正常释放锁,然后整个递归方法直接返回了。 257 | 258 | 这下子,大家知道出现什么问题了吧? 259 | 260 | 没错,递归方法其实只执行了第一层递归就返回了,其他层递归由于加锁失败,根本没法执行。 261 | 262 | 那么这个问题该如何解决呢? 263 | 264 | 答:使用`可重入锁`。 265 | 266 | 我们以redisson框架为例,它的内部实现了可重入锁的功能。 267 | 268 | 古时候有句话说得好:为人不识陈近南,便称英雄也枉然。 269 | 270 | 我说:分布式锁不识redisson,便称好锁也枉然。哈哈哈,只是自娱自乐一下。 271 | 272 | 由此可见,redisson在redis分布式锁中的江湖地位很高。 273 | 274 | 伪代码如下: 275 | ```java 276 | private int expireTime = 1000; 277 | 278 | public void run(String lockKey) { 279 | RLock lock = redisson.getLock(lockKey); 280 | this.fun(lock,1); 281 | } 282 | 283 | public void fun(RLock lock,int level){ 284 | try{ 285 | lock.lock(5, TimeUnit.SECONDS); 286 | if(level<=10){ 287 | this.fun(lock,++level); 288 | } else { 289 | return; 290 | } 291 | } finally { 292 | lock.unlock(); 293 | } 294 | } 295 | ``` 296 | 上面的代码也许并不完美,这里只是给了一个大致的思路,如果大家有这方面需求的话,可以参数一下。 297 | 298 | 接下来,聊聊redisson可重入锁的实现原理。 299 | 300 | 加锁主要是通过以下脚本实现的: 301 | ```java 302 | if (redis.call('exists', KEYS[1]) == 0) 303 | then 304 | redis.call('hset', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); 305 | return nil; 306 | end; 307 | if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) 308 | then 309 | redis.call('hincrby', KEYS[1], ARGV[2], 1); 310 | redis.call('pexpire', KEYS[1], ARGV[1]); 311 | return nil; 312 | end; 313 | return redis.call('pttl', KEYS[1]); 314 | ``` 315 | 其中: 316 | - KEYS[1]: 锁名 317 | - ARGV[1]: 过期时间 318 | - ARGV[2]: uuid + ":" + threadId,可认为是requestId 319 | 320 | 1. 先判断如果锁名不存在,则加锁。 321 | 2. 然后判断判断如果锁名和requestId值都存在,则使用hincrby命令给该锁名和requestId值计数,每次都加1。注意一下,这里就是重入锁的关键,锁重入一次就加1。 322 | 3. 如果锁名存在,但值不是requestId,则返回过期时间。 323 | 324 | 释放锁主要是通过以下脚本实现的: 325 | ```java 326 | if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) 327 | then 328 | return nil 329 | end 330 | local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1) 331 | if (counter > 0) 332 | then 333 | redis.call('pexpire', KEYS[1], ARGV[2]); return 0; 334 | else 335 | redis.call('del', KEYS[1]); 336 | redis.call('publish', KEYS[2], ARGV[1]); 337 | return 1; 338 | end; 339 | return nil 340 | ``` 341 | 1. 先判断如果锁名和requestId值不存在,则时间返回。 342 | 2. 如果锁名和requestId值存在,则重入锁减1。 343 | 3. 如果减1后,重入锁的value值还大于0,说明还有引用,则重试设置过期时间。 344 | 4. 如果减1后,重入锁的value值还等于0,则可以删除锁,然后发消息通知等待线程抢锁。 345 | 346 | > 再次强调一下,如果你们系统可以容忍数据暂时不一致,不加锁也行,我在这里只是举个例子,本节内容并不适用于所有场景。 347 | 348 | 349 | ## 6 锁竞争问题 350 | 如果有大量写入的场景,使用普通的redis分布式锁是没有问题的。 351 | 352 | 但如果有些业务场景,写入的操作比较少,反而有大量读取的操作。直接使用普通的redis分布式锁,性能会不会不太好? 353 | 354 | 我们都知道,锁的粒度越粗,多个线程抢锁时竞争就越激烈,造成多个线程锁等待的时间也就越长,性能也就越差。 355 | 356 | 所以,提升redis分布式锁性能的第一步,就是要把锁的粒度变细。 357 | ### 6.1 读写锁 358 | 359 | 众所周知,加锁的目的是为了保证,在并发环境中读写数据的安全性,即不会出现数据错误或者不一致的情况。 360 | 361 | 但在绝大多数实际业务场景中,一般是读数据的频率远远大于写数据。而线程间的并发读操作是并不涉及并发安全问题,我们没有必要给读操作加互斥锁,只要保证读写、写写并发操作上锁是互斥的就行,这样可以提升系统的性能。 362 | 363 | 我们以redisson框架为例,它内部已经实现了读写锁的功能。 364 | 365 | 读锁的伪代码如下: 366 | ```java 367 | RReadWriteLock readWriteLock = redisson.getReadWriteLock("readWriteLock"); 368 | RLock rLock = readWriteLock.readLock(); 369 | try { 370 | rLock.lock(); 371 | //业务操作 372 | } catch (Exception e) { 373 | log.error(e); 374 | } finally { 375 | rLock.unlock(); 376 | } 377 | ``` 378 | 379 | 写锁的伪代码如下: 380 | ```java 381 | RReadWriteLock readWriteLock = redisson.getReadWriteLock("readWriteLock"); 382 | RLock rLock = readWriteLock.writeLock(); 383 | try { 384 | rLock.lock(); 385 | //业务操作 386 | } catch (InterruptedException e) { 387 | log.error(e); 388 | } finally { 389 | rLock.unlock(); 390 | } 391 | ``` 392 | 将读锁和写锁分开,最大的好处是提升读操作的性能,因为读和读之间是共享的,不存在互斥性。而我们的实际业务场景中,绝大多数数据操作都是读操作。所以,如果提升了读操作的性能,也就会提升整个锁的性能。 393 | 394 | 下面总结一个读写锁的特点: 395 | - 读与读是共享的,不互斥 396 | - 读与写互斥 397 | - 写与写互斥 398 | 399 | 400 | ### 6.2 锁分段 401 | 402 | 此外,为了减小锁的粒度,比较常见的做法是将大锁:`分段`。 403 | 404 | 在java中`ConcurrentHashMap`,就是将数据分为`16段`,每一段都有单独的锁,并且处于不同锁段的数据互不干扰,以此来提升锁的性能。 405 | 406 | 放在实际业务场景中,我们可以这样做: 407 | 408 | 比如在秒杀扣库存的场景中,现在的库存中有2000个商品,用户可以秒杀。为了防止出现超卖的情况,通常情况下,可以对库存加锁。如果有1W的用户竞争同一把锁,显然系统吞吐量会非常低。 409 | 410 | 为了提升系统性能,我们可以将库存分段,比如:分为100段,这样每段就有20个商品可以参与秒杀。 411 | 412 | 在秒杀的过程中,先把用户id获取hash值,然后除以100取模。模为1的用户访问第1段库存,模为2的用户访问第2段库存,模为3的用户访问第3段库存,后面以此类推,到最后模为100的用户访问第100段库存。 413 | 414 | ![](https://pic.imgdb.cn/item/614ddc512ab3f51d91604ff0.jpg) 415 | 如此一来,在多线程环境中,可以大大的减少锁的冲突。以前多个线程只能同时竞争1把锁,尤其在秒杀的场景中,竞争太激烈了,简直可以用惨绝人寰来形容,其后果是导致绝大数线程在锁等待。现在多个线程同时竞争100把锁,等待的线程变少了,从而系统吞吐量也就提升了。 416 | 417 | > 需要注意的地方是:将锁分段虽说可以提升系统的性能,但它也会让系统的复杂度提升不少。因为它需要引入额外的路由算法,跨段统计等功能。我们在实际业务场景中,需要综合考虑,不是说一定要将锁分段。 418 | 419 | ## 7 锁超时问题 420 | 前面提到过,如果线程A加锁成功了,但是由于业务功能耗时时间很长,超过了设置的超时时间,这时候redis会自动释放线程A加的锁。 421 | 422 | 有些朋友可能会说:到了超时时间,锁被释放了就释放了呗,对功能又没啥影响。 423 | 424 | 答:错,错,错。对功能其实有影响。 425 | 426 | 通常我们加锁的目的是:为了防止访问临界资源时,出现数据异常的情况。比如:线程A在修改数据C的值,线程B也在修改数据C的值,如果不做控制,在并发情况下,数据C的值会出问题。 427 | 428 | 为了保证某个方法,或者段代码的互斥性,即如果线程A执行了某段代码,是不允许其他线程在某一时刻同时执行的,我们可以用`synchronized`关键字加锁。 429 | 430 | 但这种锁有很大的局限性,只能保证单个节点的互斥性。如果需要在多个节点中保持互斥性,就需要用redis分布式锁。 431 | 432 | 做了这么多铺垫,现在回到正题。 433 | 434 | 假设线程A加redis分布式锁的代码,包含代码1和代码2两段代码。 435 | ![](https://pic.imgdb.cn/item/614ddc662ab3f51d916078e6.jpg) 436 | 由于该线程要执行的业务操作非常耗时,程序在执行完代码1的时,已经到了设置的超时时间,redis自动释放了锁。而代码2还没来得及执行。 437 | 438 | ![](https://pic.imgdb.cn/item/614ddc8a2ab3f51d9160bae9.jpg) 439 | 440 | 此时,代码2相当于裸奔的状态,无法保证互斥性。假如它里面访问了临界资源,并且其他线程也访问了该资源,可能就会出现数据异常的情况。(PS:我说的访问临界资源,不单单指读取,还包含写入) 441 | 442 | 那么,如何解决这个问题呢? 443 | 444 | 答:如果达到了超时时间,但业务代码还没执行完,需要给锁自动续期。 445 | 446 | 我们可以使用`TimerTask`类,来实现自动续期的功能: 447 | ```java 448 | Timer timer = new Timer(); 449 | timer.schedule(new TimerTask() { 450 | @Override 451 | public void run(Timeout timeout) throws Exception { 452 | //自动续期逻辑 453 | } 454 | }, 10000, TimeUnit.MILLISECONDS); 455 | 456 | ``` 457 | 获取锁之后,自动开启一个定时任务,每隔10秒钟,自动刷新一次过期时间。这种机制在redisson框架中,有个比较霸气的名字:`watch dog`,即传说中的`看门狗`。 458 | 459 | 当然自动续期功能,我们还是优先推荐使用lua脚本实现,比如: 460 | ```java 461 | if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then 462 | redis.call('pexpire', KEYS[1], ARGV[1]); 463 | return 1; 464 | end; 465 | return 0; 466 | ``` 467 | 468 | 需要注意的地方是:在实现自动续期功能时,还需要设置一个总的过期时间,可以跟redisson保持一致,设置成30秒。如果业务代码到了这个总的过期时间,还没有执行完,就不再自动续期了。 469 | 470 | > 自动续期的功能是获取锁之后开启一个定时任务,每隔10秒判断一下锁是否存在,如果存在,则刷新过期时间。如果续期3次,也就是30秒之后,业务方法还是没有执行完,就不再续期了。 471 | 472 | ## 8 主从复制的问题 473 | 上面花了这么多篇幅介绍的内容,对单个redis实例是没有问题的。 474 | 475 | but,如果redis存在多个实例。比如:做了主从,或者使用了哨兵模式,基于redis的分布式锁的功能,就会出现问题。 476 | 477 | 具体是什么问题? 478 | 479 | 假设redis现在用的主从模式,1个master节点,3个slave节点。master节点负责写数据,slave节点负责读数据。 480 | ![](https://pic.imgdb.cn/item/614ddca92ab3f51d9160f274.jpg) 481 | 本来是和谐共处,相安无事的。redis加锁操作,都在master上进行,加锁成功后,再异步同步给所有的slave。 482 | 483 | 突然有一天,master节点由于某些不可逆的原因,挂掉了。 484 | 485 | 这样需要找一个slave升级为新的master节点,假如slave1被选举出来了。 486 | 487 | ![](https://pic.imgdb.cn/item/614ddcbe2ab3f51d91611952.jpg) 488 | 如果有个锁A比较悲催,刚加锁成功master就挂了,还没来得及同步到slave1。 489 | 490 | 这样会导致新master节点中的锁A丢失了。后面,如果有新的线程,使用锁A加锁,依然可以成功,分布式锁失效了。 491 | 492 | 那么,如果解决这个问题呢? 493 | 494 | 答:redisson框架为了解决这个问题,提供了一个专门的类:`RedissonRedLock`,使用了Redlock算法。 495 | 496 | RedissonRedLock解决问题的思路如下: 497 | 498 | 1. 需要搭建几套相互独立的redis环境,假如我们在这里搭建了3套。 499 | 2. 每套环境都有一个redisson node节点。 500 | 3. 多个redisson node节点组成了RedissonRedLock。 501 | 4. 环境包含:单机、主从、哨兵和集群模式,可以是一种或者多种混合。 502 | 503 | 在这里我们以主从为例,架构图如下: 504 | ![](https://pic.imgdb.cn/item/614ddcd12ab3f51d91613b88.jpg) 505 | 506 | RedissonRedLock加锁过程如下: 507 | 1. 循环向所有的redisson node节点加锁,假设节点数为N,例子中N等于5。 508 | 2. 如果在N个节点当中,有N/2 + 1个节点加锁成功了,那么整个RedissonRedLock加锁是成功的。 509 | 3. 如果在N个节点当中,小于N/2 + 1个节点加锁成功,那么整个RedissonRedLock加锁是失败的。 510 | 4. 如果中途发现各个节点加锁的总耗时,大于等于设置的最大等待时间,则直接返回失败。 511 | 512 | 从上面可以看出,使用Redlock算法,确实能解决多实例场景中,假如master节点挂了,导致分布式锁失效的问题。 513 | 514 | 但也引出了一些新问题,比如: 515 | 1. 需要额外搭建多套环境,申请更多的资源,需要评估一下,经费是否充足。 516 | 2. 如果有N个redisson node节点,需要加锁N次,最少也需要加锁N/2+1次,才知道redlock加锁是否成功。显然,增加了额外的时间成本,有点得不偿失。 517 | 518 | 由此可见,在实际业务场景,尤其是高并发业务中,RedissonRedLock其实使用的并不多。 519 | 520 | 在分布式环境中,CAP是绕不过去的。 521 | 522 | > CAP指的是在一个分布式系统中: 523 | > - 一致性(Consistency) 524 | > - 可用性(Availability) 525 | > - 分区容错性(Partition tolerance) 526 | 527 | > 这三个要素最多只能同时实现两点,不可能三者兼顾。 528 | 529 | 如果你的实际业务场景,更需要的是保证数据一致性。那么请使用CP类型的分布式锁,比如:zookeeper,它是基于磁盘的,性能可能没那么好,但数据一般不会丢。 530 | 531 | 如果你的实际业务场景,更需要的是保证数据高可用性。那么请使用AP类型的分布式锁,比如:redis,它是基于内存的,性能比较好,但有丢失数据的风险。 532 | 533 | > 其实,在我们绝大多数分布式业务场景中,使用redis分布式锁就够了,真的别太较真。因为数据不一致问题,可以通过最终一致性方案解决。但如果系统不可用了,对用户来说是暴击。 -------------------------------------------------------------------------------- /docs/高并发/面试必考:如何设计秒杀系统?.md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 高并发下如何设计秒杀系统?这是一个高频面试题。这个问题看似简单,但是里面的水很深,它考查的是高并发场景下,从前端到后端多方面的知识。 3 | 4 | 秒杀一般出现在商城的`促销活动`中,指定了一定数量(比如:10个)的商品(比如:手机),以极低的价格(比如:0.1元),让大量用户参与活动,但只有极少数用户能够购买成功。这类活动商家绝大部分是不赚钱的,说白了是找个噱头宣传自己。 5 | 6 | 虽说秒杀只是一个促销活动,但对技术要求不低。下面给大家总结一下设计秒杀系统需要注意的9个细节。 7 | 8 | ![](https://pic.imgdb.cn/item/610dfdc05132923bf8cf3606.jpg) 9 | 10 | 11 | ## 1 瞬时高并发 12 | 一般在`秒杀时间点`(比如:12点)前几分钟,用户并发量才真正突增,达到秒杀时间点时,并发量会达到顶峰。 13 | 14 | 但由于这类活动是大量用户抢少量商品的场景,必定会出现`狼多肉少`的情况,所以其实绝大部分用户秒杀会失败,只有极少部分用户能够成功。 15 | 16 | 正常情况下,大部分用户会收到商品已经抢完的提醒,收到该提醒后,他们大概率不会在那个活动页面停留了,如此一来,用户并发量又会急剧下降。所以这个峰值持续的时间其实是非常短的,这样就会出现瞬时高并发的情况,下面用一张图直观的感受一下流量的变化: 17 | 18 | ![](https://pic.imgdb.cn/item/610dfddf5132923bf8cf5e8a.jpg) 19 | 20 | 像这种瞬时高并发的场景,传统的系统很难应对,我们需要设计一套全新的系统。可以从以下几个方面入手: 21 | 1. 页面静态化 22 | 2. CDN加速 23 | 3. 缓存 24 | 4. mq异步处理 25 | 5. 限流 26 | 6. 分布式锁 27 | 28 | ## 2. 页面静态化 29 | 活动页面是用户流量的第一入口,所以是并发量最大的地方。 30 | 31 | 如果这些流量都能直接访问服务端,恐怕服务端会因为承受不住这么大的压力,而直接挂掉。 32 | 33 | ![](https://pic.imgdb.cn/item/610dfdf75132923bf8cf806d.jpg) 34 | 活动页面绝大多数内容是固定的,比如:商品名称、商品描述、图片等。为了减少不必要的服务端请求,通常情况下,会对活动页面做`静态化`处理。用户浏览商品等常规操作,并不会请求到服务端。只有到了秒杀时间点,并且用户主动点了秒杀按钮才允许访问服务端。 35 | 36 | ![](https://pic.imgdb.cn/item/610dfe105132923bf8cfa3fd.jpg) 37 | 这样能过滤大部分无效请求。 38 | 39 | 但只做页面静态化还不够,因为用户分布在全国各地,有些人在北京,有些人在成都,有些人在深圳,地域相差很远,网速各不相同。 40 | 41 | 如何才能让用户最快访问到活动页面呢? 42 | 43 | 这就需要使用CDN,它的全称是Content Delivery Network,即内容分发网络。 44 | 45 | ![](https://pic.imgdb.cn/item/610dfe255132923bf8cfc24f.jpg) 46 | 47 | 使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度和命中率。 48 | 49 | 50 | ## 3 秒杀按钮 51 | 52 | 大部分用户怕错过`秒杀时间点`,一般会提前进入活动页面。此时看到的`秒杀按钮`是置灰,不可点击的。只有到了秒杀时间点那一时刻,秒杀按钮才会自动点亮,变成可点击的。 53 | 54 | 但此时很多用户已经迫不及待了,通过不停刷新页面,争取在第一时间看到秒杀按钮的点亮。 55 | 56 | 从前面得知,该活动页面是静态的。那么我们在静态页面中如何控制秒杀按钮,只在秒杀时间点时才点亮呢? 57 | 58 | 没错,使用js文件控制。 59 | 60 | 为了性能考虑,一般会将css、js和图片等静态资源文件提前缓存到CDN上,让用户能够就近访问秒杀页面。 61 | 62 | 看到这里,有些聪明的小伙伴,可能会问:CDN上的js文件是如何更新的? 63 | 64 | 秒杀开始之前,js标志为false,还有另外一个随机参数。 65 | ![](https://pic.imgdb.cn/item/610dfe395132923bf8cfde69.jpg) 66 | 67 | 当秒杀开始的时候系统会生成一个新的js文件,此时标志为true,并且随机参数生成一个新值,然后同步给CDN。由于有了这个随机参数,CDN不会缓存数据,每次都能从CDN中获取最新的js代码。 68 | ![](https://pic.imgdb.cn/item/610dfe4a5132923bf8cff5eb.jpg) 69 | 70 | 此外,前端还可以加一个定时器,控制比如:10秒之内,只允许发起一次请求。如果用户点击了一次秒杀按钮,则在10秒之内置灰,不允许再次点击,等到过了时间限制,又允许重新点击该按钮。 71 | 72 | 73 | ## 4 读多写少 74 | 在秒杀的过程中,系统一般会先查一下库存是否足够,如果足够才允许下单,写数据库。如果不够,则直接返回该商品已经抢完。 75 | 76 | 由于大量用户抢少量商品,只有极少部分用户能够抢成功,所以绝大部分用户在秒杀时,库存其实是不足的,系统会直接返回该商品已经抢完。 77 | 78 | 这是非常典型的:`读多写少` 的场景。 79 | 80 | ![](https://pic.imgdb.cn/item/610dfe5c5132923bf8d00c2a.jpg) 81 | 82 | 如果有数十万的请求过来,同时通过数据库查缓存是否足够,此时数据库可能会挂掉。因为数据库的连接资源非常有限,比如:mysql,无法同时支持这么多的连接。 83 | 84 | 而应该改用缓存,比如:redis。 85 | 86 | 即便用了redis,也需要部署多个节点。 87 | ![](https://pic.imgdb.cn/item/610dfe6c5132923bf8d02327.jpg) 88 | 89 | ## 5 缓存问题 90 | 通常情况下,我们需要在redis中保存商品信息,里面包含:商品id、商品名称、规格属性、库存等信息,同时数据库中也要有相关信息,毕竟缓存并不完全可靠。 91 | 92 | 用户在点击秒杀按钮,请求秒杀接口的过程中,需要传入的商品id参数,然后服务端需要校验该商品是否合法。 93 | 94 | 大致流程如下图所示: 95 | ![](https://pic.imgdb.cn/item/610dfe7d5132923bf8d03a77.jpg) 96 | 97 | 根据商品id,先从缓存中查询商品,如果商品存在,则参与秒杀。如果不存在,则需要从数据库中查询商品,如果存在,则将商品信息放入缓存,然后参与秒杀。如果商品不存在,则直接提示失败。 98 | 99 | 这个过程表面上看起来是OK的,但是如果深入分析一下会发现一些问题。 100 | 101 | ### 5.1 缓存击穿 102 | 比如商品A第一次秒杀时,缓存中是没有数据的,但数据库中有。虽说上面有如果从数据库中查到数据,则放入缓存的逻辑。 103 | 104 | 然而,在高并发下,同一时刻会有大量的请求,都在秒杀同一件商品,这些请求同时去查缓存中没有数据,然后又同时访问数据库。结果悲剧了,数据库可能扛不住压力,直接挂掉。 105 | 106 | 如何解决这个问题呢? 107 | 108 | 这就需要加锁,最好使用分布式锁。 109 | 110 | ![](https://pic.imgdb.cn/item/610dfe8e5132923bf8d05459.jpg) 111 | 112 | 当然,针对这种情况,最好在项目启动之前,先把缓存进行`预热`。即事先把所有的商品,同步到缓存中,这样商品基本都能直接从缓存中获取到,就不会出现缓存击穿的问题了。 113 | 114 | 是不是上面加锁这一步可以不需要了? 115 | 116 | 表面上看起来,确实可以不需要。但如果缓存中设置的过期时间不对,缓存提前过期了,或者缓存被不小心删除了,如果不加速同样可能出现缓存击穿。 117 | 118 | 其实这里加锁,相当于买了一份保险。 119 | 120 | ### 5.2 缓存穿透 121 | 如果有大量的请求传入的商品id,在缓存中和数据库中都不存在,这些请求不就每次都会穿透过缓存,而直接访问数据库了。 122 | 123 | 由于前面已经加了锁,所以即使这里的并发量很大,也不会导致数据库直接挂掉。 124 | 125 | 但很显然这些请求的处理性能并不好,有没有更好的解决方案? 126 | 127 | 这时可以想到`布隆过滤器`。 128 | 129 | ![](https://pic.imgdb.cn/item/610dfe9f5132923bf8d06d5c.jpg) 130 | 131 | 系统根据商品id,先从布隆过滤器中查询该id是否存在,如果存在则允许从缓存中查询数据,如果不存在,则直接返回失败。 132 | 133 | 虽说该方案可以解决缓存穿透问题,但是又会引出另外一个问题:布隆过滤器中的数据如何更缓存中的数据保持一致? 134 | 135 | 这就要求,如果缓存中数据有更新,则要及时同步到布隆过滤器中。如果数据同步失败了,还需要增加重试机制,而且跨数据源,能保证数据的实时一致性吗? 136 | 137 | 显然是不行的。 138 | 139 | 所以布隆过滤器绝大部分使用在缓存数据更新很少的场景中。 140 | 141 | 如果缓存数据更新非常频繁,又该如何处理呢? 142 | 143 | 这时,就需要把不存在的商品id也缓存起来。 144 | 145 | ![](https://pic.imgdb.cn/item/610dfeb25132923bf8d0896e.jpg) 146 | 147 | 下次,再有该商品id的请求过来,则也能从缓存中查到数据,只不过该数据比较特殊,表示商品不存在。需要特别注意的是,这种特殊缓存设置的超时时间应该尽量短一点。 148 | 149 | 150 | ## 6 库存问题 151 | 对于库存问题看似简单,实则里面还是有些东西。 152 | 153 | 真正的秒杀商品的场景,不是说扣完库存,就完事了,如果用户在一段时间内,还没完成支付,扣减的库存是要加回去的。 154 | 155 | 所以,在这里引出了一个`预扣库存`的概念,预扣库存的主要流程如下: 156 | 157 | ![](https://pic.imgdb.cn/item/610dfec15132923bf8d0a172.jpg) 158 | 159 | 扣减库存中除了上面说到的`预扣库存`和`回退库存`之外,还需要特别注意的是库存不足和库存超卖问题。 160 | 161 | ### 6.1 数据库扣减库存 162 | 使用数据库扣减库存,是最简单的实现方案了,假设扣减库存的sql如下: 163 | ```java 164 | update product set stock=stock-1 where id=123; 165 | ``` 166 | 这种写法对于扣减库存是没有问题的,但如何控制库存不足的情况下,不让用户操作呢? 167 | 168 | 这就需要在update之前,先查一下库存是否足够了。 169 | 170 | 伪代码如下: 171 | ```java 172 | int stock = mapper.getStockById(123); 173 | if(stock > 0) { 174 | int count = mapper.updateStock(123); 175 | if(count > 0) { 176 | addOrder(123); 177 | } 178 | } 179 | ``` 180 | 大家有没有发现这段代码的问题? 181 | 182 | 没错,查询操作和更新操作不是原子性的,会导致在并发的场景下,出现库存超卖的情况。 183 | 184 | 有人可能会说,这样好办,加把锁,不就搞定了,比如使用synchronized关键字。 185 | 186 | 确实,可以,但是性能不够好。 187 | 188 | 还有更优雅的处理方案,即基于数据库的乐观锁,这样会少一次数据库查询,而且能够天然的保证数据操作的原子性。 189 | 190 | 只需将上面的sql稍微调整一下: 191 | ```java 192 | update product set stock=stock-1 where id=product and stock > 0; 193 | ``` 194 | 在sql最后加上:`stock > 0`,就能保证不会出现超卖的情况。 195 | 196 | 但需要频繁访问数据库,我们都知道数据库连接是非常昂贵的资源。在高并发的场景下,可能会造成系统雪崩。而且,容易出现多个请求,同时竞争行锁的情况,造成相互等待,从而出现死锁的问题。 197 | 198 | ### 6.2 redis扣减库存 199 | redis的`incr`方法是原子性的,可以用该方法扣减库存。 200 | 伪代码如下: 201 | ```java 202 | boolean exist = redisClient.query(productId,userId); 203 | if(exist) { 204 | return -1; 205 | } 206 | int stock = redisClient.queryStock(productId); 207 | if(stock <=0) { 208 | return 0; 209 | } 210 | redisClient.incrby(productId, -1); 211 | redisClient.add(productId,userId); 212 | return 1; 213 | ``` 214 | 代码流程如下: 215 | 1. 先判断该用户有没有秒杀过该商品,如果已经秒杀过,则直接返回-1。 216 | 2. 查询库存,如果库存小于等于0,则直接返回0,表示库存不足。 217 | 3. 如果库存充足,则扣减库存,然后将本次秒杀记录保存起来。然后返回1,表示成功。 218 | 219 | 估计很多小伙伴,一开始都会按这样的思路写代码。但如果仔细想想会发现,这段代码有问题。 220 | 221 | 有什么问题呢? 222 | 223 | 如果在高并发下,有多个请求同时查询库存,当时都大于0。由于查询库存和更新库存非原则操作,则会出现库存为负数的情况,即`库存超卖`。 224 | 225 | 当然有人可能会说,加个`synchronized`不就解决问题? 226 | 227 | 调整后代码如下: 228 | ```java 229 | boolean exist = redisClient.query(productId,userId); 230 | if(exist) { 231 | return -1; 232 | } 233 | synchronized(this) { 234 | int stock = redisClient.queryStock(productId); 235 | if(stock <=0) { 236 | return 0; 237 | } 238 | redisClient.incrby(productId, -1); 239 | redisClient.add(productId,userId); 240 | } 241 | 242 | return 1; 243 | ``` 244 | 加`synchronized`确实能解决库存为负数问题,但是这样会导致接口性能急剧下降,每次查询都需要竞争同一把锁,显然不太合理。 245 | 246 | 为了解决上面的问题,代码优化如下: 247 | ```java 248 | boolean exist = redisClient.query(productId,userId); 249 | if(exist) { 250 | return -1; 251 | } 252 | if(redisClient.incrby(productId, -1)<0) { 253 | return 0; 254 | } 255 | redisClient.add(productId,userId); 256 | return 1; 257 | ``` 258 | 该代码主要流程如下: 259 | 1. 先判断该用户有没有秒杀过该商品,如果已经秒杀过,则直接返回-1。 260 | 2. 扣减库存,判断返回值是否小于0,如果小于0,则直接返回0,表示库存不足。 261 | 3. 如果扣减库存后,返回值大于或等于0,则将本次秒杀记录保存起来。然后返回1,表示成功。 262 | 263 | 该方案咋一看,好像没问题。 264 | 265 | 但如果在高并发场景中,有多个请求同时扣减库存,大多数请求的incrby操作之后,结果都会小于0。 266 | 267 | 虽说,库存出现负数,不会出现`超卖的问题`。但由于这里是预减库存,如果负数值负的太多的话,后面万一要回退库存时,就会导致库存不准。 268 | 269 | 那么,有没有更好的方案呢? 270 | 271 | ### 6.3 lua脚本扣减库存 272 | 我们都知道lua脚本,是能够保证原子性的,它跟redis一起配合使用,能够完美解决上面的问题。 273 | 274 | lua脚本有段非常经典的代码: 275 | ```java 276 | StringBuilder lua = new StringBuilder(); 277 | lua.append("if (redis.call('exists', KEYS[1]) == 1) then"); 278 | lua.append(" local stock = tonumber(redis.call('get', KEYS[1]));"); 279 | lua.append(" if (stock == -1) then"); 280 | lua.append(" return 1;"); 281 | lua.append(" end;"); 282 | lua.append(" if (stock > 0) then"); 283 | lua.append(" redis.call('incrby', KEYS[1], -1);"); 284 | lua.append(" return stock;"); 285 | lua.append(" end;"); 286 | lua.append(" return 0;"); 287 | lua.append("end;"); 288 | lua.append("return -1;"); 289 | ``` 290 | 该代码的主要流程如下: 291 | 1. 先判断商品id是否存在,如果不存在则直接返回。 292 | 2. 获取该商品id的库存,判断库存如果是-1,则直接返回,表示不限制库存。 293 | 3. 如果库存大于0,则扣减库存。 294 | 4. 如果库存等于0,是直接返回,表示库存不足。 295 | 296 | 297 | ## 7 分布式锁 298 | 之前我提到过,在秒杀的时候,需要先从缓存中查商品是否存在,如果不存在,则会从数据库中查商品。如果数据库中,则将该商品放入缓存中,然后返回。如果数据库中没有,则直接返回失败。 299 | 300 | 大家试想一下,如果在高并发下,有大量的请求都去查一个缓存中不存在的商品,这些请求都会直接打到数据库。数据库由于承受不住压力,而直接挂掉。 301 | 302 | 那么如何解决这个问题呢? 303 | 304 | 这就需要用redis分布式锁了。 305 | 306 | ### 7.1 setNx加锁 307 | 使用redis的分布式锁,首先想到的是`setNx`命令。 308 | ```java 309 | if (jedis.setnx(lockKey, val) == 1) { 310 | jedis.expire(lockKey, timeout); 311 | } 312 | ``` 313 | 用该命令其实可以加锁,但和后面的设置超时时间是分开的,并非原子操作。 314 | 315 | 假如加锁成功了,但是设置超时时间失败了,该lockKey就变成永不失效的了。在高并发场景中,该问题会导致非常严重的后果。 316 | 317 | 那么,有没有保证原子性的加锁命令呢? 318 | ### 7.2 set加锁 319 | 使用redis的set命令,它可以指定多个参数。 320 | ```java 321 | String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); 322 | if ("OK".equals(result)) { 323 | return true; 324 | } 325 | return false; 326 | ``` 327 | 其中: 328 | - lockKey:锁的标识 329 | - requestId:请求id 330 | - NX:只在键不存在时,才对键进行设置操作。 331 | - PX:设置键的过期时间为 millisecond 毫秒。 332 | - expireTime:过期时间 333 | 由于该命令只有一步,所以它是原子操作。 334 | 335 | ### 7.3 释放锁 336 | 接下来,有些朋友可能会问:在加锁时,既然已经有了lockKey锁标识,为什么要需要记录requestId呢? 337 | 338 | 答:requestId是在释放锁的时候用的。 339 | 340 | ```java 341 | if (jedis.get(lockKey).equals(requestId)) { 342 | jedis.del(lockKey); 343 | return true; 344 | } 345 | return false; 346 | ``` 347 | 在释放锁的时候,只能释放自己加的锁,不允许释放别人加的锁。 348 | 349 | 这里为什么要用requestId,用userId不行吗? 350 | 351 | 答:如果用userId的话,假设本次请求流程走完了,准备删除锁。此时,巧合锁到了过期时间失效了。而另外一个请求,巧合使用的相同userId加锁,会成功。而本次请求删除锁的时候,删除的其实是别人的锁了。 352 | 353 | 当然使用lua脚本也能避免该问题: 354 | ```java 355 | if redis.call('get', KEYS[1]) == ARGV[1] then 356 | return redis.call('del', KEYS[1]) 357 | else 358 | return 0 359 | end 360 | ``` 361 | 它能保证查询锁是否存在和删除锁是原子操作。 362 | 363 | ### 7.4 自旋锁 364 | 上面的加锁方法看起来好像没有问题,但如果你仔细想想,如果有1万的请求同时去竞争那把锁,可能只有一个请求是成功的,其余的9999个请求都会失败。 365 | 366 | 在秒杀场景下,会有什么问题? 367 | 368 | 答:每1万个请求,有1个成功。再1万个请求,有1个成功。如此下去,直到库存不足。这就变成均匀分布的秒杀了,跟我们想象中的不一样。 369 | 370 | 如何解决这个问题呢? 371 | 372 | 答:使用自旋锁。 373 | 374 | ```java 375 | try { 376 | Long start = System.currentTimeMillis(); 377 | while(true) { 378 | String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); 379 | if ("OK".equals(result)) { 380 | return true; 381 | } 382 | 383 | long time = System.currentTimeMillis() - start; 384 | if (time>=timeout) { 385 | return false; 386 | } 387 | try { 388 | Thread.sleep(50); 389 | } catch (InterruptedException e) { 390 | e.printStackTrace(); 391 | } 392 | } 393 | 394 | } finally{ 395 | unlock(lockKey,requestId); 396 | } 397 | return false; 398 | ``` 399 | 在规定的时间,比如500毫秒内,自旋不断尝试加锁,如果成功则直接返回。如果失败,则休眠50毫秒,再发起新一轮的尝试。如果到了超时时间,还未加锁成功,则直接返回失败。 400 | 401 | ### 7.5 redisson 402 | 除了上面的问题之外,使用redis分布式锁,还有锁竞争问题、续期问题、锁重入问题、多个redis实例加锁问题等。 403 | 404 | 这些问题使用redisson可以解决,由于篇幅的原因,在这里先保留一点悬念,有疑问的私聊给我。后面会出一个专题介绍分布式锁,敬请期待。 405 | 406 | 407 | ## 8 mq异步处理 408 | 我们都知道在真实的秒杀场景中,有三个核心流程: 409 | ![](https://pic.imgdb.cn/item/610dfee35132923bf8d0d67f.png) 410 | 而这三个核心流程中,真正并发量大的是秒杀功能,下单和支付功能实际并发量很小。所以,我们在设计秒杀系统时,有必要把下单和支付功能从秒杀的主流程中拆分出来,特别是下单功能要做成mq异步处理的。而支付功能,比如支付宝支付,是业务场景本身保证的异步。 411 | 412 | 于是,秒杀后下单的流程变成如下: 413 | ![](https://pic.imgdb.cn/item/610dfefc5132923bf8d0fc4b.jpg) 414 | 如果使用mq,需要关注以下几个问题: 415 | 416 | ### 8.1 消息丢失问题 417 | 秒杀成功了,往mq发送下单消息的时候,有可能会失败。原因有很多,比如:网络问题、broker挂了、mq服务端磁盘问题等。这些情况,都可能会造成消息丢失。 418 | 419 | 那么,如何防止消息丢失呢? 420 | 421 | 答:加一张消息发送表。 422 | 423 | ![](https://pic.imgdb.cn/item/610dff165132923bf8d12336.jpg) 424 | 在生产者发送mq消息之前,先把该条消息写入消息发送表,初始状态是待处理,然后再发送mq消息。消费者消费消息时,处理完业务逻辑之后,再回调生产者的一个接口,修改消息状态为已处理。 425 | 426 | 如果生产者把消息写入消息发送表之后,再发送mq消息到mq服务端的过程中失败了,造成了消息丢失。 427 | 428 | 这时候,要如何处理呢? 429 | 430 | 答:使用job,增加重试机制。 431 | 432 | ![](https://pic.imgdb.cn/item/610dff375132923bf8d15707.jpg) 433 | 用job每隔一段时间去查询消息发送表中状态为待处理的数据,然后重新发送mq消息。 434 | 435 | ### 8.2 重复消费问题 436 | 本来消费者消费消息时,在ack应答的时候,如果网络超时,本身就可能会消费重复的消息。但由于消息发送者增加了重试机制,会导致消费者重复消息的概率增大。 437 | 438 | 那么,如何解决重复消息问题呢? 439 | 440 | 答:加一张消息处理表。 441 | ![](https://pic.imgdb.cn/item/610dff535132923bf8d181d8.jpg) 442 | 443 | 消费者读到消息之后,先判断一下消息处理表,是否存在该消息,如果存在,表示是重复消费,则直接返回。如果不存在,则进行下单操作,接着将该消息写入消息处理表中,再返回。 444 | 445 | 有个比较关键的点是:下单和写消息处理表,要放在同一个事务中,保证原子操作。 446 | 447 | ### 8.3 垃圾消息问题 448 | 这套方案表面上看起来没有问题,但如果出现了消息消费失败的情况。比如:由于某些原因,消息消费者下单一直失败,一直不能回调状态变更接口,这样job会不停的重试发消息。最后,会产生大量的垃圾消息。 449 | 450 | 那么,如何解决这个问题呢? 451 | ![](https://pic.imgdb.cn/item/610dff725132923bf8d1b2b7.jpg) 452 | 每次在job重试时,需要先判断一下消息发送表中该消息的发送次数是否达到最大限制,如果达到了,则直接返回。如果没有达到,则将次数加1,然后发送消息。 453 | 454 | 这样如果出现异常,只会产生少量的垃圾消息,不会影响到正常的业务。 455 | 456 | ### 8.4 延迟消费问题 457 | 通常情况下,如果用户秒杀成功了,下单之后,在15分钟之内还未完成支付的话,该订单会被自动取消,回退库存。 458 | 459 | 那么,在15分钟内未完成支付,订单被自动取消的功能,要如何实现呢? 460 | 461 | 我们首先想到的可能是job,因为它比较简单。 462 | 463 | 但job有个问题,需要每隔一段时间处理一次,实时性不太好。 464 | 465 | 还有更好的方案? 466 | 467 | 答:使用延迟队列。 468 | 469 | 我们都知道rocketmq,自带了延迟队列的功能。 470 | 471 | ![](https://pic.imgdb.cn/item/610dff8c5132923bf8d1d92e.jpg) 472 | 473 | 下单时消息生产者会先生成订单,此时状态为待支付,然后会向延迟队列中发一条消息。达到了延迟时间,消息消费者读取消息之后,会查询该订单的状态是否为待支付。如果是待支付状态,则会更新订单状态为取消状态。如果不是待支付状态,说明该订单已经支付过了,则直接返回。 474 | 475 | 还有个关键点,用户完成支付之后,会修改订单状态为已支付。 476 | 477 | ![](https://pic.imgdb.cn/item/610dffa55132923bf8d1ff58.jpg) 478 | 479 | 480 | 481 | ## 9 如何限流? 482 | 通过秒杀活动,如果我们运气爆棚,可能会用非常低的价格买到不错的商品(这种概率堪比买福利彩票中大奖)。 483 | 484 | 但有些高手,并不会像我们一样老老实实,通过秒杀页面点击秒杀按钮,抢购商品。他们可能在自己的服务器上,模拟正常用户登录系统,跳过秒杀页面,直接调用秒杀接口。 485 | 486 | 如果是我们手动操作,一般情况下,一秒钟只能点击一次秒杀按钮。 487 | ![](https://pic.imgdb.cn/item/610dffbe5132923bf8d223c8.jpg) 488 | 但是如果是服务器,一秒钟可以请求成上千接口。 489 | ![](https://pic.imgdb.cn/item/610dffd55132923bf8d2473f.jpg) 490 | 这种差距实在太明显了,如果不做任何限制,绝大部分商品可能是被机器抢到,而非正常的用户,有点不太公平。 491 | 492 | 所以,我们有必要识别这些非法请求,做一些限制。那么,我们该如何现在这些非法请求呢? 493 | 494 | 目前有两种常用的限流方式: 495 | 1. 基于nginx限流 496 | 2. 基于redis限流 497 | 498 | ### 9.1 对同一用户限流 499 | 为了防止某个用户,请求接口次数过于频繁,可以只针对该用户做限制。 500 | ![](https://pic.imgdb.cn/item/610dffea5132923bf8d26743.jpg) 501 | 502 | 限制同一个用户id,比如每分钟只能请求5次接口。 503 | 504 | ### 9.2 对同一ip限流 505 | 有时候只对某个用户限流是不够的,有些高手可以模拟多个用户请求,这种nginx就没法识别了。 506 | 507 | 这时需要加同一ip限流功能。 508 | ![](https://pic.imgdb.cn/item/610dffff5132923bf8d28623.jpg) 509 | 510 | 限制同一个ip,比如每分钟只能请求5次接口。 511 | 512 | 但这种限流方式可能会有误杀的情况,比如同一个公司或网吧的出口ip是相同的,如果里面有多个正常用户同时发起请求,有些用户可能会被限制住。 513 | 514 | ### 9.3 对接口限流 515 | 别以为限制了用户和ip就万事大吉,有些高手甚至可以使用代理,每次都请求都换一个ip。 516 | 517 | 这时可以限制请求的接口总次数。 518 | ![](https://pic.imgdb.cn/item/610e00135132923bf8d2a14a.jpg) 519 | 520 | 在高并发场景下,这种限制对于系统的稳定性是非常有必要的。但可能由于有些非法请求次数太多,达到了该接口的请求上限,而影响其他的正常用户访问该接口。看起来有点得不偿失。 521 | 522 | ### 9.4 加验证码 523 | 相对于上面三种方式,加验证码的方式可能更精准一些,同样能限制用户的访问频次,但好处是不会存在误杀的情况。 524 | 525 | ![](https://pic.imgdb.cn/item/610e00355132923bf8d2cf78.jpg) 526 | 527 | 通常情况下,用户在请求之前,需要先输入验证码。用户发起请求之后,服务端会去校验该验证码是否正确。只有正确才允许进行下一步操作,否则直接返回,并且提示验证码错误。 528 | 529 | 此外,验证码一般是一次性的,同一个验证码只允许使用一次,不允许重复使用。 530 | 531 | 普通验证码,由于生成的数字或者图案比较简单,可能会被破解。优点是生成速度比较快,缺点是有安全隐患。 532 | 533 | 还有一个验证码叫做:`移动滑块`,它生成速度比较慢,但比较安全,是目前各大互联网公司的首选。 534 | 535 | ### 9.5 提高业务门槛 536 | 上面说的加验证码虽然可以限制非法用户请求,但是有些影响用户体验。用户点击秒杀按钮前,还要先输入验证码,流程显得有点繁琐,秒杀功能的流程不是应该越简单越好吗? 537 | 538 | 其实,有时候达到某个目的,不一定非要通过技术手段,通过业务手段也一样。 539 | 540 | 12306刚开始的时候,全国人民都在同一时刻抢火车票,由于并发量太大,系统经常挂。后来,重构优化之后,将购买周期放长了,可以提前20天购买火车票,并且可以在9点、10、11点、12点等整点购买火车票。调整业务之后(当然技术也有很多调整),将之前集中的请求,分散开了,一下子降低了用户并发量。 541 | 542 | 回到这里,我们通过提高业务门槛,比如只有会员才能参与秒杀活动,普通注册用户没有权限。或者,只有等级到达3级以上的普通用户,才有资格参加该活动。 543 | 544 | 这样简单的提高一点门槛,即使是黄牛党也束手无策,他们总不可能为了参加一次秒杀活动,还另外花钱充值会员吧? -------------------------------------------------------------------------------- /docs/高并发/面霸:mq的6大关键问题.md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 最近mq越来越火,很多公司在用,很多人在用,但是你知道这些问题吗: 3 | 1. 我们为什么要用mq? 4 | 2. 引入mq会多哪些问题? 5 | 3. 如何解决这些问题? 6 | 7 | 本文将会一一为你解答。 8 | 9 | ## 1 传统模式有哪些痛点? 10 | ### 1.1 痛点1 11 | 有些复杂的业务系统,一次用户请求可能会同步调用N个系统的接口,需要等待所有的接口都返回了,才能真正的获取执行结果。 12 | 13 | ![](https://pic.imgdb.cn/item/610e8d415132923bf8e1cfa1.jpg) 14 | 这种同步接口调用的方式`总耗时比较长`,非常影响用户的体验,特别死在网络不稳定的情况下,极容易出现接口超时问题。 15 | 16 | ### 1.2 痛点2 17 | 很多复杂的业务系统,一般都会拆分成多个子系统。我们在这里以用户下单为例,请求会先通过订单系统,然后分别调用:支付系统、库存系统、积分系统 和 物流系统。 18 | ![](https://pic.imgdb.cn/item/610e8d515132923bf8e1eaa2.jpg) 19 | 系统之间`耦合性太高`,如果调用的任何一个子系统出现异常,整个请求都会异常,对系统的稳定性非常不利。 20 | 21 | 22 | ### 1.3 痛点3 23 | 有时候为了吸引用户,我们会搞一些活动,比如秒杀等。 24 | ![](https://pic.imgdb.cn/item/610e8d885132923bf8e22de2.jpg) 25 | 如果用户少还好,不会影响系统的稳定性。但如果用户突增,一时间所有的请求都到数据库,可能会导致数据库无法承受这么大的压力,响应变慢或者直接挂掉。 26 | 27 | ![](https://pic.imgdb.cn/item/610e8d965132923bf8e250f3.jpg) 28 | 对于这种突然出现的`请求峰值`,无法保证系统的稳定性。 29 | 30 | ## 2 为什么要用mq? 31 | 对于上面传统模式的三类问题,我们使用mq就能轻松解决。 32 | 33 | ### 2.1 异步 34 | 对于痛点1:同步接口调用导致响应时间长的问题,使用mq之后,将同步调用改成异步,能够显著减少系统响应时间。 35 | 36 | ![](https://pic.imgdb.cn/item/610e8f5c5132923bf8e4dd0e.jpg) 37 | 系统A作为消息的生产者,在完成本职工作后,就能直接返回结果了。而无需等待消息消费者的返回,它们最终会独立完成所有的业务功能。 38 | 39 | 这样能避免`总耗时比较长`,从而影响用户的体验的问题。 40 | 41 | ### 2.2 解耦 42 | 对于痛点2:子系统间耦合性太大的问题,使用mq之后,我们只需要依赖于mq,避免了各个子系统间的强依赖问题。 43 | 44 | ![](https://pic.imgdb.cn/item/610e8f715132923bf8e4fe3c.jpg) 45 | 46 | 订单系统作为消息生产者,保证它自己没有异常即可,不会受到支付系统等业务子系统的异常影响,并且各个做为消息消费者的业务子系统之间,也互不影响。 47 | 48 | 这样就把之前复杂的业务子系统的依赖关系,转换为只依赖于mq的简单依赖,从而显著的降低了系统间的耦合度。 49 | 50 | ### 2.3 消峰 51 | 对于痛点3:由于突然出现的`请求峰值`,导致系统不稳定的问题。使用mq后,能够起到消峰的作用。 52 | 53 | ![](https://pic.imgdb.cn/item/610e8f825132923bf8e518a4.jpg) 54 | 订单系统接收到用户请求之后,将请求直接发送到mq,然后订单消费者从mq中消费消息,做写库操作。如果出现`请求峰值`的情况,由于消费者的消费能力有限,会按照自己的节奏来消费消息,多的请求不处理,保留在mq的队列中,不会对系统的稳定性造成影响。 55 | 56 | 57 | ## 3 引入mq会多哪些问题? 58 | 引入mq后让我们子系统间耦合性降低了,异步处理机制减少了系统的响应时间,同时能够有效的应对`请求峰值`问题,提升系统的稳定性。 59 | 60 | 但是,引入mq同时也会带来一些问题。 61 | 62 | ### 3.1 重复消息问题 63 | 重复消费问题可以说是mq中普遍存在的问题,不管你用哪种mq都无法避免。 64 | 65 | 有哪些场景会出现重复的消息呢? 66 | 1. 消息生产者产生了重复的消息 67 | 2. kafka和rocketmq的offset被回调了 68 | 3. 消息消费者确认失败 69 | 4. 消息消费者确认时超时了 70 | 5. 业务系统主动发起重试 71 | 72 | ![](https://pic.imgdb.cn/item/610e8fa45132923bf8e54e6e.jpg) 73 | 如果重复消息不做正确的处理,会对业务造成很大的影响,产生重复的数据,或者导致数据异常,比如会员系统多开通了一个月的会员。 74 | 75 | ### 3.2 数据一致性问题 76 | 很多时候,如果mq的消费者业务处理异常的话,就会出现数据一致性问题。 77 | 比如:一个完整的业务流程是,下单成功之后,送100个积分。下单写库了,但是消息消费者在送积分的时候失败了,就会造成`数据不一致`的情况,即该业务流程的部分数据写库了,另外一部分没有写库。 78 | 79 | ![](https://pic.imgdb.cn/item/610e8fb65132923bf8e56c65.jpg) 80 | 如果下单和送积分在同一个事务中,要么同时成功,要么同时失败,是不会出现数据一致性问题的。 81 | 82 | 但由于跨系统调用,为了性能考虑,一般不会使用强一致性的方案,而改成达成最终一致性即可。 83 | 84 | ### 3.3 消息丢失问题 85 | 同样消息丢失问题,也是mq中普遍存在的问题,不管你用哪种mq都无法避免。 86 | 87 | 有哪些场景会出现消息丢失问题呢? 88 | 1. 消息生产者发生消息时,由于网络原因,发生到mq失败了。 89 | 2. mq服务器持久化时,磁盘出现异常 90 | 3. kafka和rocketmq的offset被回调时,略过了很多消息。 91 | 4. 消息消费者刚读取消息,已经ack确认了,但业务还没处理完,服务就被重启了。 92 | 93 | 导致消息丢失问题的原因挺多的,`生产者`、`mq服务器`、`消费者` 都有可能产生问题,我再这里就不一一列举了。最终的结果会导致消费者无法正确的处理消息,而导致数据不一致的情况。 94 | 95 | 96 | ### 3.4 消息顺序问题 97 | 有些业务数据是有状态的,比如订单有:下单、支付、完成、退货等状态,如果订单数据作为消息体,就会涉及顺序问题了。如果消费者收到同一个订单的两条消息,第一条消息的状态是下单,第二条消息的状态是支付,这是没问题的。但如果第一条消息的状态是支付,第二条消息的状态是下单就会有问题了,没有下单就先支付了? 98 | ![](https://pic.imgdb.cn/item/610e8fce5132923bf8e59485.jpg) 99 | 消息顺序问题是一个非常棘手的问题,比如: 100 | - `kafka`同一个`partition`中能保证顺序,但是不同的`partition`无法保证顺序。 101 | - `rabbitmq`的同一个`queue`能够保证顺序,但是如果多个消费者同一个`queue`也会有顺序问题。 102 | 103 | 如果消费者中使用多线程消费消息,也无法保证顺序。 104 | 105 | 如果消费消息时同一个订单的多条消息中,中间的一条消息出现异常情况,顺序将会被打乱。 106 | 107 | 还有如果生产者发送到mq中的路由规则,跟消费者不一样,也无法保证顺序。 108 | 109 | 110 | ### 3.5 消息堆积 111 | 如果消息消费者读取消息的速度,能够跟上消息生产者的节奏,那么整套mq机制就能发挥最大作用。但是很多时候,由于某些批处理,或者其他原因,导致消息消费的速度小于生产的速度。这样会直接导致消息堆积问题,从而影响业务功能。 112 | 113 | ![](https://pic.imgdb.cn/item/610e8fee5132923bf8e5c9f2.jpg) 114 | 这里以下单开通会员为例,如果消息出现堆积,会导致用户下单之后,很久之后才能变成会员,这种情况肯定会引起大量用户投诉。 115 | 116 | ### 3.6 系统复杂度提升 117 | 这里说的系统复杂度和系统耦合性是不一样的,比如以前只有:系统A、系统B和系统C 这三个系统,现在引入mq之后,你除了需要关注前面三个系统之外,还需要关注mq服务,需要关注的点越多,系统的复杂度越高。 118 | ![](https://pic.imgdb.cn/item/610e90015132923bf8e5e8bd.jpg) 119 | mq的机制需要:生产者、mq服务器、消费者。 120 | 121 | 有一定的学习成本,需要额外部署mq服务器,而且有些mq比如:rocketmq,功能非常强大,用法有点复杂,如果使用不好,会出现很多问题。有些问题,不像接口调用那么容易排查,从而导致系统的复杂度提升了。 122 | 123 | ## 4 如何解决这些问题? 124 | mq是一种趋势,总体来说对我们的系统是利大于弊的,难道因为它会出现一些问题,我们就不用它了? 125 | 126 | 那么我们要如何解决这些问题呢? 127 | 128 | ### 4.1 重复消息问题 129 | 不管是由于生产者产生的重复消息,还是由于消费者导致的重复消息,我们都可以在消费者中这个问题。 130 | 131 | 这就要求消费者在做业务处理时,要做幂等设计,如果有不知道如何设计的朋友,可以参考《》,里面介绍得非常详情。 132 | 133 | 在这里我推荐增加一张消费消息表,来解决mq的这类问题。消费消息表中,使用`messageId`做`唯一索引`,在处理业务逻辑之前,先根据messageId查询一下该消息有没有处理过,如果已经处理过了则直接返回成功,如果没有处理过,则继续做业务处理。 134 | 135 | ![](https://pic.imgdb.cn/item/610e90165132923bf8e60a47.jpg) 136 | 137 | ### 4.2 数据一致性问题 138 | 我们都知道数据一致性分为: 139 | - 强一致性 140 | - 弱一致性 141 | - 最终一致性 142 | 143 | 而mq为了性能考虑使用的是`最终一致性`,那么必定会出现数据不一致的问题。这类问题大概率是因为消费者读取消息后,业务逻辑处理失败导致的,这时候可以增加`重试机制`。 144 | 145 | 重试分为:`同步重试` 和 `异步重试`。 146 | 147 | 有些消息量比较小的业务场景,可以采用同步重试,在消费消息时如果处理失败,立刻重试3-5次,如何还是失败,则写入到`记录表`中。但如果消息量比较大,则不建议使用这种方式,因为如果出现网络异常,可能会导致大量的消息不断重试,影响消息读取速度,造成`消息堆积`。 148 | 149 | 150 | ![](https://pic.imgdb.cn/item/610e903d5132923bf8e64482.jpg) 151 | 152 | 而消息量比较大的业务场景,建议采用异步重试,在消费者处理失败之后,立刻写入`重试表`,有个`job`专门定时重试。 153 | 154 | 还有一种做法是,如果消费失败,自己给同一个topic发一条消息,在后面的某个时间点,自己又会消费到那条消息,起到了重试的效果。如果对消息顺序要求不高的场景,可以使用这种方式。 155 | 156 | ### 4.3 消息丢失问题 157 | 不管你是否承认有时候消息真的会丢,即使这种概率非常小,也会对业务有影响。生产者、mq服务器、消费者都有可能会导致消息丢失的问题。 158 | 159 | 为了解决这个问题,我们可以增加一张`消息发送表`,当生产者发完消息之后,会往该表中写入一条数据,状态status标记为待确认。如果消费者读取消息之后,调用生产者的api更新该消息的status为已确认。有个job,每隔一段时间检查一次消息发送表,如果5分钟(这个时间可以根据实际情况来定)后还有状态是待确认的消息,则认为该消息已经丢失了,重新发条消息。 160 | 161 | ![](https://pic.imgdb.cn/item/610e90c85132923bf8e717f5.jpg) 162 | 这样不管是由于生产者、mq服务器、还是消费者导致的消息丢失问题,job都会重新发消息。 163 | 164 | ### 4.4 消息顺序问题 165 | 消息顺序问题是我们非常常见的问题,我们以`kafka`消费订单消息为例。订单有:下单、支付、完成、退货等状态,这些状态是有先后顺序的,如果顺序错了会导致业务异常。 166 | 167 | 解决这类问题之前,我们先确认一下,消费者是否真的需要知道中间状态,只知道最终状态行不行? 168 | 169 | ![](https://pic.imgdb.cn/item/610e91195132923bf8e7953f.jpg) 170 | 171 | 其实很多时候,我真的需要知道的是最终状态,这时可以把流程优化一下: 172 | 173 | ![](https://pic.imgdb.cn/item/610e91325132923bf8e7bbf7.jpg) 174 | 这种方式可以解决大部分的消息顺序问题。 175 | 176 | 但如果真的有需要保证消息顺序的需求。订单号路由到不同的`partition`,同一个订单号的消息,每次到发到同一个`partition`。 177 | ![](https://pic.imgdb.cn/item/610e91445132923bf8e7db67.jpg) 178 | 179 | 180 | ### 4.5 消息堆积 181 | 如果消费者消费消息的速度小于生产者生产消息的速度,将会出现消息堆积问题。其实这类问题产生的原因很多,如果你想进一步了解,可以看看我的另一篇文章《我用kafka两年踩过的一一些非比寻常的坑》。 182 | 183 | 那么消息堆积问题该如何解决呢? 184 | 185 | 这个要看消息是否需要保证顺序。 186 | 187 | 如果不需要保证顺序,可以读取消息之后用多线程处理业务逻辑。 188 | 189 | ![](https://pic.imgdb.cn/item/610e91805132923bf8e838de.jpg) 190 | 191 | 这样就能增加业务逻辑处理速度,解决消息堆积问题。但是线程池的核心线程数和最大线程数需要合理配置,不然可能会浪费系统资源。 192 | 193 | 如果需要保证顺序,可以读取消息之后,将消息按照一定的规则分发到多个队列中,然后在队列中用单线程处理。 194 | 195 | ![](https://pic.imgdb.cn/item/610e91905132923bf8e85158.jpg) 196 | -------------------------------------------------------------------------------- /docs/高并发/高并发下如何保证接口幂等性?.md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | `接口幂等性`问题,对于开发人员来说,是一个跟语言无关的公共问题。本文分享了一些解决这类问题非常实用的办法,绝大部分内容我在项目中实践过的,给有需要的小伙伴一个参考。 3 | 4 | 不知道你有没有遇到过这些场景: 5 | 1. 我们在填写某些`form表单`时,保存按钮不小心快速点了两次,表中竟然产生了两条重复的数据,只是id不一样。 6 | 2. 我们的项目中为了解决`接口超时`问题,引入了`重试机制`。第一次请求接口超时了,请求方没能及时获取返回结果(此时有可能已经成功了),为了避免返回错误的结果(这种情况不可能直接返回失败吧?),于是对该请求重试几次,这样也会产生重复的数据。 7 | 3. mq消费者在读取消息时,有时候会读取到`重复消息`(至于什么原因这里先不说),从而导致产生了重复的数据。 8 | 9 | 没错,这些都是幂等性问题。 10 | 11 | `接口幂等性`是指用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。 12 | 13 | 这类问题多发于接口的: 14 | - `insert`操作,这种情况下多次请求,可能会产生重复数据。 15 | - `update`操作,如果只是单纯的更新数据,比如:```update user set status=1 where id=1```,是没有问题的。如果还有计算,比如:```update user set status=status+1 where id=1```,这种情况下多次请求,可能会导致数据错误。 16 | 17 | 那么我们要如何保证接口幂等性?本文将会告诉你答案。 18 | 19 | ## 1. insert前先select 20 | 通常情况下,在保存数据接口中,我们为了防止产生重复的数据,一般会在`insert`前,先根据`name`或`code`字段`select`一下数据。如果该数据已存在,则执行`update`操作,如果不存在,才执行 `insert`操作。 21 | 22 | ![](https://pic.imgdb.cn/item/60f41d9f5132923bf8045c1e.png) 23 | 24 | 该方案可能是我们平时在防止产生重复数据时,使用最多的方案。但是在并发场景中,要配合其他方案一起使用,否则同样会产生重复数据。 25 | 26 | ## 2. 加悲观锁 27 | 在支付场景中,用户A的账号余额有150元,想转出100元,正常情况下用户A的余额只剩50元。一般情况下,sql是这样的: 28 | ```sql 29 | update user amount = amount-100 where id=123; 30 | ``` 31 | 如果出现多次相同的请求,可能会导致用户A的余额变成负数。这种情况,用户A来可能要哭了。于此同时,系统开发人员可能也要哭了,因为这是很严重的系统bug。 32 | 33 | 为了解决这个问题,可以加悲观锁,将用户A的那行数据锁住,在同一时刻只允许一个请求获得锁,更新数据,其他的请求则等待。 34 | 35 | 通常情况下通过如下sql锁住单行数据: 36 | ```sql 37 | select * from user id=123 for update; 38 | ``` 39 | 40 | 具体流程如下: 41 | 42 | ![](https://pic.imgdb.cn/item/60f41de35132923bf8058e86.png) 43 | 44 | 45 | 具体步骤: 46 | 1. 多个请求同时根据id查询用户信息。 47 | 2. 判断余额是否不足100,如果余额不足,则直接返回余额不足。 48 | 3. 如果余额充足,则通过for update再次查询用户信息,并且尝试获取锁。 49 | 4. 只有第一个请求能获取到行锁,其余没有获取锁的请求,则等待下一次获取锁的机会。 50 | 5. 第一个请求获取到锁之后,判断余额是否不足100,如果余额足够,则进行update操作。 51 | 6. 如果余额不足,说明是重复请求,则直接返回成功。 52 | 53 | 54 | > 需要特别注意的是:如果使用的是mysql数据库,存储引擎必须用innodb,因为它才支持事务。此外,这里id字段一定要是主键或者唯一索引,不然会锁住整张表。 55 | 56 | 57 | ## 3. 加乐观锁 58 | 悲观锁需要在同一个事务操作过程中锁住一行数据,如果事务耗时比较长,会造成大量的请求等待,影响接口性能。 59 | 60 | 为了提升接口性能,我们可以使用乐观锁。需要在表中增加一个`timestamp`或者`version`字段,这里以`version`字段为例。 61 | 62 | 在更新数据之前先查询一下数据: 63 | ```sql 64 | select id,amount,version from user id=123; 65 | ``` 66 | 如果数据存在,假设查到的`version`等于`1`,再使用`id`和`version`字段作为查询条件更新数据: 67 | ```sql 68 | update user set amount=amount+100,version=version+1 where id=123 and version=1; 69 | ``` 70 | 更新数据的同时version+1,然后判断`update`操作的影响行数如果大于0,说明本次更新成功,如果等于0,说明本次更新失败。 71 | 72 | 由于第一次请求`version`等于`1`是可以成功的,操作成功后`version`变成`2`了。这时如果并发的请求过来,再执行如下sql: 73 | ```sql 74 | update user set amount=amount+100,version=version+1 where id=123 and version=1; 75 | ``` 76 | 该`update`操作不会真正更新数据,最终sql的执行结果影响行数是`0`,因为`version`已经变成`2`了,`where`中的`version=1`肯定无法满足条件。但为了保证接口幂等性,接口可以直接返回成功,因为`version`值已经修改了,那么前面必定已经成功过一次,后面都是重复的请求。 77 | 78 | 具体流程如下: 79 | ![](https://pic.imgdb.cn/item/60f41e455132923bf8075128.png) 80 | 81 | 具体步骤: 82 | 1. 先根据id查询用户信息,包含version字段 83 | 2. 根据id和version字段值作为where条件的参数,更新用户信息,同时version+1 84 | 3. 判断操作影响行数,如果影响1行,则说明是一次请求,可以做其他数据操作。 85 | 4. 如果影响0行,说明是重复请求,则直接返回成功。 86 | 87 | ## 4. 加唯一索引 88 | 绝大数情况下,为了防止重复数据的产生,我们都会在表中加唯一索引。 89 | ```sql 90 | alter table `order` add UNIQUE KEY `un_code` (`code`); 91 | ``` 92 | 加了唯一索引之后,第一次请求数据可以插入成功。但后面的相同请求,插入数据时会报`Duplicate entry '002' for key 'order.un_code`异常,表示唯一索引有冲突。虽说抛异常对数据来说没有影响,不会造成错误数据。但是为了保证接口幂等性,我们需要对该异常进行捕获,然后返回成功。 93 | 94 | 如果是`java`程序需要捕获:`DuplicateKeyException`异常,如果使用了`spring`框架还需要捕获:`MySQLIntegrityConstraintViolationException`异常。 95 | 96 | 具体流程图如下: 97 | 98 | ![](https://pic.imgdb.cn/item/60f41e785132923bf8083810.png) 99 | 100 | 101 | 具体步骤: 102 | 1. 用户通过浏览器发起请求,服务端收集数据。 103 | 2. 将该数据插入mysql 104 | 3. 判断是否执行成功,如果成功,则操作其他数据(可能还有其他的业务逻辑)。 105 | 4. 如果执行失败,捕获唯一索引冲突异常,直接返回成功。 106 | 107 | ## 5. 建防重表 108 | 有时候表中并非所有的场景都不允许产生重复的数据,只是有某些场景才不允许。这时候,直接在表中加唯一索引,显然是不太合适的。 109 | 110 | 针对这种情况,我们可以通过`建防重表`来解决问题。 111 | 112 | 该表可以只包含两个字段:`id` 和 `唯一索引`,`唯一索引`可以是多个字段比如:name、code等组合起来的唯一标识,例如:susan_0001。 113 | 114 | 具体流程图如下: 115 | ![](https://pic.imgdb.cn/item/60f41ea95132923bf8091a3b.png) 116 | 具体步骤: 117 | 1. 用户通过浏览器发起请求,服务端收集数据。 118 | 2. 将该数据插入mysql防重表 119 | 3. 判断是否执行成功,如果成功,则做mysql其他的数据操作(可能还有其他的业务逻辑)。 120 | 4. 如果执行失败,捕获唯一索引冲突异常,直接返回成功。 121 | 122 | > 需要特别注意的是:防重表和业务表必须在同一个数据库中,并且操作要在同一个事务中。 123 | 124 | ## 6. 根据状态机 125 | 很多时候业务表是有状态的,比如订单表中有:1-下单、2-已支付、3-完成、4-撤销等状态。如果这些状态的值是有规律的,按照业务节点正好是从小到大,我们就能通过它来保证接口的幂等性。 126 | 127 | 假如id=123的订单状态是`已支付`,现在要变成`完成`状态。 128 | ```sql 129 | update `order` set status=3 where id=123 and status=2; 130 | ``` 131 | 第一次请求时,该订单的状态是`已支付`,值是`2`,所以该`update`语句可以正常更新数据,sql执行结果的影响行数是`1`,订单状态变成了`3`。 132 | 133 | 后面有相同的请求过来,再执行相同的sql时,由于订单状态变成了`3`,再用`status=2`作为条件,无法查询出需要更新的数据,所以最终sql执行结果的影响行数是`0`,即不会真正的更新数据。但为了保证接口幂等性,影响行数是`0`时,接口也可以直接返回成功。 134 | 135 | 具体流程图如下: 136 | ![](https://pic.imgdb.cn/item/60f41ed85132923bf809f94a.png) 137 | 138 | 具体步骤: 139 | 1. 用户通过浏览器发起请求,服务端收集数据。 140 | 2. 根据id和当前状态作为条件,更新成下一个状态 141 | 3. 判断操作影响行数,如果影响了1行,说明当前操作成功,可以进行其他数据操作。 142 | 4. 如果影响了0行,说明是重复请求,直接返回成功。 143 | 144 | 145 | > 主要特别注意的是,该方案仅限于要更新的`表有状态字段`,并且刚好要更新`状态字段`的这种特殊情况,并非所有场景都适用。 146 | 147 | ## 7. 加分布式锁 148 | 其实前面介绍过的`加唯一索引`或者`加防重表`,本质是使用了`数据库的分布式锁`,也属于分布式锁的一种。但由于`数据库分布式锁`的性能不太好,我们可以改用:`redis`或`zookeeper`。 149 | 150 | 鉴于现在很多公司分布式配置中心改用`apollo`或`nacos`,已经很少用`zookeeper`了,我们以`redis`为例介绍分布式锁。 151 | 152 | 目前主要有三种方式实现redis的分布式锁: 153 | 1. setNx命令 154 | 2. set命令 155 | 3. Redission框架 156 | 157 | 每种方案各有利弊,具体实现细节我就不说了,有兴趣的朋友可以加我微信找我私聊。 158 | 159 | 具体流程图如下: 160 | 161 | ![](https://pic.imgdb.cn/item/60f41f175132923bf80b1e52.png) 162 | 163 | 164 | 具体步骤: 165 | 1. 用户通过浏览器发起请求,服务端会收集数据,并且生成订单号code作为唯一业务字段。 166 | 2. 使用redis的set命令,将该订单code设置到redis中,同时设置超时时间。 167 | 3. 判断是否设置成功,如果设置成功,说明是第一次请求,则进行数据操作。 168 | 4. 如果设置失败,说明是重复请求,则直接返回成功。 169 | 170 | > 需要特别注意的是:分布式锁定一要设置一个合理的过期时间,如果设置过短,无法有效的防止重复请求。如果设置过长,可能会浪费`redis`的存储空间,需要根据实际业务情况而定。 171 | 172 | ## 8. 获取token 173 | 除了上述方案之外,还有最后一种使用`token`的方案。该方案跟之前的所有方案都有点不一样,需要两次请求才能完成一次业务操作。 174 | 175 | 1. 第一次请求获取`token` 176 | 2. 第二次请求带着这个`token`,完成业务操作。 177 | 178 | 具体流程图如下: 179 | 180 | 第一步,先获取token。 181 | ![](https://pic.imgdb.cn/item/60f41f435132923bf80be6fb.png) 182 | 183 | 第二步,做具体业务操作。 184 | ![](https://pic.imgdb.cn/item/60f41fa65132923bf80dbc10.png) 185 | 186 | 187 | 具体步骤: 188 | 1. 用户访问页面时,浏览器自动发起获取token请求。 189 | 2. 服务端生成token,保存到redis中,然后返回给浏览器。 190 | 3. 用户通过浏览器发起请求时,携带该token。 191 | 4. 在redis中查询该token是否存在,如果不存在,说明是第一次请求,做则后续的数据操作。 192 | 5. 如果存在,说明是重复请求,则直接返回成功。 193 | 6. 在redis中token会在过期时间之后,被自动删除。 194 | 195 | 在这里顺便说一下,防重设计 和 幂等设计,其实是有区别的。 196 | 防重设计主要为了避免产生重复数据,对接口返回没有太多要求。而幂等设计除了避免产生重复数据之外,还要求每次请求都返回一样的结果。 197 | 198 | 以上方案是针对幂等设计的。 199 | 200 | 如果是防重设计,流程图可以改成如下: 201 | 202 | ![](https://pic.imgdb.cn/item/60f41fd95132923bf80eaab9.png) 203 | 204 | 205 | > 需要特别注意的是:token必须是全局唯一的。 206 | 207 | 208 | --------------------------------------------------------------------------------