learning, progress, future.

skydh


  • 首页

  • 归档

tcp/ip协议

发表于 2018-02-08

什么是tcp/ip协议

  tcp/ip是2个协议,tcp是运输端协议,ip是网络端协议,这2个协议一般一起使用的。ip协议是来确定和找到地址的,tcp则是具体的传输信息的工作。

tcp的三次握手和四次挥手协议

  理解这个过程首先要知道tcp的2个序号和3个标志位的含义。
  seq:表示传输数据的序号。tcp传输时每一个字节都有序号,发送时会把第一个序号发过去。接收端通过序号判断数据是否完整,如果不完整则重发。
  ack:表示确认号,接受端表示数据已经完整接受,向发送端发送确认号。表示希望接受到数据的编号。一般为接受端报文最后的序号+1.
  ACk:表示确认位。只有ACK=1时ack才会起作用。正常通信时ACK=1,第一次的话没有接收方确认是0.
  SYN:这个是同步标志位,当SYN=1,ACK=0时表示这是个连接请求报文段,握手完成后SYN标志位为1
  FIN:FIN=1表示数据已经发送完要求释放运输连接。

aaa
aaa
  简述下3次握手和四次挥手
  三次握手
  1.客户端向服务端发送连接请求包,ACk位为0,SYN为1,seq=X,ACk是因为没有接受到确认信息故为0,因此需要同步标志位来确认其为有效连接。
  2.服务端向客户端发送确认信息:标识位SYN=1,ACK=1,希望收到的下个信息的序号为ack=x+1;自身的seq序号为y.
  3.客户端向服务端发送确认报文,ACK=1,SYN=1,ack=y+1,seq=x+1。
  为何需要三次握手?
  第一次,服务端知道自己接受数据没问题,第二次,客户端知道自己自己发送数据和接受数据没问题,第三次服务端知道自己发送数据没问题。然后,数据开始发送。
  四次挥手
  1.客户发送FIN=1,seq=x,来像服务器发送终止请求。
  2.服务端接受到FIN后,发送一个ACK=1,seq=y,ACK=x+1.
  3.关闭服务端到客户端的连接,且发送一个FIN给客户端。
  4.客户端收到FIN后,且发送一个ACK=1,seq=x+1,ack=y+1.
  简而言之这样的:
  第一次客户端发送一个fin,表示自己数据发完了,服务端收到后,若是数据没有发送完,就发送一个ack,表示,已经收到你的请求,但是服务端数据没有发送完,继续发送数据,等到数据发送完了,就发送一个fin,客户端收到fin后就发送一个ack,表示确认收到,服务端就可以关闭连接了。但是客户端还是要等一个周期时间,如果客户端发送ack丢失了,服务端没有收到就会继续发送fin,直到收到信息后,才关闭,而客户端在一定周期内没有收到信号也关闭。
  为何是结束是4次握手?
  数据可能没有发送完。如果都是同时发送完了,那么也是3次握手,3次握手是由于没有数据传送
  注意到每一次连接都要消耗3次握手和4次握手,
  故有了tcp长连接和短连接,http的长连接和短连接实际上就是tcp的.

  短连接:就是一次简单的tcp连接,数据发送完直接关闭。连接→数据传输→关闭连接

  长连接:就是在一次连接内多次发送数据包,中间若是没有数据那么靠心跳保活协议维护, 连接→数据传输→保持连接(心跳)→数据传输→保持连接(心跳)→……→关闭连接
  这样子就减少了多次连接所消耗的握手   

DNS协议

发表于 2018-02-08

什么是DNS协议

  我们知道网络上每个站点的位置是靠ip来确认的,想要访问一个网站必须知道他的ip地址。但是ip地址全部是数字,很不好记忆。也不方便记忆,于是就有了域名。每个域名都有对应的ip地址,网上的域名非常多。且网上域名和ip对应关系也经常变化,因此需要有专门的把域名装换为ip的服务器,这个就是DNS服务器,我们把域名传递过去他就会返回对应的ip。我们可以用nslookup来获取域名信息。看下面的例子

aaa
  域名服务器的域名是:ns.wuhan.net.cn
  域名服务器的ip是: 202.103.24.68
  被解析的域名对应ip是:180.97.33.107 ,180.97.33.108
  被解析域名的CNAME域名是:www.a.shifen.com
  什么是CNAME?
  CNAME是将别的域名装换为一个域名,再将这个域名解析到A记录上,也就是在转换为ip,为什么需要这么做,一个网站要是有多个域名,如果对应ip变了,那么所有的对应关系都要修改,但是有了中间层这个域名,那么则只要改一下就行了。

DNS注册

  DNS是如何获取到ip和域名的对应关系呢?那是通过万网注册。DNS服务器则可以获取到其信息了。

DNS地址获取

  可以在网络配置页面进行DNS配置。可以选择自动获取,也可以设置固定的DNS地址。

本机自己配置ip

  Windows下这个目录C:\Windows\System32\drivers\etc可以配置对应域名和ip,本机解析域名时先从hosts下找,找不到再从DNS服务器上找。
  这个是我本机的hosts

        # Copyright (c) 1993-2009 Microsoft Corp.
        #
        # This is a sample HOSTS file used by Microsoft TCP/IP for Windows.
        #
        # This file contains the mappings of IP addresses to host names. Each
        # entry should be kept on an individual line. The IP address should
        # be placed in the first column followed by the corresponding host name.
        # The IP address and the host name should be separated by at least one
        # space.
        #
        # Additionally, comments (such as these) may be inserted on individual
        # lines or following the machine name denoted by a '#' symbol.
        #
        # For example:
        #
        #      102.54.94.97     rhino.acme.com          # source server
        #       38.25.63.10     x.acme.com              # x client host

        # localhost name resolution is handled within DNS itself.
        #    127.0.0.1       localhost
        #    ::1             localhost
        10.11.248.91 zjy.cscec.com.cn


session,token

发表于 2018-02-07

