学习 <阿里开发手册黄山版>

Posted by Haiming on April 11, 2025

一、编程规约

(一) 命名风格

布尔类型变量命名不要加is前缀

9.【强制】POJO 类中的任何布尔类型的变量,都不要加 is 前缀,否则部分框架解析会引起序列化错误。 说明:本文 MySQL 规约中的建表约定第 1 条,表达是与否的变量采用 is_xxx 的命名方式,所以需要在 设置从 is_xxx 到 xxx 的映射关系。 反例:定义为布尔类型 Boolean(**怀疑此处打错, 应为 boolean**) isDeleted 的字段,它的 getter 方法也是 isDeleted(),部分框架在反向解析时,“误以 为”对应的字段名称是 deleted,导致字段获取不到,得到意料之外的结果或抛出异常。

尝试了以下代码, 注意其中 isPackedBoolean 是 Boolean, isPrimaryBoolean 为 boolean :

@Data
public class POJO {
    private boolean isPrimaryBoolean;
    private Boolean isPackedBoolean;
}

经过编译之后的类为:

public class POJO {
    private boolean isPrimaryBoolean;
    private Boolean isPackedBoolean;

    public POJO() {
    }

    public boolean isPrimaryBoolean() {
        return this.isPrimaryBoolean;
    }

    public Boolean getIsPackedBoolean() {
        return this.isPackedBoolean;
    }

    public void setPrimaryBoolean(boolean isPrimaryBoolean) {
        this.isPrimaryBoolean = isPrimaryBoolean;
    }

    public void setIsPackedBoolean(Boolean isPackedBoolean) {
        this.isPackedBoolean = isPackedBoolean;
    }
...

可见其中 isPrimaryBoolean 字段的 get 和 set 方法并未再添加 is 前缀, 而是直接生成 isPrimaryBooleansetPrimaryBoolean 两个方法.

lombok 之中 @Data 注解对基本类型的 boolean 和包装类型的 Boolean 生成的 getter/setter 不同.

命名规约

  1. 获取单个对象的方法用 get 做前缀

  2. 获取多个对象的方法用 list 做前缀, 复数结尾, 比如 listItems()

  3. 获得统计值的方法用 count 做前缀

  4. 插入的方法用 save 做前缀

  5. 删除的方法用 remove 做前缀

  6. 修改的方法用 update 做前缀

(三) 代码格式

单个方法总行数不超过80行

代码逻辑分清红花和绿叶, 主干和分支, 分支代码抽取出来成为额外方法.

(四) OOP 规约

浮点数不能判断等值

计算机无法精确标识浮点数, 因此无法直接判断等值. 我个人的建议是, 不要在传值的地方用浮点数, 统一用 BigDecimal.

BigDecimal 使用 compareTo() 方法等值比较

bigDecimal 的 equals() 是带上值和精度的, 因此要使用忽略精度的 compareTo()

个人理解, 从语义上讲, bigDecimal 既需要在对象层面的判断相等的 equals(), 也需要在值层面判断相等的 compareTo()

BigDecimal 仅使用 String 类型的参数初始化

如果使用 BigDecimal(double), 可能存在精度损失的风险, 在精确计算或者比较的场景下导致业务逻辑异常.

BigDecimal g = new BigDecimal(0.1F);实际的存储值为:0.100000001490116119384765625

还是一样的, java 内部的浮点数天生有坑, 根源还是二进制转换无法精确转换十进制的小数部分.

基本数据类型与包装数据类型的使用标准

除了局部变量之外, 全部使用包装数据类型!

个人理解 ,因为包装数据类型默认为null, 可以提醒数据拿取方业务可能不正常, 但是如果是 integer, 那么只会返回0, 业务方并不知道是真的没有数据, 还是数据是0.

primary type 有默认值, 永远不为空这一点确实有利有弊.

在一个POJO类之中禁止同时存在 isXXX() 和 getXxx()

在框架, 比如 jackson 或者 hibernate 通过反射拿取属性的时候, 同时存在这两个方法, 其无法确定优先使用哪个方法!!!

比如:

public class Person {
    private boolean employed;

    public boolean isEmployed() {
        return employed;
    }

    public Boolean getEmployed() {
        return employed ? Boolean.TRUE : Boolean.FALSE;
    }

    public void setEmployed(boolean employed) {
        this.employed = employed;
    }
}

在使用jackson 序列化的时候, 模型可能选择 getEmployed(), 返回 Boolean 对象, 而不是 primary type 的 boolean.

对于布尔类型, 遵循单一 getter 规则

  • 对于 boolean 属性,始终使用 isXxx() 作为 getter 方法。
  • 对于 Boolean 属性,使用 getXxx(),但避免与 isXxx() 共存。

使用下标访问 String 的 split 方法得到的数组时候, 注意最后一个分隔符之后是否有内容的检查和index检查.

        String str = "a,b,c,,";
        String[] arr = str.split(",");
        //arr = ["a", "b", "c"], 长度只是3
        System.out.println(arr.length); // 报错

在 split() 之中, 如果不传入 limit参数, 等于 limit = 0.

  • 当 limit 为 0 时,遍历得到的数组,从尾部向前检查连续的空字符串(空格不算空字符串, 所以不会被干掉) ,并将它们剔除,直到遇到非空的项为止

简言之, split() 帮我们自动做了 trim, 所以在使用的时候应该注意下标, 而不是最后一个元素是否为空(如果是空, 已经trim掉了)

final 的使用

  1. 不允许继承的类, 比如 String

  2. 不允许修改引用的对象

  3. 不允许覆写 Override 的方法

(五) 日期时间

日期格式化之中 pattern 标识年份, 统一用小写的 y

日期格式化的时候, yyyy 标识那一天所在的年份, YYYY 是 week in which year.

某程序员因使用 YYYY/MM/dd 进行日期格式化,2017/12/31 执行结果为 2018/12/31,造成线上故障。

(六) 集合处理

hashCode 和 equals 的处理

  1. 覆写 equals 必须 覆写 hashCode

  2. Set 或者是 Map 的key的对象类型, 必须覆写 hashCode 和 equals()

java.util.stream.Collectors 类的 toMap() 一定要加入mergeFunction避免重复key异常

在处理集合的时候, 无法保证内部是否有一样的数值, 所以要自定义mergeFunction. 一般情况下mergeFunction 都是用新的替换旧的(list里面有多个重复元素对), 不然要仔细想想,是否能用map?

java.util.stream.Collectors 类的 toMap() 要注意 value 为null 时候会抛出NPE

List<Pair<String, Double>> pairArrayList = new ArrayList<>(2);                                                             
pairArrayList.add(Pair.of("version1", 8.3));                                                                               
pairArrayList.add(Pair.of("version2", null));                                                                              
// 抛出 NullPointerException 异常                                                                                              
Map<String, Double> map = pairArrayList.stream().collect(Collectors.toMap(Pair::getKey, Pair::getValue, (v1, v2) -> v2));  

原因: 其底层实现为 java.util.HashMap#merge. 方法的意义是将新的KV对插入到现有的map里面, 基本理解为:

  1. K 不存在或者现有 map 中 K 对应的 V 为 null, 直接将新的 V 插入

  2. K 存在, 使用 remappingFunction 合并旧的 V 和新的 V.

那么如果允许value 为 null, null 本身可能没有值, 也可能标识有值但是值显式的设置为null, 到底这个新的值要怎么去做合并呢?

merge 方法专注将两个非空的值做合并.

ArrayList 的 subList 结果不可强转成 ArrayList,否则会抛出 ClassCastException 异常

因为其实现其实是内部的 java.util.ArrayList.SubList, 可以看做 arraylist 的一个视图, 对于sublist 所有的操作都会反映在这个视图上面而已.

Map 的方法 keySet() / values() / entrySet() 返回集合对象时, 不可添加元素

宏观角度讲, 这三个反应的是视图, 视图可以减少元素, 并且会被之前的 map 的元素修改同步影响, 但是视图不应该添加元素, 因为视图之中添加了元素, 对应backed的map并不知道应该怎么同步处理.

个人理解, keySet() 和 values() 只是map的一部分(K 或者 V), 单纯对任何一个添加元素, 那么之前的map就不一致了, 所以不能加.

entrySet() 之中, 添加一个键值对的同时必须更新map的key和value, 这个也是在视图之中难以完成的. 比如万一这个key重复了? value 为null了? 使用什么样的mergeFunction? 都是难以单纯抉择的.

不要使用 Collections 类返回 emptyList() / singletonList(), 其均不可操作

如果查询无结果,返回 Collections.emptyList() 空集合对象,调用方一旦在返回的集合中进行了添加元素的操作,就会触发 UnsupportedOperationException 异常。

subList 之中对父集合的元素的修改, 会导致子列表的修改报 ConcurrentModificationException 异常

很好理解, 父列表做完操作, 那么正在遍历的子列表是跟着更新还是不更新? 咋都不对, 不如直接异常了事

集合转数组, 必须使用 toArray(T[] array), 传入为类型一致, 长度为0的空数组

如果直接调用 toArray(), 返回的是Object[], 强转其他类型的数组, 就会报 ClassCastException.

