『幻夜』

huanye作为『白夜行』的姊妹篇,两者叙事主线,节奏把握极其类似,因为有了『白夜行』的先入为主,所以读『幻夜』少了点酣畅淋漓的阅读体验。不过仍值得约等于5星的评价。

作为一本独立存在的推理小说,东野圭吾在架构故事的时候似乎做到了面面俱到,故事的真相也在一次次的伏笔中得以揭开,然而,有一个问题是我掩卷之前一直期待却未得到解答的,就是新海美冬策划这一系列的原始动机。我知道她的目的是通过一次又一次的构陷以巧取豪夺的方式得到自己想要的东西,名誉,地位,金钱等等,抛开故弄玄虚为了吸引读者这个原因不提,因为我觉得东野圭吾不会这么low,新海美冬改变身份这一设定是否过于累赘和多此一举呢?假设,加藤亘警官和水原雅也的调查属实,真正的新海美冬在地震中死去,之后该身份一直以来都是由white night服装店老板娘假扮,那么如上所说,老板娘假扮新海美冬缺少动机。新海美冬是个很普通的人,普通家庭普通长相,假扮她除了得到一个身份外得不到其他更多地东西了,凭借老板娘的魅力和手段,有没有这个身份根本无关紧要。

如果新海美冬的话属实,从头到尾美冬一直是美冬,只是因为崇拜老板娘而有意向她学习,包括整容换成她的容颜,从心理学上说有根有据,但是解释不通新海美冬为何要杀害曾我孝道。即便她和曾我孝道见了面,曾我孝道疑惑现在新海美冬和照片中长得不一样,这要解释完全不成问题,毕竟女大十八变,跟何况还整过容。所以从这点来看,新海美冬对加藤亘说的话为假。

不像『白夜行』结局,东野圭吾给了一个充分的理由揭开唐泽雪穗和桐原亮司不择手段策划杀人的原因,『幻夜』从伊始至结尾,新海美冬就不停地在干损人利己的事情,一副蛇蝎美人的人物形象。有人理解为『幻夜』是『白夜行』的续集,因为从时间上衔接得起来,唐泽雪穗开了一家服装店,经营不善,后来趁着阪神大地震,换了一个新海美冬的身份以原有的那套寄居蟹手段东山再起,这倒是能在一定程度上解释了之前提到的新海美冬的原始动机,但是无法解释为什么唐泽雪穗能潦倒得这么彻底,她在『白夜行』中积累的人脉和资源,包括筱冢家族,不可能在短短几年时间里全都消失不见。

所以以我之见,『白夜行』和『幻夜』还是得当成两个独立的故事来看待,只是恰好两个女主角的城府和手段类似而已。也就是说,我更倾向于将『幻夜』中新海美冬身份被冒充缺少原始动机当成本书的唯一缺陷。

另外,水原雅也差桐原亮司好几条街,最后他杀美冬不成,反而和加藤亘同归于尽了,这个逻辑略奇葩。他真的应该和冈田有子待一块的,如果说新海美冬夺去了他的灵魂,那么冈田有子能够将自己的灵魂分给他一半。

--EOF--

一次Java内存泄露问题排查

近期随着管理平台用户增加,后台多次出现OutOfMemoryError错误,尝试着把堆内存从2G加到4G(-Xms4096m -Xmx4096m)也不顶用,隔段时间就OOM,看来是出现内存泄露了。于是在Tomcat启动脚本中加入JVM参数-XX:+HeapDumpOnOutOfMemoryError,使其OOM时把堆内存dump成文件,便于分析。今天,程序又一次OOM,我拿到了这个4G大的java_pid32693.hprof文件。打开 Eclipse Memory Analyzer(MAT),导入dump文件,内存泄露问题显而易见:
 Eclipse Memory Analyzer

根据提示,有一个ConcurrentHashMap数据结构占用了所有4G内存中的99.11%,该Map名为asyncContentMap,后面是它的package路径。

