Spring Boot 使用Redis缓存

续接上文:Spring Boot 1.5.4集成Redis

Spring Cache的官方文档,请看这里

缓存存储

Spring 提供了很多缓存管理器,例如:

  • SimpleCacheManager
  • EhCacheCacheManager
  • CaffeineCacheManager
  • GuavaCacheManager
  • CompositeCacheManager
    这里我们要用的是除了核心的Spring框架之外,Spring Data提供的缓存管理器:RedisCacheManager

在Spring Boot中通过@EnableCaching注解自动化配置合适的缓存管理器(CacheManager),默认情况下Spring Boot根据下面的顺序自动检测缓存提供者:

  • Generic
  • JCache (JSR-107)
  • EhCache 2.x
  • Hazelcast
  • Infinispan
  • Redis
  • Guava
  • Simple

但是因为我们之前已经配置了redisTemplate了,Spring Boot就无法自动给RedisCacheManager设置redisTemplate了,所以接下来要自己配置CacheManager 。

  1. 首先修改RedisConfig配置类,添加@EnableCaching注解,并继承CachingConfigurerSupport,重写CacheManager 方法
...
@Configuration
@EnableCaching
public class RedisConfig extends CachingConfigurerSupport {

    @Bean
    public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<String, String>();
        redisTemplate.setConnectionFactory(factory);
        redisTemplate.afterPropertiesSet();
        setSerializer(redisTemplate);
        return redisTemplate;
    }

    private void setSerializer(RedisTemplate<String, String> template) {
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(jackson2JsonRedisSerializer);
    }

@Bean
    public CacheManager cacheManager(RedisTemplate redisTemplate) {
        RedisCacheManager rcm = new RedisCacheManager(redisTemplate);
        // 设置缓存过期时间,秒
        rcm.setDefaultExpiration(60);
        return rcm;
    }
...

Spring提供了如下注解来声明缓存规则:

  • @Cacheable triggers cache population
  • @CacheEvict triggers cache eviction
  • @CachePut updates the cache without interfering with the method execution
  • @Caching regroups multiple cache operations to be applied on a method
  • @CacheConfig shares some common cache-related settings at class-level
注  解 描  述
@Cacheable 表明Spring在调用方法之前,首先应该在缓存中查找方法的返回值。如果这个值能够找到,就会返回缓存的值。否则的话,这个方法就会被调用,返回值会放到缓存之中
@CachePut 表明Spring应该将方法的返回值放到缓存中。在方法的调用前并不会检查缓存,方法始终都会被调用
@CacheEvict 表明Spring应该在缓存中清除一个或多个条目
@Caching 这是一个分组的注解,能够同时应用多个其他的缓存注解
@CacheConfig 可以在类层级配置一些共用的缓存配置

@Cacheable和@CachePut有一些共有的属性

属  性 类  型 描  述
value String[] 要使用的缓存名称
condition String SpEL表达式,如果得到的值是false的话,不会将缓存应用到方法调用上
key String SpEL表达式,用来计算自定义的缓存key
unless String SpEL表达式,如果得到的值是true的话,返回值不会放到缓存之中
  1. 在一个请求方法上加上@Cacheable注解,测试下效果
    @Cacheable(value="testallCache")
    @RequestMapping(value = "/redis/user/{userId}", method = RequestMethod.GET)
    public User getUser(@PathVariable() Integer userId) {
        User user = userService.getUserById(userId);
        return user;
    }
  2. 然后访问这个请求,控制台就报错啦。
    java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String
    at org.springframework.data.redis.serializer.StringRedisSerializer.serialize(StringRedisSerializer.java:33)
    at org.springframework.data.redis.cache.RedisCacheKey.serializeKeyElement(RedisCacheKey.java:74)
    at org.springframework.data.redis.cache.RedisCacheKey.getKeyBytes(RedisCacheKey.java:49)
    at org.springframework.data.redis.cache.RedisCache$1.doInRedis(RedisCache.java:176)
    at org.springframework.data.redis.cache.RedisCache$1.doInRedis(RedisCache.java:172)
    at org.springframework.data.redis.core.RedisTemplate.execute(RedisTemplate.java:207)

    原因如下:
    先看一下Redis缓存默认的Key生成策略

    • If no params are given, return SimpleKey.EMPTY.
    • If only one param is given, return that instance.
    • If more the one param is given, return a SimpleKey containing all parameters.

从上面的生成策略可以知道,上面的缓存testallCache使用的key是整形的userId参数,但是我们之前在redisTemplate里设置了template.setKeySerializer(new StringRedisSerializer());,所以导致类型转换错误。虽然也可以使用SpEL表达式生成Key(详见这里),但是返回结果还是需要是string类型(比如#root.methodName就是,#root.method就不是),更通用的办法是重写keyGenerator定制Key生成策略。

  1. 修改RedisConfig类,重写keyGenerator方法:
    @Bean
    public KeyGenerator keyGenerator() {
        return new KeyGenerator() {
            @Override
            public Object generate(Object target, Method method, Object... params) {
                StringBuilder sb = new StringBuilder();
                sb.append(target.getClass().getName());
                sb.append(":" + method.getName());
                for (Object obj : params) {
                    sb.append(":" + obj.toString());
                }
                return sb.toString();
            }
        };
    }
  2. 再次进行刚才的请求(分别以1,2作为userId参数),浏览器结果如下图:image
    image
    使用redisclient工具查看下:image
    image
    可以看到Redis里保存了:
  • 两条string类型的键值对:key就是上面方法生成的结果,value就是user对象序列化成json的结果
  • 一个有序集合:其中key为@Cacheable里的value+~keys,分数为0,成员为之前string键值对的key

这时候把userId为1的用户的username改为ansel(原来是ansel1),再次进行https://localhost:8443/redis/user/1 请求,发现浏览器返回结果仍是ansel1,证明确实是从Redis缓存里返回的结果。

image

image

缓存更新与删除

  1. 更新与删除Redis缓存需要用到@CachePut和@CacheEvict。这时候我发现如果使用上面那种key的生成策略,以用户为例:它的增删改查方法无法保证生成同一个key(方法名不同,参数不同),所以修改一下keyGenerator,使其按照缓存名称+userId方式生成key:
    @Bean
    public KeyGenerator keyGenerator() {
        return new KeyGenerator() {
            @Override
            public Object generate(Object target, Method method, Object... params) {
                StringBuilder sb = new StringBuilder();
                String[] value = new String[1];
                // sb.append(target.getClass().getName());
                // sb.append(":" + method.getName());
                Cacheable cacheable = method.getAnnotation(Cacheable.class);
                if (cacheable != null) {
                    value = cacheable.value();
                }
                CachePut cachePut = method.getAnnotation(CachePut.class);
                if (cachePut != null) {
                    value = cachePut.value();
                }
                CacheEvict cacheEvict = method.getAnnotation(CacheEvict.class);
                if (cacheEvict != null) {
                    value = cacheEvict.value();
                }
                sb.append(value[0]);
                for (Object obj : params) {
                    sb.append(":" + obj.toString());
                }
                return sb.toString();
            }
        };
    }
  2. 接下来编写user的增删改查方法:
    @CachePut(value = "user", key = "#root.caches[0].name + ':' + #user.userId")
    @RequestMapping(value = "/redis/user", method = RequestMethod.POST)
    public User insertUser(@RequestBody User user) {
        user.setPassword(SystemUtil.MD5(user.getPassword()));
        userService.insertSelective(user);
        return user;
    }
    
    @Cacheable(value = "user")
    @RequestMapping(value = "/redis/user/{userId}", method = RequestMethod.GET)
    public User getUser(@PathVariable Integer userId) {
        User user = userService.getUserById(userId);
        return user;
    }
    //#root.caches[0].name:当前被调用方法所使用的Cache, 即"user"
    @CachePut(value = "user", key = "#root.caches[0].name + ':' + #user.userId")
    @RequestMapping(value = "/redis/user", method = RequestMethod.PUT)
    public User updateUser(@RequestBody User user) {
        user.setPassword(SystemUtil.MD5(user.getPassword()));
        userService.updateByPrimaryKeySelective(user);
        return user;
    }
    
    @CacheEvict(value = "user")
    @RequestMapping(value = "/redis/user/{userId}", method = RequestMethod.DELETE)
    public void deleteUser(@PathVariable Integer userId) {
        userService.deleteByPrimaryKey(userId);
    }

    因为新增和修改传递的参数为user对象,keyGenerator无法获取到userId,只好使用SpEL显示标明key了。

然后进行测试:

进行insert操作:

image

插入后,进行get请求:

image

查看Redis存储:

image

image


进行update操作:

image

更新后,进行get请求:

image

查看Redis存储:

image


进行delete操作:

image

查看Redis存储:

image

发现user:3的记录已经没有了,只剩user:1,user:2了


一直很想知道网上很多用之前那种keyGenerator方法的,他们是怎么进行缓存更新和删除的,有知道的可以告知下。

Spring Boot 1.5.4集成Redis

本文示例源码,请看这里

如何安装与配置Redis,请看这里


  1. 首先添加起步依赖:
    <dependency>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-starter-data-redis</artifactId>  
    </dependency>  

    该依赖里默认包含了spring-data-redis和Jedis依赖,见这里

  2. 编辑application.properties,配置Redis
    # Redis 配置
    # Redis数据库索引(默认为0)
    spring.redis.database=0
    # Redis服务器地址
    spring.redis.host=192.168.10.128
    # Redis服务器连接端口
    spring.redis.port=6379
    # Redis服务器连接密码(默认为空)
    spring.redis.password=123qwe
    # 连接池最大连接数(使用负值表示没有限制)
    spring.redis.pool.max-active=8
    # 连接池最大阻塞等待时间(使用负值表示没有限制)
    spring.redis.pool.max-wait=-1
    # 连接池中的最大空闲连接
    spring.redis.pool.max-idle=8
    # 连接池中的最小空闲连接
    spring.redis.pool.min-idle=0
    # 连接超时时间(毫秒)
    spring.redis.timeout=0
  3. 添加一个string类型的键值对,测试一下
@RestController
public class RedisController {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @RequestMapping(value = "/redis/string", method = RequestMethod.GET)
    public void insertString() {
        stringRedisTemplate.opsForValue().set("stringKey", "stringValue");
    }   
}

可以看到已经添加进去了:

[root@localhost ~]# redis-cli
127.0.0.1:6379> get stringKey
"stringValue"

如果这不是一个Spring Boot项目,要想使用spring-data-redis还至少需要进行下面的配置:

    @Bean
    public RedisConnectionFactory jedisConnectionFactory() {
        JedisConnectionFactory jcf = new JedisConnectionFactory();
        jcf.setHostName("192.168.10.128");
        jcf.setPort(6379);
        jcf.setPassword("123qwe");
        return jcf;
    }

    @Bean
    public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<String, String>();
        redisTemplate.setConnectionFactory(jedisConnectionFactory());
        return redisTemplate;
    }

