JWT的基本概念

由于 http 是无状态的,所以如果要服务端要标示用户的话只能是通过 session 和 cookie 组合的方式来进行交互。

然而,这种方式由于高度依赖于服务端,在单机模式下,固然没有问题,但是,比方说要增加机器的时候,同一个用户如何在两台机器上都识别正确,这也是一个需要考虑的问题。

虽然,业界已经有很多方案可以解决这种问题,比方说,可以把 session_key 记录到数据库,或者记录到 redis 等等。今天,我们来介绍一下另一种校验方案 JWT

什么是 JWT?

jwt 是 json web token 的缩写,是目前较流行的跨域认证解决方案。 JWT 一共由 3 个部分组成,分别是 headerpayloadSignature

header(头部)

header 通常由两部分的 json 数据格式组成,algtyp, alg 表示签名的算法(algorithm),而 typ 表示这个 token 的类型,通常来说,都是 JWT。如下所示

1
2
3
4
{
"alg": "HS256",
"typ": "JWT"
}

接着,通过 Base64URL 算法转换成字符串,转换后,该字符串就是 jwt 三段中的第一段

payload(载荷)

payload 也是一个 json 对象,用于存放所需传输的数据,比方说可以存放 user 的相关信息。JWT 还定义了 7 个官方字段:

  • iss (issuer):签发人
  • exp (expiration time):过期时间
  • sub (subject):主题
  • aud (audience):受众
  • nbf (Not Before):生效时间
  • iat (Issued At):签发时间
  • jti (JWT ID):编号
    1
    2
    3
    4
    5
    6
    7
    {
    "iss": "yigger",
    "iat": 1562767301,
    "exp": 1562770901,
    "aud": "yigger.cn",
    "sub": "yigger@example.com"
    }
    当然,你不一定全部都要用上,具体还是得看你业务需要,但是,JWT 默认是不加密的,请不要把敏感信息放入到此字段!!!

同样的,跟第一步一样,通过 Base64URL 把 json 对象转换为字符串生成第二段内容

Signature(签名)

签名需要以上两步所生成的内容,然后再根据密钥(该密钥只有你自己知道),和指定的加密算法,就可以生成了第三段的内容了

1
2
3
4
5
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret
)

至此,已经生成了三段内容了,然后 token = 第一段字符串 + "." + 第二段字符串 + "." + 第三段字符串

以下就是一个 token 的示例格式

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

实际使用

  1. 通过登录,输入账号密码,服务端校验成功后,返回 token
  2. 客户端可存储在本地,然后下次请求那些需要认证的接口的时候,把它带过去。需要注意的是,之前用 session 方式的话,是通过 cookie 带到后端的,但是 jwt 这种方式,是通过附加在 header 参数的 authorization 字段,值的前面加Bearer关键字和空格。如:authorization: Bearer token
  3. 服务端校验客户端传送过来的 token …

总结

jwt 使得服务端变得无状态,无论 token 发送到哪台服务器,只要有相同的逻辑代码,都能校验通过,换句话说,服务端变成无状态了。但是弊端也是存在的:

  1. 因为 token 一旦生成了,可用性就会延续到过期时间为止,你无法中断一个正在使用的 token,除非更改后端的加密算法,这也意外着之前签发的所有 token 全部失效。
  2. session 机制具有续签机制,但是要实现 token 的续签,可能会相对麻烦些,具体方案也有,已经有人总结出来了,具体可查看参考链接的第二个链接

参考

https://jwt.io/introduction/

http://blog.didispace.com/learn-how-to-use-jwt-xjf/

Sidekiq源码学习

在公司项目里面,我们是用 sidekiq 来做消息队列的,但是都仅仅是停留在用的阶段,前几天有空看了下源码才知道内部结构原来是这么回事

下面我从使用的过程一直走到源码内部来看看整个过程是怎样的,请坐好扶稳

模拟场景:异步发送邮件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 定义发送邮件的 worker
class EmailWorker
include Sidekiq::Worker
def perform(email, content)
Email.send(email, content) # 发送邮件
end
end

# 调用端
class EmailController
def welcome
EmailWorker.perform_async("xxx@xx.com", "welcome to my home")
end
end