回忆了一下,这个貌似是很早以前实现过的一个实例状态自动刷新功能,管理平台有一个实例列表页面,当实例状态变化时,无需用户刷新页面,状态栏自动变更。为了前端通用,放弃Websocket、socket.io,用了最简单的ajax实现,浏览器端和管理平台间用long polling实现状态推送。下图就是整个状态自动刷新功能的示意图,管理平台接到请求后,使用Servlet 3.0的异步Servlet挂住浏览器端连接,并将请求上下文(AsyncContext)放入队列。另一方面,通过worker线程池处理请求队列里的请求,worker线程做的事情就是依次轮询后端服务(Service1,Service2...Service N)接口,判断其实例状态有无变化,如果状态变化,则取出前端请求上下文,返回响应。用户就能从浏览器得知实例状态变更,之后,再发起一个HTTP long polling请求,重复上述流程。当然,long polling有个超时时间,由管理平台控制,当对后端服务连续polling了一定时间都没有状态变化时,也要将响应返回给浏览器,使之进入下一个long polling周期。
Long Polling

要实现上述描述的功能,管理平台这边需维护两个状态,分别对应两个线程安全的数据结构。一个是请求队列LinkedBlockingQueue asyncContextList,worker线程池会从请求队列中取出浏览器请求;另一个是映射表ConcurrentHashMap asyncContentMap,用于存放异步请求和改请求附带的一些元信息,比如上一次polling后端Service的时间戳、响应值等等。根据MAT的提示asyncContentMap出现内存泄露,那么可以肯定是有些异步请求已经完成响应,但是忘了从映射表里把记录清除。检查了一下程序,的确如此,加入remove操作后问题解决:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
AsyncContext asyncContext = Container.asyncContextList.take();
AsyncContentDeliver asyncDeliver = Container.asyncContentMap.get(asyncContext);
 
//略去其余部分
 
if (respEntry.getResponseBody().equals(lastResponseBody)) {
  // 与上一次polling相比,实例状态未发生改变,异步请求重新入队。
  Container.asyncContextList.put(asyncContext);
  continue;
} else {// 实例状态发生了改变,进行响应。
  try {
    asyncContext.getResponse().setCharacterEncoding("UTF-8");
    asyncContext.getResponse().getWriter().print(respEntry.getResponseBody());
    asyncContext.complete();
    Container.asyncContentMap.remove(asyncContext); // BugFix!
  } catch (IOException e) {
    e.printStackTrace();
    Container.asyncContextList.put(asyncContext);
  }
}

总结这次Bug修复,深深感受到一点:编程语言始终解决不了业务逻辑层面上的内存泄露问题,资源的分配和释放程序员必须谨记在心。

--EOF--

keepalived vip漂移基本原理及选举算法

keepalived可以将多个无状态的单点通过虚拟IP(以下称为VIP)漂移的方式搭建成一个高可用服务,常用组合比如keepalived+nginx,lvs,haproxy和memcached等。它的实现基础是VRRP协议,包括核心的MASTER竞选机制都是在VRRP协议所约定的。

一、配置说明:
keepalived的配置位于/etc/keepalived/keepalived.conf,配置文件格式包含多个必填/可选的配置段,部分重要配置含义如下:
global_defs: 全局定义块,定义主从切换时通知邮件的SMTP配置。
vrrp_instance: vrrp实例配置。
vrrp_script: 健康检查脚本配置。

细分下去,vrrp_instance配置段包括:
state: 实例角色。分为一个MASTER和一(多)个BACKUP。
virtual_router_id: 标识该虚拟路由器的ID,有效范围为0-255。
priority: 优先级初始值,竞选MASTER用到,有效范围为0-255。
advert_int: VRRP协议通告间隔。
interface: VIP所绑定的网卡,指定处理VRRP多播协议包的网卡。
mcast_src_ip: 指定发送VRRP协议通告的本机IP地址。
authentication: 认证方式。
virtual_ipaddress: VIP。
track_script: 健康检查脚本。

vrrp_script配置段包括:
script: 一句指令或者一个脚本文件,需返回0(成功)或非0(失败),keepalived以此为依据判断其监控的服务状态。
interval: 健康检查周期。
weight: 优先级变化幅度。
fall: 判定服务异常的检查次数。
rise: 判定服务正常的检查次数。

这里有MASTERBACKUP的参考配置。