session

  什么是session?
  session是一个用户会话的配置属性信息,同时存储在用户和服务器里面。
  session的作用。
  用于解决http/https的无状态特性。用于区分确认用户。用户登录后,服务器发放一个sessionid给客户端,自身内存也保存一个一样的sessionid,等到下次用户在访问服务器时,http/https的head头将携带这个sessionId,服务器程序则会判断此id是否存在用于区分不同用户。
  session的缺点以及解决方案
  如果登录用户太多,一台服务器肯定不够,那么就会集群分部,就是用多台服务器来分流处理。前面的负载均衡器则来分发请求。为了避免sessionid在服务器的不同步问题,可以用session sticky来让用户连接到固定的服务器。也可以让服务器之间相互复制,也可以建立一个专门存放sessionid的服务器

token

  token则是与session与众不同。如果session占的是空间,那么token占的则是时间。现在服务器性能那么好,时间的消耗微乎其微。而且可以避免上述sessionid在不同服务器之间的问题。
  token是怎么处理的?
  用户登录系统后携带的用户id,服务器用专门的密钥对其这个用户id来加密,生成一个签名。然后把这个签名和数据一起作为token发给客户端,自身则是不保存,重要的事情说三遍,自身不保存。用户再来访问时则要携带这个token,服务器则是把这个token取出来,在对这个id来用相同方式加密,如果签名不一样则是,无法通过了。
  大致是这种原理,一般来说还会在token里面加个过期时间之类的。
  目前很多小网站没有都是获取到大公司的授权码来登录,用到的也是token
  用下面转载自码农翻身的一张图来解释下

aaa

  一目了然。

海量数据和高并发解决方案

发表于 2018-02-05

ps :读书笔记

海量数据解决方案

缓存和页面静态化

  缓存就是把从数据库中的数据暂时存起来,下次使用时无需在查询数据库。缓存分为程序直接保存到内存和框架框架2种。程序缓存一般使用currentHashMap直接保存到内存。框架缓存的话有redis,memcache等。
  ps:空数据值问题。
  缓存创建的时候把没有数据的缓存用特定的符号来表示。因为这种模式下如果从缓存中获取不到数据,就会查询数据库,但是其本身就没有数据的话。那么每次都要查询一次数据库,不合理。
  页面静态化:是将程序生成的页面保存起来。这样下次调用直接就使用。连程序这一关也过了。更加快速。可以在程序中使用velocity等技术来生成静态页面,也可以通过上层缓存Nginx来生成。

数据库优化

  1.表结构优化:设计合理的符合规范的表。
  2.sql优化:根据日志以及其他工具分析那条sql语句最耗时,在针对性的有的放矢的优化,要统筹好,不能只针对一条语句,优化时要考虑到表上的其他语句综合考虑。
  3.分区:一个表中数据量太大时,那么分区就可以使用了。分区是将数据按照一定规则把数据分到不同区来保存,这样子操作数据时,数据量更少。查询数据时只在一定区间进行。且这种操作时对程序透明的。程序无需修改。
  4.分表:分表就是把表横向切分为几个表。第一种方式就是为了减少数据,比如一张表里面某个字段是分类。可以更具这个分类来分为多个表。以此来减少每个表的数据量。第二种方式是由于某个表某些字段经常被查,但是不修改,某些字段需要进场修改,那么分表是个不错的选择,因为对于mysql之类的表来说,增删改操作时要加锁的,无论是什么隔离级别。,这样子加锁范围就减少了。对于mysql来说不是问题,但是对于其他数据库来说就不知道了。对于mysql来说可以照样读取数据,对于某些数据库或者se隔离级别的mysql,这条记录也是不可读的。需要等待数据库释放这条记录的锁。
  5.索引优化:对于mysql的innodb来说,我上篇博客已经说过了。这里简单说下,最左匹配原则,综合所有查询语句,找出,最佳的索引创建原则和最佳的查询语句,比如,你有联合索引(a,b,c)。你的查询语句为b=1 and c=1 那么要么调整查询语句让其条件多个a=?要么联合索引(a,b,c)调整为(b,c,a)。其次对于mysql来说。一条语句有且只用一个表有且只用一条索引。至于多表查询时连表的语句也会加入到索引里面。
  6.存储过程:对于复杂的sql来说来说,直接使用存储过程来调用,可以有效提高效率。

活跃数据分离

  一个数据量很大的表,只有一小部分数据是活跃数据,经常被查询,更多的数据则是惰性数据,偶尔被调用一下。那么我们可以用2个表来保存,第一个表是活跃数据保存,第二个表是惰性数据保存。这样子可以有效提高效率。至于是否活跃数据,怎么分配就要看自己方业务逻辑怎么实现的了。

批量读取和延迟修改

  1.批量读取:故名思议,把一堆查询结合成一条查询,比如。有的业务是要查询一次做个操作,那么可以把这些查询放在一个in()语句里面。又或者高并发下,把几秒的异步请求统一查询处理。
  2.延迟修改:就是把一些频繁修改的数据放到一个缓存里面去,然后定时把缓存的数据刷到数据库里面,这个缓存和普通缓存不一样,这个缓存的数据库不是完整的。程序查询时同时读取数据库和缓存的数据,综合读取之。

读写分离

  aaa

  先上一张图,这个图是书里面的,说起来很简单就是把读取数据和增删改数据分离到不同数据库里面。增删改放到主数据库里面,读取数据则是放到从数据库里面。主数据的数据通过底层同步到从数据库里面。

分布式数据库

  分布式数据库是将不同的表放到不同的数据库里面,然后再放到不同的服务器里面,这样子查询时可以使用多台服务器来运行,可以有效提高效率,主要用于超复杂耗时的查询。这个可以和读写分离一起使用,搭配使用。另一种情况是不同业务的表放在不同数据库里面,可以起到分流的作用

NoSql和Hadoop

  NoSql和sql比起来就是非结构化的,就是没有定义好的字段,类型啊之类的,但是NOsql是通过多个块存储数据,因此效率速度很快,被广泛应用于大数据
  Hadoop:Hadoop是针对大数据处理的一套框架。

aaa

  这个是Hadoop存储图,就是把表的数据块分为多个节点保存。这样子可以并发处理并且可以保存数据的稳定性。Hadoop是对每一个数据块找到的节点并处理,然后在统一处理,得到最终结果(这块不熟)

