无题
Redis常见数据类型和应用场景
1.String(SDS)
- SDS 不仅可以保存文本数据,还可以保存二进制数据。因为
SDS使用len属性的值而不是空字符来判断字符串是否结束,并且 SDS 的所有 API 都会以处理二进制的方式来处理 SDS 存放在buf[]数组里的数据。所以 SDS 不光能存放文本数据,而且能保存图片、音频、视频、压缩文件这样的二进制数据。 - **SDS 获取字符串长度的时间复杂度是 O(1)**。因为 C 语言的字符串并不记录自身长度,所以获取长度的复杂度为 O(n);而 SDS 结构里用
len属性记录了字符串长度,所以复杂度为O(1)。 - Redis 的 SDS API 是安全的,拼接字符串不会造成缓冲区溢出。因为 SDS 在拼接字符串之前会检查 SDS 空间是否满足要求,如果空间不够会自动扩容,所以不会导致缓冲区溢出的问题。
常用指令
1 | # 设置 key-value 类型的值 |
场景使用
1.计数器(计算访问次数、转发、库存数量、文章的阅读量等等)
因为 Redis 处理命令是单线程,所以执行命令的过程是原子的。因此 String 数据类型适合计数场景
1 | # 设置 key-value 类型的值 |
2.缓存对象(用户信息等)
使用 String 来缓存对象有两种方式:
- 直接缓存整个对象的 JSON,命令例子:
SET user:1 '{"name":"xiaolin", "age":18}'。 - 采用将 key 进行分离为 user:ID:属性,采用 MSET 存储,用 MGET 获取各属性值,命令例子:
MSET user:1:name xiaolin user:1:age 18 user:2:name xiaomei user:2:age 20。
3.分布式锁
SET 命令有个 NX 参数可以实现「key不存在才插入」,可以用它来实现分布式锁:
- 如果 key 不存在,则显示插入成功,可以用来表示加锁成功;
- 如果 key 存在,则会显示插入失败,可以用来表示加锁失败。
一般而言,还会对分布式锁加上过期时间,分布式锁的命令如下:
1 | SET lock_key unique_value NX PX 10000 |
- lock_key 就是 key 键;
- unique_value 是客户端生成的唯一的标识;
- NX 代表只在 lock_key 不存在时,才对 lock_key 进行设置操作;
- PX 10000 表示设置 lock_key 的过期时间为 10s,这是为了避免客户端发生异常而无法释放锁。
而解锁的过程就是将 lock_key 键删除,但不能乱删,要保证执行操作的客户端就是加锁的客户端。所以,**解锁的时候,我们要先判断锁的 unique_value 是否为加锁客户端(可重入锁推荐使用哈希)**,是的话,才将 lock_key 键删除。
可以看到,解锁是有两个操作,这时就需要 Lua 脚本来保证解锁的原子性,因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,保证了锁释放操作的原子性。
1 | // 释放锁时,先比较 unique_value 是否相等,避免锁的误释放 |
这样一来,就通过使用 SET 命令和 Lua 脚本在 Redis 单节点上完成了分布式锁的加锁和解锁。
4.共享Session信息
通常我们在开发后台管理系统时,会使用 Session 来保存用户的会话(登录)状态,这些 Session 信息会被保存在服务器端,但这只适用于单系统应用,如果是分布式系统此模式将不再适用。
例如用户一的 Session 信息被存储在服务器一,但第二次访问时用户一被分配到服务器二,这个时候服务器并没有用户一的 Session 信息,就会出现需要重复登录的问题,问题在于分布式系统每次会把请求随机分配到不同的服务器。
因此,我们需要借助 Redis 对这些 Session 信息进行统一的存储和管理,这样无论请求发送到那台服务器,服务器都会去同一个 Redis 获取相关的 Session 信息,这样就解决了分布式系统下 Session 存储的问题。
2.List(压缩列表+双向链表)
- 如果列表的元素个数小于
512个(默认值,可由list-max-ziplist-entries配置),列表每个元素的值都小于64字节(默认值,可由list-max-ziplist-value配置),Redis 会使用压缩列表作为 List 类型的底层数据结构; - 如果列表的元素不满足上面的条件,Redis 会使用双向链表作为 List 类型的底层数据结构;
常用命令
1 | # 将一个或多个值value插入到key列表的表头(最左边),最后的值在最前面 |
使用场景
消息队列
消息保序:使用 LPUSH + RPOP;
阻塞读取:使用 BRPOP;
重复消息处理:生产者自行实现全局唯一 ID;
1
2
3#把一条全局 ID 为 111000102、库存量为 99 的消息插入了消息队列,消费者要记录已经处理过的消息的 ID。当收到一条消息后,消费者程序就可以对比收到的消息 ID 和记录的已处理过的消息 ID,来判断当前收到的消息有没有经过处理。如果已经处理过,那么,消费者程序就不再进行处理了。
> LPUSH mq "111000102:stock:99"
(integer) 1消息的可靠性:使用 BRPOPLPUSH
为了留存消息,List 类型提供了
BRPOPLPUSH命令,这个命令的作用是让消费者程序从一个 List 中读取消息,同时,Redis 会把这个消息再插入到另一个 List(可以叫作备份 List)留存。这样一来,如果消费者程序读了消息但没能正常处理,等它重启后,就可以从备份 List 中重新读取消息并进行处理了。
缺陷:List 不支持多个消费者消费同一条消息,不支持消费者组的实现
3.Hash(压缩列表+哈希表)
Hash 类型的底层数据结构是由压缩列表或哈希表实现的:
- 如果哈希类型元素个数小于
512个(默认值,可由hash-max-ziplist-entries配置),所有值小于64字节(默认值,可由hash-max-ziplist-value配置)的话,Redis 会使用压缩列表作为 Hash 类型的底层数据结构; - 如果哈希类型元素不满足上面条件,Redis 会使用哈希表作为 Hash 类型的 底层数据结构。
常用命令
1 | # 存储一个哈希表key的键值 |
使用场景
缓存对象(如购物车)
1 | # 存储一个哈希表uid:1的键值 |
以用户 id 为 key,商品 id 为 field,商品数量为 value,恰好构成了购物车的3个要素,如下图所示。
涉及的命令如下:
- 添加商品:
HSET cart:{用户id} {商品id} 1 - 添加数量:
HINCRBY cart:{用户id} {商品id} 1 - 商品总数:
HLEN cart:{用户id} - 删除商品:
HDEL cart:{用户id} {商品id} - 获取购物车所有商品:
HGETALL cart:{用户id}
当前仅仅是将商品ID存储到了Redis 中,在回显商品具体信息的时候,还需要拿着商品 id 查询一次数据库,获取完整的商品的信息。
4.Set(整数集合+哈希表)
Set 类型是一个无序并唯一的键值集合,它的存储顺序不会按照插入的先后顺序进行存储。
一个集合最多可以存储 2^32-1 个元素。概念和数学中个的集合基本类似,可以交集,并集,差集等等,所以 Set 类型除了支持集合内的增删改查,同时还支持多个集合取交集、并集、差集。
Set 类型的底层数据结构是由哈希表或整数集合实现的:
- 如果集合中的元素都是整数且元素个数小于
512(默认值,set-maxintset-entries配置)个,Redis 会使用整数集合作为 Set 类型的底层数据结构; - 如果集合中的元素不满足上面条件,则 Redis 使用哈希表作为 Set 类型的底层数据结构。
常用命令
1 | # 往集合key中存入元素,元素存在则忽略,若key不存在则新建 |
使用场景
集合的主要几个特性,无序、不可重复、支持并交差等操作。
因此 Set 类型比较适合用来数据去重和保障数据的唯一性,还可以用来统计多个集合的交集、错集和并集等,当我们存储的数据是无序并且需要去重的情况下,比较适合使用集合类型进行存储。
但是要提醒你一下,这里有一个潜在的风险。Set 的差集、并集和交集的计算复杂度较高,在数据量较大的情况下,如果直接执行这些计算,会导致 Redis 实例阻塞。
在主从集群中,为了避免主库因为 Set 做聚合计算(交集、差集、并集)时导致主库被阻塞,我们可以选择一个从库完成聚合统计,或者把数据返回给客户端,由客户端来完成聚合统计。
点赞
Set 类型可以保证一个用户只能点一个赞,这里举例子一个场景,key 是文章id,value 是用户id。
uid:1 、uid:2、uid:3 三个用户分别对 article:1 文章点赞了。
1 | # uid:1 用户对文章 article:1 点赞 |
共同关注
Set 类型支持交集运算,所以可以用来计算共同关注的好友、公众号等。
key 可以是用户id,value 则是已关注的公众号的id。
uid:1 用户关注公众号 id 为 5、6、7、8、9,uid:2 用户关注公众号 id 为 7、8、9、10、11。
1 | # uid:1 用户关注公众号 id 为 5、6、7、8、9 |
抽奖活动
存储某活动中中奖的用户名 ,Set 类型因为有去重功能,可以保证同一个用户不会中奖两次。
key为抽奖活动名,value为员工名称,把所有员工名称放入抽奖箱 :
1 | >SADD lucky Tom Jerry John Sean Marry Lindy Sary Mark |
5.Zset(压缩列表+跳表)
有序集合保留了集合不能有重复成员的特性(分值可以重复),但不同的是,有序集合中的元素可以排序。
- 如果有序集合的元素个数小于
128个,并且每个元素的值小于64字节时,Redis 会使用压缩列表作为 Zset 类型的底层数据结构; - 如果有序集合的元素不满足上面的条件,Redis 会使用跳表作为 Zset 类型的底层数据结构;
常用命令
1 | # 往有序集合key中加入带分值元素 |
使用场景
排行榜
电话、姓名排序
6.BitMap
Bitmap,即位图,是一串连续的二进制数组(0和1),可以通过偏移量(offset)定位元素。BitMap通过最小的单位bit来进行0|1的设置,表示某个元素的值或者状态,时间复杂度为O(1)。
由于 bit 是计算机中最小的单位,使用它进行储存将非常节省空间,特别适合一些数据量大且使用二值统计的场景。
常用命令
使用场景
签到统计
判断用户登录态
连续签到用户总数
Redis如何实现服务高可用?
1.主从复制
2.哨兵模式
3.切片集群
缓存雪崩、击穿、穿透的解决方案?
数据库和缓存如何保证一致性?
更新数据库+更新缓存
应用场景:业务对缓存命中率有很高的要求(缓存的命中率越高则表示使用缓存的收益越高,应用的性能越好(响应时间越短、吞吐量越高),抗并发的能力越强。)
1.先更新数据库,再更新缓存
存在问题:写写并发,更新同一条数据,导致数据不一致
解决方案:
方案1:在更新缓存前先加个分布式锁,保证同一时间只运行一个请求更新缓存,就会不会产生并发问题了,当然引入了锁后,对于写入的性能就会带来影响。
方案2:在更新完缓存时,给缓存加上较短的过期时间,这样即时出现缓存不一致的情况,缓存的数据也会很快过期,对业务还是能接受的。
2.先更新缓存,再更新数据库
存在问题:写写并发,更新同一条数据,导致数据不一致
解决方案:
方案:在更新完缓存时,给缓存加上较短的过期时间,这样即时出现缓存不一致的情况,缓存的数据也会很快过期,对业务还是能接受的。
Cache Aside 策略
思路:在更新数据时,不更新缓存,而是删除缓存中的数据。然后,到读取数据时,发现缓存中没了数据之后,再从数据库中读取数据,更新到缓存中。
写策略的步骤:
- 更新数据库中的数据;
- 删除缓存中的数据。
读策略的步骤:
- 如果读取的数据命中了缓存,则直接返回数据;
- 如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入到缓存,并且返回给用户。
写策略顺序探讨:
3.先删除缓存,后更新数据库
存在问题:写读并发
以用户表的场景来分析。假设某个用户的年龄是 20,请求 A 要更新用户年龄为 21,所以它会删除缓存中的内容。这时,另一个请求 B 要读取这个用户的年龄,它查询缓存发现未命中后,会从数据库中读取到年龄为 20,并且写入到缓存中,然后请求 A 继续更改数据库,将用户的年龄更新为 21。
解决方案:延迟双删
请求 A 的睡眠时间就需要大于请求 B 「从数据库读取数据 + 写入缓存」的时间。
4.先更新数据库,再删除缓存
可能存在的问题:读写并发
假如某个用户数据在缓存中不存在,请求 A 读取数据时从数据库中查询到年龄为 20,在未写入缓存中时另一个请求 B 更新数据。它更新数据库中的年龄为 21,并且清空缓存。这时请求 A 把从数据库中读到的年龄为 20 的数据写入到缓存中。
从上面的理论上分析,先更新数据库,再删除缓存也是会出现数据不一致性的问题,但是在实际中,这个问题出现的概率并不高。因为缓存的写入通常要远远快于数据库的写入,所以在实际中很难出现请求 B 已经更新了数据库并且删除了缓存,请求 A 才更新完缓存的情况。所以,「先更新数据库 + 再删除缓存」的方案,是可以保证数据一致性的。
兜底策略:给缓存数据加上了「过期时间」,就算在这期间存在缓存数据不一致,有过期时间来兜底,这样也能达到最终一致性。
如果删除缓存失败怎么办?
在删除缓存(第二个操作)的时候失败了,导致缓存中的数据是旧值,会导致数据库与缓存数据不一致。
解决方法:不管是先操作数据库,还是先操作缓存,只要第二个操作失败都会出现数据一致的问题。
1.重试机制
我们可以引入消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列,由消费者来操作数据。
- 如果应用删除缓存失败(返回1成功),可以从消息队列中重新读取数据,然后再次删除缓存,这个就是重试机制。当然,如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。
- 如果删除缓存成功,就要把数据从消息队列中移除,避免重复操作,否则就继续重试。
2.订阅MySQL binlog,再操作缓存。
「先更新数据库,再删缓存」的策略的第一步是更新数据库,那么更新数据库成功,就会产生一条变更日志,记录在 binlog 里。于是我们就可以通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除
Volatile
lock指令
Sychronize锁升级过程
简单来说在JVM中monitorenter和monitorexit字节码依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的;然而在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境)如果每次都调用Mutex Lock那么将严重的影响程序的性能。不过在jdk1.6中对锁的实现引入了大量的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销。
无锁->偏向锁->轻量级锁->重量级锁(此过程是不可逆的)
markWord有64位,记录了锁信息和GC信息
无锁态: 偏向锁位0 锁标志位 01
偏向锁: 偏向锁位1 锁标志位 01
自旋锁: 偏向锁位失效 锁标志位 00
重量锁: 偏向锁位失效 锁标志位 10
分代年龄: 当一个对象被垃圾回收器回收一次, GC+1, 回收次数超过一定数值 转移至老年代 .
hashCode: 31位记录了hashCode, 如果对hashCode方法进行了调用, 则会把值记录在其中
最开始一个对象 被new出来 没有任何锁Object o = new Object();此时是无锁态;来了个线程,当第一个线程要使用这个对象的时候,先不上锁 而是有个指针指向当前使用的线程, 升级为偏向锁,有一个指向当前线程的指针, 标记当前线程在使用这把锁, 称之为偏向锁,(我们可以理解为, markword中有一块地方记录了线程的线程id);当有线程来竞争锁的时候, 开始线程竞争,先撤销偏向锁状态,每个线程都有自己的线程栈, 在自己的线程栈中生成lock record(锁记录),谁先抢夺到资源, 则会把自己线程的lock Record的记录, 记录在轻量级锁中,使用自旋(CAS)的方式抢夺资源,此时有竞争的状态则升级为了轻量级锁,当进入轻量级锁以后 未能竞争上岗的线程则会一直自旋, 等待拿到轻量级锁,但是自旋的过程很消耗CPU (一般来讲有锁的情况下 线程是在队列里等待 不消耗CPU),所以需要下一步的锁升级(默认自旋超过10次 或者 自旋等待线程超过CPU线程二分之一 ),当JVM发现线程竞争比较激烈, 且超过了JVM的自旋次数,JVM会向操作系统申请一把大锁(互斥锁Mutex);在进入重量级锁后, 重量级锁会生成等待队列,所有线程进入等待队列,synchronize队列不是有序的(非公平锁)
操作系统的锁
互斥锁、自旋锁、读写锁、悲观锁、乐观锁
最底层的两种就是会「互斥锁和自旋锁」,有很多高级的锁都是基于它们实现的。
- 互斥锁加锁失败后,线程会释放 CPU ,给其他线程;
- 自旋锁加锁失败后,线程会忙等待,直到它拿到锁;
在项目中有什么难点?
在我负责的这部分用到了多线程(数据量比较大、处理快一点、异步方式)、MQ、以及分布式锁的一些知识
线程池的线程数配多少?
线程池里的线程数量设置多少合适
- CPU密集型(加密、计算hash等):最佳线程数为CPU核心数的1-2倍左右。
- 耗时IO型(读写数据库、文件、网络读写等):最佳线程数一般会大于CPU核心数很多倍
- 线程数=CPU核心数*(1+平均等待时间/平均工作时间)
- 更精确可以压测。(模拟数据,在测试环境跑一下,看单位时间内能跑多少数据,看一下性能)
线程为什么不是配置越多越好?
因为线程过多增加cpu上下文切换成本,反而影响性能
协程能支持更多的并发,有了解协程和线程的区别吗?
在一个线程内实现并行并发的效果
线程的切换(cpu的切换)?
切换的时候cpu的缓存失效,只能重新从内存中去读取线程相关内容(上一次执行的位置等信息),因为缓存不断的失效,不断的从内存中读取,会造成一些性能的下降
每次CPU内部的缓存线程共享的吗?还是线程私有的?
线程私有的,用volatile(不用加锁,汇编的层面有个lock的指令,)、sychronized(粒度较大,当锁升级为重量级锁的时候,会造成线程阻塞)或lock锁实现线程间的同步
哪种场景适合用volatile?哪种场景适合用sychronized?哪种场景适合用lock?
线程池
1 | public ThreadPoolExecutor(int corePoolSize, |
corePoolSize线程池中的核心线程数,当提交一个任务时,线程池创建一个新线程执行任务,直到当前线程数等于corePoolSize, 即使有其他空闲线程能够执行新来的任务, 也会继续创建线程;如果当前线程数为corePoolSize,继续提交的任务被保存到阻塞队列中,等待被执行;如果执行了线程池的prestartAllCoreThreads()方法,线程池会提前创建并启动所有核心线程。workQueue用来保存等待被执行的任务的阻塞队列. 在JDK中提供了如下阻塞队列:ArrayBlockingQueue: 基于数组结构的有界阻塞队列,按FIFO排序任务;LinkedBlockingQueue: 基于链表结构的阻塞队列,按FIFO排序任务,吞吐量通常要高于ArrayBlockingQueue;SynchronousQueue: 一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQueue;PriorityBlockingQueue: 具有优先级的无界阻塞队列;
LinkedBlockingQueue比ArrayBlockingQueue在插入删除节点性能方面更优,但是二者在put(), take()任务的时均需要加锁,SynchronousQueue使用无锁算法,根据节点的状态判断执行,而不需要用到锁,其核心是Transfer.transfer().
maximumPoolSize线程池中允许的最大线程数。如果当前阻塞队列满了,且继续提交任务,则创建新的线程执行任务,前提是当前线程数小于maximumPoolSize;当阻塞队列是无界队列, 则maximumPoolSize则不起作用, 因为无法提交至核心线程池的线程会一直持续地放入workQueue.keepAliveTime线程空闲时的存活时间,即当线程没有任务执行时,该线程继续存活的时间;默认情况下,该参数只在线程数大于corePoolSize时才有用, 超过这个时间的空闲线程将被终止;unitkeepAliveTime的单位threadFactory创建线程的工厂,通过自定义的线程工厂可以给每个新建的线程设置一个具有识别度的线程名。默认为DefaultThreadFactoryhandler线程池的饱和策略,当阻塞队列满了,且没有空闲的工作线程,如果继续提交任务,必须采取一种策略处理该任务,线程池提供了4种策略:AbortPolicy: 直接抛出异常,默认策略;CallerRunsPolicy: 用调用者所在的线程来执行任务;DiscardOldestPolicy: 丢弃阻塞队列中靠最前的任务,并执行当前任务;DiscardPolicy: 直接丢弃任务;
当然也可以根据应用场景实现RejectedExecutionHandler接口,自定义饱和策略,如记录日志或持久化存储不能处理的任务。
如果一个线程池要关闭它?你会用什么方法?
遍历线程池中的所有线程,然后逐个调用线程的interrupt方法来中断线程.
**shutdown():**将线程池里的线程状态设置成SHUTDOWN状态, 然后中断所有没有正在执行任务的线程.停止接收新的任务并且等待已经提交的任务(包含提交正在执行和提交未执行)执行完成。当所有提交任务执行完毕,线程池即被关闭。
**awaitTermination():**阻塞等待所有任务都执行完毕再关闭线程池,接收人timeout和TimeUnit两个参数,用于设定超时时间及单位。当等待超过设定时间时,会监测ExecutorService是否已经关闭,若关闭则返回true,否则返回false。一般情况下会和shutdown方法组合使用。
1 | public class TestShutDown { |
shutdownNow():将线程池里的线程状态设置成STOP状态, 然后停止所有正在执行或暂停任务的线程.
任务的提交
1、execut()可以添加一个Runable任务,submit()不仅可以添加Runable任务还可以添加Callable任务。
2、execut()没有返回值,而submit()在添加Callable任务时会有返回值(再添加Runable任务时也有,不过无意义),可以通过返回值来查看线程执行的情况。
3、如果发生异常submit()可以通过捕获Future.get抛出的异常,而execute()会终止这个线程。
通过submit方法提交的Callable任务会被封装成了一个FutureTask对象。通过Executor.execute方法提交FutureTask到线程池中等待被执行,最终执行的是FutureTask的run方法;
1 | // submit方法在AbstractExecutorService中的实现 |
配置线程池需要考虑因素
从任务的优先级,任务的执行时间长短,任务的性质(CPU密集/ IO密集),任务的依赖关系这四个角度来分析。并且近可能地使用有界的工作队列。
性质不同的任务可用使用不同规模的线程池分开处理:
- CPU密集型: 尽可能少的线程,Ncpu+1
- IO密集型: 尽可能多的线程, Ncpu*2,比如数据库连接池
- 混合型: CPU密集型的任务与IO密集型任务的执行时间差别较小,拆分为两个线程池;否则没有必要拆分。
监控线程池的状态
可以使用ThreadPoolExecutor以下方法:
getTaskCount()Returns the approximate total number of tasks that have ever been scheduled for execution.返回线程池收到的任务总数。getCompletedTaskCount()Returns the approximate total number of tasks that have completed execution. 返回线程池完成任务的数量。返回结果少于getTaskCount()。getLargestPoolSize()Returns the largest number of threads that have ever simultaneously been in the pool.返回线程池曾经达到的线程的最大数。 返回结果小于等于maximumPoolSizegetPoolSize()Returns the current number of threads in the pool.当前线程池的大小。getActiveCount()Returns the approximate number of threads that are actively executing tasks. 获得线程池中当前活动线程的数量。- BlockingQueue getQueue() 返回阻塞队列。
longAddress和AtomicLong区别?(高并发场景下效率高)
RetrreenLock(JUC包里)和sychronized(JVM层面关键字)区别?
ThreadLocal
重入锁概念?
假如说有一个线程已经持有这个锁了,有可能以递归的形式或者调用另外一个方法的方式再次去尝试持有这个锁,可重入锁就不用再去竞争了,就可以直接进入到这个锁里
redis实现重入锁?
(可以,value) (锁计数,占有线程)
redis存一个整数,先判断锁计数是不是0,是0,就可以持有,不是0,判断是不是当前线程,是当前线程就Increment加1,释放锁就减1,减为0就将(key,value)删除掉或置为空,原子性的过程可以用Lua脚本来保证,锁应该设置一个超时时间,防止客户端挂掉
代码中事务传播怎么做的?
事务传播行为用来描述由某一个事务传播行为修饰的方法被嵌套进另一个方法的时事务如何传播。
用伪代码说明:
1 | public void methodA(){ |
7种
REQUIRED:在外围方法未开启事务的情况下Propagation.REQUIRED修饰的内部方法会新开启自己的事务,且开启的事务相互独立,互不干扰。在外围方法开启事务的情况下Propagation.REQUIRED修饰的内部方法会加入到外围方法的事务中,所有Propagation.REQUIRED修饰的内部方法和外围方法均属于同一事务,只要一个方法回滚,整个事务均回滚。
REQUIRES_NEW:
https://mp.weixin.qq.com/s/IglQITCkmx7Lpz60QOW7HA
什么是编程式事务?什么是声明式事务?
所谓声明式事务,就是通过配置的方式,比如通过配置文件(xml)或者注解的方式,告诉spring,哪些方法需要spring帮忙管理事务,然后开发者只用关注业务代码,而事务的事情spring自动帮我们控制。
编程式事务基于底层的API,如PlatformTransactionManager、TransactionDefinition 和 TransactionTemplate 等核心接口,开发者完全可以通过编程的方式来进行事务管理。编程式事务方式需要是开发者在代码中手动的管理事务的开启、提交、回滚等操作。
Spring容器加载,启动过程?
beanFactory
动态代理?
模板方法?
秒杀系统设计
https://mp.weixin.qq.com/s/KWb3POodisbOEsQVblsoGw
秒杀出现的几个问题:
高并发:秒杀大家都知道如果真的营销到位,价格诱人,几十万的流量我觉得完全不是问题,单机的Redis我感觉3-4W的QPS还是能顶得住的,但是再高了就没办法了,那这个数据随便搞个热销商品的秒杀可能都不止了。大量的请求进来,我们需要考虑的点就很多了,缓存雪崩,缓存击穿,缓存穿透这些我之前提到的点都是有可能发生的,出现问题打挂DB那就很难受了,活动失败用户体验差,活动人气没了,最后背锅的还是开发。 CDN资源静态化(秒杀一般都是特定的商品还有页面模板,就把能提前放入cdn服务器的东西都放进去,反正把所有能提升效率的步骤都做一下,减少真正秒杀时候服务器的压力。) 限流组件、风控 秒杀数量过高用MQ改库
超卖:但凡是个秒杀,都怕超卖,我这里举例的只是尿不湿,要是换成100个MacBook Pro,商家的预算经费卖100个可以赚点还可以造势,结果你写错程序多卖出去200个,你不发货用户投诉你,平台封你店,你发货就血亏,你怎么办?Redis Lua脚本
恶意请求:你这么低的价格,假如我抢到了,我转手卖掉我不是血赚?那简单啊,我知道你什么时候抢,我搞个几十台机器搞点脚本,我也模拟出来十几万个人左右的请求,那我是不是意味着我基本上有80%的成功率了。 风控控制
链接暴露:开发知道地址,在秒杀的时候自己提前请求。。。秒杀链接加盐:把URL动态化,就连写代码的人都不知道,你就通过MD5之类的摘要算法加密随机的字符串去做url,然后通过前端代码获取url后台校验才能通过。
数据库:每秒上万甚至十几万的QPS(每秒请求数)直接打到数据库,基本上都要把库打挂掉,而且你服务不单单是做秒杀的还涉及其他的业务,你没做降级、限流、熔断啥的,别的一起挂,小公司的话可能全站崩溃404。
数据库用MySQL只要连接池设置合理一般问题是不大的,不过一般大公司不缺钱而且秒杀这样的活动十分频繁,我之前所在的公司就是这样秒杀特卖这样的场景一直都是不间断的。单独给秒杀建立一个数据库,为秒杀服务,表的设计也是竟可能的简单点,现在的互联网架构部署都是分库的。至于表就看大家怎么设计了,该设置索引的地方还是要设置索引的,建完后记得用explain看看SQL的执行计划。
分区分库分表
对于一个日活用户在百万数量级的商城来说,每天产生的订单数量可能在百万级,特别在一些活动促销期间,甚至上千万。假设我们基于单表来实现,每天产生上百万的数据量,不到一个月的时间就要承受上亿的数据,这时单表的性能将会严重下降。因为 MySQL 在 InnoDB 存储引擎下创建的索引都是 基于 B+ 树实现的,所以查询时的 I/O 次数很大程度取决于树的高度,随着 B+ 树的树高增高,I/O 次数增加,查询性能也就越差。
当我们面对一张海量数据的表时,通常有分区、NoSQL 存储、分表分库等优化方案。
分区:分区的底层虽然也是基于分表的原理实现的,即有多个底层表实现,但分区依然是在单库下进行的,在一些需要提高并发的场景中的优化空间非常有限,且一个表最多只能支持 1024个分区。面对日益增长的海量数据,优化存储能力有限。不过 在一些非海量数据的大表中,我们可以考虑使用分区来优化表性能。
NoSQL 存储:基于键值对存储,虽然查询性能非常高,但在一些方面仍然存在短板。例如,不是关系型数据库,不支持事务以及稳定性方面相对 RDBMS 差一些。虽然有些NoSQL 数据库也实现了事务,宣传具有可靠的稳定性,但 目前 NoSQL 还是主要用作辅助存储 。
分库分表:在我看来,能不分表分库就不要分表分库。 在单表的情况下,当业务正常时,我们使用单表即可,而当业务出现了性能瓶颈时,我们首先考虑用分区的方式来优化,如果分区优化之后仍然存在后遗症,此时我们再来考虑分表分库。
我们知道,如果在单表单库的情况下,当数据库表的数据量逐渐累积到一定的数量时(5000W 行或 100G 以上),操作数据库的性能会出现明显下降,即使我们使用索引优化或读写库分离,性能依然存在瓶颈。此时,如果每日数据增长量非常大,我们就应该考虑分表,避免单表数据量过大,造成数据库操作性能下降。 面对海量数据,除了单表的性能比较差以外,我们在单表单库的情况下,数据库连接数、磁盘 I/O 以及网络吞吐等资源都是有限的,并发能力也是有限的。所以,在一些大数据量且高并发的业务场景中,我们就需要考虑分表分库来提升数据库的并发处理能力,从而提升应用的整体性能。
为了更容易理解这些问题,我们将对一个订单表进行分库分表,通过详细的业务来分析这些问题。
假设我们有一张订单表以及一张订单详情表,每天的数据增长量在 60W 单,平时还会有一些促销类活动,订单增长量在千万单。为了提高系统的并发能力,我们考虑将订单表和订单详情表做分库分表。除了分表,因为用户一般查询的是最近的订单信息,所以热点数据比较
集中,我们还可以考虑用表分区来优化单表查询。
通常订单的分库分表要么基于订单号 Hash 取模实现,要么根据用户 ID Hash 取模实现。订单号 Hash 取模的好处是数据能均匀分布到各个表中,而缺陷则是一个用户查询所有订单时,需要去多个表中查询。
由于订单表用户查询比较多,此时我们应该考虑使用用户 ID 字段做 Hash 取模,对订单表进行水平分表。如果需要考虑高并发时的订单处理能力,我们可以考虑基于用户 ID 字段Hash 取模实现分库分表。这也是大部分公司对订单表分库分表的处理方式。
分库分表存在问题:
1.分布式事务问题
在提交订单时,除了创建订单之外,我们还需要扣除相应的库存。而订单表和库存表由于垂直分库,位于不同的库中,这时我们需要通过分布式事务来保证提交订单时的事务完整性。通常,我们解决分布式事务有两种通用的方式:两阶事务提交(2PC)以及补偿事务提交(TCC)。有关分布式事务的内容,我将在第 41 讲中详细介绍。通常有一些中间件已经帮我们封装好了这两种方式的实现,例如 Spring 实现的 JTA,目前阿里开源的分布式事务中间件 Fescar,就很好地实现了与 Dubbo 的兼容。
2.跨节点JOIN查询问题
3.跨节点分页查询问题
4.全局主键ID问题
分布式事务
两阶事务提交(2PC)以及补偿事务提交(TCC)
zookeeper
- 配置管理
- 集群管理
- 分布式锁
分布式锁、集群选举机制
RPC框架的架构原理
三部分组成:服务提供者、注册中心、服务消费者
一致性哈希
当我们想提高系统的容量,就会将数据水平切分到不同的节点来存储,也就是将数据分布到了不同的节点。比如一个分布式 KV(key-valu) 缓存系统,某个 key 应该到哪个或者哪些节点上获得,应该是确定的,不是说任意访问一个节点都可以得到缓存结果的。
有的同学可能很快就想到了:哈希算法。因为对同一个关键字进行哈希计算,每次计算都是相同的值,这样就可以将某个 key 确定到一个节点了,可以满足分布式系统的负载均衡需求。
哈希算法最简单的做法就是进行取模运算,比如分布式系统中有 3 个节点,基于 hash(key) % 3 公式对数据进行了映射。
但是有一个很致命的问题,如果节点数量发生了变化,也就是在对系统做扩容或者缩容时,必须迁移改变了映射关系的数据,否则会出现查询不到数据的问题。
一致哈希算法也用了取模运算,但与哈希算法不同的是,哈希算法是对节点的数量进行取模运算,而一致哈希算法是对 2^32 进行取模运算,是一个固定的值。
我们可以把一致哈希算法是对 2^32 进行取模运算的结果值组织成一个圆环,就像钟表一样,钟表的圆可以理解成由 60 个点组成的圆,而此处我们把这个圆想象成由 2^32 个点组成的圆,这个圆环被称为哈希环
一致性哈希要进行两步哈希:
- 第一步:对存储节点进行哈希计算,也就是对存储节点做哈希映射,比如根据节点的 IP 地址进行哈希;
- 第二步:当对数据进行存储或访问时,对数据进行哈希映射;
所以,一致性哈希是指将「存储节点」和「数据」都映射到一个首尾相连的哈希环上。
问题来了,对「数据」进行哈希映射得到一个结果要怎么找到存储该数据的节点呢?
答案是,映射的结果值往顺时针的方向的找到第一个节点,就是存储该数据的节点。
因此,在一致哈希算法中,如果增加或者移除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响。
但是一致性哈希算法并不保证节点能够在哈希环上分布均匀,这样就会带来一个问题,会有大量的请求集中在一个节点上。
另外,在这种节点分布不均匀的情况下,进行容灾与扩容时,哈希环上的相邻节点容易受到过大影响,容易发生雪崩式的连锁反应。
比如,上图中如果节点 A 被移除了,当节点 A 宕机后,根据一致性哈希算法的规则,其上数据应该全部迁移到相邻的节点 B 上,这样,节点 B 的数据量、访问量都会迅速增加很多倍,一旦新增的压力超过了节点 B 的处理能力上限,就会导致节点 B 崩溃,进而形成雪崩式的连锁反应。
所以,一致性哈希算法虽然减少了数据迁移量,但是存在节点分布不均匀的问题。
如何通过虚拟节点提高均衡度?
所以这个时候我们就加入虚拟节点,也就是对一个真实节点做多个副本。
具体做法是,不再将真实节点映射到哈希环上,而是将虚拟节点映射到哈希环上,并将虚拟节点映射到实际节点,所以这里有「两层」映射关系。
另外,虚拟节点除了会提高节点的均衡度,还会提高系统的稳定性。当节点变化时,会有不同的节点共同分担系统的变化,因此稳定性更高。
比如,当某个节点被移除时,对应该节点的多个虚拟节点均会移除,而这些虚拟节点按顺时针方向的下一个虚拟节点,可能会对应不同的真实节点,即这些不同的真实节点共同分担了节点变化导致的压力。
而且,有了虚拟节点后,还可以为硬件配置更好的节点增加权重,比如对权重更高的节点增加更多的虚拟机节点即可。
因此,带虚拟节点的一致性哈希方法不仅适合硬件配置不同的节点的场景,而且适合节点规模会发生变化的场景。
类的生命周期
其中类加载的过程包括了加载、验证、准备、解析、初始化五个阶段。在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,*而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定)*。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。
类的加载: 查找并加载类的二进制数据
加载时类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:
- 通过一个类的全限定名来获取其定义的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。
相对于类加载的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。
JVM类加载机制
双亲委派机制过程?
- 当AppClassLoader(应用程序类加载器)加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。
- 当ExtClassLoader(扩展类加载器)加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。
- 如果BootStrapClassLoader(启动类加载器)加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载;
- 若ExtClassLoader也加载失败,则会使用AppClassLoader来加载,如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException。
自定义类加载器
通常情况下,我们都是直接使用系统类加载器。但是,有的时候,我们也需要自定义类加载器。比如应用是通过网络来传输 Java 类的字节码,为保证安全性,这些字节码经过了加密处理,这时系统类加载器就无法对其进行加载,这样则需要自定义类加载器来实现。自定义类加载器一般都是继承自 ClassLoader 类,从上面对 loadClass 方法来分析来看,我们只需要重写 findClass 方法即可。
验证: 确保被加载的类的正确性
验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致会完成4个阶段的检验动作:
文件格式验证: 验证字节流是否符合Class文件格式的规范;例如: 是否以0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。元数据验证: 对字节码描述的信息进行语义分析(注意: 对比javac编译阶段的语义分析),以保证其描述的信息符合Java语言规范的要求;例如: 这个类是否有父类,除了java.lang.Object之外。字节码验证: 通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。符号引用验证: 确保解析动作能正确执行。
准备: 为类的静态变量分配内存,并将其初始化为默认值
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。
解析: 把类中的符号引用转换为直接引用
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用就是一组符号来描述目标,可以是任何字面量。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
初始化
初始化,为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。在Java中对类变量进行初始值设定有两种方式:
- 声明类变量是指定初始值
- 使用静态代码块为类变量指定初始值
JVM初始化步骤
- 假如这个类还没有被加载和连接,则程序先加载并连接该类
- 假如该类的直接父类还没有被初始化,则先初始化其直接父类
- 假如类中有初始化语句,则系统依次执行这些初始化语句
类初始化时机: 只有当对类的主动使用的时候才会导致类的初始化,类的主动使用包括以下六种:
- 创建类的实例,也就是new的方式
- 访问某个类或接口的静态变量,或者对该静态变量赋值
- 调用类的静态方法
- 反射(如Class.forName(“com.pdai.jvm.Test”))
- 初始化某个类的子类,则其父类也会被初始化
- Java虚拟机启动时被标明为启动类的类(Java Test),直接使用java.exe命令来运行某个主类
使用
类访问方法区内的数据结构的接口, 对象是Heap区的数据。
卸载
Java虚拟机将结束生命周期的几种情况
- 执行了System.exit()方法
- 程序正常执行结束
- 程序在执行过程中遇到了异常或错误而异常终止
- 由于操作系统出现错误而导致Java虚拟机进程终止
为什么 MySQL InnoDB 选择 B+tree 作为索引的数据结构?
另外,B+Tree 叶子节点采用的是双链表连接,适合 MySQL 中常见的基于范围的顺序查找,而 B 树无法做到这一点。
Hash 在做等值查询的时候效率贼快,搜索复杂度为 O(1)。
但是 Hash 表不适合做范围查询,它更适合做等值的查询,这也是 B+Tree 索引要比 Hash 表索引有着更广泛的适用场景的原因
什么时候适用索引?
- 字段有唯一性限制的,比如商品编码;
- 经常用于
WHERE查询条件的字段,这样能够提高整个表的查询速度,如果查询条件不是一个字段,可以建立联合索引。 - 经常用于
GROUP BY和ORDER BY的字段,这样在查询的时候就不需要再去做一次排序了,因为我们都已经知道了建立索引之后在 B+Tree 中的记录都是排序好的。
什么时候不需要创建索引什么时候不需要创建索引?
WHERE条件,GROUP BY,ORDER BY里用不到的字段,索引的价值是快速定位,如果起不到定位的字段通常是不需要创建索引的,因为索引是会占用物理空间的。- 字段中存在大量重复数据,不需要创建索引,比如性别字段,只有男女,如果数据库表中,男女的记录分布均匀,那么无论搜索哪个值都可能得到一半的数据。在这些情况下,还不如不要索引,因为 MySQL 还有一个查询优化器,查询优化器发现某个值出现在表的数据行中的百分比很高的时候,它一般会忽略索引,进行全表扫描。
- 表数据太少的时候,不需要创建索引;
- 经常更新的字段不用创建索引,比如不要对电商项目的用户余额建立索引,因为索引字段频繁修改,由于要维护 B+Tree的有序性,那么就需要频繁的重建索引,这个过程是会影响数据库性能的。
有什么优化索引的方法?
这里说一下几种常见优化索引的方法:
- 前缀索引优化:使用前缀索引是为了减小索引字段大小,可以增加一个索引页中存储的索引值,有效提高索引的查询速度。在一些大字符串的字段作为索引时,使用前缀索引可以帮助我们减小索引项的大小。
- 覆盖索引优化;
- 主键索引最好是自增的;
- 防止索引失效;
当我们使用左或者左右模糊匹配的时候,也就是 like %xx 或者 like %xx%这两种方式都会造成索引失效;
当我们在查询条件中对索引列做了计算、函数、类型转换操作,这些情况下都会造成索引失效;
联合索引要能正确使用需要遵循最左匹配原则,也就是按照最左优先的方式进行索引的匹配,否则就会导致索引失效。
在 WHERE 子句中,如果在 OR 前的条件列是索引列,而在 OR 后的条件列不是索引列,那么索引会失效。
既然有HTTP协议,为什么还要有RPC?
TCP:面向连接、可靠、基于字节流
字节流可以理解为一个双向的通道里流淌的数据,这个数据其实就是我们常说的二进制数据,简单来说就是一大堆 01 串。纯裸TCP收发的这些 01 串之间是没有任何边界的,你根本不知道到哪个地方才算一条完整消息。
正因为这个没有任何边界的特点,所以当我们选择使用TCP发送**”夏洛”和”特烦恼”的时候,接收端收到的就是“夏洛特烦恼”,这时候接收端没发区分你是想要表达“夏洛”+”特烦恼”还是“夏洛特”+”烦恼”。这就是所谓的粘包问题**。
于是我们会把每条要发送的数据都包装一下,比如加入消息头,消息头里写清楚一个完整的包长度是多少,根据这个长度可以继续接收数据,截取出来后它们就是我们真正要传输的消息体。
而这里头提到的消息头,还可以放各种东西,比如消息体是否被压缩过和消息体格式之类的,只要上下游都约定好了,互相都认就可以了,这就是所谓的协议。
于是基于TCP,就衍生了非常多的协议,比如HTTP和RPC。
值得注意的是,虽然大部分RPC协议底层使用TCP,但实际上它们不一定非得使用TCP,改用UDP或者HTTP,其实也可以做到类似的功能。
而RPC协议,也跟HTTP类似,也是通过建立TCP长链接进行数据交互,但不同的地方在于,RPC协议一般还会再建个连接池,在请求量大的时候,建立多条连接放在池内,要发数据的时候就从池里取一条连接出来,用完放回去,下次再复用,可以说非常环保。
由于连接池有利于提升网络请求性能,所以不少编程语言的网络库里都会给HTTP加个连接池,比如go就是这么干的。
body则是放我们真正需要传输的内容,而这些内容只能是二进制01串,毕竟计算机只认识这玩意。所以TCP传字符串和数字都问题不大,因为字符串可以转成编码再变成01串,而数字本身也能直接转为二进制。但结构体呢,我们得想个办法将它也转为二进制01串,这样的方案现在也有很多现成的,比如json,protobuf。
这个将结构体转为二进制数组的过程就叫序列化,反过来将二进制数组复原成结构体的过程叫反序列化。
而RPC,因为它定制化程度更高,可以采用体积更小的protobuf或其他序列化协议去保存结构体数据,同时也不需要像HTTP那样考虑各种浏览器行为,比如302重定向跳转啥的。因此性能也会更好一些,这也是在公司内部微服务中抛弃HTTP,选择使用RPC的最主要原因。
大厂里面如何做高性能数据库的一些优化的?
1.常见的sql优化、索引覆盖
2.反范式设计:逻辑上同一个字段我们缓存(冗余)在多个表里,为什么要做这种表的冗余,因为很多时候我们去做查询,要去做关联查询,这个对性能的影响还是比较大的,如果单纯的是查询可能还好,但是如果是更新,比如说有A、B两张表,这两张表有一个外键,它们把它关系起来的,这个时候你可能先去查B表,然后再通过查出来的这个值去更新A表,这样你会发现整个事务会拉大,比如说你B表查出来的数据量比较大,那你本地的内存压力也会比较大,如果你把这个WHERE条件的字段直接冗余到A表里面,你就可以单表去进行更新了,这样就能避免我之前说的两个问题,而且能提升整个表更新的一个并发量,但是这里也会有另外一个副作用,你这个where条件本身的字段去更新的话,你就要去更新两张表,如果更新的这个字段是非常高频的操作,那其实本身也会拉大这个事务,所以你就要结合你的场景去分析,它到底是属于哪一种情况,去做这种反范式设计。
3.不要去设置外键:你要去做一个更新,如果涉及到外键的话,你要额外的去做一些操作,而且这个操作之间可能会去加锁,那这样对更新操作其实是有些性能影响的,所以外键这个东西,我们一般是不会直接设置在数据库上,更多是在业务层面去做一个约束。这也可以延伸一下很多公司的数据库的隔离级别并不是采用默认的可重复读,而是采用的是读已提交,不同的隔离级别很大程度都是通过一些锁机制去实现的,所以省掉这些锁的开销能一定程度提升你的一个并发度,这就是性能跟完整性一些权衡取舍,这个也要根据你的实际业务场景来
4、字段扩展性问题:一个很简单的例子,比如说你在业务上要填一个表格,但是这个表格的样式或者字段,它是动态变化的,那这个数据库表字段的设计应该怎么去做,这里就有两种方案了,第一种方案,就是把所有的表单类型给它枚举出来,把每一个要填的项目都给它建成一个独立的字段,这样你就会发现一个问题,这个表会变得非常大,而且后面新增加了表的样式之后,你的字段也会不断的去调整,数据库建表字段应该是一个比较稳定的概念,如果你频繁根据业务去进行一些调整,去进行动态的调整,那这个运维的成本其实是非常高的,这里很多人会想到用NOSQL像mongoDB这种文档数据库去做这种事情,因为它不需要事先去定义它的结构,其实就在关系型数据库层面,我们也可以去建一个通用的字段,这个字段就存一个字符串,这个字符串就是一个JSON,你这个表单的全部内容就存在一个JSON给它传进去,去保存整个表单它里面填的所有的key value,你要去展示表格的时候后端不需要关心这个表里面具体有哪些key value,直接把这个字符串丢给前端,它自己去解析就行了,后端对于这个表单内容的解析就会非常简单,其实这个方案也是有一定缺陷的,比如说你要通过这个表里面的一些key-value去做一些索引(查询),但是你在表里面存的就是一个字符串,你怎么去做索引呢,你当然可以用like一些语句去实现,但是这样性能就非常低了,你没办法用到mysql底层的一些B+树的索引,所以mysql的高版本也支持JSON的这种数据结构好像也支持索引,但是我们实际在生产环境中,还是会去考虑这个点,如果真的里面的这个字段需要索引的话,我们会把它拆出来
5.冷热分离:我们可以在表层面去做一个冷热分离,比如说商品表它有些字段是高频的要增删改查的,但是有一些字段它基本上不怎么变化,而且这些不变化的字段存的数据量还比较大,如果把所有的字段放在一个商品表里面,那这样的话,后面的数据增删改查其实性能都会非常低,所以关于商品的存储模型,我们会建两张表,一张是商品的主表,另外一张是商品的扩展表,把频繁增删改查的字段放在主表里面去,然后把用的比较少的一些字段放在扩展表里面去,那这样主表它的一个增删改查,包括它索引的构建,这样性能都能提升很多
6.事务:数据库的集群,我们去做拆分的时候,会习惯性的去考虑同一块业务把它放在一起,这确实也是符合微服务的一些思想的,但是我i们还是要去考虑一些技术上的挑战,比如后期业务上同一些操作可能涉及到事务问题,尽量把它放在一个数据库里面。还是拿电商的场景,你数据库分片的时候,你不知道通过哪一个维度去分片,但是你知道有些操作是在用户维度的,有些操作是在店铺的维度,那同一个店铺的一些操作,那我们就去给它做一个分片。分布式事务最好的解决方案就是你在做服务拆分的时候,数据库层面你都要考虑好,怎么去尽量避免分布式事务。
7.尽量单表操作:还是要看具体场景,像一些传统的系统,它大量的业务都是用存储过程去实现的,一个SQL几千行,人家系统也运行的好好的,也没有什么大的问题啊,所以说存在即合理,这个真的是要看具体场景,那什么场景比较适合用这种单表操作呢,互联网公司一般都是采用这种增量式迭代的开发,一个很大的原因是你未来的需求是不确定的,所以抛开这种连表查询的性能问题,可能更多的就是为了后期的分库分表更加的方便,再加上应用服务器这一块,它弹性也是很大的,如果内存上有压力的话,我们可以去横向的扩容,也算是一种以空间换时间的思想,但是这个性能没有绝对的优劣,因为你单表去JOIN,虽然在数据库层面开销要大一点,但是应用节点跟数据库之间的IO其实只有一次,所以说具体哪一种性能好一点,这个还不好说,主要还是你整个SQL写的很复杂之后,你后期去做分库分表,做拆分的时候会非常的麻烦,同样你要去做这一块的前瞻性设计,你要考虑你这个是用自增ID还是用这种分布式的ID,如果你这个表的ID要去做外键,而且后期大概率这个表要去做分库分表,那你尽量前期就去做分布式ID的生成