        List<String> list = new ArrayList<>();
        list.add("a");
        list.add("b");
        // 下面代码会报 ClassCastException
        String[] array = (String[]) list.toArray();

正解:

        List<String> list = new ArrayList<>();
        list.add("a");
        list.add("b");

        String[] array = list.toArray(new String[0]);

为什么参数传0? 动态创建和 size 相同的数组, 性能最好

Arrays.asList() 返回的 list 不能使用 add/remove/clear 修改集合相关方法

Arrays.asList() 返回的是 java.util.Arrays.ArrayList!!! 是一个内部类, 并没有实现集合的修改这些方法,使用会直接报错 java.lang.UnsupportedOperationException

Arrays.asList 体现的是适配器模式, 只是给外界看看, 底层还是数组, 没有集合的方法

PECS原则,<? extends T> 只能接收返回数据, <? super T> 只能写入数据

<? extends T> 只能确认内部的元素都是T的子类, 那么T的不同子类都符合条件, 如果允许塞入的话, 编译器是不知道内部具体类型的. 但是整个列表一定可以按照T类型来进行读取.

<? super T> 只能确认内部的元素都是T的父类, 所以只要是T或者T的子类都可以进行塞入, 但是读取的时候并不知道其中元素的实际类型, 所以无法进行读取

public class PECSExample {
    static class Animal {
        @Override public String toString() { return "Animal"; }
    }
    static class Dog extends Animal {
        @Override public String toString() { return "Dog"; }
    }
    static class Cat extends Animal {
        @Override public String toString() { return "Cat"; }
    }
    static class Poodle extends Dog {
        @Override public String toString() { return "Poodle"; }
    }

    public static List<? extends Animal> printAnimalNames(List<? extends Animal> animals) {
        //下面代码异常
        animals.add(new Dog());
    }

    public static void addAnimals(List<? super Animal> animals) {
        //下面代码异常
        for (Animal animal : animals) {
            System.out.println(animal);
        }
    }
}

未定义泛型的集合类型赋值给泛型类型的集合, 在使用集合元素时, 需要判断 instanceOf 来避免 ClassCastException 异常

个人认为, 这种只会出现在很老的代码里面, 新代码之中一定会使用泛型

泛型由来:

jdk5 前没有泛型的概念, 集合内部只存储了 Object 类型的对象. 这意味着可以向同一个 List 之中添加各种类型的对象, 比如 String, Integer 或者任何其他类型. 但是在取出元素的时候, 需要进行强制的类型转换, 且在编译的时候无法得到编译器的检查, 容易 ClassCastException

下面这段代码可以运行:

        List notGenerics = new ArrayList();
        notGenerics.add("abcd");
        notGenerics.add(1);
        notGenerics.add(new Object());
        for (Object o : notGenerics) {
            System.out.println(o);
        }
        // abcd
        // 1
        // java.lang.Object@35fb3008

在jdk5引入泛型之后, 下面是保持兼容的措施:

  1. 类型擦除: 编译之后去掉泛型信息, 变成该类extend的类或者Object类, 让字节码可以和旧版JVM兼容. 编译器在编译的时候进行类型检查, 且自动插入类型转换

  2. 原始类型: 仍然允许使用原始类型的泛型类, 比如 List, 在牺牲编译时类型安全的情况下允许新代码和旧代码兼容,

  3. 桥接方法: 当一个类继承或实现泛型类或接口, 类型擦除可能导致方法签名在字节码上变化. 为了保持多态性, 编译器会自动生成一个具有原始类型签名的桥接方法, 内部会实际调用自己编写的具有泛型签名的方法.

下面是例子:

class Node<T> {
    public T data;
    public void setData(T data) { this.data = data; }
}
// 自己实现的方法
class MyNode extends Node<String> {
    @Override
    public void setData(String data) { // 覆盖了泛型方法
        System.out.println("MyNode setData: " + data);
        super.setData(data);
    }
}

在编译 MyNode 时,编译器会发现 Node<T>setData(T data) 在擦除后变成 setData(Object data)。而 MyNode 中定义的是 setData(String data)。为了让通过 Node 引用调用 setData 时能够正确调用到 MyNode 的覆盖版本,编译器会为 MyNode 生成一个桥接方法:

// 由编译器在 MyNode.class 中自动生成的桥接方法(示意)
public void setData(Object data) {
    setData((String) data); // 调用实际编写的 setData(String) 方法
}

我们再回到正题, 那么将jdk5之前写的不带泛型的集合赋值给新的带泛型的集合的时候, 我们就需要在使用的时候和jdk5之前一样, 要带上 instanceof

        List notGenerics = new ArrayList();
        notGenerics.add("abcd");
        notGenerics.add(1);
        notGenerics.add(new Object());

        List<String> rawList = notGenerics;
        for (Object s : rawList) {
        // 注意此处
            if (s instanceof String) {
                System.out.println(s);
            }
        }

元素的remove 要使用 iterator 方式, 需要并发操作则给 iterator 加锁.

不可以在foreach 之中对集合做增加和删除操作, 要做的话需要用iterator.

iterator 通过 modCount 来记录集合被修改的次数, 如果和期望不匹配直接抛异常, 从而实现状态一致

一个有趣的小情况: 集合只有在第一个元素遍历之后才会检查是否modified, 所以下面两种情况输出不同:

        List<String> list = new ArrayList<>();
        list.add("1");
        list.add("2");

        for (String s : list) {
            if ("1".equals(s)) {
                list.remove(s);
            }
        }
        System.out.println(list); // 输出 [2]

        for (String s : list) {
            if ("2".equals(s)) {
                list.remove(s); // 报错 java.util.ConcurrentModificationException
            }
        }

使用 entrySet 来遍历Map的KV, 而不是 keySet

keySet 对map做了两次操作, 一次是转为 iterator, 一次是从hashMap之中取出 keySet 的对应value

Map类集合不能存储null的情况

个人认为, 实际上null 作为key 不具有业务含义, 应该在写入map的时候就过滤掉key为null的情况.

结论: HashMap 可以有一个为null的key, value可以为null.

ConcurrentHashMap 之中的key和value都不可以为null

原因: map.get(key)==null 有两种情况

  1. 这个key在map中不存在

  2. 这个key在map之中存在, 但是显式的设置成了null

HashMap 是单线程使用的情况, 可以用 containsKey 来做检查:

if (map.containsKey(key)) {
    // 键存在,值为null
} else {
    // 键不存在
}

但是在多线程环境之中, 在检查 containsKey的时候, 可能有其他线程直接写入了对应的值, 所以无法判断到底是哪一种情况, 因此禁止 concurrentHashMap 的 value 为null

用 Set 的元素唯一性来做集合的去重操作

(七) 并发处理

线程必须通过ThreadPoolExecutor 的形式显式参数处理生成

SimpleDateFormat 线程不安全, 不能定义成 static 变量. 建议使用 java.time.format.DateTimeFormatter

SimpleDateFormat 内部维护一个线程不安全的 calendar 类, 所以在多线程情况下可能导致日期格式化或者日期生成错乱. 使用 java.time.format.DateTimeFormatter 的话, 其内部是不可变且线程安全的 ,且性能更好

Instant 相关使用方式

instant 内部维护一个long ,代表 epoch 秒, 和一个int,代表当前秒之中的纳秒.

instant 是不可变的, 每次操作之后其会返回一个新的 instant

ThreadLocal 之中的值必须手动回收

在线程池等情况下, 线程是复用的, 那么threadLocal 之中的值也是复用的, 如果不手动清除, 会造成业务混乱

尽量在代码之中 try … finally …之中回收

在尽量小的代码块之中加锁

加锁的区域尽量小, 这样让需要竞争状态下面的代码尽量少的发生竞争

在同时加锁时, 按照顺序对不同对象加锁

如果线程1 加锁顺序为 A,B,C, 线程2 和线程1不同, 那么可能出现死锁

Java 之中阻塞等待获取锁的正确使用方式

1. 在try代码块之外调用加锁方法 lock.lock()

加锁操作必须在代码块的外面, 而不能在代码块的里面.