但是,从springboot的Redis自动配置类RedisAutoConfiguration.java里可以看到,springboot已经帮我们配置好了。

Spring Data Redis提供了两个模板:

  • RedisTemplate
  • StringRedisTemplate

RedisTemplate会使用JdkSerializationRedisSerializer,这意味着key和value都会通过Java进行序列化。 StringRedisTemplate默认会使用StringRedisSerializer

所以要是操作字符串的话,用StringRedisTemplate就可以了。但要是想要存储一个对象(比如:User),我们就需要使用RedisTemplate,并对key采用string序列化方式,对value采用json序列化方式,这时候就需要对redisTemplate自定义配置了:

@Bean
    public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<String, String>();

        redisTemplate.setConnectionFactory(factory);
        redisTemplate.afterPropertiesSet();
        setSerializer(redisTemplate);
        return redisTemplate;
    }

    private void setSerializer(RedisTemplate<String, String> template) {
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(jackson2JsonRedisSerializer);
    }

添加一条数据,测试效果:

    @RequestMapping(value = "/redis/string/object", method = RequestMethod.GET)
    public void insertStringObject() {
        User user = new User();
        user.setUserId(1);
        user.setUsername("user1");
        user.setPassword("password1");
        redisTemplate.opsForValue().set("stringKeyObject", user);
    }

在redis-cli里查看一下:

127.0.0.1:6379> get stringKeyObject
"[\"com.ansel.testall.mybatis.model.User\",{\"userId\":1,\"username\":\"user1\",\"password\":\"password1\"}]"

使用代码获取刚才存储的对象:

    @RequestMapping(value = "/redis/string/object/get", method = RequestMethod.GET)
    public User getStringObject() {
        User user = (User) redisTemplate.opsForValue().get("stringKeyObject");
        return user;
    }

image

更多方法详见下表:

方  法 子API接口 描  述
opsForValue() ValueOperations 操作具有简单值的条目
opsForList() ListOperations 操作具有list值的条目
opsForSet() SetOperations 操作具有set值的条目
opsForZSet() ZSetOperations 操作具有ZSet值(排序的set)的条目
opsForHash() HashOperations 操作具有hash值的条目
boundValueOps(K) BoundValueOperations 以绑定指定key的方式,操作具有简单值的条目
boundListOps(K) BoundListOperations 以绑定指定key的方式,操作具有list值的条目
boundSetOps(K) BoundSetOperations 以绑定指定key的方式,操作具有set值的条目
boundZSet(K) BoundZSetOperations 以绑定指定key的方式,操作具有ZSet值(排序的set)的条目
boundHashOps(K) BoundHashOperations 以绑定指定key的方式,操作具有hash值的条目

Java NIO 基础知识

前言

前言部分是科普,读者可自行选择是否阅读这部分内容。

为什么我们需要关心 NIO?我想很多业务猿都会有这个疑问。

我在工作的前两年对这个问题也很不解,因为那个时候我认为自己已经非常熟悉 IO 操作了,读写文件什么的都非常溜了,IO 包无非就是 File、RandomAccessFile、字节流、字符流这些,感觉没什么好纠结的。最混乱的当属 InputStream/OutputStream 一大堆的类不知道谁是谁,不过了解了装饰者模式以后,也都轻松破解了。

在 Java 领域,一般性的文件操作确实只需要和 java.io 包打交道就可以了,尤其对于写业务代码的程序员来说。不过,当你写了两三年代码后,你的业务代码可能已经写得很溜了,蒙着眼睛也能写增删改查了。这个时候,也许你会想要开始了解更多的底层内容,包括并发、JVM、分布式系统、各个开源框架源码实现等,处于这个阶段的程序员会开始认识到 NIO 的用处,因为系统间通讯无处不在。

可能很多人不知道 Netty 或 Mina 有什么用?和 Tomcat 有什么区别?为什么我用 HTTP 请求就可以解决应用间调用的问题却要使用 Netty?

当然,这些问题的答案很简单,就是为了提升性能。那意思是 Tomcat 性能不好?当然不是,它们的使用场景就不一样。当初我也不知道 Nginx 摆在 Tomcat 前面有什么用,也是经过实践慢慢领悟到了那么些意思。

Nginx 是 web 服务器,Tomcat/Jetty 是应用服务器,Netty 是通讯工具。

也许你现在还不知道 NIO 有什么用,但是一定不要放弃学习它。

缓冲区操作

缓冲区是 NIO 操作的核心,本质上 NIO 操作就是缓冲区操作。

写操作是将缓冲区的数据排干,如将数据从缓冲区持久化到磁盘中。

读操作是将数据填充到缓冲区中,以便应用程序后续使用数据。

当然,我们这里说的缓冲区是指用户空间的缓冲区。

Java NIO 基础知识

.

简单分析下上图。应用程序发出读操作后,内核向磁盘控制器发送命令,要求磁盘返回相应数据,磁盘控制器通过 DMA 直接将数据发送到内核缓冲区。一旦内核缓冲区满了,内核即把数据拷贝到请求数据的进程指定的缓冲区中。

DMA: Direct Memory Access

Wikipedia:直接内存访问是计算机科学中的一种内存访问技术。它允许某些电脑内部的硬件子系统(电脑外设),可以独立地直接读写系统内存,而不需中央处理器(CPU)介入处理 。在同等程度的处理器负担下,DMA 是一种快速的数据传送方式。很多硬件的系统会使用 DMA,包含硬盘控制器、绘图显卡、网卡和声卡。

也就是说,磁盘控制器可以在不用 CPU 的帮助下就将数据从磁盘写到内存中,毕竟让 CPU 等待 IO 操作完成是一种浪费

很容易看出来,数据先到内核,然后再从内核复制到用户空间缓冲区的做法并不高效,下面简单说说为什么需要这么设计。

  • 首先,用户空间运行的代码是不可以直接访问硬件的,需要由内核空间来负责和硬件通讯,内核空间由操作系统控制。
  • 其次,磁盘存储的是固定大小的数据块,磁盘按照扇区来组织数据,而用户进程请求的一般都是任意大小的数据块,所以需要由内核来负责协调,内核会负责组装、拆解数据。

内核空间会对数据进行缓存和预读取,所以,如果用户进程需要的数据刚好在内核空间中,直接拷贝过来就可以了。如果内核空间没有用户进程需要的数据的话,需要挂起用户进程,等待数据准备好。

虚拟内存

这个概念大家都懂,这里就继续啰嗦一下了,虚拟内存是计算机系统内存管理的一种技术。前面说的缓存区操作看似简单,但是具体到底层细节,还是蛮复杂的。

下面的描述,我尽量保证准确,但是不会展开得太具体,因为虚拟内存还是蛮复杂的,要完全介绍清楚,恐怕需要很大的篇幅,如果读者对这方面的内容感兴趣的话,建议读者寻找更加专业全面的介绍资料,如《深入理解计算机系统》。

物理内存被组织成一个很大的数组,每个单元是一个字节大小,然后每个字节都有一个唯一的物理地址,这应该很好理解。

虚拟内存是对物理内存的抽象,它使得应用程序认为它自己拥有连续可用的内存(一个连续完整的地址空间),而实际上,应用程序得到的全部内存其实是一个假象,它通常会被分隔成多个物理内存碎片(后面说的页),还有部分暂时存储在外部磁盘存储器上,在需要时进行换入换出。

举个例子,在 32 位系统中,每个应用程序能访问到的内存是 4G(32 位系统的最大寻址空间 2^32),这里的 4G 就是虚拟内存,每个程序都以为自己拥有连续的 4G 空间的内存,即使我们的计算机只有 2G 的物理内存。也就是说,对于机器上同时运行的多个应用程序,每个程序都以为自己能得到连续的 4G 的内存。这中间就是使用了虚拟内存。

我们从概念上看,虚拟内存也被组织成一个很大的数组,每个单元也是一个字节大小,每个字节都有唯一的虚拟地址。它被存储于磁盘上,物理内存是它的缓存。

物理内存作为虚拟内存的缓存,当然不是以字节为单位进行组织的,那样效率太低了,它们之间是以页(page)进行缓存的。虚拟内存被分割为一个个虚拟页,物理内存也被分割为一个个物理页,这两个页的大小应该是一致的,通常是 4KB – 2MB。

举个例子,看下图:

Java NIO 基础知识

.

进程 1 现在有 8 个虚拟页,其中有 2 个虚拟页缓存在主存中,6 个还在磁盘上,需要的时候再读入主存中;进程 2 有 7 个虚拟页,其中 4 个缓存在主存中,3 个还在磁盘上。

在 CPU 读取内存数据的时候,给出的是虚拟地址,将一个虚拟地址转换为物理地址的任务我们称之为地址翻译。在主存中的查询表存放了虚拟地址到物理地址的映射关系,表的内容由操作系统维护。CPU 需要访问内存时,CPU 上有一个叫做内存管理单元的硬件会先去查询真实的物理地址,然后再到指定的物理地址读取数据。

上面说的那个查询表,我们称之为页表,虚拟内存系统通过页表来判断一个虚拟页是否已经缓存在了主存中。如果是,页表会负责到物理页的映射;如果不命中,也就是我们经常会见到的概念缺页,对应的英文是 page fault,系统首先判断这个虚拟页存放在磁盘的哪个位置,然后在物理内存中选择一个牺牲页,并将虚拟页从磁盘复制到内存中,替换这个牺牲页。

在磁盘和内存之间传送页的活动叫做交换(swapping)或者页面调度(paging)。

下面,简单介绍下虚拟内存带来的好处。

SRAM缓存:表示位于 CPU 和主存之间的 L1、L2 和 L3 高速缓存。

DRAM缓存:表示虚拟内存系统的缓存,缓存虚拟页到主存中。