高并发处理方案

静态资源分离

  就是将图片,视频,css,等文件保存到另外一个服务器中,使用2级域名,通过不同域名,可以让浏览器迅速获取到资源而不用访问应用服务器。

aaa

页面缓存

  就是将程序生成的页面缓存保存下来,下次访问时就不用再用cpu来生成数据了。浪费其资源。可以使用Nginx服务器自带的缓存机制,也可以使用专门的squid来处理。(ps:对于一些页面某些数据经常变化,但是整体不变,那么我们可以使用ajax来请求重新获取数据来更新界面)

集群和分布式

  集群就是相同的程序放到多个服务器里面,主要起到分流的作用。分布式就是更具业务逻辑将程序拆分到不同服务器上。这2个可以一起使用。(至于集群导致的session和token问题,下一章会有篇关于session和token)。不用业务之间的联系可以通过RPC来处理,我们这边业务较为复杂,将大量的程序拆分成一个个的微服务。每个微服务之间通过dubbo来传递消息。

反向代理

aaa

  反向代理:就是客户端访问的服务器不直接提供资源,该服务器从别的服务器获取资源并返回给用户主要由3个作用
  1.可以负载均衡
  2.可以转发请求
  3.可以作为前端服务器和实际请求服务器集成。
  ps:反向代理和代理服务器不一样。反向代理是用户不知道这个事,一切都是透明的。代理服务器则是用于代替用户获取资源在返回给用户,需要用户手动设置。

CDN

aaa

  CDN是个特殊的页面缓存服务器,和普通的服务器相比,CDN服务器遍布全国各地,当接受到用户请求时,会将其分配到对应的最合适节点,根据地域等信息来分配,如图所示为其中一个实现方式。   

mysql 联合索引匹配原则

发表于 2018-01-24

读mysql文档有感

  看了mysql关于索引的文档,网上有一些错误的博客文档,这里我自己记一下。

几个重要的概念

  1.对于mysql来说,一条sql中,一个表无论其蕴含的索引有多少,但是有且只用一条。
  2.对于多列索引来说(a,b,c)其相当于3个索引(a),(a,b),(a,b,c)3个索引,又由于mysql的索引优化器,其where条件后的语句是可以乱序的,比如(b,c,a)也是可以用到索引。如果条件中a,c出现的多,为了更好的利用索引故最好将其修改为(a.c,b)。

ICP概念

  看了一篇大神的博客,上面说了通用索引匹配原则,这里也顺便说下。
  1.Index range 先确认索引的起止范围。
  2.Index Filter 索引过滤。
  3.Table Filter 表过滤。
  传说中mysql5.6后提出的icp就是多了第二步,以前Index filter是放在数据上操作的,现在5.6后多了第二步,因此效率提高了很多。

表的结构