  1. 加锁方法和try 代码块之间不能有任何可能抛出异常的方法调用

为什么?

首先我们明确, 加锁之后的 try 代码块之中是业务逻辑, 在 finally 之中需要执行 lock.unlock() 逻辑.

  1. 为什么加锁方法和try 代码块之间不能有任何可能抛出异常的方法调用? 如果先执行 lock.lock(), 成功之后执行这个可能抛出异常的方法调用, 那么如果这个调用抛出异常, finally() 之中的方法不会被执行, 整个锁就无法被其他线程获取, 导致事故.

  2. 如果在 try 块之内调用 lock.lock(), 会有什么后果? try 内部调用lock.lock(), 如果在其之前抛出异常, 那么会对还未加锁的对象 unlock, 导致出现 IllegalMonitorStateException 异常. lock.lock() 本身也可能出现异常, 那么也会和上面情况一样, 给出 IllegalMonitorStateException 异常

Lock lock = new ReentrantLock(); // 创建一个锁对象,例如 ReentrantLock
// ...
lock.lock(); // 在 try 块之外加锁
try {
    doSomething(); // 执行一些操作
    doOthers();    // 执行其他操作
} finally {
    lock.unlock(); // 在 finally 块中释放锁
}

在尝试执行上锁段代码之前, 必须先判断当前线程是否持有锁

使用 tryLock() 来进行检测, 确定当前线程持有锁, 才能继续对应代码段逻辑

 Lock lock = new ReentrantLock();
        System.out.println("here is some logic");
        boolean isLocked = lock.tryLock();
        if (isLocked) {
            try {
                System.out.println("here executing all logics");
            } finally {
                lock.unlock();
            }
        }

使用 ScheduledExecutorService 来进行定时任务的调度

java.util.Timer 内部是一个单线程执行所有任务, 所以一旦一个任务抛出异常, 就会影响所有任务;但是 ScheduledExecutorService 内部是一个线程池, 所以其在任何一个任务抛出异常的时候都没有问题;

Timer 有问题的例子:

        Timer timer = new Timer();
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                throw new RuntimeException("Task1 failed!"); // 未捕获异常
            }
        }, 1000);

        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("Task2 running"); //永远走不到
            }
        }, 2000);

悲观锁: 一锁二判三更新四释放

CountDownLatch 正确使用方法

CountDownLatch 本身是计数器, 主线程需要等待各个在线程结束计算返回后的汇总值进行下一步动作. 每个子线程在结束自己的任务之后需要调用 countDown() 来让计数器 -1.

需要注意的点:

  1. countDown() 必须被子线程调用到, 所以要放在 try…catch…finally 的 finally 里面. 不然主线程可能卡死

  2. 主线程的 await() 一定要用带超时的版本, 这样方便兜底. 不然子线程万一未正常调用 countDown() 就会卡死

  3. 主线程的 try…catch… 和子线程的 try…catch… 本身是两个东西, 所以要分别的 try..catch

Random 实例不可多线程使用, 会因竞争同一个seed导致性能下降

多线程之中需要使用 ThreadLocalRandom 来在多线程之中使用Random

Random 包括 java.util.Random 的实例或者 Math.random() 的方式.

Random 之中随机数的获取是多线程安全的, 但是其会原子更新需要的 seed 值, 大概流程如下:

newSeed = f(oldSeed)
随机数 = g(newSeed)

问题就出在这”原子更新seed值”, 多线程条件下更新种子, 需要每一个线程都去竞争更新seed值, 那么在高并发的时候, 对 seed 值的更新就成为卡点, 导致大量线程阻塞

双重校验锁的volatile作用

老面试题了.

例子:

public class DoubleCheckLockingTest {
    private volatile Item item;

    private Item getItem() {
        if (item == null) {
            synchronized (this) {
                if (item == null) {
                    item = new Item();
                }
            }
        }
        return item;
    }


}
  1. 两次检验 item 的作用, 是防止多个线程第一次检验 item 的时候均为空, 导致多个线程进入 synchronized 结构体, 从而初始化多个对象

  2. volatile 的作用, 因为 item = new Item(); 这个步骤不是原子化的, 对象分配是:

    1. allocate: 分配 item 的内存空间

    2. construct: 调用构造函数以初始化变量

    3. assign: 把分配的内存地址赋值给 item 的引用变量

    如果不加 volatile, 那么这三步的顺序可能不对, 比如 1->3->2, 那么在一个对象还没初始化完成的时候就有可能被其他线程发现且返回, 导致对象不可用, 出现异常

    ThreadLocal 使用 Static 修饰

        private static final ThreadLocal<SharedResource> resourceThreadLocal = new ThreadLocal<>();
    

    上面的使用方法是其正确方法, 原因有以下几个:

  3. threadLocal 既然作为key, 那么就是全局唯一的, static 可以保证变量只被初始化一次. 所有访问这个类的线程, 都拿取的是同样的一个 threadLocal 实例

  4. 防止内存泄漏: 如果每个线程都有自己的 threadLocal 实例, 那么假设有 M 个线程和 N 个类的实例, 一共会有 M*N 个线程局部变量的类实例!

  5. 便于全局访问: 便于同一个线程之中的不同组件进行访问. 比如在服务器模型之中, 用户的单个请求是一个线程从头到尾执行的, 但是这个线程会使用不同组件, 比如 controller, 然后调用 service 的方法等等. 这样的情况下会在 thradLocal 之中放入一些公共信息, 比如鉴权,header 或者 traceId 等. 那如果每个组件拿取的是不同的 threadLocal 变量, 自然也就无法获得相同信息

为什么不同线程对同一个 threadLocal 对象可以取出不同的值

每个线程的 threadLocal 的值, 实际上是存储在线程内部的 threadLocalMap 当中的, threadLocal 对象更像是一个统一的key, 每个线程去自己的 threadLocalMap 之中取值的时候, 都用同样的一个key 来做读写操作, 但是每个map本身不同, 自然读写的值也不同

threadLocal 无法解决共享对象的更新问题

threadLocal 只能作为key , 但是如果threadLocal 中对于这个key 拿取的值是一个共享的对象, 其也无法解决竞态问题, 还是要进行上锁操作

(八) 控制语句

switch 块之中, 必须用 continue/break/return 终止, 且哪怕default语句什么也没有, 也必须附上

switch 括号之中之中不能为null, 所以必须先判空

比如 switch(s), 如果 s == null 的话, 其解析的时候会先调用 s.hashCode(), 所以会直接报 java.lang.NullPointerException. 其他的类型, 比如 integer, 也会因为类似的原因报 java.lang.NullPointerException.

三目运算符 condition ? 表达式 1:表达式 2 中, 需要对表达式1和表达式2进行判空, 避免自动拆箱出现NPE

三目表达式的 表达式1 和 表达式2 之中, 只要一个是原始类型, 另一个包装类型就会被拆箱, 这时候如果包装类型对应的是null, 就会出现 NPE

        Integer a = 1;
        Integer b = 2;
        Integer c = null;
        Boolean flag = false;
        Integer result = flag ? a * b : c; // a*b 进行算术运算操作, 自动拆箱变成int, c也被自动拆箱, 导致NPE

高并发场景不可以使用等于作为判定条件, 而要是范围判定

如果并发处理没有做好, 会发生击穿操作, 比如 奖品数量 == 0 的时候, 不再允许用户抽奖, 但是并发过高, 导致奖品数量变成负数, 造成活动无法终止

在条件判断之中不要执行复杂语句, 而是将其结果赋值给一个有意义的Boolean, 提高可读性

(九) 注释规约

所有抽象方法都必须给出 javaDoc 注释

所有的枚举类型字段必须要有注释,说明每个数据项的用途。

(十) 前后端规约

超大整数使用 String 类型返回, 避免 JS 之中 Number 类型可表示长度不够从而数值错误