物理内存访问速度比高速缓存要慢 10 倍左右,而磁盘要比物理内存慢大约 100000 倍。所以,DRAM 的缓存不命中比 SRAM 缓存不命中代价要大得多,因为 DRAM 缓存一旦不命中,就需要到磁盘加载虚拟页。而 SRAM 缓存不命中,通常由 DRAM 的主存来服务。而从磁盘的一个扇区读取第一个字节的时间开销比起读这个扇区中连续的字节要慢大约 100000 倍。

了解 Kafka 的读者应该知道,消息在磁盘中的顺序存储对于 Kafka 的性能至关重要。

结论就是,IO 的性能主要是由 DRAM 的缓存是否命中决定的。

内存映射文件

英文名是 Memory Mapped Files,相信大家也都听过这个概念,在许多对 IO 性能要求比较高的 java 应用中会使用到,它是操作系统提供的支持,后面我们在介绍 NIO Buffer 的时候会碰到的 MappedByteBuffer 就是用来支持这一特性的。

是什么:

我们可以认为内存映射文件是一类特殊的文件,我们的 Java 程序可以直接从内存中读取到文件的内容。它是通过将整个文件或文件的部分内容映射到内存页中实现的,操作系统会负责加载需要的页,所以它的速度是非常快的。

优势:

  • 一旦我们将数据写入到了内存映射文件,即使我们的 JVM 挂掉了,操作系统依然会帮助我们将这部分内存数据持久化到磁盘上。当然了,如果是断电的话,还是有可能会丢失数据的。
  • 另外,它比较适合于处理大文件,因为操作系统只会在我们需要的页不在内存中时才会去加载页数据,而用其处理大量的小文件反而可能会造成频繁的缺页。
  • 另一个重要的优势就是内存共享。我们可以在多个进程中同时使用同一个内存映射文件,也算是一种进程间协作的方式吧。想像下进程间的数据通讯平时我们一般采用 Socket 来请求,而内存共享至少可以带来 10 倍以上的性能提升。

我们还没有接触到 NIO 的 Buffer,下面就简单地示意一下:

import
 java.io.RandomAccessFile;import java.nio.MappedByteBuffer;import 