定义了 EmailWorker 以后,在 controller 层就可以直接调用 这个 worker,为什么不是 EmailWorker.perform 而是 perform_async? 因为 perform 是实例方法,而 perform_async 是类方法,所以,真正工作的是 perform_async,OK,那下面来看看 perform_async 方法做了什么处理,需要注意的是,perform_async 所接收的参数是跟 perform 是一致的

Sidekiq 是如何生产数据的?(生产者)

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
为了排版以下内容只列出关键方法,均有删减
def perform_async(*args)
# 1. 把类对象和参数组成 hash 后传递给 client_push
client_push("class" => self, "args" => args)
end

def client_push(item)
# 2. 实例化一个 client 把 item 参数传递给 push 方法
Sidekiq::Client.new(pool).push(item)
end
def push(item)
normed = normalize_item(item) # 格式化 hash
payload = process_single(item["class"], normed) # 中间件方法,后面插曲有提到

if payload
raw_push([payload]) # 关键方法
payload["jid"]
end
end

def raw_push(payloads)
@redis_pool.with do |conn|
conn.multi do
atomic_push(conn, payloads)
end
end
true
end

# 最终处理的方法
def atomic_push(conn, payloads)
# 是否定时任务,如果是定时任务,则按照定时任务的逻辑来处理字符串后放入队列
if payloads.first["at"]
conn.zadd("schedule", payloads.map { |hash|
at = hash.delete("at").to_s
[at, Sidekiq.dump_json(hash)]
})
else
# 非定时任务,记录入队时间,把它放入 redis 的列表
queue = payloads.first["queue"]
now = Time.now.to_f
to_push = payloads.map { |entry|
entry["enqueued_at"] = now
Sidekiq.dump_json(entry)
}
conn.sadd("queues", queue)
conn.lpush("queue:#{queue}", to_push)
end
end

完。

以上,就是 EmailWorker.perform_async("xxx@xx.com", "welcome to my home") 所做的处理,其实归根结底,生产者所做的事情都比较简单,就是把类名和参数格式化一下,然后存储到 redis 的队列里面去

插曲(中间件)

在客户端代码中,process_single 所做的事情就是调用已注册的中间件,具体可参考以下这个文件

基于此中间件,你可以定义在消息塞入队列前后的操作

1
2
3
4
5
6
7
8
9
10
11
12
13
# Sidekiq.configure_client do |config|
# config.client_middleware do |chain|
# chain.insert_after ActiveRecord, MyClientHook
# end
# end

# class MyClientHook
# def call(worker_class, msg, queue, redis_pool)
# puts "Before push"
# result = yield
# puts "After push"
# result
# end

如何进行消费?(消费者)

消费者比较复杂,但是仔细看代码还是能看明白的,sidekiq的代码真的写得非常棒,基本上不需要怎么看注释,很自然而然的就让下读,读的很舒服。

当在命令行敲击 bundle exec sidekiq 的时候,实际上运行的就是以下 3 行代码

1
2
3
cli = Sidekiq::CLI.instance
cli.parse
cli.run

当然了,这三行代码只是一个入口,我们可以慢慢往下看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
以下内容有删减
# 首先,打开了管道
self_read, self_write = IO.pipe
# 注册信号量
sigs.each do |sig|
trap sig do
self_write.write("#{sig}\n")
end
end

launcher.run # 主要方法

# 死循环一直读取管道,直到发送了信号量才进行 handle_signal,可参考后面的信号量说明
while (readable_io = IO.select([self_read]))
signal = readable_io.first[0].gets.strip
handle_signal(signal)
end

注意到第一步,只是简单的进行一个死循环,等待用户给信号量,比如 kill -USR2 等信息,主要方法在于 launcher.run,以下,是 launcher.run 方法,也仅仅是做了 3 件事,第一:注册心跳包,定时检测状态;第二:不知道,往下看;第三:也不知道,往下看;

1
2
3
4
5
def run
@thread = safe_thread("heartbeat", &method(:start_heartbeat))
@poller.start
@manager.start
end

沿着 @poller.start 的调用栈一直往下走,最后可以发现实际上执行的是以下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Enq
def enqueue_jobs(now = Time.now.to_f.to_s, sorted_sets = SETS)
# A job's "score" in Redis is the time at which it should be processed.
# Just check Redis for the set of jobs with a timestamp before now.
Sidekiq.redis do |conn|
sorted_sets.each do |sorted_set|
# Get the next item in the queue if it's score (time to execute) is <= now.
# We need to go through the list one at a time to reduce the risk of something
# going wrong between the time jobs are popped from the scheduled queue and when
# they are pushed onto a work queue and losing the jobs.
while (job = conn.zrangebyscore(sorted_set, "-inf", now, limit: [0, 1]).first)