JavaScript中Number类型的原理类似于 java 的 double, 由三部分组成:1位符号位、11位指数位和52位小数位3。对于整数来说,安全整数范围是-2^53+1到2^53-1,因为此时刚好用完了52位小数位加上隐含的1位整数位3

但是 java long 类型最大为 2^63-1, 那么这部分差值就可能被截断从而造成数值错误

http 请求通过url 传递参数时候, 不能超过 2048 字节

URL 之中的非 ASCII 和 特殊字符需要进行编码, 会增加实际字节长度, 比如:

  • 一个英文字母或数字通常占用1个字节

  • 一个空格编码为”%20”,占用3个字节

  • 一个汉字通常编码为”%XX%XX%XX”,占用9个字节左右

因此,当URL包含非英文字符或特殊字符时,实际可传输的信息量将显著减少。

http 的 body 内容需要控制长度, 不然后端解析会出错

不只是 nginx, tomcat 等有最大的内容限制 ,很多CDN也有自己的长度限制, 这个需要注意.

server 内部重定向必须使用 forward, 外部重定向地址必须使用URL 统一代理模块生成

前后端的时间格式统一为 “yyyy-MM-dd HH:mm:ss GMT”

(十一) 其他

正则表达式使用预编译功能

不要在调用方法内部进行编译, 而是在类之中使用 private static final 的成员变量来创建.

Math.random() 返回的是 double 类型, 如果要获取整数类型的随机数, 使用 Random.nextInt()

二、异常日志

(二) 异常处理

Java 类库之中可以通过预检查方式规避的 RuntimeException 应该通过检查筛出, 比如 NullPointerException, IndexOutOfBoundsException 等

能提前检查出来的就提前检查, 不要用catch 的方式再来做检验, 不然 catch 承载的工作太多(预期外+预期内都要检查)

异常捕获后不要用来做流程控制或者条件控制

一个异常非常消耗资源:

  1. throw 操作会创建异常对象, 且填充堆栈跟踪

语义职责也不同, 异常是用来处理程序运行之中的非预期错误

catch 时候需要分清永远不会出错的代码和非稳定代码, 对于非稳定代码的catch 要尽量区分异常类型, 再做相应处理

不要一个大 try catch 直接包裹住所有的代码段, 而是分清哪边可能抛出异常哪边不会, 再做对应的处理

事务之中如果异常被catch 之后需要回滚, 一定要注意手动回滚事务

我建议是在catch异常结束处理之后, 直接将异常再次抛出, 这样让最外层继续感知异常并且触发回滚

资源对象, 流对象等最好使用 try-with-resource 方式进行关闭.

为什么try…catch…finally 不好?

假设资源初始化异常或者调用 close() 异常, 那么finally 之中调用对象的 close() 就会出问题, 在finally 之中抛出异常. 为了防御, 需要进行冗余编码, 比如:

ResourceType resource = null;
try {
    resource = new ResourceType(); // 初始化资源,这里可能抛出异常
    // 使用资源... 这里也可能抛出异常
} catch (Exception e) {
    // 处理使用资源时发生的异常
    e.printStackTrace();
} finally {
    if (resource != null) { // 1. 非空检查
        try {
            resource.close(); // 2. 关闭资源,可能抛出异常
        } catch (Exception closeException) { // 3. 捕获并处理关闭时发生的异常
            closeException.printStackTrace(); // 避免覆盖原始异常
        }
    }
}
如果

try-with-resource 解决了什么问题

解决了finally块在资源管理上面的笨重和冗余. 其实现了自动管理机制

在 try 后面的括号之中进行资源的初始化.

try (ResourceType resource1 = new ResourceType1(); // 分号分隔多个资源
     ResourceType resource2 = new ResourceType2()) {
    // 使用 resource1 和 resource2
    // ... 代码块 ...
} catch (Exception e) {
    // 处理 try 代码块中发生的异常
} finally {
    // 可选的 finally 块,在资源关闭后执行
}

条件:

必须实现java.lang.AutoCloseable 接口(或者其子接口 java.io.Closeable)

原理:

编译器自动生成字节码, 在try块结束完毕之后调用资源的 close() 方法.

优点

开发不用再去手动 close(). 减少样板代码和各种手动处理错误的工作

多个异常如何处理

  1. try 块和 close() 都抛出异常 try 之中的异常会被正常抛出, close() 之中的会被抑制住(supressed) 且附加到主异常上, 可以通过 Throwable.getSuppressed() 来获得这些异常. 这样避免了异常屏蔽, 保留完整异常

  2. 只有 close() 抛出异常: 正常抛出

相对比, 在finally 之中, 如果 try 和 finally 都抛出异常, 那么一般是 finally 之中的异常会覆盖掉try 之中的原始异常!!

不要在finally 之中使用 return, 不然会覆盖掉代码块本身的 return

主线程和线程池的异常沟通方式

主线程和线程池之中的异步线程是独立的线程, 他们有各自独立的调用栈, 当异常在工作线程抛出的时候, 其会在对应的工作线程的调用栈传播. 主线程和异步工作线程是分离的, 所以工作线程的异常事件不会自动被捕获.

主线程如果要看到线程池之中的异常, 需要:

  1. future,get(), 直接在需要的时候拿到 future 的结果, 如果异步线程是异常, 那么 get() 会抛出 ExecutionException

  2. UncaughtExceptionHandler: 全局默认的异常处理, 当线程因为未捕获异常而终止的时候, 其被调用

  3. 在异步任务内部进行 try catch, 当异步任务自己能处理异常的时候, 直接选择这个, 因为其封装了错误处理逻辑

  4. 重写 ThreadPoolExecutor.afterExecute(): 这个钩子方法可以在任务执行之后检查是否有异常发生.

通常使用第一种 future.get() 和 第三种 异步任务内部进行 try catch

线程池对异常线程怎么处理

如果某个线程通过 execute() 抛出的异常未捕获且终止的时候, 线程池通常会移除这个死掉的线程, 然后创建一个新的工作线程来代替.

在使用外部依赖的时候, 异常使用 throwable 来进行拦截

反射机制调用方法时候, 如果找不到方法, 其会抛出 NoSuchMethodException. 找不到方法的可能原因为:

  1. 外部依赖的包在类冲突时候, 仲裁机制选择了错误的类, 从而导致真正使用的方法不存在

  2. 字节码修改框架之中修改了相应的方法名, 这种情况下哪怕代码编译期是正确的, 但是在运行时候也会抛出 NoSuchMethodError

NPE的可能场景

  1. 返回类型为基本数据类型, 但是return 其包装类型的对象的时候, 可能产生NPE
public int getInteger(){
    return new Integer; // 这个地方如果 Integer 对应的对象是null, 触发自动拆箱, 直接NPE
}
  1. 数据库 db 查不到数据, 返回 null

  2. 集合本身 isNotEmpty(), 但是取出元素是null 集合本身非空, 但是其内部存储的元素都是null, 这样拿取时候可能也是null. 要注意

  3. 微服务调用或者RPC调用, 必须考虑连接失败等情况导致返回值为null

  4. 在 session 之中拿取的对象可能是null

  5. 级连调用非常非常 容易产生NPE! 级联调用 obj.getA().getB().getC();

在处理级联的时候, optional 确实更清晰好用:

public String getUserCountry_Optional(User user) {
    return Optional.ofNullable(user)
                   .map(User::getAddress)
                   .map(Address::getCountry)
                   .map(Country::getIsoCode)
                   .map(String::toUpperCase)
                   .orElse("UNKNOWN");
}

这样就避免了层层嵌套去找值.

(三) 日志规约

使用日志框架(slf4j) 而非日志系统 (log4j)

  1. 日志框架本身可以解耦底层的日志系统实现

  2. 相对性能有优化, 在实际记录日志的时候才会构建字符串

日志输出时候, 字符串变量之间的拼接使用占位符 {}

异常信息要包含 1. 案发现场参数 2. 异常堆栈

五、MySQL 数据库

表示是和否概念的字段, 必须使用 is_xxx 的方式命名, 数据类型是 unsigned tinyint(1表示是 0表示否)

POJO 类型之中的布尔类型的变量, 都不要加 is 前缀, 所以需要在 <resultMap> 之中设置 is_xxx 到 xxx 类型的映射关系. 因为是非负数, 所以为 unsigned tinyint

表名和字段名必须使用小写字母和数字, snake 方式命名.

mysql 在windows下面不区分大小写, 但是在linux 下面是区分大小写的, 因此各种命名都不能出现大写字母.