java.nio.channels.FileChannel;public class MemoryMappedFileInJava {  
private static int count = 10485760; //10 MB public static void 
main(String[] args) throws Exception { RandomAccessFile memoryMappedFile
 = new RandomAccessFile("largeFile.txt", "rw"); // 将文件映射到内存中,map 方法 
MappedByteBuffer out = 
memoryMappedFile.getChannel().map(FileChannel.MapMode.READ_WRITE, 0, 
count); // 这一步的写操作其实是写到内存中,并不直接操作文件 for (int i = 0; i < count; i++) {
 out.put((byte) 'A'); } System.out.println("Writing to Memory Mapped 
File is completed"); // 这一步的读操作读的是内存 for (int i = 0; i < 10 ; i++) { 
System.out.print((char) out.get(i)); } System.out.println("Reading from 
Memory Mapped File is completed"); }}

我们需要注意的一点就是,用于加载内存映射文件的内存是堆外内存。

参考资料:Why use Memory Mapped File or MapppedByteBuffer in Java

分散/聚集 IO

scatter/gather IO,个人认为这个看上去很酷炫,实践中比较难使用到。

分散/聚集 IO(另一种说法是 vectored I/O 也就是向量 IO)是一种可以在单次操作中对多个缓冲区进行输入输出的方法,可以把多个缓冲区的数据写到单个数据流,也可以把单个数据流读到多个缓冲区中。

Java NIO 基础知识

.

Java NIO 基础知识

.

这个功能是操作系统提供的支持,Java NIO 包中已经给我们提供了操作接口 。这种操作可以提高一定的性能,因为一次操作相当于多次的线性操作,同时这也带来了原子性的支持,因为如果用多线程来操作的话,可能存在对同一文件的操作竞争。

非阻塞 IO

相信读者在很多地方都看到过说 NIO 其实不是代表 New IO,而是 Non-Blocking IO,我们这里不纠结这个。我想之所以会有这个说法,是因为在 Java 1.4 第一次推出 NIO 的时候,提供了 Non-Blocking IO 的支持。

在理解非阻塞 IO 前,我们首先要明白,它的对立面 阻塞模式为什么不好。

比如说 InputStream.read 这个方法,一旦某个线程调用这个方法,那么就将一直阻塞在这里,直到数据传输完毕,返回 -1,或者由于其他错误抛出了异常。

我们再拿 web 服务器来说,阻塞模式的话,每个网络连接进来,我们都需要开启一个线程来读取请求数据,然后到后端进行处理,处理结束后将数据写回网络连接,这整个流程需要一个独立的线程来做这件事。那就意味着,一旦请求数量多了以后,需要创建大量的线程,大量的线程必然带来创建线程、切换线程的开销,更重要的是,要给每个线程都分配一部分内存,会使得内存迅速被消耗殆尽。我们说多线程是性能利器,但是这就是过多的线程导致系统完全消化不了了。

通常,我们可以将 IO 分为两类:面向数据块(block-oriented)的 IO 和面向流(stream-oriented)的 IO。比如文件的读写就是面向数据块的,读取键盘输入或往网络中写入数据就是面向流的。

注意,这节混着用了流和通道这两个词,提出来这点是希望不会对读者产生困扰。

面向流的 IO 往往是比较慢的,如网络速度比较慢、需要一直等待用户新的输入等。

这个时候,我们可以用一个线程来处理多个流,让这个线程负责一直轮询这些流的状态,当有的流有数据到来后,进行相应处理,也可以将数据交给其他子线程来处理,这个线程继续轮询。

问题来了,不断地轮询也会带来资源浪费呀,尤其是当一个线程需要轮询很多的数据流的时候。

现代操作系统提供了一个叫做 readiness selection 的功能,我们让操作系统来监控一个集合中的所有的通道,当有的通道数据准备好了以后,就可以直接到这个通道获取数据。当然,操作系统不会通知我们,但是我们去问操作系统的时候,它会知道告诉我们通道 N 已经准备好了,而不需要自己去轮询(后面我们会看到,还要自己轮询的 select 和 poll)。

后面我们在介绍 Java NIO 的时候会说到 Selector,对应类 java.nio.channels.Selector,这个就是 java 对 readiness selection 的支持。这样一来,我们的一个线程就可以更加高效地管理多个通道了。

Java NIO 基础知识

.

上面这张图我想大家也都可能看过,就是用一个 Selector 来管理多个 Channel,实现了一个线程管理多个连接。说到底,其实就是解决了我们前面说的阻塞模式下线程创建过多的问题。

在 Java 中,继承自 SelectableChannel 的子类就是实现了非阻塞 IO 的,我们可以看到主要有 socket IO 中的 DatagramChannel 和 SocketChannel,而 FileChannel 并没有继承它。所以,文件 IO 是不支持非阻塞模式的。

在系统实现上,POSIX 提供了 select 和 poll 两种方式。它们两个最大的区别在于持有句柄的数量上,select 最多只支持到 FD_SETSIZE(一般常见的是 1024),显然很多场景都会超过这个数量。而 poll 我们想创建多少就创建多少。它们都有一个共同的缺点,那就是当有任务完成后,我们只能知道有几个任务完成了,而不知道具体是哪几个句柄,所以还需要进行一次扫描。

正是由于 select 和 poll 的不足,所以催生了以下几个实现。BSD& OS X 中的 kqueue,Solaris 中的 /dev/poll,还有 Linux 中的 epoll。

Windows 没有提供额外的实现,只能使用 select。

在不同的操作系统上,JDK 分别选择相应的系统支持的非阻塞实现方式。

异步 IO

我们知道 Java 1.4 引入了 New IO,从 Java 7 开始,就不再是 New IO 了,而是 More New IO 来临了,我们也称之为 NIO2。

Java7 在 NIO 上带来的最大的变化应该就属引入了 Asynchronous IO(异步 IO)。本来吧,异步 IO 早就提上日程了,可是大佬们没有时间完成,所以才一直拖到了 java 7 的。废话不多说,简单来看看异步 IO 是什么。

要说异步 IO 是什么,当然还得从 Non-Blocking IO 没有解决的问题入手。非阻塞 IO 很好用,它解决了阻塞式 IO 的等待问题,但是它的缺点是需要我们去轮询才能得到结果。

而异步 IO 可以解决这个问题,线程只需要初始化一下,提供一个回调方法,然后就可以干其他的事情了。当数据准备好以后,系统会负责调用回调方法。

异步 IO 最主要的特点就是回调,其实回调在我们日常的代码中也是非常常见的。

最简单的方法就是设计一个线程池,池中的线程负责完成一个个阻塞式的操作,一旦一个操作完成,那么就调用回调方法。比如 web 服务器中,我们前面已经说过不能每来一个请求就新开一个线程,我们可以设计一个线程池,在线程池外用一个线程来接收请求,然后将要完成的任务交给线程池中的线程并提供一个回调方法,这样这个线程就可以去干其他的事情了,如继续处理其他的请求。等任务完成后,池中的线程就可以调用回调方法进行通知了。

另外一种方式就是自己不设计线程池,让操作系统帮我们实现。流程也是基本一样的,提供给操作系统回调方法,然后就可以干其他事情了,等操作完成后,操作系统会负责回调。这种方式的缺点就是依赖于操作系统的具体实现,不过也有它的一些优势。

首先,我们自己设计处理任务的线程池的话,我们需要掌握好线程池的大小,不能太大,也不能太小,这往往需要凭我们的经验;其次,让操作系统来做这件事情的话,操作系统可以在一些场景中帮助我们优化性能,如文件 IO 过程中帮助更快找到需要的数据。

操作系统对异步 IO 的实现也有很多种方式,主要有以下 3 中:

  1. Linux AIO:由 Linux 内核提供支持
  2. POSIX AIO:Linux,Mac OS X(现在该叫 Mac OS 了),BSD,solaris 等都支持,在 Linux 中是通过 glibc 来提供支持的。
  3. Windows:提供了一个叫做 completion ports 的机制。

这篇文章 asynchronous disk I/O 的作者表示,在类 unix 的几个系统实现中,限制太多,实现的质量太差,还不如自己用线程池进行管理异步操作。

而 Windows 系统下提供的异步 IO 的实现方式有点不一样。它首先让线程池中的线程去自旋调用 GetQueuedCompletionStatus.aspx) 方法,判断是否就绪。然后,让任务跑起来,但是需要提供特定的参数来告诉执行任务的线程,让线程执行完成后将结果通知到线程池中。一旦任务完成,操作系统会将线程池中阻塞在 GetQueuedCompletionStatus 方法的线程唤醒,让其进行后续的结果处理。

Windows 智能地唤醒那些执行 GetQueuedCompletionStatus 方法的线程,以让线程池中活跃的线程数始终保持在合理的水平。这样就不至于创建太多的线程,降低线程切换的开销。

Java 7 在异步 IO 的实现上,如果是 Linux 或者其他类 Unix 系统上,是采用自建线程池实现的,如果是 Windows 系统上,是采用系统提供的 completion ports 来实现的。

所以,在非阻塞 IO 和异步 IO 之间,我们应该怎么选择呢?

如果是文件 IO,我们没得选,只能选择异步 IO。

如果是 Socket IO,在类 unix 系统下我们应该选择使用非阻塞 IO,Netty 是基于非阻塞模式的;在 Windows 中我们应该使用异步 IO。

当然了,Java 的存在就是为了实现平台无关化,所以,其实不需要我们选择,了解这些权当让自己涨点知识吧。

总结

和其他几篇文章一样,也没什么好总结的,要说的都在文中了,希望读者能学到点东西吧。

如果哪里说得不对了,我想也是正常的,我这些年写的都是 Java,对于底层了解得愈发的少了,所以如果读者发现有什么不合理的内容,非常希望读者可以提出来。

Linux下同步工具inotify+rsync使用详解

1. rsync

1.1 什么是rsync

rsync是一个远程数据同步工具,可通过LAN/WAN快速同步多台主机间的文件。它使用所谓的“Rsync演算法”来使本地和远程两个主机之间的文件达到同步,这个算法只传送两个文件的不同部分,而不是每次都整份传送,因此速度相当快。所以通常可以作为备份工具来使用。

运行Rsync server的机器也叫backup server,一个Rsync server可同时备份多个client的数据;也可以多个Rsync server备份一个client的数据。Rsync可以搭配ssh甚至使用daemon模式。Rsync server会打开一个873的服务通道(port),等待对方rsync连接。连接时,Rsync server会检查口令是否相符,若通过口令查核,则可以开始进行文件传输。第一次连通完成时,会把整份文件传输一次,下一次就只传送二个文件之间不同的部份。

基本特点:

  1. 可以镜像保存整个目录树和文件系统;
  2. 可以很容易做到保持原来文件的权限、时间、软硬链接等;
  3. 无须特殊权限即可安装;
  4. 优化的流程,文件传输效率高;
  5. 可以使用rcp、ssh等方式来传输文件,当然也可以通过直接的socket连接;
  6. 支持匿名传输。

命令语法:
rsync的命令格式可以为以下六种:
rsync [OPTION]… SRC DEST
rsync [OPTION]… SRC [USER@]HOST:DEST
rsync [OPTION]… [USER@]HOST:SRC DEST
rsync [OPTION]… [USER@]HOST::SRC DEST
rsync [OPTION]… SRC [USER@]HOST::DEST
rsync [OPTION]… rsync://[USER@]HOST[:PORT]/SRC [DEST]

对应于以上六种命令格式,我们可以总结rsync有2种不同的工作模式:

  • shell模式:使用远程shell程序(如ssh或rsh)进行连接。当源路径或目的路径的主机名后面包含一个冒号分隔符时使用这种模式,rsync安装完成后就可以直接使用了,无所谓启动。(目前没有尝试过这个方法)
  • daemon模式:使用TCP直接连接rsync daemon。当源路径或目的路径的主机名后面包含两个冒号,或使用rsync://URL时使用这种模式,无需远程shell,但必须在一台机器上启动rsync daemon,默认端口873,这里可以通过rsync --daemon使用独立进程的方式,或者通过xinetd超级进程来管理rsync后台进程。

当rsync作为daemon运行时,它需要一个用户身份。如果你希望启用chroot,则必须以root的身份来运行daemon,监听端口,或设定文件属主;如果不启用chroot,也可以不使用root用户来运行daemon,但该用户必须对相应的模块拥有读写数据、日志和lock file的权限。当rsync以daemon模式运行时,它还需要一个配置文件——rsyncd.conf。修改这个配置后不必重启rsync daemon,因为每一次的client连接都会去重新读取该文件。

我们一般把DEST远程服务器端成为rsync Server,运行rsync命令的一端SRC称为Client。

安装:
rsync在CentOS6上默认已经安装,如果没有则可以使用yum install rsync -y,服务端和客户端是同一个安装包。

# rsync -h

1.2 同步测试

关于rsync命令的诸多选项说明,见另外一篇文章rsync与inotifywait命令和配置选项说明

1.2.1 本机文件夹同步

# rsync -auvrtzopgP –progress /root/ /tmp/rsync_bak/

会看到从/root/传输文件到/tmp/rsync_bak/的列表和速率,再运行一次会看到sending incremental file list下没有复制的内容,可以在/root/下touch某一个文件再运行看到只同步了修改过的文件。

上面需要考虑以下问题:

  • 删除/root/下的文件不会同步删除/tmp/rsync_bak,除非加入--delete选项
  • 文件访问时间等属性、读写等权限、文件内容等有任何变动,都会被认为修改
  • 目标目录下如果文件比源目录还新,则不会同步
  • 源路径的最后是否有斜杠有不同的含义:有斜杠,只是复制目录中的文件;没有斜杠的话,不但要复制目录中的文件,还要复制目录本身

1.3 同步到远程服务器

在服务器间rsync传输文件,需要有一个是开着rsync的服务,而这一服务需要两个配置文件,说明当前运行的用户名和用户组,这个用户名和用户组在改变文件权限和相关内容的时候有用,否则有时候会出现提示权限问题。配置文件也说明了模块、模块化管理服务的安全性,每个模块的名称都是自己定义的,可以添加用户名密码验证,也可以验证IP,设置目录是否可写等,不同模块用于同步不同需求的目录。

1.3.1 服务端配置文件

/etc/rsyncd.conf:

#2014-12-11 by Sean
uid=root
gid=root
use chroot=no
max connections=10
timeout=600
strict modes=yes
port=873
pid file=/var/run/rsyncd.pid
lock file=/var/run/rsyncd.lock
log file=/var/log/rsyncd.log
[module_test]
path=/tmp/rsync_bak2
comment=rsync test logs
auth users=sean
uid=sean
gid=sean
secrets file=/etc/rsyncd.secrets
read only=no
list=no
hosts allow=172.29.88.204
hosts deny=0.0.0.0/32

这里配置socket方式传输文件,端口873,[module_test]开始定义一个模块,指定要同步的目录(接收)path,授权用户,密码文件,允许哪台服务器IP同步(发送)等。关于配置文件中选项的详细说明依然参考rsync与inotifywait命令和配置选项说明

经测试,上述配置文件每行后面不能使用#来来注释

/etc/rsyncd.secrets:

sean:passw0rd

一行一个用户,用户名:密码。请注意这里的用户名和密码与操作系统的用户名密码无关,可以随意指定,与/etc/rsyncd.conf中的auth users对应。

修改权限:chmod 600 /etc/rsyncd.d/rsync_server.pwd

1.3.2 服务器启动rsync后台服务

修改/etc/xinetd.d/rsync文件,disable 改为 no

# default: off
# description: The rsync server is a good addition to an ftp server, as it \
# allows crc checksumming etc.
service rsync
{
4disable = no
4flags = IPv6
4socket_type = stream
4wait = no
4user = root
4server = /usr/bin/rsync
4server_args = –daemon
4log_on_failure += USERID
}

执行service xinetd restart会一起重启rsync后台进程,默认使用配置文件/etc/rsyncd.conf。也可以使用/usr/bin/rsync --daemon --config=/etc/rsyncd.conf

为了以防rsync写入过多的无用日志到/var/log/message(容易塞满从而错过重要的信息),建议注释掉/etc/xinetd.conf的success:

# log_on_success = PID HOST DURATION EXIT

如果使用了防火墙,要添加允许IP到873端口的规则。

# iptables -A INPUT -p tcp -m state –state NEW -m tcp –dport 873 -j ACCEPT
# iptables -L 查看一下防火墙是不是打开了 873端口
# netstat -anp|grep 873

建议关闭selinux,可能会由于强访问控制导致同步报错。

1.3.3 客户端测试同步

单向同步时,客户端只需要一个包含密码的文件。
/etc/rsync_client.pwd:

passw0rd

chmod 600 /etc/rsync_client.pwd

命令:
将本地/root/目录同步到远程172.29.88.223的/tmp/rsync_bak2目录(module_test指定):

/usr/bin/rsync -auvrtzopgP –progress –password-file=/etc/rsync_client.pwd /root/ sean@172.29.88.223::module_test

当然你也可以将远程的/tmp/rsync_bak2目录同步到本地目录/root/tmp:

/usr/bin/rsync -auvrtzopgP –progress –password-file=/etc/rsync_client.pwd sean@172.29.88.223::module_test /root/

从上面两个命令可以看到,其实这里的服务器与客户端的概念是很模糊的,rsync daemon都运行在远程172.29.88.223上,第一条命令是本地主动推送目录到远程,远程服务器是用来备份的;第二条命令是本地主动向远程索取文件,本地服务器用来备份,也可以认为是本地服务器恢复的一个过程。

1.4 rsync不足

与传统的cp、tar备份方式相比,rsync具有安全性高、备份迅速、支持增量备份等优点,通过rsync可以解决对实时性要求不高的数据备份需求,例如定期的备份文件服务器数据到远端服务器,对本地磁盘定期做数据镜像等。

随着应用系统规模的不断扩大,对数据的安全性和可靠性也提出的更好的要求,rsync在高端业务系统中也逐渐暴露出了很多不足,首先,rsync同步数据时,需要扫描所有文件后进行比对,进行差量传输。如果文件数量达到了百万甚至千万量级,扫描所有文件将是非常耗时的。而且正在发生变化的往往是其中很少的一部分,这是非常低效的方式。其次,rsync不能实时的去监测、同步数据,虽然它可以通过crontab方式进行触发同步,但是两次触发动作一定会有时间差,这样就导致了服务端和客户端数据可能出现不一致,无法在应用故障时完全的恢复数据。基于以上原因,rsync+inotify组合出现了!

2. inotify-tools

2.1 什么是inotify

inotify是一种强大的、细粒度的、异步的文件系统事件监控机制,Linux内核从2.6.13开始引入,允许监控程序打开一个独立文件描述符,并针对事件集监控一个或者多个文件,例如打开、关闭、移动/重命名、删除、创建或者改变属性。

CentOS6自然已经支持:
使用ll /proc/sys/fs/inotify命令,是否有以下三条信息输出,如果没有表示不支持。

total 0
-rw-r–r– 1 root root 0 Dec 11 15:23 max_queued_events
-rw-r–r– 1 root root 0 Dec 11 15:23 max_user_instances
-rw-r–r– 1 root root 0 Dec 11 15:23 max_user_watches
  • /proc/sys/fs/inotify/max_queued_evnets表示调用inotify_init时分配给inotify instance中可排队的event的数目的最大值,超出这个值的事件被丢弃,但会触发IN_Q_OVERFLOW事件。
  • /proc/sys/fs/inotify/max_user_instances表示每一个real user ID可创建的inotify instatnces的数量上限。
  • /proc/sys/fs/inotify/max_user_watches表示每个inotify instatnces可监控的最大目录数量。如果监控的文件数目巨大,需要根据情况,适当增加此值的大小。

inotify-tools:

inotify-tools是为linux下inotify文件监控工具提供的一套C的开发接口库函数,同时还提供了一系列的命令行工具,这些工具可以用来监控文件系统的事件。 inotify-tools是用c编写的,除了要求内核支持inotify外,不依赖于其他。inotify-tools提供两种工具,一是inotifywait,它是用来监控文件或目录的变化,二是inotifywatch,它是用来统计文件系统访问的次数。

下载inotify-tools-3.14-1.el6.x86_64.rpm,通过rpm包安装:

# rpm -ivh /apps/crm/soft_src/inotify-tools-3.14-1.el6.x86_64.rpm
warning: /apps/crm/soft_src/inotify-tools-3.14-1.el6.x86_64.rpm: Header V3 DSA/SHA1 Signature, key ID 4026433f: NOKEY
Preparing… ########################################### [100%]
1:inotify-tools ########################################### [100%]
# rpm -qa|grep inotify
inotify-tools-3.14-1.el5.x86_64

2.2 inotifywait使用示例

监控/root/tmp目录文件的变化:

/usr/bin/inotifywait -mrq –timefmt ‘%Y/%m/%d%H:%M:%S‘ –format ‘%T %w %f‘ \
-e modify,delete,create,move,attrib /root/tmp/

上面的命令表示,持续监听/root/tmp目录及其子目录的文件变化,监听事件包括文件被修改、删除、创建、移动、属性更改,显示到屏幕。执行完上面的命令后,在/root/tmp下创建或修改文件都会有信息输出:

2014/12/11-15:40:04 /root/tmp/ new.txt
2014/12/11-15:40:22 /root/tmp/ .new.txt.swp
2014/12/11-15:40:22 /root/tmp/ .new.txt.swx
2014/12/11-15:40:22 /root/tmp/ .new.txt.swx
2014/12/11-15:40:22 /root/tmp/ .new.txt.swp
2014/12/11-15:40:22 /root/tmp/ .new.txt.swp
2014/12/11-15:40:23 /root/tmp/ .new.txt.swp
2014/12/11-15:40:31 /root/tmp/ .new.txt.swp
2014/12/11-15:40:32 /root/tmp/ 4913
2014/12/11-15:40:32 /root/tmp/ 4913
2014/12/11-15:40:32 /root/tmp/ 4913
2014/12/11-15:40:32 /root/tmp/ new.txt
2014/12/11-15:40:32 /root/tmp/ new.txt~
2014/12/11-15:40:32 /root/tmp/ new.txt

3. rsync组合inotify-tools完成实时同步

这一步的核心其实就是在客户端创建一个脚本rsync.sh,适用inotifywait监控本地目录的变化,触发rsync将变化的文件传输到远程备份服务器上。为了更接近实战,我们要求一部分子目录不同步,如/root/tmp/log和临时文件。

3.1 创建排除在外不同步的文件列表

排除不需要同步的文件或目录有两种做法,第一种是inotify监控整个目录,在rsync中加入排除选项,简单;第二种是inotify排除部分不监控的目录,同时rsync中也要加入排除选项,可以减少不必要的网络带宽和CPU消耗。我们选择第二种。

3.1.1 inotifywait排除

这个操作在客户端进行,假设/tmp/src/mail/2014/以及/tmp/src/mail/2015/cache/目录下的所有文件不用同步,所以不需要监控,/tmp/src/下的其他文件和目录都同步。(其实对于打开的临时文件,可以不监听modify时间而改成监听close_write

inotifywait排除监控目录有--exclude <pattern>--fromfile <file>两种格式,并且可以同时使用,但主要前者可以用正则,而后者只能是具体的目录或文件。

# vi /etc/inotify_exclude.lst:
/tmp/src/pdf
@/tmp/src/2014

使用fromfile格式只能用绝对路径,不能使用诸如*正则表达式去匹配,@表示排除。

如果要排除的格式比较复杂,必须使用正则,那只能在inotifywait中加入选项,如--exclude '(.*/*\.log|.*/*\.swp)$|^/tmp/src/mail/(2014|201.*/cache.*)',表示排除/tmp/src/mail/以下的2014目录,和所有201*目录下的带cache的文件或目录,以及/tmp/src目录下所有的以.log或.swp结尾的文件。

3.1.2 rsync排除

使用inotifywait排除监控目录的情况下,必须同时使用rsync排除对应的目录,否则只要有触发同步操作,必然会导致不该同步的目录也会同步。与inotifywait类似,rsync的同步也有--exclude--exclude-from两种写法。

个人还是习惯将要排除同步的目录卸载单独的文件列表里,便于管理。使用--include-from=FILE时,排除文件列表用绝对路径,但FILE里面的内容请用相对路径,如:
/etc/rsyncd.d/rsync_exclude.lst

mail/2014/
mail/201*/201*/201*/.??*
mail??*
src/*.html*
src/js/
src/ext3/
src/2014/20140[1-9]/
src/201*/201*/201*/.??*
membermail/
membermail??*
membermail/201*/201*/201*/.??*

排除同步的内容包括,mail下的2014目录,类似2015/201501/20150101/下的临时或隐藏文件,等。

3.2 客户端同步到远程的脚本rsync.sh

下面是一个完整的同步脚本,请根据需要进行裁剪,rsync.sh

#rsync auto sync script with inotify
#2014-12-11 Sean
#variables
current_date=$(date +%Y%m%d_%H%M%S)
source_path=/tmp/src/
log_file=/var/log/rsync_client.log
#rsync
rsync_server=172.29.88.223
rsync_user=sean
rsync_pwd=/etc/rsync_client.pwd
rsync_module=module_test
INOTIFY_EXCLUDE=‘(.*/*\.log|.*/*\.swp)$|^/tmp/src/mail/(2014|20.*/.*che.*)’
RSYNC_EXCLUDE=‘/etc/rsyncd.d/rsync_exclude.lst’
#rsync client pwd check
if [ ! -e ${rsync_pwd} ];then
echo -e “rsync client passwod file ${rsync_pwd} does not exist!”
exit 0
fi
#inotify_function
inotify_fun(){
/usr/bin/inotifywait -mrq –timefmt ‘%Y/%m/%d-%H:%M:%S’ –format ‘%T %w %f’ \
–exclude ${INOTIFY_EXCLUDE} -e modify,delete,create,move,attrib ${source_path} \
| while read file
do
/usr/bin/rsync -auvrtzopgP –exclude-from=${RSYNC_EXCLUDE} –progress –bwlimit=200 –password-file=${rsync_pwd} ${source_path} ${rsync_user}@${rsync_server}::${rsync_module}
done
}
#inotify log
inotify_fun >> ${log_file} 2>&1 &

--bwlimit=200用于限制传输速率最大200kb,因为在实际应用中发现如果不做速率限制,会导致巨大的CPU消耗。

在客户端运行脚本# ./rsync.sh即可实时同步目录。

疑问
对于rsync的同步海量存在一个疑问,假如我的文件数很多即使在排除不监控和不同步目录的情况下依然有10万个文件,仅文件列表就达10M,那么岂不是每一次有文件产生或修改都会触发同步,很容易导致大部分情况下在传输文件列表和进行列表的比对,仅同步一个小文件而使用的网络带宽和CPU代价很高,特别是网络状况不佳时,上一次的列表还未传送完,又有新的文件产生触发发送文件列表。不知道rsync内部有没有这样的处理?

其他功能:双向同步sersync2实时同步多远程服务器

参考

lambda 表达式和闭包

区分lambda表达式和闭包

熟悉的Javascript或者Ruby的同学,可能对另一个名词:闭包更加熟悉。因为一般闭包的示例代码,长得跟lambda差不多,导致我也在以前很长一段时间对这两个概念傻傻分不清楚。其实呢,这两个概念是完全不同维度的东西。

闭包是个什么东西呢?我觉得Ruby之父松本行弘在《代码的未来》一书中解释的最好:闭包就是把函数以及变量包起来,使得变量的生存周期延长。闭包跟面向对象是一棵树上的两条枝,实现的功能是等价的。

这样说可能不够直观,我们还是用代码说话吧。其实Java在很早的版本就支持闭包了,只是因为应用场景太少,这个概念一直没得到推广。在Java6里,我们可以这样写:

public static Supplier<Integer> testClosure(){

final int i = 1;

return new Supplier<Integer>() {

@Override

public Integer get() {

return i;

}

};

}

public interface Supplier<T> {

T get();

}

看出问题了么?这里i是函数testClosure的内部变量,但是最终返回里的匿名对象里,仍然返回了i。我们知道,函数的局部变量,其作用域仅限于函数内部,在函数结束时,就应该是不可见状态,而闭包则将i的生存周期延长了,并且使得变量可以被外部函数所引用。这就是闭包了。这里,其实我们的lambda表达式还没有出现呢!

而支持lambda表达式的语言,一般也会附带着支持闭包了,因为lambda总归在函数内部,与函数局部变量属于同一语句块,如果不让它引用局部变量,不会让人很别扭么?例如Python的lambda定义我觉得是最符合λ算子的形式的,我们可以这样定义lambda:

#!/usr/bin/python

y = 1

f=lambda x: x + y

print f(2)

y = 3

print f(2)

输出:

3

5

这里y其实是外部变量。

Java中闭包带来的问题

在Java的经典著作《Effective Java》、《Java Concurrency in Practice》里,大神们都提到:匿名函数里的变量引用,也叫做变量引用泄露,会导致线程安全问题,因此在Java8之前,如果在匿名类内部引用函数局部变量,必须将其声明为final,即不可变对象。(Python和Javascript从一开始就是为单线程而生的语言,一般也不会考虑这样的问题,所以它的外部变量是可以任意修改的)。

在Java8里,有了一些改动,现在我们可以这样写lambda或者匿名类了:

public static Supplier<Integer> testClosure() {

int i = 1;

return () -> {

return i;

};

}

这里我们不用写final了!但是,Java大神们说的引用泄露怎么办呢?其实呢,本质没有变,只是Java8这里加了一个语法糖:在lambda表达式以及匿名类内部,如果引用某局部变量,则直接将其视为final。我们直接看一段代码吧:

public static Supplier<Integer> testClosure() {

int i = 1;

i++;

return () -> {

return i; //这里会出现编译错误

};

}

明白了么?其实这里我们仅仅是省去了变量的final定义,这里i会强制被理解成final类型。很搞笑的是编译错误出现在lambda表达式内部引用i的地方,而不是改变变量值的i++…这也是Java的lambda的一个被人诟病的地方。我只能说,强制闭包里变量必须为final,出于严谨性我还可以接受,但是这个语法糖有点酸酸的感觉,还不如强制写final呢…

C#中 Thread,Task,Async/Await,IAsyncResult 的那些事儿!

说起异步,Thread,Task,async/await,IAsyncResult 这些东西肯定是绕不开的,今天就来依次聊聊他们

1.线程(Thread)

多线程的意义在于一个应用程序中,有多个执行部分可以同时执行;对于比较耗时的操作(例如io,数据库操作),或者等待响应(如WCF通信)的操作,可以单独开启后台线程来执行,这样主线程就不会阻塞,可以继续往下执行;等到后台线程执行完毕,再通知主线程,然后做出对应操作!

在C#中开启新线程比较简单

复制代码
static void Main(string[] args)
{
    Console.WriteLine("主线程开始");
    //IsBackground=true,将其设置为后台线程
    Thread t = new Thread(Run) { IsBackground = true };
    t.Start();
   Console.WriteLine("主线程在做其他的事!");
    //主线程结束,后台线程会自动结束,不管有没有执行完成
    //Thread.Sleep(300);
    Thread.Sleep(1500);
    Console.WriteLine("主线程结束");
}
static void Run()
{
    Thread.Sleep(700);
    Console.WriteLine("这是后台线程调用");
}
复制代码

执行结果如下图,

可以看到在启动后台线程之后,主线程继续往下执行了,并没有等到后台线程执行完之后。

1.1 线程池

试想一下,如果有大量的任务需要处理,例如网站后台对于HTTP请求的处理,那是不是要对每一个请求创建一个后台线程呢?显然不合适,这会占用大量内存,而且频繁地创建的过程也会严重影响速度,那怎么办呢?线程池就是为了解决这一问题,把创建的线程存起来,形成一个线程池(里面有多个线程),当要处理任务时,若线程池中有空闲线程(前一个任务执行完成后,线程不会被回收,会被设置为空闲状态),则直接调用线程池中的线程执行(例asp.net处理机制中的Application对象),

使用事例:

复制代码
for (int i = 0; i < 10; i++)
{
    ThreadPool.QueueUserWorkItem(m =>
    {
        Console.WriteLine(Thread.CurrentThread.ManagedThreadId.ToString());
    });
}
Console.Read();
复制代码

运行结果:

可以看到,虽然执行了10次,但并没有创建10个线程。

 1.2 信号量(Semaphore)

Semaphore负责协调线程,可以限制对某一资源访问的线程数量

这里对SemaphoreSlim类的用法做一个简单的事例:

复制代码
static SemaphoreSlim semLim = new SemaphoreSlim(3); //3表示最多只能有三个线程同时访问
static void Main(string[] args)
{
    for (int i = 0; i < 10; i++)
    {
        new Thread(SemaphoreTest).Start();
    }
    Console.Read();
}
static void SemaphoreTest()
{
    semLim.Wait();
    Console.WriteLine("线程" + Thread.CurrentThread.ManagedThreadId.ToString() + "开始执行");
    Thread.Sleep(2000);
    Console.WriteLine("线程" + Thread.CurrentThread.ManagedThreadId.ToString() + "执行完毕");
    semLim.Release();
}
复制代码

执行结果如下:

可以看到,刚开始只有三个线程在执行,当一个线程执行完毕并释放之后,才会有新的线程来执行方法!

除了SemaphoreSlim类,还可以使用Semaphore类,感觉更加灵活,感兴趣的话可以搜一下,这里就不做演示了!

2.Task

Task是.NET4.0加入的,跟线程池ThreadPool的功能类似,用Task开启新任务时,会从线程池中调用线程,而Thread每次实例化都会创建一个新的线程。

复制代码
Console.WriteLine("主线程启动");
//Task.Run启动一个线程
//Task启动的是后台线程,要在主线程中等待后台线程执行完毕,可以调用Wait方法
//Task task = Task.Factory.StartNew(() => { Thread.Sleep(1500); Console.WriteLine("task启动"); });
Task task = Task.Run(() => { 
    Thread.Sleep(1500);
    Console.WriteLine("task启动");
});
Thread.Sleep(300);
task.Wait();
Console.WriteLine("主线程结束");
复制代码

执行结果如下:

开启新任务的方法:Task.Run()或者Task.Factory.StartNew(),开启的是后台线程

要在主线程中等待后台线程执行完毕,可以使用Wait方法(会以同步的方式来执行)。不用Wait则会以异步的方式来执行。

比较一下Task和Thread:

复制代码
static void Main(string[] args)
{
    for (int i = 0; i < 5; i++)
    {
        new Thread(Run1).Start();
    }
    for (int i = 0; i < 5; i++)
    {
        Task.Run(() => { Run2(); });
    }
}
static void Run1()
{
    Console.WriteLine("Thread Id =" + Thread.CurrentThread.ManagedThreadId);
}
static void Run2()
{
    Console.WriteLine("Task调用的Thread Id =" + Thread.CurrentThread.ManagedThreadId);
}
复制代码

执行结果:

可以看出来,直接用Thread会开启5个线程,用Task(用了线程池)开启了3个!

2.1 Task<TResult>

Task<TResult>就是有返回值的Task,TResult就是返回值类型。

复制代码
Console.WriteLine("主线程开始");
//返回值类型为string
Task<string> task = Task<string>.Run(() => {
    Thread.Sleep(2000); 
    return Thread.CurrentThread.ManagedThreadId.ToString(); 
});
//会等到task执行完毕才会输出;
Console.WriteLine(task.Result);
Console.WriteLine("主线程结束");
复制代码

运行结果:

通过task.Result可以取到返回值,若取值的时候,后台线程还没执行完,则会等待其执行完毕!

简单提一下:

Task任务可以通过CancellationTokenSource类来取消,感觉用得不多,用法比较简单,感兴趣的话可以搜一下!

 3. async/await

async/await是C#5.0中推出的,先上用法:

复制代码
static void Main(string[] args)
{
    Console.WriteLine("-------主线程启动-------");
    Task<int> task = GetStrLengthAsync();
    Console.WriteLine("主线程继续执行");
    Console.WriteLine("Task返回的值" + task.Result);
    Console.WriteLine("-------主线程结束-------");
}

static async Task<int> GetStrLengthAsync()
{
    Console.WriteLine("GetStrLengthAsync方法开始执行");
    //此处返回的<string>中的字符串类型,而不是Task<string>
    string str = await GetString();
    Console.WriteLine("GetStrLengthAsync方法执行结束");
    return str.Length;
}

static Task<string> GetString()
{
   //Console.WriteLine("GetString方法开始执行")
    return Task<string>.Run(() =>
    {
        Thread.Sleep(2000);
        return "GetString的返回值";
    });
}
复制代码

async用来修饰方法,表明这个方法是异步的,声明的方法的返回类型必须为:void,Task或Task<TResult>。

await必须用来修饰Task或Task<TResult>,而且只能出现在已经用async关键字修饰的异步方法中。通常情况下,async/await成对出现才有意义,

看看运行结果:

可以看出来,main函数调用GetStrLengthAsync方法后,在await之前,都是同步执行的,直到遇到await关键字,main函数才返回继续执行。

那么是否是在遇到await关键字的时候程序自动开启了一个后台线程去执行GetString方法呢?

现在把GetString方法中的那行注释加上,运行的结果是:

大家可以看到,在遇到await关键字后,没有继续执行GetStrLengthAsync方法后面的操作,也没有马上反回到main函数中,而是执行了GetString的第一行,以此可以判断await这里并没有开启新的线程去执行GetString方法,而是以同步的方式让GetString方法执行,等到执行到GetString方法中的Task<string>.Run()的时候才由Task开启了后台线程!

那么await的作用是什么呢?

可以从字面上理解,上面提到task.wait可以让主线程等待后台线程执行完毕,await和wait类似,同样是等待,等待Task<string>.Run()开始的后台线程执行完毕,不同的是await不会阻塞主线程,只会让GetStrLengthAsync方法暂停执行。

那么await是怎么做到的呢?有没有开启新线程去等待?

只有两个线程(主线程和Task开启的线程)!至于怎么做到的(我也不知道……>_<),大家有兴趣的话研究下吧!

4.IAsyncResult

IAsyncResult自.NET1.1起就有了,包含可异步操作的方法的类需要实现它,Task类就实现了该接口

在不借助于Task的情况下怎么实现异步呢?

复制代码
class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("主程序开始--------------------");
        int threadId;
        AsyncDemo ad = new AsyncDemo();
        AsyncMethodCaller caller = new AsyncMethodCaller(ad.TestMethod);

        IAsyncResult result = caller.BeginInvoke(3000,out threadId, null, null);
        Thread.Sleep(0);
        Console.WriteLine("主线程线程 {0} 正在运行.",Thread.CurrentThread.ManagedThreadId)
        //会阻塞线程,直到后台线程执行完毕之后,才会往下执行
        result.AsyncWaitHandle.WaitOne();
        Console.WriteLine("主程序在做一些事情!!!");
        //获取异步执行的结果
        string returnValue = caller.EndInvoke(out threadId, result);
        //释放资源
        result.AsyncWaitHandle.Close();
        Console.WriteLine("主程序结束--------------------");
        Console.Read();
    }
}
public class AsyncDemo
{
    //供后台线程执行的方法
    public string TestMethod(int callDuration, out int threadId)
    {
        Console.WriteLine("测试方法开始执行.");
        Thread.Sleep(callDuration);
        threadId = Thread.CurrentThread.ManagedThreadId;
        return String.Format("测试方法执行的时间 {0}.", callDuration.ToString());
    }
}
public delegate string AsyncMethodCaller(int callDuration, out int threadId);
复制代码