# Pop item off the queue and add it to the work queue. If the job can't be popped from
# the queue, it's because another process already popped it so we can move on to the
# next one.
if conn.zrem(sorted_set, job)
Sidekiq::Client.push(Sidekiq.load_json(job))
Sidekiq.logger.debug { "enqueued #{sorted_set}: #{job}" }
end
end
end
end
end
end

其实也比较好理解,就是排序列表后,取出定时任务,然后放入到队列里面去执行,比如说你设置了今晚 10 点的发送邮件的任务,你在早上8点就已经调用了并且启动了 sidekiq,那么 sidekiq 会检测定时任务的列表,直到晚上 10 点,sidekiq 会把这个任务的相关信息取出来,然后发送到处理任务的队列里面去,也就是 Sidekiq::Client.push(Sidekiq.load_json(job))

OK,看完 poller.start 方法的处理逻辑以后,可以继续往下看 @manager.start 方法,是的,这个就是真正进行消费的代码逻辑

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
def initialize(options = {})
logger.debug { options.inspect }
@options = options
@count = options[:concurrency] || 10
@done = false
@workers = Set.new
# 根据 count 的设置,来决定实例化多少个 `Processor` 对象,默认是 10 个
@count.times do
@workers << Processor.new(self)
end
@plock = Mutex.new
end

# Processor.new(self) 执行 options[:concurrency] 次 start
def start
@workers.each do |x|
x.start
end
end

# 继续进入 Processor 类的 start 方法看

def start
@thread ||= safe_thread("processor", &method(:run))
end

def run
process_one until @done
@mgr.processor_stopped(self)
rescue Sidekiq::Shutdown
@mgr.processor_stopped(self)
rescue Exception => ex
@mgr.processor_died(self, ex)
end

看到 start 方法大概就已经明白了,其实就是起 N 个线程来跑 run 方法,而继续往下看 run 方法实际上就是不断的从队列里面阻塞的取数据,下面就是取数据的关键方法

1
2
3
4
def retrieve_work
work = Sidekiq.redis { |conn| conn.brpop(*queues_cmd) }
UnitOfWork.new(*work) if work
end

消费者的大概流程

image

存在的问题

  1. sidekiq是能保证顺序的,但是因为从队列里面取数据的时候是阻塞的取的,所以造成了尽管有 20 个线程,某个线程从 redis 取数据的时候,其它 19 个线程是处于等待的状态的,并不能实现完美的并发消费。
  2. 基于 redis 的队列使得结构较为单一,意思就是队列只有一个,但是线程太多了,无法同时进行处理。不过,sidekiq 里面可以设置不同的队列名称,使得可以并发的执行不同的队列名
  3. 如何保证消息的可靠性?因为线程从队列里面拿出来以后,这条消息就相当于被消费了,那么如果线程拿出来后就死掉的话,这条消息是不是就丢了呢?

信号量(插曲)

在看到处理服务端的的时候,有一段代码是关于处理信号量的

1
2
3
4
5
6
7
8
9
sigs = %w[INT TERM TTIN TSTP]
...
sigs.each do |sig|
trap sig do
self_write.write("#{sig}\n")
end
rescue ArgumentError
puts "Signal #{sig} not supported"
end

这段代码是为了发出信号的时候,通知管道的另一端处理,那么信号量是怎么一回事? 看看下面代码

1
2
3
4
5
6
7
8
9
puts "I have PID #{Process.pid}"

Signal.trap("USR1") {puts "prodded me"}

loop do
sleep 5
puts "doing stuff"
end

首先,可以先 ruby example.rb ,然后呢,怎么触发信号量? 通过 ps -ef | grep 找到对应的进程,再次执行 kill -USR1 xxx,就会触发 puts prodded me

解释信号的一篇文章

小 Demo 测试队列的示例代码

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
require 'sidekiq'

Sidekiq.configure_client do |config|
config.redis = { db: 1 }
end

Sidekiq.configure_server do |config|
config.redis = { db: 1 }
end