之所以windows 下面不区分大小写, 但是linux 下面区分大小写, 是因为windows 系统本身默认使用大小写不敏感的文件系统, 比如 NTFS, FAT32, 但是 linux 使用的是大小写敏感的文件系统.

linux 之中一切都是文件, 所以数据库底层存储也就是一个目录, 目录里面有各种表的文件. 为了和操作系统本身的文件系统特性匹配, mysql 在不同的操作系统上面有不同的大小写规则.

在 Linux 下的 MySQL,默认情况下,数据库名、表名和表的别名是区分大小写的。而列名、索引名和列的别名则始终不区分大小写

主键索引为 pk_字段名, 唯一索引为 uk_字段名, 普通索引名则为 idx_字段名

小数类型必须为 decimal, 禁止使用 float 或者 double

varchar 不会预先分配存储空间, 长度不能超过 5000. 存储更长的字符串时, 定义其类型为 text, 独立一张表且用主键对应, 避免影响其他字段搜索

varchar(n) 实现方式: 记录实际的字符内容, 外加两个字节记录长度. n 代表其能存储的长度

text 实现方式: 不会直接存储在数据行内, 而是表外的一个独立区域, 且数据行内只会保留一个指向实际文本的指针. MySQL 默认对 TEXT 列索引只包含前255个字符

表必备三字段: id, db_create_time, db_update_time

id: bigint unsigned auto_increment.

db_create_time, db_update_time: timestamp.

mysql 之中 date_time 和 timestamp 区别

二者除了date_time 可以表示的时间范围更大之外, 主要的区别是 date_time 不随着时区的变化而变化, 但是 timestamp 跟着时区变化

datetime 类型存储的字面意义的日期和时间, 在国际化的公司之内不能用!!

  • 例如,如果服务器时区是东八区 (UTC+8),你插入 ‘2024-05-17 10:00:00’,那么存储的就是 ‘2024-05-17 10:00:00’。即使你将客户端或服务器的连接时区更改为其他时区,读取该值时仍然是 ‘2024-05-17 10:00:00’。

timestamp 类型在存储和检索的时候会进行时区转换.

mysql 会将客户端插入的时间从链接的有效时区转换为UTC再进行存储, 在读取的时候也会将 UTC 时间转换为当前有效时区进行展示

  • 示例

    • 假设服务器时区和客户端连接时区均为东八区 (UTC+8)。

    • 你向 timestamp 列插入 ‘2024-05-17 18:00:00’ (北京时间)。

    • MySQL 会将其转换为 UTC 时间 ‘2024-05-17 10:00:00’ UTC 进行存储。

    • 当你查询这个值时,如果你的连接时区仍然是东八区,MySQL 会将 UTC 时间转换回来,显示 ‘2024-05-17 18:00:00’。

    • 但如果你将连接时区改为东京时间 (UTC+9),再查询同一个值,MySQL 会将 ‘2024-05-17 10:00:00’ UTC 转换为东京时间,显示 ‘2024-05-17 19:00:00’。

数据库之中不能使用物理删除操作, 而要使用逻辑删除

表的命名规则为 “业务名称_表的作用”

库名和应用名称一致

db 之中的冗余字段原则

首先我们明确什么是冗余字段: 冗余字段是为了在查找过程之中join表或者去其他服务查询, 所以在当前表之中冗余一个和当前表业务无关的字段. 以空间换时间.

另外冗余字段可以对历史数据”定格”, 保留原始信息, 满足审计,纠纷等需求

  1. 不是频繁修改的字段: 频繁修改的字段需要同时修改其本身的表和这张业务表的冗余字段, 且容易造成数据不一致

  2. 不是唯一索引的字段: 唯一索引到另一张表, 那么假设另一张表的这个条目删除了, 这张表之中的冗余的字段该如何处理? 这种情况下一旦两张表数据不一致, 那么后期也很难修复

  3. 不是 varchar 超长字段或者text字段, 避免空间的无效浪费和索引失效

单表超过500万行或者单表容量超过 2GB, 才需要进行分库分表

首先, 在这个规范提出之前, 行业内部一般说的是 超过 2000万行, 单表性能会急剧下降. 我们分析下这个数字的原因:

首先分析一下大表的性能瓶颈可能的方面:

  1. 索引相关:

    1. mysql innodb 引擎为了加速查询, 会将表的索引加载到内存 innodb buffer pool 之中. 当索引可以全部加载内存的时候, 基于索引的查询会很快. 但是超过内存范围之外, mysql就需要频繁的读取硬盘再load到内存之中, 这个磁盘IO就会很慢. 查询效率显著下降

    2. B+ 数索引结构和层高: 回顾一下B+ 树, 其为只有底层叶子节点存储数据, 非叶子节点仅存储索引和指向下一层的指针. 那么就意味着每一个数据的存取都需要遍历这个树的全部层高. 这种情况下, 层高越高, 对磁盘的使用越多. 将B+树限制在3层以内是比较好的方式.

innodb 页大小默认为 16KB,

非叶子节点: 假设主键是 BIGINT 类型的 8 字节, InnoDB 内部节点每个索引除了主键之外, 还要存储指向下一页数据的指针 (6字节). 假设一个节点页可用大小为 15KB(除去页头等), 能存储 15KB/14B = 1097 个指针.

叶子节点: 真实数据假设一条 1KB, 那么一页可以存储 15KB/1KB = 15 条

那么3 层的 B+ 树可以存储的最大行数为: 1097*1097*15 = 18051135, 差不多 1800 万行.

由于以上都是估计, 随着业务场景变动参数都可能变化, 所以大概2000万行左右是三层B+ 树最大的容量

我们算完了这个 2000 万是怎么来的 , 再看下为什么阿里这个手册给的是 500万或者 2GB, 任何一个达到都要考虑分库分表呢?

  1. 实际行大小和索引开销: 实际应用之中, 主键索引之外可能还有二级索引, 二级索引占用大量空间的同时, 其叶子节点存储的也是主键值. 如果主键本身较大, 二级索引会更加庞大, 那么索引的总大小可能在更少行数就达到瓶颈

  2. 性能极限是不能超过的, 而不是要向着这个极限去逼近. 在500万行的时候性能可能已经出现稍微下滑.

  3. buffer pool 的实际可用性: 服务器内存不都给 buffer pool, buffer pool 又是服务所有表的索引, 所以单个大表能使用的 buffer pool 也是有限的

  4. 并发和锁: 单表数据量过大的时候, 高并发场景下锁的竞争也会更大,影响整体吞吐量

  5. 大表的操作成本, 比如 DDL 等操作风险更高

2GB 的算法:

500万行的表, 每一行 400 字节, 那么就约为2GB了. 如果行数据很小, 虽然2GB可以存储的行数更多, 但是索引本身也成为了瓶颈

(二) 索引规约

业务上具有唯一特性的字段, 必须建成唯一索引

超过3个表禁止join, 需要 join 的字段, 必须保证关联字段有索引

对 varchar 类型建立索引时候, 必须指定索引长度, 根据实际文本区分度决定索引长度

一半对字符串类型建立长度为 20 的索引, 区分度会高达90% 以上.

ALTER TABLE t_demo ADD INDEX idx_name(name(10));表示只对name字段的前10个字符创建索引

可以使用:

区分度 = COUNT(DISTINCT column_name) / COUNT(*)

或者对于前缀索引:

区分度 = COUNT(DISTINCT LEFT(column_name, prefix_length)) / COUNT(*)

区分度接近1的话, 表示索引的选择性越好.

但是前缀索引情况下无法进行覆盖索引, 也就是需要回表

不同长度的索引的区分度计算,注意如果有重复行, 那么distinct 之后的数据就会变少, 前缀索引使用率肯定会更低.:

SELECT
    COUNT(DISTINCT LEFT(your_column, 5)) / COUNT(*) AS distinction_len_5,
    COUNT(DISTINCT LEFT(your_column, 10)) / COUNT(*) AS distinction_len_10,
    COUNT(DISTINCT LEFT(your_column, 15)) / COUNT(*) AS distinction_len_15,
    COUNT(DISTINCT LEFT(your_column, 20)) / COUNT(*) AS distinction_len_20
FROM
    your_table;

利用覆盖索引进行查询操作, 避免回表

有order by 的场景, 需要利用索引的有序性.