关键步骤就是红色字体的部分,运行结果:

和Task的用法差异不是很大!result.AsyncWaitHandle.WaitOne()就类似Task的Wait。

 5.Parallel

最后说一下在循环中开启多线程的简单方法:

复制代码
Stopwatch watch1 = new Stopwatch();
watch1.Start();
for (int i = 1; i <= 10; i++)
{
    Console.Write(i + ",");
    Thread.Sleep(1000);
}
watch1.Stop();
Console.WriteLine(watch1.Elapsed);

Stopwatch watch2 = new Stopwatch();
watch2.Start();

//会调用线程池中的线程
Parallel.For(1, 11, i =>
{
    Console.WriteLine(i + ",线程ID:" + Thread.CurrentThread.ManagedThreadId);
    Thread.Sleep(1000);
});
watch2.Stop();
Console.WriteLine(watch2.Elapsed);
复制代码

运行结果:

循环List<T>:

复制代码
List<int> list = new List<int>() { 1, 2, 3, 4, 5, 6, 6, 7, 8, 9 };
Parallel.ForEach<int>(list, n =>
{
    Console.WriteLine(n);
    Thread.Sleep(1000);
});
复制代码

执行Action[]数组里面的方法:

复制代码
Action[] actions = new Action[] { 
   new Action(()=>{
       Console.WriteLine("方法1");
   }),
    new Action(()=>{
       Console.WriteLine("方法2");
   })
};
Parallel.Invoke(actions);
复制代码