CREATE TABLE `left_test` (
          `id` int(11) NOT NULL,
          `a` int(11) DEFAULT NULL,
          `b` int(11) DEFAULT NULL,
          `c` int(11) DEFAULT NULL,
          `d` int(11) DEFAULT NULL,
         `e` int(11) DEFAULT NULL,
           PRIMARY KEY (`id`),
          KEY `m_index` (`a`,`b`,`c`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8

  且插入了100万条数据。

sql的分析

select * from left_table where id=1。
select * from left_table where id>1 and id<3

  使用了聚集索引,id为主键,那么这个表里面id则是聚集索引列,这条sql默认使用了聚集索引来搜索。  

select * from left_table where a=1
select * from left_table where a=1 and b=1
select * from left_table where a=1 and b=1 and c=1

  使用联合索引(a,b,c)。其中这些条件可以可以乱序,因为mysql的sql优化器会优化这些代码

select * from left_table where a<1
select * from left_table where a<1 and b<1
select * from left_table where a<1 and b<1 and c<1

  对于现在mysql5.7中,小于可以乱序(mysql优化器优化了),但是按照最左匹配原则。比如条件(b),(c),(b,c)组合就不行。

select * from left_table where b<1
select * from left_table where b<1 and c<1
select * from left_table where c<1 

  这个组合就用不到索引,因为不符合最左匹配原则。

select * from left_table where a=1 and id=2

  这里面id是聚簇索引列,而a是个二级索引列,那么这个是用聚集索引列,不用(a,b,c)这个索引,因为对于mysql 5.7 innodb 这个版本一条sql里面索引只能用一条。至于用那个,则是mysql自身的算法选择了。经过大量测试实验,规则如下,如果索引列数据数据一模一样,那么是谁先创建就选谁,如不一样,那么谁占用的列越多,或者列的数据越复杂则选它。当然最好手动指定。

mysql Innodb索引

发表于 2018-01-22

基本概念

  对于mysql目前的默认存储引擎Innodb来说,索引分为2个,一个是聚集索引,一个是普通索引(也叫二级索引)。
  聚集索引:聚集索引的顺序和数据在磁盘的顺序一致,因此查询时使用聚集索引,效率更高,但是因此聚集索引也只能一条。一般来说,主键就是聚集索引,当然没有主键的话,就会创建一个隐藏的列来作为聚集索引列。B+树的叶子节点就是数据值
  2级索引就是不是聚集索引的索引了。其叶子节点存的是指向数据值的地址。

为什么需要索引?

  数据存储到数据库中是以数据块作为基本单位存放的,每个表有个参数叫块因子,就是一个数据块能存多少行记录,这个和你设置的数据块大小以及表的设计相关。
  如果对一个无序字段进行搜索,那么就只能使用线性搜索。时间复杂度为n/2,如果是一个有序字段那么可以使用2分查找法,时间复杂度为log2 (N)。
  如果对关键字段建立了索引,那么就会对这个关键字生成索引文件,也是以数据块作为单位存储的。第一这个数据是有序的,第二他的块因子很大,为什么呢?因为他只是一列或者几列数据,不是整列数据。
  举个例子:
  前置条件:数据块默认大小:1024字节,表行长度500字节,某索引字节5字节。数据5000000条。
  1:无索引:查找字段无序:1024/500=2, 5000000/2=2500000块数据块,线性搜索:查找1250000次。
  2:无索引:查找字段有序, 1024/500=2,5000000/2=2500000块数据块,有序,使用2分查找。Log2(2500000)=752 575

  3:有索引:1024/5=204, 5000000/204=24509块数据块,有序,使用2分查找。Log2(24509)=7378。
  数量级:百万,十万,千。在这里,我只是用2分查找来代替引用,只是为了形成一个对比。索引不是链表是B+树(Innodb)

为何索引是B+树?

  一般来说,索引本身也很大,不可能全部存储在内存中,因此索引往往以索引文件的形式存储的磁盘上。这样的话,索引查找过程中就要产生磁盘I/O消耗,相对于内存存取,I/O存取的消耗要高几个数量级,所以评价一个数据结构作为索引的优劣最重要的指标就是在查找过程中磁盘I/O操作次数的渐进复杂度。换句话说,索引的结构组织要尽量减少查找过程中磁盘I/O的存取次数。
  因此为了提高效率,要尽量减少磁盘I/O。为了达到这个目的,磁盘往往不是严格按需读取,而是每次都会预读,即使只需要一个字节,磁盘也会从这个位置开始,顺序向后读取一定长度的数据放入内存。
  预读的长度一般为页(page)的整倍数。页是计算机管理存储器的逻辑块,硬件及操作系统往往将主存和磁盘存储区分割为连续的大小相等的块,每个存储块称为一页(在许多操作系统中,页得大小通常为4k),主存和磁盘以页为单位交换数据。
  数据库系统的设计者巧妙利用了磁盘预读原理,将一个节点的大小设为等于一个页,这样每个节点只需要一次I/O就可以完全载入。为了达到这个目的,在实际实现B-Tree还需要使用如下技巧:每次新建节点时,直接申请一个页的空间,这样就保证一个节点物理上也存储在一个页里,加之计算机存储分配都是按页对齐的,就实现了一个node只需一次I/O。
  相对高瘦平衡二叉树,b+树就显得很矮胖,故查询次数也就更低一些,上面说的节点,就是索引。B树已经很不错了,为何会是b+树,他们2个怎么说呢,b树的节点利用率更加高,每一个节点都是存放了数据,而B+树则是只有叶子节点存放了数据,并且相邻叶子节点之间有个链接,作为一个小链表结构,那么这同时意味着非叶子几点几乎都没有使用,造成了浪费,但是对于数据库来说,我们很多查询都是范围查询,如果有了链表那么就不用再去父节点重新查询新数据了,更加适合于数据库的使用

  

mysql varchar到底能存多少字符。

发表于 2018-01-18

utf8编码的varchar

  Mysql记录行数据是有限的。大小为64k,即65535个字节,而varchar要用1-2字节来存储字段长度,小于255的1字节,大于255的2字节。
  Mysql 5.0后,英文字符固定都是一个字节,汉字字符根据编码方式占不同字节,Utf-8占3个字节,gbk占了2个字节。
  第一,当编码方式为utf-8时,varchar存到21845就存不下了.也就是最大长度是21844.根据上面信息可以推算出 ( 65535-2 )/3=21844余1
  例子如下:
  aaa

   aaa

GBK编码的varchar

  当编码格式为GBK时,varchar能存多少字符呢?经过推理可知大约能存32766个字符,(65535-2)/2=32766余1。
  那么看看实验结果如下:
 aaa

   aaa

为何提出这个问题?

  前段时间一个哥们提bug,要把备注等字段全部最大大小设置为1000,甚至更多,这个表的字段本身就已经很多了,而我们设计表时一般都默认使用utf8这个编码格式,那么一个汉字就占了3个字节,故一个行记录的长度就会短了些,数据占用存储资源也会多了些,然后修改的时候成功的报了row size too large的这个错误。这里提出来也是为了让大家注意下。

总结

  设计表的时候不同的编码格式会导致varchar的最大值发生变化,varchar(数值),这个数值指的是字符数,也可以说是一个字,但是不是字节,当然存储的数据还是一个英文占一个字节,一个汉字根据编码格式占不同字节。

  

mysql 锁,事务,隔离级别

发表于 2017-12-27

概念

事务

  原子性:事务必须是一个自动工作的单元,要么全部执行,要么全部不执行.
  一致性:事务结束的时候,所有的内部数据都是正确的。
  隔离性:并发多个事务时,各个事务不干涉内部数据,处理的都是另外一个事务处理之前或之后的数据。
  持久性:事务提交之后,数据是永久性的,不可再回滚。
  在mysql 中start transaction with consistent snapshot,这个是立即开启事务,无论是否使用了表。而begin则是在操作第一个表的时候才开始开启事务。

锁

  锁分为3个级别,分别是全局锁,表锁,行锁。
  全局锁:Flush tables with read lock 这个来来加全局锁,整个库都被锁起来了,只能读,不能写操作。一般用于全局逻辑备份。
  但是针对innodb的可重复读的级别下是可以直接开启一个事务来进行全局数据备份。这个全局锁主要针对mysql里面的非innodb引擎。
  表级锁:表锁,元数据锁。
  表锁:lock tables t1 read/write,给这个表加了锁,那就无法对这个表操纵数据。我们可以用unlock tables主动释放锁。也可以客户端断开连接的时候自动释放。read是只能读,write是无法读写。
  元数据锁:这个锁很有意思,当访问一个表(增删改查),会对这个表增加一个MDL读锁。而当对这个表结构做修改时,就会加一个MDL写锁。读锁之间不互斥,没有任何影响,但是写锁和读锁,写锁和写锁都是互斥的,必须等待前面的完成,才可以继续。
  当我们对表进行字段修改时,那么会扫描整个表的数据。因此我们需要注意,加字段对数据库的影响。
  存在这么一种情况,一个表,被多个连接事务读取数据,没有释放,接着这个表要进行表结构修改,需要获取MDL写锁,但是前面的事务不释放,那就无法获取到锁。只能阻塞,但是后面的请求都要等前面的获取到写锁,且释放锁才可以获取到读锁。那么整个表都不可用。
  对于高频,长事务表,我们可以在alter语句里面加等待时间,然后DBA手动重试。

  这里主要说2个行级锁,共享锁(s),排它锁(x).
  共享锁:事务T对数据对象a加了s锁,那么这个事务T只能对数据读而不能写,而其他事务也不能对这个数据加其他锁,只能加S锁,直到这个事务T执行完毕,这就保证了这个数据在事务T中不变。
  排它锁:事务T对数据对象加了X锁,事务T可以对数据修改读取,但是其他事务无法对数据加其他任意锁,也不能读取和修改该数据对象。
  这个s锁和x锁都是select语句用出来的。lock in share mode,就是加读锁,for update 就是加写锁。
  next-key lock:mysql加锁的基本单位,它是行锁+间隙锁。前开后闭的区间。

死锁

  2个事务,出现循环等待的情况。事务A锁了资源a1,事务B锁了资源b1,然后事务A想获取b1的资源,锁等待。事务B想获取a1资源,锁等待,死锁产生。
  2个方案:。
  1.进入等待状态,直到超时。超时参数可以通过参数innodb_lock_wait_timeout来设置。默认是50s。一般不采用这个方式。
  2.发起死锁检测,发现死锁后,主动回滚死锁链条的某一个事务,让其他事务得以执行。将参数innodb_deadlock_detect设置为on表示开启这个逻辑。我们一般采用这个方法。
  但是这个是有代价的。该逻辑如下:每当一个事务被锁住的时候,就要看看它依赖的线程有没有被别人锁住,不断循环,最后判断是否出现循环等待,也就是死锁。
  如果是所有事务都是更新同一行的操作,那么死锁检查操作就是每个新来的被堵住的线程都要判断是否是因为自己的到来而导致了死锁,如果是1000的并发,那么其操作则是100w量级的。这样会导致cpu 100%异常。
  对于热点行数据更新问题。有以下方案:
  1.确定不是死锁,把死锁检查关掉
  2.做一个数据库中间件来判断来控制请求量
  3.在代码层面吧热门行数据分割。

四大隔离级别

  Read uncommitted (读未提交):这个可能造成脏读。其效果如下,2个事务A,B。A查询数据,不提交。B修改数据,不提交。A查询数据,发现B未提交的数据已经查询出来了。
  Read committed (读已提交):这个可以避免脏读,只能读取到提交后的数据。但是可能造成不可重复读问题。2个事务A,B。A查询数据,不提交。B修改数据,提交。A查询数据,发现B未提交的数据已经查询出来了,造成了2次查询效果不一致问题。
  Repeatable read (可重复读):这个可以避免可重复读,但是无法避免幻读。2个事务A,B。A查询数据,不提交。B在A的查询范围内insert一条数据,提交。A查询数据,发现多了几条数据。造成了幻读。mysql在rr级别下是没有幻读的
  Serializable (串行化):可以避免脏读,不可重复读,幻读的问题,但是新能较低。

mysql的Innodb的上述情况。

预备知识

2PL:Two-Phase Locking

  这个是2阶段锁机制。说的是锁操作分为两个阶段:加锁阶段与解锁阶段,并且保证加锁阶段与解锁阶段不相交。也就是说,一个事务里面,对于要加锁的数据加了锁,直到事务结束(正常commit或者rollback),才会释放锁。因此,我们建议把需要加锁的sql放在后面执行,如此加锁时间会短很多。

mvcc和innodb的mvcc

  mvcc:多版本并发控制,适用于读多于写的情况,一行数据多个版本,读取数据可能是某个历史版本。可以有效见面加锁。
  Innodb的mvcc实现,网上有多个版本解释了innodb的mvcc实现,但是很多都无法自圆其说,这里找到一个版本是专业的DBA写的(我比较信服的一个版本)这里简述下:
  1.在Mysql中MVCC是在Innodb存储引擎中得到支持的,Innodb为每行记录都实现了三个隐藏字段:
  6字节的事务ID(DB_TRX_ID)
  7字节的回滚指针(DB_ROLL_PTR)
  隐藏的ID。
  MVCC 在mysql 中的实现依赖的是 undo log 与 read view
  undo log是为回滚而用,具体内容就是copy事务前的数据库内容(行)到undo buffer,在适合的时间把undo buffer中的内容刷新到磁盘。
  read view: 主要用来判断当前版本数据的可见性。在innodb中,创建一个新事务的时候,innodb会将当前系统中的活跃事务列表(trx_sys->trx_list)创建一个副本(read view),副本中保存的是系统当前不应该被本事务看到的其他事务id列表。当用户在这个事务中要读取该行记录的时候,innodb会将该行当前的版本号与该read view进行比较。
  系统维护一个事务id,每次开启新事物都会+1。当修改数据时,才会修改对应数据行的事务id,并且指向以前的版本。
  RR特性:只有在当前事务开启前提交的数据才可见哦。
  RC特性:只要提交的数据都可见。
  1.设该行的当前事务id为trx_id_0,read view中最早的事务id为trx_id_1, 最迟的事务id为trx_id_2。
  2.如果trx_id_0< trx_id_1或者trx_id_0=当前事务id的话,那么表明该行记录所在的事务已经在所有新事务创建之前就提交了,那么无论是RR还是RC都是绝对可见的,所以该行记录的当前值是可见的。跳到步骤6.
  3.如果trx_id_0>trx_id_2的话,那么表明该行记录所在的事务在本次新事务创建之后才开启,那么显然该行记录的当前值不可见.跳到步骤5。
  4.如果trx_id_1<=trx_id_0<=trx_id_2, 那么从trx_id_1到trx_id_2进行遍历,如果trx_id_0等于他们之中的某个事务id的话,说明这个记录行修改时的事务在本事务开启前没有提交,那么这行数据不可见。否则就是在本事务开启前就已经提交了,那么可见了跳到步骤5.
  5.从该行记录的DB_ROLL_PTR指针所指向的回滚段中取出最新的undo-log的版本号,将它赋值该trx_id_0,然后跳到步骤2.
  6.将该可见行的值返回。
  总结:开启一个新事务,获取到一个read view,当前事务是最大的,因为开启一个加1一下,然后查询数据,只要查询的数据的行事务id等于当前事务号,或者<当前事务号,且不在read view中,那么说明这个行事务id是在本事务开启前就提交了,所以这行是可见的,如果在read view里面或者>当前事务号,那么都是在本事务开启前没有提交的,那么对于RR来说都是不可见的。只能回滚到上一条数据中,在进行判断。
  数据可见分3个情况:等于当前事务id,下雨活跃最小事务id,不在活跃事务id表里面。  

  至于RR和RC的区别在于,RR是事务开启时获得一份ReadView,而RC则是每次语句都获取一次ReadView,这个造成了不同隔离级别的快照读的可见性的区别了。
  RC总结如下:
  开启一个新事务,不获取到一个read view,而是在查询时获取到这个Read View,只要查出的这个数据的事务id不在这个read view里面,那么就是可见的了。因为在这个read view里面,对于当前查询来说都是为提交的啊,因为是活跃的事务,而不在这个里面的都是已经提交的事务,对于RC来说,只要是提交的事务,都是可见的。
  只要你在我之前提交了,那么我的read view里面就没有你,那么我就可见你了。RC的判断很简单,每次查询时都会取一个read view,只要这个行数据不在这个我得read view里面即可。
  这个就完美解释了不同隔离级别数据的可见性问题了

Innodb的四大隔离级别与标准的区别

  RU,RC.Serializable和标准一样,但是RR不一样,RR解决了幻读的问题。 怎么实现的后面说。

一条简单的语句如何加锁?加什么锁。

 CREATE TABLE `user` (

 `id` int(11) NOT NULL,

  `city` varchar(16) NOT NULL,

  `name` varchar(16) NOT NULL,

  PRIMARY KEY (`id`),

  KEY `city` (`city`),

) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin;

 数据为(0,0,0)(5,5,5)(10,10,10)(15,15,15)

  1.delete from user where id=5;
  按照规则1,这个先加一个(0,5]的next-key锁,再按照规则3,给唯一索引加锁时,退化为行锁。因此只加了5这个行锁。

  2.delete from user where id=7;
  按照规则1,这个先加一个(5,10]的next-key锁,再按照规则4,等值查询,给索引加锁时,向右遍历最后一个不满足条件,退化为间隙锁,因此加锁为(5,10)。  

3.delete from user where city=5;  
按照规则1,先加锁(0,5],由于是非唯一索引,继续向右,加锁(5,10],根据规则4,退化为(5,10),因此加锁范围为(0,10)

4.delete from user where id>=5 and id<6; 
  按照规则,首先是加锁(0,5],根据规则3,给唯一索引加锁时,退化为行锁,因此变成只对5加了行锁。范围查询,继续向右,再次加锁(5,10],因此加锁范围[5,10].

  5.delete from user where city>=5 and city<6; 
  按照规则,加锁(0,5],范围查询继续向右再加锁(5,10]。因此加锁范围(0,10].

  6.delete from user where id>=5 and id<=10;
  按照规则,首先是加锁(0,5],根据规则3,给唯一索引加锁时,退化为行锁,因此变成只对5加了行锁。范围查询,继续向右,再次加锁(5,10],再次根据规则5,加锁(10,15]因此加锁范围[5,15].

  7.delete from user where id>=5 and id<6 limit 1; 
  这个和4几乎一样,但是多了一个limit,逻辑和前面一样,但是已经找到5这个数据删除了,后面的就不加锁了,因为任务完成了。

加锁规则如下:

  1.加锁的基本单位是next-key lock,

  2.只有访问到的对象才会加锁

  3.索引上的等值查询,给唯一索引加锁时,会退化为行锁。

  4.索引上的等值查询,向右遍历时且最后一个值不满足条件,next-lock退化为间隙锁。

  5.唯一索引上的范围查询会查询到第一个不满足条件的为止。

6.间隙锁之间互不冲突,只和在这个区间插入数据冲突。

参考书籍:mysql实战45讲。以上五个规则来自该作者的整理。经测试,实用有效。

简述线程池

发表于 2017-12-27

基本概念

  线程池顾名思义线程的池子。
  工作者线程和任务队列2个概念组成。
  工作者线程主要线程就是一个循环,循环从对列中接受任务执行,任务队列则是保存待执行的任务。
  线程池其优点是可以重用线程,避免创建大量的线程,也避免了创建线程的开销。

线程池的基本属性

  java中线程池的实现类是ThreadPoolExecutor.
  线程池的大小和以下四个参数相关。
  1.corePoolSize:核心线程数。
  2.maximumPoolSize:最大线程个数。
  3.keepAliveTime和unit:空闲线程存活时间。
  逻辑是这样的:创建一个线程池后,里面没有一个线程,当新任务到来时,如果当前线程个数小于corePoolSize,无论是否有空闲线程,都创建线程,直到=corePoolSize,而大于corePoolSize后就会进入队列里面排队,如果队列满了,那么就会创建新的线程直到数量达到maximumPoolSize。而keepAliveTime则是当当前线程数目大于corePoolSize时,空闲线程时间达到了这个值,那么这个线程就会被终结掉。
  任务拒绝策略:就是上述中maximumPoolSize的任务队列都满了。新任务来了,如何处理?
  默认会抛出异常,类型是RejectedExecutionException。
  ThreadPoolExecutor.AbortPolicy:这就是默认的方式,抛出异常
  ThreadPoolExecutor.DiscardPolicy:静默处理,忽略新任务,不抛异常,也不执行
  ThreadPoolExecutor.DiscardOldestPolicy:将等待时间最长的任务扔掉,然后自己排队
  ThreadPoolExecutor.CallerRunsPolicy:在任务提交者线程中执行任务,而不是交给线程池中的线程执行
  预配置好线程池。
  工厂类Executor提供了一些预制好的线程池


public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue(),
threadFactory));
}
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue());
}
public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue(),
threadFactory);
}

  等还有些。这里只是说出几个,看看其实现就知道他的意思了,