Sidekiq.configure_client do |config|
class MyClientHook
def call(worker_class, msg, queue, redis_pool)
puts "Before push"
result = yield
puts "After push"
puts result
result
end
end
config.client_middleware do |chain|
chain.add MyClientHook
end
end

class OurWorker
include Sidekiq::Worker

def perform(msg)
puts "hello #{msg}"
end
end
# 启动服务端
# bundle exec sidekiq -r ./woker.rb
# 启动客户端
# bundle exec irb -r ./woker.rb
# OurWorker.perform_async("abc") # 生产一条数据
# 输出: {"class"=>"OurWorker", "args"=>["abc"], "retry"=>true, "queue"=>"default", "jid"=>"966a57511c1d2b8d314b1318", "created_at"=>1563630849.305185}

Rails 的 ActiveSupport::Concern

相信使用过 rails 的朋友们都经常会看到或者会使用到 ActiveSupport::Concern 这个模块,但是有没有想过为什么要使用这个模块呢?

因为以下内容会涉及上篇文章的内容,如果还不了解 ruby 的 include 和 extend 关键字的话,可先看看 关于 Ruby 的 include 和 extend

在上一篇文章的末尾我们说到可以通过 include 的钩子方法 included 来进行引入类方法,在看了 ActiveSupport::Concern 之后,我相信你会有新的领悟。

源码地址: https://github.com/rails/rails/blob/master/activesupport/lib/active_support/concern.rb

总的来说,Concern 模块所做的方法就是封装了 include 的钩子,之后以一种更友好的方式来引入模块,此话怎讲?看看示例吧

通常来说,按照上一篇文章,这段代码还这么写..

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 示例代码01
module Skill
def self.included(base)
base.extend(ClassMethod)
base.class_eval do
def fly
puts 'I can fly'
end
end
end

module ClassMethod
def run
puts 'Everybody can run'
end
end
end

class User
include Skill
end

user = User.new
user.fly # 输出 I can fly

使用了 ActiveSupport::Concern 改进后

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 示例代码02
require 'active_support/concern'
module Skill
extend ActiveSupport::Concern

included do
def fly
puts 'I can fly'
end
end

class_methods do
def run
puts 'Everybody can run'
end
end
end

class User
include Skill
end

user = User.new
user.fly # 输出 I can fly

仔细对比一下会发现,示例代码01 和 02 的区别在于原来的 ClassMethod 使用 class_methods 代码块来代替,而原来的 include 回调方法也使用了 included 代码块来代替

对应的查看一下源码你就会发现,其实 concern 模块只是帮我们再次封装了一层而已,我们分两点来查看

class_methods 方法做了什么处理?

1
2
3
4
5
6
7
8
9
10
11
# 源代码01
# Define class methods from given block.
# You can define private class methods as well.
# ....
def class_methods(&class_methods_module_definition)
mod = const_defined?(:ClassMethods, false) ?
const_get(:ClassMethods) :
const_set(:ClassMethods, Module.new)

mod.module_eval(&class_methods_module_definition)
end

通过 class_methods 的源码和注释可以知道,class_methods 所做的处理无非就是把代码块的内容整合到名为 ClassMethods 的代码块中

included 方法做了什么处理?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 源码02
# Evaluate given block in context of base class,
# so that you can write class macros here.
# When you define more than one +included+ block, it raises an exception.
def included(base = nil, &block)
if base.nil?
if instance_variable_defined?(:@_included_block)
if @_included_block.source_location != block.source_location
raise MultipleIncludedBlocks
end
else
@_included_block = block
end
else
super
end
end

咋看好像这个方法也没干啥啊,只是简单的变量赋值,好像也没有说像 base.extend 这样的代码?其实源文件也就几个方法,再仔细看看,发现一个“可疑的家伙” - append_features 方法,方法倒是有这么句 base.extend const_get(:ClassMethods) if const_defined?(:ClassMethods) 代码,这个操作有点像之前在 def self.included(base) 里面所做的操作,而且也没有显式的进行调用,所以我推断这个方法必有蹊跷

append_features 钩子

果然,一顿网络冲浪后,发现这个方法并不简单,原来 append_features 才是 “真正干活的回调方法”,还是看看代码比较好理解

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
module A
def self.included(target)
v = target.instance_methods.include?(:method_name)
puts "in included: #{v}"
end