6.异步的回调

文中所有Task<TResult>的返回值都是直接用task.result获取,这样如果后台任务没有执行完毕的话,主线程会等待其执行完毕,这样的话就和同步一样了(看上去一样,但其实await的时候并不会造成线程的阻塞,web程序感觉不到,但是wpf,winform这样的桌面程序若不使用异步,会造成UI线程的阻塞)。简单演示一下Task回调函数的使用:

复制代码
Console.WriteLine("主线程开始");
Task<string> task = Task<string>.Run(() => {
    Thread.Sleep(2000); 
    return Thread.CurrentThread.ManagedThreadId.ToString(); 
});
//会等到任务执行完之后执行
task.GetAwaiter().OnCompleted(() =>
{
    Console.WriteLine(task.Result);
});
Console.WriteLine("主线程结束");
Console.Read();
复制代码

执行结果:

OnCompleted中的代码会在任务执行完成之后执行!

另外task.ContinueWith()也是一个重要的方法:

复制代码
Console.WriteLine("主线程开始");
Task<string> task = Task<string>.Run(() => {
    Thread.Sleep(2000); 
    return Thread.CurrentThread.ManagedThreadId.ToString(); 
});

task.GetAwaiter().OnCompleted(() =>
{
    Console.WriteLine(task.Result);
});
task.ContinueWith(m=>{Console.WriteLine("第一个任务结束啦!我是第二个任务");});
Console.WriteLine("主线程结束");
Console.Read();
复制代码