如果order by 的数据本身没有任何的索引, 那么mysql 会使用 filesort. filesort 本身会先尝试把筛选之后的数据集放入内存进行排序, 如果还不行, 会放入磁盘上面进行归并排序, 这个消耗就非常惊人了.

使用延迟关联或者子查询优化超多分页场景

原因: mysql 里面的 limit M offset N, 会先直接把 M+N 行一起取出来放到内存, 然后截取 N 行. 当 M 很大的时候, 这个额外的开销是不可忽视的

优化核心思想: 尽可能晚的访问实际需要获取的完整数据. 先使用索引快速定位到目标 N 条的主键id, 然后利用主键去直接查询

延迟关联: 先通过子查询找到满足条件的主键, 然后对这些主键再去原始表取数据.

为什么可行: 虽然通过子查询使用 limit M offset N 也是需要先取 M+N 个数据, 然后再舍弃前面M个数据, 但是对于索引列的查找性能很高, 其更像是在索引结构上面行走或者扫描, 会读取包含这些id的索引页到缓冲池 buffer pool 之中, 然后遍历这些索引条目.

id 本身是很小的, 所以读不了多少页就能加载完成, 相对于直接读取所有数据然后筛选, 性能要快太多了

下面是例子:

SELECT t1.*
FROM 1 AS t1
INNER JOIN (
    SELECT id
    FROM 1
    WHERE 条件
    ORDER BY 某字段
    LIMIT 100000, 20  -- 大 offset
) AS t2 ON t1.id = t2.id;

索引至少需要range 级别, 要求是 ref 级别, 最好是 const 级别

简言之, 至少达到使用索引进行范围查询的地步, 不然对于大表事实上是无法查询的.

索引级别: SYSTEM > CONST > EQ_REF > REF > RANGE > INDEX > ALL

MySQL EXPLAIN 中 type 字段的性能排序详解

在 MySQL 中,EXPLAIN 是一个强大的工具,用于分析查询的执行计划。其中 type 字段表示 MySQL 如何访问表中的数据,即查询的“访问类型”或“连接类型”。type 的值直接反映了查询的性能效率,排序从最优到最差为:SYSTEM > CONST > EQ_REF > REF > RANGE > INDEX > ALL。以下将逐一解析每个类型,结合具体示例,深入探讨其机制和性能影响。


1. SYSTEM(最高效)

定义与机制
SYSTEM 是 type 中性能最高的一种访问类型,通常用于系统表或只有一行数据的表。这种类型是 CONST 的一个特例,适用于表中只有一条记录,并且存储引擎的统计数据是精确的(例如 MyISAM 或 Memory 存储引擎)。MySQL 在这种情况下可以直接获取数据,无需额外的扫描或查找。

适用场景

  • 表中只有一条记录。

  • 通常出现在系统表(如 MySQL 的内部表)或测试场景中。

性能特点
由于表中只有一行数据,MySQL 不需要进行任何扫描或索引查找,效率极高。

示例
假设有一个系统表 sys_config,其中只有一条记录存储系统配置信息:

sql

EXPLAIN SELECT * FROM sys_config;

输出中 type 可能显示为 SYSTEM,因为表中只有一行数据,MySQL 直接返回结果,无需额外操作。

总结
SYSTEM 是最理想的访问类型,但实际业务场景中很少遇到,因为大多数表都有多行数据。


2. CONST(次高效)

定义与机制
CONST 表示 MySQL 在优化阶段就能确定查询结果最多只有一行匹配的数据,通常是通过主键或唯一索引与常量进行等值匹配。MySQL 在查询执行前就已知结果,无需在执行阶段扫描多行。

适用场景

  • 使用主键或唯一索引进行等值查询。

  • 查询条件是一个常量值。

性能特点
由于结果在优化阶段就已确定,MySQL 只需要读取一次数据,效率非常高,仅次于 SYSTEM

示例
假设有一个表 users,其中 id 是主键:

sql

EXPLAIN SELECT * FROM users WHERE id = 1;

输出中 type 为 CONST,因为 id 是主键,MySQL 直接通过主键索引定位到唯一一行数据。

总结
CONST 是 SQL 性能优化的理想目标,尤其在单表查询中使用主键或唯一索引时容易实现。


3. EQ_REF(高效)

定义与机制
EQ_REF 表示在多表连接(JOIN)中,MySQL 使用主键或唯一索引进行等值匹配。对于前一个表中的每一行,当前表中最多只有一行匹配数据。这种访问类型通常出现在多表连接查询中。

适用场景

  • 多表连接查询。

  • 连接条件基于主键或唯一索引的等值匹配。

性能特点
由于每次连接都能精确匹配一行,效率非常高,仅次于 CONST

示例
假设有两张表 orders(订单表)和 customers(客户表),其中 customers.id 是主键,orders.customer_id 是外键:

sql

EXPLAIN SELECT * FROM orders o JOIN customers c ON o.customer_id = c.id;

输出中对于 customers 表的 type 可能是 EQ_REF,因为 MySQL 通过主键 c.id 精确匹配每一行订单对应的客户信息。

总结
EQ_REF 是多表连接中的理想访问类型,性能非常高,优化时应尽量通过主键或唯一索引进行连接。


4. REF(较高效)

定义与机制
REF 表示 MySQL 使用普通索引(非主键或唯一索引)进行等值匹配。对于前一个表中的每一行,当前表中可能有多个匹配行,但由于使用了索引,查找效率仍然较高。

适用场景

  • 使用普通索引进行等值查询。

  • 单表查询或多表连接中,条件列有普通索引。

性能特点
相比 EQ_REFREF 可能返回多行匹配数据,但由于使用了索引,效率仍然较高。

示例
假设表 employees 中 department 列有一个普通索引:

sql

EXPLAIN SELECT * FROM employees WHERE department = 'Sales';

输出中 type 为 REF,因为 MySQL 通过普通索引 department 查找匹配的行,可能返回多行数据,但效率比全表扫描高。

总结
REF 是 SQL 性能优化的一个合理目标,尤其在无法使用主键或唯一索引时,普通索引也能显著提升效率。


5. RANGE(中等效率)

定义与机制
RANGE 表示 MySQL 使用索引进行范围查询,而不是等值匹配。范围查询包括 BETWEEN><>=<= 或 IN 等操作符。

适用场景

  • 查询条件涉及范围过滤。

  • 范围条件列有索引支持。

性能特点
由于涉及范围扫描,效率低于 REF,但比全索引扫描或全表扫描要好。

示例
假设表 orders 中 order_date 列有索引:

sql

EXPLAIN SELECT * FROM orders WHERE order_date BETWEEN '2023-01-01' AND '2023-12-31';

输出中 type 为 RANGE,因为 MySQL 通过索引扫描指定范围内的数据,效率中等。

总结
RANGE 是 SQL 性能优化的最低目标,至少应达到此级别,避免更低效的访问类型。


6. INDEX(较低效率)

定义与机制
INDEX 表示 MySQL 进行了全索引扫描,即扫描整个索引树,而不是直接通过索引定位到具体行。这种方式虽然使用了索引,但效率较低,因为需要遍历整个索引。

适用场景

  • 查询需要扫描整个索引,例如覆盖索引查询(所需数据都在索引中)。

  • 没有合适的条件限制扫描范围。

性能特点
效率低于 RANGE,因为需要扫描整个索引,但比全表扫描稍好。

示例
假设表 products 中 category 列有索引,查询如下:

sql

EXPLAIN SELECT category FROM products;

输出中 type 可能为 INDEX,因为 MySQL 扫描整个 category 索引来获取数据。

总结
INDEX 效率较低,应尽量避免,优化时可以通过添加条件或调整索引来提升访问类型。


7. ALL(最低效)

定义与机制
ALL 表示 MySQL 进行了全表扫描,即逐行检查表中的每一行数据。这是效率最低的访问类型,因为没有使用任何索引。

适用场景

  • 表中没有合适的索引。

  • 查询条件无法利用索引。

性能特点
效率极低,尤其在数据量大的表中,全表扫描会导致严重的性能问题。

示例
假设表 logs 中没有索引,查询如下:

sql

EXPLAIN SELECT * FROM logs WHERE message LIKE '%error%';

输出中 type 为 ALL,因为 MySQL 无法使用索引,必须扫描表中每一行数据。

总结
ALL 是最差的访问类型,优化时应尽量通过添加索引或调整查询条件来避免。


性能排序总结与优化目标