二、选举算法
keepalived中优先级高的节点为MASTER。MASTER其中一个职责就是响应VIP的arp包,将VIP和mac地址映射关系告诉局域网内其他主机,同时,它还会以多播的形式(目的地址224.0.0.18)向局域网中发送VRRP通告,告知自己的优先级。网络中的所有BACKUP节点只负责处理MASTER发出的多播包,当发现MASTER的优先级没自己高,或者没收到MASTER的VRRP通告时,BACKUP将自己切换到MASTER状态,然后做MASTER该做的事:1.响应arp包,2.发送VRRP通告。

MASTER和BACKUP节点的优先级如何调整?
首先,每个节点有一个初始优先级,由配置文件中的priority配置项指定,MASTER节点的priority应比BAKCUP高。运行过程中keepalived根据vrrp_script的weight设定,增加或减小节点优先级。规则如下:

1. 当weight > 0时,vrrp_script script脚本执行返回0(成功)时优先级为priority + weight, 否则为priority。当BACKUP发现自己的优先级大于MASTER通告的优先级时,进行主从切换。
2. 当weight < 0时,vrrp_script script脚本执行返回非0(失败)时优先级为priority + weight, 否则为priority。当BACKUP发现自己的优先级大于MASTER通告的优先级时,进行主从切换。 3. 当两个节点的优先级相同时,以节点发送VRRP通告的IP作为比较对象,IP较大者为MASTER。 以上文中的配置为例: HOST1: 10.15.8.100, priority=91, MASTER(default) HOST2: 10.15.8.101, priority=90, BACKUP VIP: 10.15.8.102 weight = 2 抓包命令: tcpdump -nn vrrp 示例一:HOST1和HOST2上keepalived和nginx均正常。

1
2
3
4
16:33:07.697281 IP 10.15.8.100 > 224.0.0.18: VRRPv2, Advertisement, vrid 102, 
prio 93, authtype simple, intvl 1s, length 20
16:33:08.697588 IP 10.15.8.100 > 224.0.0.18: VRRPv2, Advertisement, vrid 102, 
prio 93, authtype simple, intvl 1s, length 20

此时HOST1优先级为priority + weight = 93,HOST2优先级为priority + weight = 92,HOST1仍为MASTER。

示例二:关闭HOST1上的nginx。

1
2
3
4
5
6
7
8
16:33:09.697928 IP 10.15.8.100 > 224.0.0.18: VRRPv2, Advertisement, vrid 102, 
prio 93, authtype simple, intvl 1s, length 20
16:33:10.698285 IP 10.15.8.100 > 224.0.0.18: VRRPv2, Advertisement, vrid 102, 
prio 91, authtype simple, intvl 1s, length 20
16:33:10.698482 IP 10.15.8.101 > 224.0.0.18: VRRPv2, Advertisement, vrid 102, 
prio 92, authtype simple, intvl 1s, length 20
16:33:11.699441 IP 10.15.8.101 > 224.0.0.18: VRRPv2, Advertisement, vrid 102, 
prio 92, authtype simple, intvl 1s, length 20

HOST1上的nginx关闭后,killall -0 nginx返回非0,HOST1通告的优先级为priority = 91,HOST2的优先级为priority + weight = 92,HOST2抢占成功,被选举为MASTER。相关日志可tail /var/log/messages。

由此可见,主从的优先级初始值priority和变化量weight设置非常关键,配错的话会导致无法进行主从切换。比如,当MASTER初始值定得太高,即使script脚本执行失败,也比BACKUP的priority + weight大,就没法进行VIP漂移了。所以priority和weight值的设定应遵循: abs(MASTER priority - BAKCUP priority) < abs(weight)。 另外,当网络中不支持多播(例如某些云环境),或者出现网络分区的情况,keepalived BACKUP节点收不到MASTER的VRRP通告,就会出现脑裂(split brain)现象,此时集群中会存在多个MASTER节点。 --EOF--

gen_server behaviour exercise

In these exercises, we’ll make a server in the module job_centre, which uses gen_server to implement a job management service. The job center keeps a queue of jobs that have to be done. The jobs are numbered. Anybody can add jobs to the queue. Workers can request jobs from the queue and tell the job center that a job has been performed. Jobs are represented by funs. To do a job F, a worker must evaluate the function F().
1. Implement the basic job center functionality, with the following interface:
job_centre:start_link() -> true.
    Start the job center server.