def self.append_features(target)
v = target.instance_methods.include?(:method_name)
puts "in append features before: #{v}"
super
v = target.instance_methods.include?(:method_name)
puts "in append features after: #{v}"
end

def method_name
end
end

class X
include A
end

# 以上代码的输出为
# in append features before: false
# in append features after: true
# in included: true

没错,append_features 的方法是先于 included 执行的回调方法,可以在引入模块的前后进行基本的变量设置等操作

OK,了解了 append_features 方法之后,再回到源码上看下面这段代码,你会发现,“卧槽,还是看不懂啊,他在干什么”

1
2
3
4
5
6
7
8
9
10
11
12
def append_features(base) #:nodoc:
if base.instance_variable_defined?(:@_dependencies)
base.instance_variable_get(:@_dependencies) << self
false
else
return false if base < self
@_dependencies.each { |dep| base.include(dep) }
super
base.extend const_get(:ClassMethods) if const_defined?(:ClassMethods)
base.class_eval(&@_included_block) if instance_variable_defined?(:@_included_block)
end
end

其实这段代码在处理一个依赖的问题,考虑下面的情景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
module Foo
def self.included(base)
base.class_eval do
def self.method_injected_by_foo
...
end
end
end
end

module Bar
def self.included(base)
base.method_injected_by_foo
end
end

class Host
# 我实际上只想引用 Bar 模块
# 引入 Foo 模块是因为 Bar 模块的某方法依赖于 Foo,所以我不得不在 Bar 之前引用一下 Foo
include Foo
include Bar
end

以上情景就导致了依赖的产生,我只是想使用 Bar 模块却把 Foo 模块也引进来了,这不符合 Ruby 的优雅哲学,所以是否可以这样做呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
module Foo
def self.included(base)
base.class_eval do
def self.method_injected_by_foo
...
end
end
end
end

module Bar
include Foo
def self.included(base)
base.method_injected_by_foo
end
end

class Host
include Bar
end

很遗憾,这种方式是会报错的,为什么?

因为我在终端试过是会报错的,报错信息