执行结果:

ContinueWith()方法可以让该后台线程继续执行新的任务。

Task的使用还是比较灵活的,大家可以研究下,好了,以上就是全部内容了,篇幅和能力都有限,希望对大家有用!

curl查询公网出口IP

 

不管是在家里还是办公室,或者是公司的主机,很多时候都是在内网中,也就是说很多都是通过 NAT上网的,有时候需要查询下出口的公网IP,如果有浏览器,可以用百度, google搜 ip 这个关键词得到公网IP,那要是在命令行下呢? 下面是运维开发群的大神们分享的几个接口,整理了下分享给大家。

liuzhizhi@lzz-rmbp|logs # curl ipinfo.io
{
  "ip": "114.110.1.38",
  "hostname": "No Hostname",
  "city": "Beijing",
  "region": "Beijing Shi",
  "country": "CN",
  "loc": "39.9289,116.3883",
  "org": "AS4808 CNCGROUP IP network China169 Beijing Province Network"
}%

liuzhizhi@lzz-rmbp|logs # curl ip.cn
当前 IP:114.110.1.38 来自:北京市 广东恒敦通信技术北京分公司

liuzhizhi@lzz-rmbp|~ # curl cip.cc
IP  : 114.110.1.38
地址  : 中国  北京市
数据二 : 北京市 | 广东恒敦通信技术北京分公司
URL : http://www.cip.cc/114.110.1.38

liuzhizhi@lzz-rmbp|~ # curl myip.ipip.net
当前 IP:114.110.1.38  来自于:中国 北京 北京 联通/电信

liuzhizhi@lzz-rmbp|~ # curl ifconfig.me
114.110.1.38

liuzhizhi@lzz-rmbp|logs # curl http://members.3322.org/dyndns/getip
114.110.1.38

几个网址也非常好记忆

  • ip.cn
  • ipinfo.io
  • cip.cc
  • ifconfig.me
  • myip.ipip.net

超实用压力测试工具-ab工具

写在前面


在学习ab工具之前,我们需了解几个关于压力测试的概念

  1. 吞吐率(Requests per second)
    概念:服务器并发处理能力的量化描述,单位是reqs/s,指的是某个并发用户数下单位时间内处理的请求数。某个并发用户数下单位时间内能处理的最大请求数,称之为最大吞吐率。
    计算公式:总请求数 / 处理完成这些请求数所花费的时间,即
    Request per second = Complete requests / Time taken for tests
  2. 并发连接数(The number of concurrent connections)
    概念:某个时刻服务器所接受的请求数目,简单的讲,就是一个会话。
  3. 并发用户数(The number of concurrent users,Concurrency Level)
    概念:要注意区分这个概念和并发连接数之间的区别,一个用户可能同时会产生多个会话,也即连接数。
  4. 用户平均请求等待时间(Time per request)
    计算公式:处理完成所有请求数所花费的时间/ (总请求数 / 并发用户数),即
    Time per request = Time taken for tests /( Complete requests / Concurrency Level)
  5. 服务器平均请求等待时间(Time per request: across all concurrent requests)
    计算公式:处理完成所有请求数所花费的时间 / 总请求数,即
    Time taken for / testsComplete requests
    可以看到,它是吞吐率的倒数。
    同时,它也=用户平均请求等待时间/并发用户数,即
    Time per request / Concurrency Level

ab工具简介


ab全称为:apache bench

  • 在官网上的解释如下:

ab是Apache超文本传输协议(HTTP)的性能测试工具。其设计意图是描绘当前所安装的Apache的执行性能,主要是显示你安装的Apache每秒可以处理多少个请求。

  • 其他网站解释:

ab是apache自带的压力测试工具。ab非常实用,它不仅可以对apache服务器进行网站访问压力测试,也可以对或其它类型的服务器进行压力测试。比如nginx、tomcat、IIS等。

下载ab工具


进入apache官网 http://httpd.apache.org/ 下载apache即可

启动ab工具


以windows环境下,apache安装路径为C:\apache\Apache24\为例

打开终端,输入命令
cd C:\apache\Apache24\bin
即可启动ab

开始测试


输入命令
ab -n 100 -c 10 http://test.com/
其中-n表示请求数,-c表示并发数

其余命令请参见 http://apache.jz123.cn/programs/ab.html

测试结果分析


上面的命令运行完毕后就出来测试报告了

完整测试报告
  • 这段展示的是web服务器的信息,可以看到服务器采用的是nginx,域名是wan.bigertech.com,端口是80
服务器信息
  • 这段是关于请求的文档的相关信息,所在位置“/”,文档的大小为338436 bytes(此为http响应的正文长度)
文档信息
  • 这段展示了压力测试的几个重要指标
重要指标

Concurrency Level: 100
//并发请求数
Time taken for tests: 50.872 seconds
//整个测试持续的时间
Complete requests: 1000
//完成的请求数
Failed requests: 0
//失败的请求数

Total transferred: 13701482 bytes
//整个场景中的网络传输量
HTML transferred: 13197000 bytes
//整个场景中的HTML内容传输量