package threadPool;

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class Test {
    public static void main(String[] args) {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 10, 200, TimeUnit.MILLISECONDS,
                new ArrayBlockingQueue<Runnable>(5));

        for (int i = 0; i < 15; i++) {
            MyTask myTask = new MyTask(i);
            executor.execute(myTask);
            System.out.println("线程池中线程数目:" + executor.getPoolSize() + ",队列中等待执行的任务数目:" + executor.getQueue().size()
                    + ",已执行玩别的任务数目:" + executor.getCompletedTaskCount());
        }
        executor.shutdown();
    }
    }
class MyTask implements Runnable {

    private int taskNum;

    public MyTask(int num) {
        this.taskNum = num;
    }

    @Override
    public void run() {
        System.out.println("正在执行task " + taskNum);
        try {
            Thread.currentThread().sleep(4000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("task " + taskNum + "执行完毕");
    }
}


  结果如下:

正在执行task 0
线程池中线程数目:1,队列中等待执行的任务数目:0,已执行玩别的任务数目:0
线程池中线程数目:2,队列中等待执行的任务数目:0,已执行玩别的任务数目:0
正在执行task 1
线程池中线程数目:3,队列中等待执行的任务数目:0,已执行玩别的任务数目:0
正在执行task 2
线程池中线程数目:4,队列中等待执行的任务数目:0,已执行玩别的任务数目:0
正在执行task 3
线程池中线程数目:5,队列中等待执行的任务数目:0,已执行玩别的任务数目:0
正在执行task 4
线程池中线程数目:5,队列中等待执行的任务数目:1,已执行玩别的任务数目:0
线程池中线程数目:5,队列中等待执行的任务数目:2,已执行玩别的任务数目:0
线程池中线程数目:5,队列中等待执行的任务数目:3,已执行玩别的任务数目:0
线程池中线程数目:5,队列中等待执行的任务数目:4,已执行玩别的任务数目:0
线程池中线程数目:5,队列中等待执行的任务数目:5,已执行玩别的任务数目:0
线程池中线程数目:6,队列中等待执行的任务数目:5,已执行玩别的任务数目:0
正在执行task 10
线程池中线程数目:7,队列中等待执行的任务数目:5,已执行玩别的任务数目:0
线程池中线程数目:8,队列中等待执行的任务数目:5,已执行玩别的任务数目:0
正在执行task 12
线程池中线程数目:9,队列中等待执行的任务数目:5,已执行玩别的任务数目:0
正在执行task 13
正在执行task 11
线程池中线程数目:10,队列中等待执行的任务数目:5,已执行玩别的任务数目:0
正在执行task 14
task 0执行完毕
正在执行task 5
task 4执行完毕
task 3执行完毕
正在执行task 7
task 2执行完毕
task 1执行完毕
正在执行task 8
正在执行task 6
task 13执行完毕
task 14执行完毕
task 12执行完毕
task 11执行完毕
task 10执行完毕
正在执行task 9
task 5执行完毕
task 7执行完毕
task 8执行完毕
task 6执行完毕
task 9执行完毕

  网上直接找了个例子跑了一下,只要理解了上面的原理,这些就很好理解了。

附上一个简单线程池实现代码:

实现思路很简单

创建多个不断循环的线程,不断监听是否有任务,没有则进入wait状态。有任务塞入队列时则唤醒之。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
package tutorials;

import java.util.concurrent.LinkedBlockingQueue;

public class ThreadPool {
private final int nThreads;
private final PoolWorker[] threads;
private final LinkedBlockingQueue queue;

public ThreadPool(int nThreads) {
this.nThreads = nThreads;
queue = new LinkedBlockingQueue();
threads = new PoolWorker[nThreads];

for (int i = 0; i < nThreads; i++) {
threads[i] = new PoolWorker();
threads[i].start();
}
}

public void execute(Runnable task) {
synchronized (queue) {
queue.add(task); // 向队列中添加任务
queue.notify(); // 唤醒一个线程
}
}

private class PoolWorker extends Thread {
public void run() {
Runnable task;

while (true) {
synchronized (queue) {
while (queue.isEmpty()) {
try {
queue.wait(); // 线程没有任务,进入睡眠
} catch (InterruptedException e) {
System.out.println("An error occurred while queue is waiting: " + e.getMessage());
}
}
// 线程被唤醒之后,会顺利执行到这里
task = queue.poll(); // 获取任务
}

// If we don't catch RuntimeException,
// the pool could leak threads
try {
task.run(); // 执行任务
} catch (RuntimeException e) {
System.out.println("Thread pool is interrupted due to an issue: " + e.getMessage());
}
}
}
}
}

volatile,原子变量和ThreadLocal

发表于 2017-12-27

概念

多线程中的变量

  首先我介绍的是volatile关键字,其次是原子变量,最后则是ThreadLocal线程本地变量

java基本内存模型

  用到volatile这个关键字以及后面的原子变量之前,我们必须先了解一下什么是java基本内存模型。
  先明确几个概念:
  主内存:主内存就是所有线程共享的内存,对于一个共享变量来说,主内存存放其真实数据(本尊数据)
  线程工作内存:线程对数据操作时,都会有自己的工作内存,对共享变量操作前,会先从主内存中获取到值,操作完后在回写回去。   

volatile

  现在有2个线程A,B,他们要主内存中间的一个变量s=0;此时A线程要修改这个共享变量,它是先获取到这个值复制到线程工作内存里面去,然后在线程工作内存里面把这个值修改了,然后把这个值再写到主内存里面去。此时B读取这个s变量,那么值可能是0,也可能是线程A所修改的值。
  使用volatile这个关键字可以避免上述这种情况(使用锁来对变量加锁或者synchronized开销太大)。
  针对上述的例子,volatile的可见性保证了不会出现上述问题。
  什么是可见性呢?
  当一个线程修改了变量的值,新的值会立刻同步到主内存当中。而其他线程读取这个变量的时候,也会从主内存中拉取最新的变量值。
  当然这种可见性不是原子性哦。当遇到以下情况会有问题
  1.多个线程同时修改变量且修改时依赖变量本身。
  2.多个volatile变量维护一个条件,若是别的线程对其多个变量修改,那么可能造成条件的不成立。
  针对上述情况于是有了原子变量(后面介绍),保证了其原子性。
  volatile还有一个特性就是禁止指令重排序。那么什么是指令重排序呢?
  指令重排序:编译器的字节码的重排序。cpu指令的重排序。
  指令重排序的目的是在不改变单线程下程序的逻辑下,优化程序执行效率。对于多线程于是就有了问题。有的程序时单线程下,调换一下顺序也没什么的,但是,对于多线程,调换一下顺序,可能回到其他线程造成大的影响。
  而volatile则是解决了这个问题,他利用了内存屏障来来辅助解决了这个。

原子变量

  说到原子变量就不得不说CAS。
  什么是CAS呢?
  就是更新一个值的时候,查询内存中的值,和自己要更新前获取到的值是否一致,若是一致,那么更新。
  与synchronized相比,cas是乐观锁,我认为并发不会修改到我的值,不加锁,只是提前获取到值,要更新的时候在比对一下,若是内存的值和我的值一致,那么更新,否则不更新。而synchronized则是不管什么直接加锁的。因此是悲观锁。
  什么是ABA问题?
  3个线程A,B,C对cas变量a修改。A,B线程获取到了变量a,A修改变量为b,B线程阻塞,C线程获取到变量b,并把b改成了a,B线程不阻塞了,继续执行,执行成功,这就是ABA问题。这个B线程不应该执行的,但是还是执行了。如这个变量是个对象,其引用没有变化,但是具体指变了,那么会出大问题的。解决方案就是在cas变量上加个版本号或者时间戳来限定。
  具体demo就是Atomic开头的类。具体我就不详细说了。
  与加锁相比这个更加轻量级。

ThreadLocal

  线程本地变量是说,每个线程都有同一个变量的独有拷贝ThreadLocal是一个泛型类,接受一个类型参数T,它只有一个空的构造方法。这个直接看个demo


package thread;

public class ThreadLocal001 {
static ThreadLocal local = new ThreadLocal();

public static void main(String[] args) throws InterruptedException {
    Thread child = new Thread() {
        @Override
        public void run() {
            System.out.println("child" + local.get());
            local.set(200);
            System.out.println("child" + local.get());
        }
    };
    local.set(100);
    child.start();
    child.join();
    System.out.println("main" + local.get());
}

}

  结果如下:


childnull
child200
main100

  这说明,main线程对local变量的设置对child线程不起作用,child线程对local变量的改变也不会影响main线程,它们访问的虽然是同一个变量local,但每个线程都有自己的独立的值,这就是线程本地变量的含义。

ThreadLocal原理解析。

  Thread类里面有一个属性:

ThreadLocal.ThreadLocalMap threadLocals = null;

  ThreadLocal 里面的set 方法:

   public void set(T value) {
    //获取当前线程
    Thread t = Thread.currentThread();
    //获取当前线程的ThreadLocalMap对象。
    ThreadLocalMap map = getMap(t);
    //有则把值放进去,没有则创建ThreadLocalMap对象。
    if (map != null)
        map.set(this, value);
    else

        createMap(t, value);
}

  ThreadLocal的getMap方法:

ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

  ThreadLocal的createMap方法:

void createMap(Thread t, T firstValue) {
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

  我们发现值是存在当前线程的的一个内部类里面,存的就是当前threadlocal和值的键值对。

  ThreadLocalMap的构造方法:

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
        //创建一个数组,数组对象为entity,初始化大小为16
        table = new Entry[INITIAL_CAPACITY];
        int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
        //根据ThreadLocal的hash值确定其数组位置,在将值放进去
        table[i] = new Entry(firstKey, firstValue);
        size = 1;
        setThreshold(INITIAL_CAPACITY);
    }

  ThreadLocalMap的set方法:

private void set(ThreadLocal<?> key, Object value) {

       // We don't use a fast path as with get() because it is at
       // least as common to use set() to create new entries as
       // it is to replace existing ones, in which case, a fast
       // path would fail more often than not.

       Entry[] tab = table;
       int len = tab.length;
       int i = key.threadLocalHashCode & (len-1);

       for (Entry e = tab[i];
            e != null;
            e = tab[i = nextIndex(i, len)]) {
           ThreadLocal<?> k = e.get();

           if (k == key) {
               e.value = value;
               return;
           }

           if (k == null) {
               replaceStaleEntry(key, value, i);
               return;
           }
       }

       tab[i] = new Entry(key, value);
       int sz = ++size;
       if (!cleanSomeSlots(i, sz) && sz >= threshold)
           rehash();
   }

  从什么这些我们可以看出来,具体的值是存在Thread对象里面的,因此不同线程之间相互没有影响。具体一点。每个Thread类里面有个属性: ThreadLocal.ThreadLocalMap threadLocals = null。显然这个属性类是ThreadLocal的内部类。我们看看ThreadLocal的get方法:

public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}