1
undefined method `method_injected_by_foo' for Host:Class (NoMethodError)

原因在于:在 Bar 模块引用 Foo 的时候,Foo 模块的 included 的回调参数 base 的值不再是 Host,而是 Bar,也就是说 method_injected_by_foo 这个方法是属于 Bar 模块的,而不是 Host 类

换用 concern 模块来实现的话,是这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
require 'active_support/concern'

module Foo
extend ActiveSupport::Concern
included do
def self.method_injected_by_foo
...
end
end
end

module Bar
extend ActiveSupport::Concern
include Foo

included do
self.method_injected_by_foo
end
end

class Host
include Bar # It works, now Bar takes care of its dependencies
end

为什么使用 concern 后变得可行?这就得益于 append_features 方法,在方法内部处理了模块引用之间的依赖的关系,现在回过头去看 append_features 方法,大概也就明白了为什么需要这么处理了吧。(什么??还不明白?多看几遍

完。

Redis的两种持久化

什么是持久化?

redis 为了提高获取数据的速度,把数据都存储在内存里面。但是快往往是需要付出代价的,每当程序崩溃或者系统意外重启的时候,存储在内存里面的数据就不复存在了,所以需要一种机制来保证数据可以在意外崩溃的时候恢复。

持久化的两种方式

  • RDB快照
  • AOF(append-only-file)

RDB快照

save 命令

执行 save 命令将会阻塞当前客户端的处理,此时,客户端所进行的工作仅仅只是把内存中的数据存储到 dump.rdb 文件里面去

save命令可以通过 save 60 10000 这种形式来设置 60秒内有10000次写入 就会触发一次 bgsave 命令

bgsave 命令

bgsave 命令与 save 命令的区别在于,bgsave 会 fork 一个新的进程来专门负责数据的备份,而主进程客户端将继续处理读写请求。

存在问题

当数据只有几个 GB 的时候,使用快照来存储自然是没有问题,但是当数据量剧增的时候,比方说当内存数据增加到100个G,bgsave 方式 fork 子进程所耗费的时间也越来越多,从而可能会导致系统性能底下等问题,甚至可能导致 Redis 暂时停顿数秒

如何解决

这种情况下可以考虑关闭自动保存快照的功能,转用手动执行。但是手动执行带来的风险就是可能会丢失数据,如果是对系统要求极为严格的程序,考虑使用 AOF 功能

AOF(append-only-file)

区别于 rdb 的快照存储方式,AOF 存储方式并不是存储内存里面的数据,而是存储写操作的命令。比如说:set message 1,快照的方式会存储 message 1 这条数据,而 aof 的方式会把 set message 1 这条命令记录到 appendfile.aof 这个文件中,为了让体积尽可能小,redis 不会直接明文存储这句命令,而是通过 redis 的通信协议(RESP)格式来进行存储

AOF 工作流程

命令追加(append)

在执行写命令的时候,redis 不会马上把命令写入到文件,而是先把命令写入到 AOF缓冲区,然后再由缓冲区写入到文件。因为如果直接写入到文件的话,大量的 硬盘IO 可能会造成系统的不可用

image

文件写入和文件同步

redis提供了多种机制来把缓冲区的数据写入到磁盘文件,这部分操作将由操作系统的 write 和 fsync 来完成

write 命令: 当用户调用 write 函数将数据写入到文件的时候,数据会先存储在内存缓冲区里,等到填满内存缓冲区或超过了指定时间,才真正的把数据写入到磁盘的文件

fsync 命令:这句命令会立即将缓存区的数据写入到磁盘的文件

所以,基于以上说明, redis 提供了几种选项来让用户选择 AOF缓冲区的同步策略

image

redis 同步策略

redis 的同步策略由配置文件中的 appendfsync 参数来决定,有以下三种参数

  • always: 每当数据写入到 AOF 缓冲区的时候,立马调用系统 fsync 函数,把缓冲区的数据同步到磁盘文件。这种方法会造成大量的磁盘 IO,影响 redis 甚至系统的正常使用。
  • no: 命令写入到缓冲区后,redis层面就不管了了,然后交由操作系统来把数据写入到内存缓冲区,再每个周期(30s)同步一次内存的数据到磁盘文件,这种方式相当于每过 30s 把文件存储到磁盘文件(实际上时间会有偏差,但基本是这个意思)。这种情况下还是可能会造成 30s 内的数据丢失
  • everysec: 命令写入 aof 缓冲区后,由线程来执行 write 函数,让他存储到内存缓冲区,然后,由另一个线程每隔一秒执行一次 fsync

everysec 是前两种策略的折中方案,这种方式能很好的平衡数据的完备和系统的安全性,将数据丢失降低到 1s

AOF重写机制

随着 AOF 的执行,文件的体积势必会越来越大,如果不加以控制,最终肯定会占满整个磁盘的空间。所以,也就有了 AOF 重写机制

需要说明的是,重写并不会对已有的 aof 文件进行操作!!!该过程仅仅只是把当前进程内的数据转换为写命令

为了说明这一点,举个简单的例子

1
2
3
4
5
6
# 假设服务器对键list执行了以下命令
127.0.0.1:6379> RPUSH list "A" "B"
(integer) 2
127.0.0.1:6379> RPUSH list "C"
(integer) 3
127.0.0.1:6379> RPUSH list "D" "E"

当前列表键list在数据库中的值就为[“A”, “B”, “C”, “D”, “E”]。要使用尽量少的命令来记录list键的状态,最简单的方式不是去读取和分析现有AOF文件的内容,而是直接读取list键在数据库中的当前值,然后用一条RPUSH list “A”, “B”, “C”, “D”, “E”代替前面的命令。

过程如下:

  1. 系统检测到目前符合重写的条件,则 fork 子进程来进行重写操作(因为如果由主线程来完成操作会造成操作阻塞)
  2. 由子进程来操作重写的过程中,中途服务器执行了写/删命令,这种情况下,如果不处理的话,会造成新的 aof 文件与现有数据内容不一致。
  3. 那么,就引入了新的缓存区,AOF重写缓存区,把这段时间内产生的操作都写入到这个缓存区,等到整个重写过程完成以后,再把这个缓冲区的内容写入到新的 aof 文件即可。

image

参考:

https://blog.csdn.net/hezhiqiang1314/article/details/69396887

https://www.cnblogs.com/kismetv/p/9137897.html

单例模式

单例模式:保证一个类仅有一个实例,并提供一个访问它全局访问点

比如下面的类就是一个单例模式

普通的单例模式

1
2
3
4
5
6
7
8
9
10
class Singleton {
public static Singleton instance;
private Singletone {}
public getInstance() {
if (instance == null) {
instance = new Singletone();
}
return instance
}
}

以上的类就是不允许你在外部直接 new Singleton,每次获取这个类实例只能通过 Singleton.getInstance() 这种方式来获取,而这个方案保证了只实例化一次类实例

多线程的单例模式
上面的一个模式仅仅对于单线程有用,在多线程的世界里或者是在高并发程序中,这种方法往往是无效的,为什么呢?

因为单线程是从上往下执行的,而多线程是同时一起执行的,所以,里面的 new Singletone() 这个方法在高并发的情况下,肯定会存在多个线程都走到这一步,也就造成了实例化了多次 instance,这显然和单例模式冲突的

我们可以通过加锁来解决这个问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Singleton {
private static Singleton instance;
private static readonly object syncLock = new Object();
private Singletone {}
public getInstance() {
if (instance == null) {
lock(syncLock) { // 疑问1:为什么不直接 lock(instance) ?
if (instance == null) { // 疑问2: 前面已经有判断 instance == null 了,为什么这里还要判断呢?
instance = new Singletone();
}
}
}
return instance
}
}

对比单线程的代码,我们增加多了一把锁来完成

疑问1:为什么不直接 lock(instance) 呢?

因为在加锁的时候还不知道 instance 到底有没有被实例化

疑问2:前面已经有判断instance == null 了,为什么下面还要加上 if (instance == null) 呢?

这是因为当两个线程同时到达第一个判断的时候,必然有一方是先抢占到锁的,那么另一方就只能等待他释放锁(就好像只有一间洗手间,两个人同时进来,那当然只能有一个先进去,而另外一个在里面等待),当第一个线程进去以后,发现没有实例化,那么实例化之后释放锁就 return 了,而第二个线程进来后通过判断发现已经 instance 了,所以就不重复实例化了,也直接 return 了。所以,如果没有 if 判断的话,第二个线程进来以后仍然会继续实例化,这也就和单例模式冲突了。

参考:《大话设计模式》的单例模式

关于测试

前言

关于测试这方面的知识其实很早就想去接触和了解,然而公司的项目测试系统并不是特别完善,导致整个开发组没有几个人在写测试,我觉得这是一件非常可怕的事情。在我看来,一个有效且可靠的测试是可以避免很多低级错误,而且也极大的减轻了测试人员的负担,像我们开发组常年堆积 PR,一方面是我们 CI 架构的问题,另一方面是开发者对自己的代码没有足够的信心,导致一些较简单的 Bug 修复都要“麻烦”测试人员,如何解决这些问题呢?让我们来了解一下测试吧。

测试的基本概念

读大学的时候,如果是科班出身的开发者,应该都有上过一门测试的课程(吧),我不清楚是不是每个计算机专业的都有这门课,反正我们当时的的确确是有这门课,而且在当时看来是挺枯燥的,我当时天真的认为测试还需要这么麻烦,什么等价类,边界问题,我心想这些问题不是开发阶段就能发现的吗?开发完肯定要跑跑程序啊,跑程序过程中肯定会注意到这些点呀。想想当时还确实是 too young,实际上很多问题是开发过程注意不到的,因为很多时候我们都是在专注的写业务,赶时间,并没有留给我们过多的时间去考虑这些细节。下面来回顾一下几个基本概念

白盒测试

白盒测试又称为“结构测试”和“逻辑驱动测试”

定义:把测试对象看做一个透明的盒子,它允许测试人员利用程序内部的逻辑结构及有关信息,设计或选择测试用例,对程序所有逻辑路径进行测试。

通俗来说,白盒测试就相当于假设一个盒子是全透明的,全透明意味着你可以看到内部的所有结构,然后设计数据来对内部的结构进行覆盖性测试,放到代码层面来说呢,就是需要测试人员熟悉你编写的代码,看懂你的代码是在做什么,从而设计一系列的数据进行下面几种测试,如:

  • 语句覆盖
  • 判定覆盖
  • 条件覆盖
  • 判定/条件覆盖
  • 条件组合覆盖
    这种情况需要测试人员的技术水平比较高,需要能看懂代码而且明白业务逻辑是在干什么,但是一般小公司都不会有这种类型的测试人员,考虑到成本可能相对较高。所以这种测试白盒测试只能由你,也就是写代码的那个人来进行,只有你知道哪里可能会出现边界值问题,哪里会有死循环等等一系列潜在的 Bug

黑盒测试

黑盒测试也称功能测试,它是通过测试来检测每个功能是否都能正常使用。
黑盒测试是用得相对较多的一种测试方法,黑盒测试是站在用户的角度来进行测试。举个例子,就拿 Web 项目来说,某开发者提交了一个新功能,该功能是一个登录的功能,那测试人员需要做的事情是什么呢?

  1. 拉取该功能代码的分支
  2. 部署到测试环境
  3. 开始测试
    怎么测试? 就按用户的操作来一遍,首先,输入账号密码,测试能不能登录成功,然后,输入一些不合法的数据,再次测试登录,查看网页上的提示信息是否正确,比如说登录失败,那为什么失败,网页上有给出错误提示吗?是没有输入验证码还是密码错误……

TDD(Test-Driven Development)

TDD是测试驱动开发(Test-Driven Development)的英文简称,是敏捷开发中的一项核心实践和技术,也是一种设计方法论。TDD的原理是在开发功能代码之前,先编写单元测试用例代码,测试代码确定需要编写什么产品代码。TDD虽是敏捷方法的核心实践,但不只适用于XP(Extreme Programming),同样可以适用于其他开发方法和过程。 - 引自百度百科

传统的编码方式

传统的方式在当前来看仍然有很多的人在采用,特别是一些初创团队,为什么?因为赶需求呀,一般有如下这类编码方式

  • 接到一个大需求,不去分解,直接一顿操作输出,这种情况到最后代码都会变成一坨东西,而且还不能轻易去变动,因为这就像一座没有地基的房子,稍微一个小改动,整栋大厦都崩塌
  • 一个小改动,但是涉及很多模块的调用,只管自己当前的需求完成就好,看到效果 OK 就万事大吉,
  • 调试,调了好久,终于调出来了,不去细究原因,结果一些边界值线上就出现了 500

TDD编码方式

  • 大任务先分解成 N 个子任务,从每个最小的子任务下手
  • 大模块先画出流程图
  • 根据流程图进行编写测试,注意:是先写测试
  • 边写测试边实现代码逻辑,这也就是为什么要分解任务,如果任务不是足够小的话,你是无法边写测试边写逻辑的,这得乱成什么样子啊

BDD(Behavior Driven Development)

BDD是行为驱动开发,它是从 TDD 发展而来的一种软件开发方法。它的不同之处在于,它是用一种共享语言编写的,这可以改进技术团队和非技术团队以及利益相关者之间的交流。在这两种开发方法中,测试都是在代码之前编写的,但是在BDD中,测试更以用户为中心

不知道大家注意到没有,BDD 三个单词都没有含有 Test 的字眼,这说明这种模式跟测试无关(Behavior driven development has nothing to do with testing),BDD 主要是帮助你跟你的leader,boss 或者是运营等沟通交流需求。在公司日常需求讨论大会上,产品经理、运营或者设计的小姐姐们都喜欢讨论当前这个需求需要怎么做,基于用户的角度去看这个需求,以及用户的动作行为(behavior),其实这就是一种 BDD

Given-When-Then

原谅我真的不知道怎么把它翻译为中文,符合这种模式下的一般都是 BDD

  1. 给(Given)定一个实际的场景
  2. 当(When)动作或行为发生时
  3. 然后(Then)产出结果

这么说可能不太理解,一个比较实际的例子就是:

  1. 假设用户没有在登录表单填入任何数据
  2. 这时,用户点击登录
  3. 然后显示错误信息

优势

  • 你不再需要去定义各种各样的测试,而是定义用户的行为
  • 你可以用这种方式来跟你的开发人员、运营人员以及产品经理进行沟通,降低沟通成本
  • BDD 学习的周期相对较短,毕竟不需要编码
  • 在开发前就已经明确完成时的目标

劣势

  • 使用 BDD 的前提需要 TDD 的经验,因为如果你不知道技术方案的实现,BDD 讨论出来的结果也是毫无意义的
  • BDD 与 瀑布方法不兼容

参考资料:
《深度解读 - TDD》
《What is BDD - 01》
《What is BDD - 02》