Requests per second: 19.66 [#/sec] (mean)
//吞吐率,大家最关心的指标之一,相当于 LR 中的每秒事务数,后面括号中的 mean 表示这是一个平均值
Time per request: 5087.180 [ms] (mean)
//用户平均请求等待时间,大家最关心的指标之二,相当于 LR 中的平均事务响应时间,后面括号中的 mean 表示这是一个平均值
Time per request: 50.872 [ms] (mean, across all concurrent requests)
//服务器平均请求处理时间,大家最关心的指标之三

Transfer rate: 263.02 [Kbytes/sec] received
//平均每秒网络上的流量,可以帮助排除是否存在网络流量过大导致响应时间延长的问题

  • 这段表示网络上消耗的时间的分解
网络消耗时间
  • 这段是每个请求处理时间的分布情况,50%的处理时间在4930ms内,66%的处理时间在5008ms内…,重要的是看90%的处理时间。
响应情况

关于登录的问题


有时候进行压力测试需要用户登录,怎么办?
请参考以下步骤:

  1. 先用账户和密码登录后,用开发者工具找到标识这个会话的Cookie值(Session ID)记下来
  2. 如果只用到一个Cookie,那么只需键入命令:
    ab -n 100 -C key=value http://test.com/

    如果需要多个Cookie,就直接设Header:
    ab -n 100 -H “Cookie: Key1=Value1; Key2=Value2” http://test.com/

总结


总的来说ab工具ab小巧简单,上手学习较快,可以提供需要的基本性能指标,但是没有图形化结果,不能监控。因此ab工具可以用作临时紧急任务和简单测试。
同类型的压力测试工具还有:webbench、siege、http_load等


作者:橙子酱
链接:https://www.jianshu.com/p/43d04d8baaf7
來源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

为博客添加Let’s Encrypt 免费证书

前段时间闹得沸沸扬扬的沃通丑闻事件最终以 Google,Firefox 相继宣布停止信任沃通和 StartCom 的证书宣告结束。按照沃通 CA 以及 StartCom 的证书在国内外的使用广泛程度,估计得有一大批网站需要更换 SSL 证书。

本博客在建立之初就使用了 Let’s Encrypt 的免费证书,这个被沃通说成是「非常危险」的 CA,恰恰是一个不错的选择。

在之前的文章中提到,Let’s Encrypt 是一家旨在消除当前手动创建和安装证书的复杂过程的自动化流程,为安全网站提供免费的 SSL/TLS 证书的数字证书认证机构。在 2015 年 9 月 7 日推出之后后不到半年的时间里就签发了一百万张证书,而现在每天都要签发 5.5 万张证书。可以说 Let’s Encrypt 对 HTTPS 的普及起到了至关重要的作用。

关于 HTTPS

大概从去年开始,我偶尔会发现之前收藏的网站变成了未收藏状态,仔细一看才发现原来是这些网站的地址都变成了 https 开头。

越来越多的网站开始采用 HTTPS 加密,同时 Mozilla、Google 等公司也在促进 HTTPS 的普及。可以看到,全面启用 HTTPS 已经是大势所趋。

2016年10月13日 Firefox 用户加载的半数网页启用了 HTTPS
2016年11月04日 Chrome 用户访问的网页 HTTPS 超过一半

什么是 HTTPS

简单地来说,HTTPS 是建立在非对称加密方式上的保证通信双方信息传输安全性的一种超文本传输安全协议,能够防止信息在传送过程中被监听和篡改。

为什么要采用 HTTPS

可能你会觉得,只有那些银行或者购物网站等涉及到财产安全的网站才需要采用 HTTPS 加密,或者只有登录的时候需要 HTTPS,一般的个人网站根本没有必要。但我想你肯定不会希望自己网站的图片都被替换成小广告吧(滑稽?

另外,Mozilla 和 Google 等主流浏览器公司也在逐步淘汰 HTTP,终极目标是彻底使用 HTTPS 取代 HTTP。为此 Google 还于 2014 年 8 月 6 日宣布提高 HTTPS 网站的排名。

2015 年 4 月 30 日 Mozilla 宣布淘汰 HTTP
2016 年 9 月 08 日 谷歌 Chrome56 正式将 HTTP 页面标记「不安全」

不加密的 HTTP 连接是不安全的,你和目标服务器之间的任何中间人能读取和操纵传输的数据,最简单的例子就是运营商劫持的页面跳转和小广告,你很可能根本不知道你看到的广告是否是网站发布的。而且中间人能够注入的代码不仅仅是看起来无害的广告,他们还可能注入具有恶意目的的代码。

为网站启用 HTTPS

申请 Let’s Encrypt 证书

Let’s Encrypt 官方提供了方便快捷的部署工具 Certbot,可以实现自动部署甚至自动续签。

安装 Certbot

在 Ubuntu 上只需要简单的几行命令:

$ sudo apt-get update
$ sudo apt-get install software-properties-common
$ sudo add-apt-repository ppa:certbot/certbot
$ sudo apt-get update
$ sudo apt-get install python-certbot-nginx

其他的发行版可以在这里选择。

使用 webroot 自动生成证书

Certbot 支持多种不同的「插件」来获取证书,这里选择使用 webroot 插件,它可以在不停止 Web 服务器的前提下自动生成证书,使用 --webroot 参数指定网站的根目录。

$ sudo certbot certonly --webroot -w /var/www/wordpress -d liangjb.cc

这样,在 /var/www/wordpress/ 目录下创建临时文件 .well-known/acme-challenge ,通过这个文件来证明对域名 liangjb.cc 的控制权,然后 Let’s Encrypt 验证服务器发出 HTTP 请求,验证每个请求的域的 DNS 解析,验证成功即颁发证书。

验证过程中需要配置服务器接受来自http端口当前请求,如果是采用proxy_pass方式则需要预先设置好root目录。

location ^~ /.well-known/acme-challenge/ {
   default_type "text/plain";
   root     /var/www/wordpress;
}

location = /.well-known/acme-challenge/ {
   return 404;
}

生成的 pem 和 key 在 /etc/letsencrypt/live/ 目录下

cert.pem 用户证书
chain.pem 中间证书
fullchain.pem 证书链, chain.pem + cert.pem
privkey.pem 证书私钥

配置 Nginx

修改 Nginx 配置文件中关于证书的配置,配置文件可以通过 Mozilla SSL Configuration Generator 生成。

ssl_certificate /etc/letsencrypt/live/liangjb.cc/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/liangjb.cc/privkey.pem;

然后重启 Nginx ,应该就可以看到小绿标了。

$ sudo systemctl restart nginx

自动续期方式1

Let’s Encrypt 的证书有效期为 90 天,不过我们可以通过 crontab 定时运行命令更新证书。

先运行以下命令来测试证书的自动更新:

cerbot renew dryrun

如果一切正常,就可以编辑 crontab 定期运行以下命令:

cerbot renew

执行频率只要小于 30 天一次即可,因为证书默认从有效期内第 60 天开始被视为即将过期。

sudo crontab -e

添加配置

30 2 * * 1 /usr/bin/certbot renew  >> /var/log/le-renew.log

自动续期方式2

通过 systemd 来自动执行证书续期任务。

$ sudo vim /etc/systemd/system/letsencrypt.service
[Unit]
Description=Let's Encrypt renewal

[Service]
Type=oneshot  
ExecStart=/usr/bin/letsencrypt renew  
ExecStartPost=/bin/systemctl reload nginx.service  

然后增加一个 systemd timer 来触发这个服务:

$ sudo vim /etc/systemd/system/letsencrypt.timer
[Unit]
Description=Monthly renewal of Let's Encrypt's certificates

[Timer]
OnCalendar=daily  
Persistent=true

[Install]
WantedBy=timers.target  

启用服务,开启 timer:

$ sudo systemctl enable letsencrypt.service
$ sudo systemctl start letsencrypt.timer

HTTPS 评分

完成配置之后可以使用以下两个工具对博客 HTTPS 配置的安全性进行评分。

扩展阅读

  1. Certbot#ubuntuxenial-nginx
  2. User Guide — Certbot documentation #Webroot
  3. Let’s Encrypt,免费好用的 HTTPS 证书
  4. Let’s Encrypt 给网站加 HTTPS 完全指南

SSL在https和MySQL中的原理思考

之前对HTTPS通信过程有过了解,HTTPS是应用HTTP协议使用SSL加密的版本,在TCP和HTTP之间增加SSL协议。通过握手阶段认证双方身份,协商对称秘钥对通信信息进行加密。此处只描述常用的服务器单向验证,大致过程简要描述如下:
0:事先Web服务器把自己的公钥和Web信息提交给权威CA,CA确认后,用自己的私钥将Web信息以及公钥的文摘签名,制成数字证书交给Web服务器;
客户端Web浏览器事先安装被信任的权威CA的根证书(未签名证书或者自签名证书)
1:客户端向服务器发起连接请求,协商使用的SSL版本、非对称加密算法、对称加密算法以及摘要生成算法,双方达成共识
2:Web服务器向客户端发送自己的数字证书,客户端用CA的根证书解密,证明Web服务器身份真实,同时证明服务器公钥正确
3:客户端用服务器公钥加密一个随机数,作为通信收发数据的对称秘钥,发送给服务器
4:服务器用自己的私钥解密,拿到对称秘钥,返回ACK
5:客户端和服务器使用对称秘钥开始通信
到学习MySQL的SSL连接配置时产生一个疑问,HTTPS有CA作为可信第三方,负责确认服务器身份,而MySQL连接通信只2方,没听说还有个CA从中协调啊,那还怎么SSL啊?
通过网上查资料,发现自己对SSL相关的很多概念理解不是很准确,对之前CA的验证方式理解不对。首先明确一些概念:
公私钥对:非对称加密算法,公钥和私钥成对出现,用公钥加密用私钥解密,用私钥加密用公钥解密
CA:证书颁发机构,通信双方可信的第三方。自己有公私钥对,网站想证明自己真实可信,但用户不相信自己,只相信CA说的,于是网站提交自己的信息和公钥给CA,CA核实网站信息和提交的公钥,觉得靠谱,于是签名,制成证书,交给网站成为一个资质。
签名:别人不知道我的私钥,但是知道我的公钥。怎么证明这文件是我认证的呢?我用自己的私钥加密,别人用我的公钥解密成功,那肯定知道是我加密的,别人干不了。具体一点,先对文本内容计算散列值,然后对这个散列值用自己的私钥加密。(别人对文本内容计算散列值,然后用我的私钥解密得到的值做对比,一致证明签名OK)
证书:包含3部分,通信方具体信息(地点、域名、组织、拥有者等)、通信方的公钥、权威CA的签名。(具体信息和公钥计算散列值,然后对这个散列值用自己的私钥加密)
根证书:权威CA也有自己的证书(毕竟需要CA的公钥来验证网站证书真伪),那CA的证书谁签名啊?毕竟没有更高一级了,所以根证书是未签名的或者是自签名的,没人给这个证书背书了,所以叫做根,是信任链的起点,都可以理解了。
再看MySQL的SSL连接配置,思考SSL通信过程,就可以理解为什么需要这些文件了(此处描述SSL单向验证模式)
MySQL服务器端要配置3个文件:ssl-ca.pem, ssl-key.pem, ssl-cert.pem
客户端连接时需要文件:ssl-ca.pem
ssl-ca.pem就充当了可信的第三方,CA根证书,文件里包含了CA的信息和公钥,客户端和服务器都有。
1.客户端向MySQL服务器发起连接请求,双方协商加密算法、SSL版本等
2.服务器向客户端发来自己的证书(ssl-cert.pem的内容,CA签名的),客户端用ssl-ca.pem的公钥解密,确认服务器身份和公钥真实。
3.客户端产生随机数作为对称加密的秘钥,用服务器公钥加密,发送给服务器
4.服务器用自己的私钥(ssl-key.pem)解密,拿到这个随机数,返回ACK
5.双方用随机数做密钥,以对称加密方式通信