job_centre:add_job(F) -> JobNumber.
    Add a job F to the job queue. Return an integer job number.
job_centre:work_wanted() -> {JobNumber,F} | no.
    Request work. When a worker wants a job, it calls job_centre:work_wanted(). If there are jobs in the queue, a tuple {JobNumber, F} is returned. The worker performs the job by evaluating F(). If there are no jobs in the queue, no is returned. Make sure the same job cannot be allocated to more than one worker at a time. Make sure that the system is fair, meaning that jobs are handed out in the order they were requested.
job_centre:job_done(JobNumber)
    Signal that a job has been done. When a worker has completed a job, it must call job_centre:job_done(JobNumber).
2. Add a statistics call called job_centre:statistics() that reports the status of the jobs in the queue and of jobs that are in progress and that have been done.
3. Add code to monitor the workers. If a worker dies, make sure the jobs it was doing are returned to the pool of jobs waiting to be done.
4. Check for lazy workers; these are workers that accept jobs but don’t deliver on time. Change the work wanted function to return {JobNumber, JobTime, F} where JobTime is a time in seconds that the worker has to complete the job by. At time JobTime - 1, the server should send a hurry_up message to the worker if it has not finished the job. And at time JobTime + 1, it should kill the worker process with an exit(Pid, youre_fired) call.

Ans:

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
-module(job_center).
-behaviour(gen_server).
 