  我们先获取到当前Thread类,然后的到其ThreadLocalMap类属性,我们的值就存在里面。这个类的属性如下:Entry[]数组,这个key和value分别是ThreadLocal,value。完美解释。

  内存泄漏的问题,我们看2段代码即可明白。

static class Entry extends WeakReference<ThreadLocal<?>> {
       /** The value associated with this ThreadLocal. */
       Object value;

       Entry(ThreadLocal<?> k, Object v) {
           //这里让key放到上层父类处理,使其变成弱引用
           super(k);
           value = v;
       }
   }

  首先来说,如果把ThreadLocal置为null,那么意味着Heap中的ThreadLocal实例不在有强引用指向,只有弱引用存在,因此GC是可以回收这部分空间的,也就是key是可以回收的。但是value却存在一条从Current Thread过来的强引用链。因此只有当Current Thread销毁时,value才能得到释放。
  因此,只要这个线程对象被gc回收,就不会出现内存泄露,但在threadLocal设为null和线程结束这段时间内不会被回收的,就发生了我们认为的内存泄露。最要命的是线程对象不被回收的情况,比如使用线程池的时候,线程结束是不会销毁的,再次使用的,就可能出现内存泄露。事实上,在ThreadLocalMap中的set/getEntry方法中,会对key为null(也即是ThreadLocal为null)进行判断,如果为null的话,那么是会对value置为null的。

避免方法,先将value remove掉,

  

  

1…111213

skydh

skydh

126 日志
© 2020 skydh
本站访客数:
由 Hexo 强力驱动
|
主题 — NexT.Pisces v5.1.3