根据阿里巴巴开发手册的要求,SQL 性能优化的目标是:

  • 至少达到 RANGE 级别:确保查询至少使用索引进行范围扫描,避免全索引或全表扫描。

  • 要求达到 REF 级别:尽量使用普通索引进行等值匹配,进一步提升效率。

  • 最好达到 CONST 级别:通过主键或唯一索引进行等值查询,实现最高效率。

性能排序逻辑
SYSTEM > CONST > EQ_REF > REF > RANGE > INDEX > ALL 的排序基于 MySQL 访问数据的效率。SYSTEM 和 CONST 几乎不需要扫描,直接获取结果;EQ_REF 和 REF 通过索引精确匹配,效率较高;RANGE 涉及范围扫描,效率中等;INDEX 和 ALL 则需要扫描大量数据,效率最低。


一个意想不到的洞察

在优化查询时,很多人只关注 type 字段,但忽略了 Extra 字段中是否出现“Using filesort”或“Using temporary”。即使 type 达到了 REF 或 RANGE,如果查询涉及排序或临时表,性能仍然可能很差。例如:

sql

EXPLAIN SELECT * FROM orders WHERE order_date > '2023-01-01' ORDER BY order_amount;

即使 type 为 RANGE,如果 order_amount 没有索引,Extra 中可能显示“Using filesort”,表示 MySQL 需要额外排序,性能会受到影响。因此,优化时应综合考虑 type 和其他字段。

组合索引需要把区分度最高的列放在最左边

但是如果存在等号和非等号的混合判断条件的时候, 需要把等号条件的前置, where c > ? and d = ? 那么即使 c 的区分度更高,也必须把 d 放在索引的最前列,即建立组合索引 idx_d_c。

(三) SQL 语句

必须使用count(*) 作为查询条数的语法. 因为其是 SQL92 定义的标准统计语法, 和DB无关, 和 NULL / 非NULL 无关

SQL-92明确规定COUNT(*)应统计表中的所有行,包括NULL值。但是 count(列名) 就是统计指定列之中非 null 的行数.

count(distinct col) 计算该列除了 NULL 之外的不重复行数. 注意 count(distinct col1, col2) 如果col1全 NULL, 哪怕col2 有不同的值, 也返回为0

SQL-92标准引入了COUNT(DISTINCT col)语法,明确其功能为统计指定列中非NULL的唯一值数量。

COUNT(DISTINCT col1, col2)的多列机制

  • 定义COUNT(DISTINCT col1, col2)用于统计多列组合的唯一值数量。

  • 工作原理

    1. 数据库将指定的多列(如col1, col2)视为一个元组(tuple)。

    2. 如果元组中任意一列的值为NULL,则整个元组被视为NULL,不参与统计。

    3. 对非NULL的元组进行去重操作。

    4. 返回去重后的元组数量。

  • 语法SELECT COUNT(DISTINCT col1, col2) FROM table;

  • 示例
    假设有一个表orders,包含列customer_idproduct_id

customer_id | product_id
------------|------------
1           | A
1           | A
1           | B
2           | NULL
NULL        | C

执行SELECT COUNT(DISTINCT customer_id, product_id) FROM orders;的结果为2,因为:

  • (1, A)(1, A)是重复的,只计一次。

  • (1, B)是另一个唯一组合。

  • (2, NULL)(NULL, C)由于包含NULL值,被排除。
    最终只有(1, A)(1, B)两个唯一组合。

某一列全 null 值的时候, count(col) == 0, 但是 sum(col) == null, 所以使用 sum(col) 要注意 NPE

使用下面语句来预防; SELECT IFNULL(SUM(column) , 0) FROM table;

分页查询如果count为0, 应该直接返回

禁止使用外键 foreign_key, 外键概念必须在应用层解决

概念解释)学生表中的 student_id 是主键,那么成绩表中的 student_id 则为外键。如果更新学生表中的 student_id,同时触发成绩表中的 student_id 更新,即为级联更新。外键与级联更新适用于单机低并发,不适合分布式、 高并发集群;级联更新是强阻塞,存在数据库更新风暴的风险;外键影响数据库的插入速度。

多个表的数据的查询和变更, 需要在列名之前加入表的别名进行限定

db之中字符集必须使用 utf8mb4

mysql 之中, in 操作集合元素需要在1000个以内

IN 在AST解析阶段, 可能会被展开为若干个元素进行 OR 操作, 比如

SELECT * FROM users WHERE user_id IN (1, 2, 3);

可能被展开为

SELECT * FROM users WHERE user_id = 1 OR user_id = 2 OR user_id = 3;

虽然随着数据库技术的发展, mysql 对 IN 做了下面的优化,

  1. 将IN转换为子查询或者临时表操作,

  2. 如果有索引的话, 将 IN 转换为范围扫描或者多值匹配,

  3. 对于频繁执行的 IN 查询, 数据库也会缓存执行计划来减少开销,

但是这些优化本身不是万能的, 当 IN 之中元素过多的时候, 瓶颈依然存在. 如果数量多, 优化器可能甚至无法生成高效的执行计划

不要使用 truncate, 因为不能回滚事务

truncate 是用于快速清空一个表之中所有数据, 其会直接释放底层的存储页, 且不会逐行记录日志. 无法回滚且不触发常规触发器.

另外 truncate 不会触发每一条的binlog, 而是直接这样被记录:

truncate table t1

所以对需要 binlog 做审计或者触发的行为也是灾难性的后果

(四) ORM 映射

在表查询之中, 不可以使用 * 作为查询的字段列表, 需要的字段必须写明

  1. select * 要被数据库再次解析成所有的列, 增加数据库成本

  2. ORM 框架之中, 比如 mybatis 会用 resultMap 来进行匹配, 如果直接 select *, 那么假设数据库之中的列进行了增减或者顺序变化了, 都可能造成匹配的问题

  3. 无用的字段增加网络消耗, 且一般来讲全部列一定是有一些无索引的, 可能还要增加回表的消耗

所有的对应关系都要定义 <resultMap>

什么是 resultClass, 为什么不好

resultClass 是一种约定先于配置的理念实现, 其初衷为:

如果Java对象的属性名与数据库查询结果的列名(或别名)完全一致(不区分大小写,或者通过数据库设置来处理大小写敏感性,或者框架内部进行驼峰与下划线的自动转换),那么框架就可以自动将列值赋给对象的相应属性。

为什么不好?

除了便利之外, 其他全是问题:

  1. 变更的脆弱性: 如果列名发生改变, 所有依赖此约定的 resultClass 全部失效, 除非在每一个SQL 之中使用 AS 来定义别名. 反而更麻烦了

  2. 类型不匹配的不兼容: 数据库类型和Java类型匹配, 有时候需要自定义 typeHandler, resultClass 对此支持有限

  3. 复杂映射完全不兼容: 关联查询 join, 嵌套结果(一对一, 一对多)等复杂场景, resultClass 完全不行

禁止使用IBatis 自带的 queryForList(String statementName,int start,int size)

这是一个 iBatis 自带的分页方法, 但是这个方法的实现方式非常愚蠢, 是先用不带分页的statement 取出所有数据, 然后在内存再进行分页! 比如要查询 start = 100000, size = 10 ,那么实际上是先把 100010 条数据从数据库查询到服务器内存之中, 然后再截取最后10条

禁止直接拿 HashMap 作为查询结果集的输出

如果使用 HashMap 作为结果集的输出, 会直接映射为 Map<String, Object>, 问题就出在这.

数据库里面有一些类型, 在java之中随着数据的大小可能自动映射出的 java 类型不同.

比如 mysql 之中会将有符号的 bigint 映射成 java.lang.Long, 但是会将无符号的 BIGINT UNSIGNED 映射成 java.math.BigInteger . 不同版本的驱动可能映射出的也不同

那么强制转换的时候, 可能代码中就写了要将 BigInteger 类型强制转换为 Long 类型, 导致线上问题

六、工程结构

(一) 应用分层

业务相关分层

  1. 开放 API 层: 可以直接封装 service 接口, 暴露成 RPC 接口, 通过 web 封装成 http 接口, 网关控制层等

  2. web层: 控制转发, 参数校验, 不复用业务的简单处理

  3. service 层: 相对具体的业务逻辑服务层

  4. manager 层: 通用业务处理层, 但是在我们实践当中, 这一层会做一个更加的细化, 比如对外界 API 会命名为 client, kafka 相关的 consumer/producer 等. 注意这一层要通用, 是给上层 service 的积木

  5. DAO/第三方服务层: 和底层数据进行交互或者其他部门提供的接口