-export([start_link/0, add_job/2, work_wanted/0, job_done/1, statistics/0]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-define(SERVER, ?MODULE).
-define(TIME, 1000).
-record(state, {undo, doing, done, index}).
%%%===================================================================
%%% API
%%%===================================================================
start_link() ->
  gen_server:start_link({local, ?SERVER}, ?MODULE, [], []).
add_job(F, Time) ->
  gen_server:call(?MODULE, {add_job, F, Time}).
work_wanted() ->
  gen_server:call(?MODULE, get_job).
job_done(JobNumber) ->
  gen_server:cast(?MODULE, {job_done, JobNumber}).
statistics() ->
  gen_server:call(?MODULE, statistics).
%%%===================================================================
%%% gen_server callbacks
%%%===================================================================
init([]) ->
  process_flag(trap_exit, true),
  timer:send_interval(?TIME, self(), check_worker),
  {ok, #state{undo = queue:new(), doing = [], done = [], index = 1}}.
 
handle_call({add_job, F, Time}, _From, #state{undo = UndoPool, index = Idx} = State) ->
  State1 = State#state{undo = queue:in({Idx, Time, F}, UndoPool), index = Idx + 1},
  {reply, Idx, State1};
handle_call(get_job, {From, _}, #state{undo = UndoPool, doing = DoingPool} = State) ->
  {Reply, State1} = case queue:out(UndoPool) of
                      {{value, {Idx, Time, Fun}}, UndoPool2} ->
                        {{Idx, Time, Fun}, State#state{undo = UndoPool2, doing = [{Idx, Time, Fun, From, now_in_secs()} | DoingPool]}};
                      {empty, _} ->
                        {no, State}
                    end,
  erlang:monitor(process, From),
  {reply, Reply, State1};
handle_call(statistics, _From, #state{undo = UndoPool, doing = DoingPool, done = DonePool} = State) ->
  io:format("undo:~p~n", [UndoPool]),
  io:format("doing:~p~n", [DoingPool]),
  io:format("done:~p~n", [DonePool]),
  {reply, [{undo, queue:len(UndoPool)}, {doing, length(DoingPool)}, {done, length(DonePool)}], State};
handle_call(_Request, _From, State) ->
  {reply, ok, State}.
 
handle_cast({job_done, JobNumber}, #state{doing = DoingPool, done = DonePool} = State) ->
  Item = lists:keyfind(JobNumber, 1, DoingPool),
  DoingPool2 = lists:delete(Item, DoingPool),
  {Idx, Time, Fun, From, _} = Item,
  DonePool2 = [{Idx, Time, Fun, From} | DonePool],
  {noreply, State#state{doing = DoingPool2, done = DonePool2}};
handle_cast(_Request, State) ->
  {noreply, State}.
 
handle_info({'DOWN', _Ref, process, From, normal}, #state{undo = UndoPool, doing = DoingPool} = State) ->
  State1 = case lists:keyfind(From, 4, DoingPool) of
             Item when is_tuple(Item) ->
               {Idx, Time, Fun, _From, _} = Item,
               DoingPool2 = lists:delete(Item, DoingPool),
               State#state{undo = queue:in({Idx, Time, Fun}, UndoPool), doing = DoingPool2};
             false -> State
           end,
  {noreply, State1};
handle_info(check_worker, #state{undo = UndoPool, doing = DoingPool} = State) ->
  Now = now_in_secs(),
  F = fun(Doing) ->
    {Idx, Time, Fun, From, StartTime} = Doing,
    if
      Now =:= StartTime + Time - 1 -> From ! hurry_up;
      Now =:= StartTime + Time + 1 ->
        exit(From, youre_fired),
        State#state{undo = queue:in({Idx, Time, Fun}, UndoPool)};
      true -> ok
    end
  end,
  lists:foreach(F, DoingPool),
  {noreply, State};
handle_info(Info, State) ->
  io:format("extra info:~p~n", [Info]),
  {noreply, State}.
 
terminate(_Reason, _State) ->
  ok.
code_change(_OldVsn, State, _Extra) ->
  {ok, State}.
%%%===================================================================
%%% Internal functions
%%%===================================================================
now_in_secs() ->
  {A, B, _} = os:timestamp(),
  A * 1000000 + B.

Test:

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
$ erl -sname aa
Erlang/OTP 17 [erts-6.1] [source] [64-bit] [smp:4:4] [async-threads:10]
 
Eshell V6.1  (abort with ^G)
1> c(job_center).
{ok,job_center}
2> F1 = fun() -> 1 end.
#Fun<erl_eval.20.90072148>
3> F2 = fun() -> 2 end.
#Fun<erl_eval.20.90072148>
4> job_center:add_job(F1, 10).
1
5> job_center:add_job(F2, 10).
2
6> job_center:add_job(F1, 20).
3
7> job_center:add_job(F2, 20).
4
8> job_center:statistics().
undo:{[{4,20,#Fun<erl_eval.20.90072148>},
       {3,20,#Fun<erl_eval.20.90072148>},
       {2,10,#Fun<erl_eval.20.90072148>}],
      [{1,10,#Fun<erl_eval.20.90072148>}]}
doing:[]
done:[]
[{undo,4},{doing,0},{done,0}]
9> job_center:work_wanted().
{1,10,#Fun<erl_eval.20.90072148>}
10> job_center:job_done(1).
ok
11> job_center:statistics().
undo:{[{4,20,#Fun<erl_eval.20.90072148>},{3,20,#Fun<erl_eval.20.90072148>}],
      [{2,10,#Fun<erl_eval.20.90072148>}]}
doing:[]
done:[{1,10,#Fun<erl_eval.20.90072148>,<0.83.0>}]
[{undo,3},{doing,0},{done,1}]
12> job_center:work_wanted().
{2,10,#Fun<erl_eval.20.90072148>}
13> receive V -> V end.
hurry_up
 
=ERROR REPORT==== 24-Aug-2014::14:37:03 ===
** Generic server job_center terminating
** Last message in was {'EXIT',<0.83.0>,youre_fired}
** When Server state == {state,{[{4,20,#Fun<erl_eval.20.90072148>}],
                                [{3,20,#Fun<erl_eval.20.90072148>}]},
                               [{2,10,#Fun<erl_eval.20.90072148>,<0.83.0>,
                                 1408862212}],
                               [{1,10,#Fun<erl_eval.20.90072148>,<0.83.0>}],
                               5}
** Reason for termination ==
** youre_fired
** exception error: youre_fired

Exercises from 『Programming Erlang (2Edtion)』.

--EOF--

理解Oauth2.0的设计

oauth2
上图是从网上找来的Web Server Flow类型的Oauth2.0流程,授权流程较为清晰。相比Oauth1.0,它已经精简了很多,但是仍旧是一个复杂的授权协议。

1. 为什么有authorization_code?

authorization_code在Oauth2.0中是用来跟Authorization Server换取access_token的。如果没有省略它,直接将上图中的(3)、(4)步骤合并,那么会存在两个问题:
1). access_token容易泄露:如果access_token由Authorization Server通过HTTP 302重定向的方式返回,容易被人通过嗅探、扫日志、扫浏览器记录等方式窃取。别人拿到这个access_token就可以根据授权scope任意存取Resource Server上的资源。要避免被窃取,势必要求第三方应用提供的redirect_uri是可信信道,例如https + POST请求,那就对第三方应用要求太高了。
2). 第三方应用(如上图中的facebook.com)身份难以确认:Authorization Server要确认第三方应用的身份,必然要求应用对步骤(1)的请求进行签名,这会加大协议复杂性。

加了authorization_code后,只要保证步骤(4)、(5)的信道是安全的即可,即便authorization_code在步骤(3)中泄露,别人拿去也没用,因为没法换取access_token。通过authorization_code换取access_token最简单的方式是使用https,由Authorization Server提供https支持,这样第三方应用直接将secret传过去即可,secret作为口令的方式使用。如果没有https链路,secret可以用来对请求进行签名。就不存在上面说的两个问题了。

注:User-Agent flow (Implicit Grant)类型的Oauth授权流程中没有authorization_code,access_token是直接通过步骤(3)的redirect_uri返回,所以安全性没有Web Server Flow类型的高。User-Agent flow一般用在没有服务器端的手机APP,浏览器插件(JS)中,这类应用难以向Authorization Server证明自己身份,因为secret会暴露在外,所以干脆取消authorization_code换access_token的流程。

2. 为什么在获取authorization_code时加入state参数可以避免CSRF攻击?

一般第三方应用在引导用户跳转到Authorization Server之前,先将生成的state参数(随机数)放入session中,然后通过步骤(1)传给Authorization Server,Authorization Server会原样返回,第三方应用得到Authorization Server返回的state参数后,跟session中的比对即可。

假设没有state参数,试想以下场景:有一个第三方应用给大家发红包,会发到google账户里,用户用google账户登录第三方应用后即可提现。恶意用户M用自己的google账户完成Oauth授权的步骤(1)、(2)、(3),拿到步骤(3)中的authorization_code,然后在自己的个人网站上伪造个跨站请求(如用iframe,src填上第三方应用的redirect_uri?code=${authorization_code}),某个小白用户A访问M的个人网站后,实际上就触发了第三方应用的授权流程,第三方应用收到一个authorization_code,然后拿这个authorization_code去交换access_token,用access_token去获取google账户信息(实际上是恶意用户M的),然后给他发红包。

现在有了state参数,每次第三方应用在redirect_uri上收到一个请求,首先判断请求中的state参数和当前会话中的state参数是否一致。如果一致,表示此请求是正常的授权流程;如果不一致,则表示是CSRF攻击。

3. 为什么会有refresh_token?

refresh_token是一个长期有效的token,被设计用来在当前access_token过期后重新获取一个新的access_token,它由Authorization Server返回(需事先在获取authorization_code时指定access_type=offline)。不同于access_token有个短暂的过期时间,refresh_token只有用户主动取消授权才会失效。

给access_token一个过期时间,同时通过一个长期有效的refresh_token来换取access_token,这么设计的出发点在于性能层面。假如没有refresh_token,并且access_token不设置过期时间,那么为了支持用户取消授权,必须要求Resoure Server在收到每一次API请求,都将access_token送到Authorization Server进行验证,看token是否仍旧有效。如果用户取消授权,token失效;如果未取消授权,token有效。Authorization Server验证access_token的过程无疑是耗时的(有读数据库或者缓存的操作)。有了access_token过期时间,access_token就可以被设计为自编码(self-encoded),通过一些CPU计算进行解码既可验证此token是否有效有权,每次API请求均可减少一次读数据库或者缓存的时间。当然每次用refresh_token获取新access_token还是避免不了一次读数据库或者缓存的操作,但是这个消耗可以忽略不计了。这个设计的缺陷就是用户取消授权会有一定延时。除非access_token过期,否则第三方应用可以拿着access_token继续调用Resource Server上的资源,即便用户已经取消了授权。

--EOF--