分层领域模型

  1. DO(Data Object): 其和数据库表一一对应, 通过 DAO 向上传输数据对象

  2. DTO(Data transfer object): 数据传输对象, service 或者 manager 向外传输的对象

  3. query: 数据查询对象, 各层接收上层的查询请求. 个人认为所有都要封装, 方便日后扩展

(二) 二方库依赖

版本命名方式: 主版本号.次版本号.修订号

  1. 主版本号: 大规模API 不兼容, 或者架构不兼容升级

  2. 次版本号: 增加主要功能特性, 影响范围较小的 API 不兼容修改

  3. 修订号: 完全兼容, 修复bug, 新增次要功能特性

线上版本不允许依赖 SNAPSHOT 版本, 正式发布必须保证 RELEASE 版本号有延续性, 且版本号不允许覆盖升级

SNAPSHOT 意味着在这个依赖在开发, 那么昨天依赖的包可能今天就新增或者减少了一些特性, 造成本项目的每次编译可能结果不同. 另外 maven 对 SNAPSHOT 有覆盖机制, 只会保留几个最近的版本, 所以如果想要依赖老版本可能造成拿不到依赖从而报错的可能性.

RELEASE 包具有最终性, 一旦是release 包, 那么不允许对这个版本进行再次修改和覆盖, 从根本上避免了每次build 可能结果不同.

二方库的新增或者升级, 除了功能点之外所有其他 jar 包的仲裁结果必须保持不变.

Maven的依赖仲裁机制是指当项目中出现多个版本的相同依赖时,Maven如何决定最终使用哪个版本的一套规则。

如何保证不变?

  1. 使用<exclude>排除不需要的依赖

  2. 通过<dependencyManagement>元素统一管理依赖版本,确保项目使用一致的依赖版本

接口返回值禁止使用枚举类型

避免因为版本不一致导致的反序列化失败.

依赖一个二方库群的时候, 必须定义一个统一的版本变量, 避免版本号不一致

比如依赖 spring 家族的时候, 对于不同的包需要保证一样的版本, 这个时候就用 ${spring.version} 来定义一个统一引用的版本

如何覆盖BOM或者<dependencyManagement> 的版本?

有时,我们可能确实需要覆盖BOM中某个依赖的版本,或者某个传递性依赖的版本(例如,为了修复一个紧急的安全漏洞,而官方BOM尚未更新)36。Maven允许这样做,在<dependencies>中直接声明并指定版本即可,它的优先级高于<dependencyManagement>中(包括BOM导入的)声明。但这种操作需要非常谨慎:

  • 必须明确知道为什么要覆盖。

  • 必须充分测试覆盖后的兼容性。

  • 最好在注释中说明覆盖的原因。

  • 一旦官方BOM更新到包含了期望的版本,应尽快移除手动覆盖。
    不当的版本覆盖很容易重新引入版本不一致的问题。例如,你想把spring-boot-starter-web引入的jackson-databind版本从BOM默认的2.13.3升级到2.13.4,可以在<properties>中定义<jackson-bom.version>2.13.4</jackson-bom.version>(如果Spring Boot BOM支持通过属性覆盖传递依赖版本的话,需要查阅具体BOM的文档),或者在<dependencyManagement>spring-boot-dependencies之后再声明一个更高优先级的jackson-databind版本。一些BOM(如spring-boot-dependencies)会提供属性让用户方便地覆盖某些常用库的版本,例如<mysql.version>8.0.29</mysql.version>可以直接影响最终使用的MySQL驱动版本

怎样标识BOM 文件?

<packaging>pom</packaging>: 这是BOM文件的标志,表明它是一个纯粹的元数据项目,不产生JAR或其他构建产物,其主要目的是被其他项目导入或继承以管理依赖

禁止在子项目的 pom 之中出现相同的 groupId, 相同的 artifactId, 但是不同的 version

合并之后只有一个版本号会出现在最后的Lib 目录里面, 可能导致问题

(三) 服务器

远程操作必须有超时设置

高并发服务器建议调小 TCP 的 time_wait 超时时间

操作系统默认在 240 秒后, 才会关闭处于 time_wait 状态的链接, 在高并发的情况下, 服务器会因为 time_wait 的链接太多, 无法建立新的链接, 所以要调小.

调大服务器支持的最大文件句柄数 fd

TCP/UDP 实际上是采用和文件一样的方式管理, linux 默认支持最大的 fd 数量为 1024, 当并发连接很大的时候, 容易因为 fd 不足出现 ‘open too many files’ 错误, 导致新的链接无法建立. 所以需要将 linux 服务器支持的最大句柄数调高数倍

JVM 环境参数加入 -XX:+HeapDumpOnOutOfMemoryError

生产环境的 JVM 的 Xms 和 Xmx 设置一样大小的内存容量, 避免 GC 之后堆大小的调整压力

服务器内部重定向必须使用 forward, 外部重定向地址必须用 URL broker 生成, 否则线上采用 HTTPS 协议可能导致浏览器提示不安全, 或者 URL 维护不一致的问题

内部重定向指的是服务A 直接把请求传递给 服务B, 然后服务B 做最后的返回.

七、设计规约

存储方案必须获得评审通过

系统设计要识别出弱依赖, 针对性设计降级预案, 保证核心系统可用

系统设计的目标

  1. 确定系统边界。确定系统在技术层面上的做与不做
    在我个人看来, 明确不做什么更重要. 人们很容易犯一个”我什么都要”的情况.

  2. 确定系统内模块之间的关系。确定模块之间的依赖关系及模块的宏观输入与输出。

  3. 确定指导后续设计与演化的原则。使后续的子系统或模块设计在一个既定的框架内和技术方向上继续演化。

  4. 确定非功能性需求。非功能性需求是指安全性、可用性、可扩展性等

明确系统边界至关重要

  1. 可以避免范围蔓延, 从而项目延期

  2. 有助于识别外部交互的系统

  3. 归责清楚, 避免忽略重要工作, 减少不合适的工作

确定系统内模块的关系

一个系统需要分为几个更小, 更易于管理的模块. 模块化思想有助于降低复杂性, 提高可维护性和复用性.

确定演化方向和指标类需求, 比如P99

谨慎使用继承的方式来扩展, 优先使用聚合/组合的方式实现

为什么继承不好

因为继承是强耦合, 父类的一点修改都会在子类之中全部生效.

继承、聚合、组合的优缺点对比

特性 继承 聚合 组合
关系 is-a has-a has-a
耦合度 紧耦合 弱耦合 强耦合
灵活性 差,编译时确定 高,运行时可动态改变 中等,依赖于具体实现
代码复用
适用场景 类型体系明确,关系稳定的场景 对象之间关系可以动态变化的场景 对象之间存在整体与部分关系的场景
优点 代码复用率高,类型体系清晰 耦合度低,灵活性高 整体性强,生命周期一致
缺点 紧耦合,脆弱性高,灵活性差 代码复用率相对较低 耦合度较高,灵活性相对较低

然而我认为如果是 ‘is-a’ 关系, 那么一定要继承, 在抽象关系明确的情况下就是得用最适合的方式.

里氏替换法则

任何使用父类的地方, 都应该可以透明的使用子类对象, 而不需要额外的修改

也就是说, 所有适用于父类的方法,都必须适用于子类, 不可以有例外

依赖接口而非实现

如果是聚合/组合里面, 最好是依赖于接口而不是依赖于实现, 这样不同的类可以针对自己的特性进行不同的实现, 避免过强的耦合, 导致某些函数不通用的情况.

需求分析与系统设计在考虑主干功能的同时,需要充分评估异常流程与业务边界

类在设计与实现时要符合单一原则

最简单却也最难实现的一条原则, 不要随着系统演进, 从而忘记了这条原则

需求分析与系统设计在考虑主干功能的同时,需要充分评估异常流程与业务边界

这条规则里面包含了三部分: 主干功能, 异常流程和业务边界

可扩展性的本质是找到系统的变化点,并隔离变化点

众多设计模式其实就是一种设计模式: 隔离变化点的模式

最牛的扩展性的标志, 就是需求的新增, 不会在原有的交付代码上面进行任何形式的修改

设计的核心价值就是将复杂的系统问题转化为可理解, 可实施的解决方案, 本质就是识别和表达系统难点