Tallate

该吃吃该喝喝 啥事别往心里搁

TALLATE / PERSONAL KNOWLEDGE INTERFACE

探索 Tallate

一个从后端系统走向 AI Agent 的工程师,正在思考、构建和记录什么?

本地缓存

由线上缓存 bug 引起的对本地缓存的思考

LoadingCache 是 Guava 提供的一个本地缓存组件,但是我对它是又爱又恨,一方面因为 LoadingCache 比较完善,免去很多应用层缓存的细节问题(如何写出 GC 友好的缓存?),而另一方面如果对 LoadingCache 了解不够深入又容易出现奇奇怪怪的问题。下面就来描述一下之前碰到过的两个 LoadingCache 的坑,首先给出最简单的缓存配置:

1
2
3
4
5
6
7
8
9
LoadingCache<String, Object> cache = CacheBuilder.newBuilder()
.expireAfterAccess(60 * 1000, MILLISECONDS) // 1
.maximumSize(500)
.build(new CacheLoader<String, Object>() {
@Override
public Object load(String id) throws Exception {
return query(id); // 2
}
});
  1. 在构建 LoadingCache 的时候可以配置缓存的过期策略,LoadingCache 其实有三种常用的过期策略:
    • expireAfterAccess:在最后一次访问后空闲一段时间才过期;
    • expireAfterWrite:在最后一次写后空闲一段时间才过期;
    • refreshAfterWrite:同样是写后空闲一段时间过期,和上一个的区别是不会阻塞过期时到达的请求,因为刷新一般需要请求远程服务来获取数据,会有比较长的延迟,refreshAfterWrite 会先返回旧数据,而 expireAfterWrite 会先阻塞这些请求。
      如果是为了吞吐量起见,一般使用 refreshAfterWrite 更多,如果是为了保证同步性,则是使用 expireAfterWrite 更多。
      因为我们使用缓存的场景是“读多写少的场景”,读端是提供给用户的,而写端由甲方客户控制,当它们更新了某个 id 的数据后,希望能够马上展示到用户眼前,换句话说,缓存应当能够被马上刷新,但是前面的配置中使用的是 expireAfterAccess,因为用户的访问非常频繁,所以缓存一直不能过期,上线的数据不能及时地生效,导致甲方爸爸非常生气。
  2. 缓存更新的时候一般会从远程服务或数据库查询数据,这里没有考虑返回值为空的情况,为了保险起见,一般都是需要进行空值校验的,而且如果这里返回了空值,LoadingCache 会直接抛出异常。

所以更合理的配置方式应该是下面这样的:

1
2
3
4
5
6
7
8
9
10
LoadingCache<String, Object> cache = CacheBuilder.newBuilder()
.refreshAfterWrite(60 * 1000, MILLISECONDS) // 1
.maximumSize(500)
.build(new CacheLoader<String, Object>() {
@Override
public Object load(String id) throws Exception {
Object res = query(id); // 2
return res == null ? DEFAULT_OBJ : res;
}
});

所以,当我们在使用缓存时,一般是事先考虑:

  • dataSource:当没有命中时,从哪里获取数据?一般请求下会调用其他服务的远程接口,也可以直接从数据库、缓存中间件查询,但是要注意数据隔离性,比如权限服务就不适合直接到缓存或数据库中查询订单数据,因为这样不利于后期扩容,而且入口越多越不安全;
  • expire:定义淘汰策略,比如有一段时间没有访问就淘汰,比如容量限制,当超出容量的时候如何淘汰一般有 LRU(Least Recently Used)、LFU(Least Frequently Used),可以使用 weigher 设置每个 key 的权重;

如果是一些要求不高的内部信息管理系统,这些属性不需要太关注,但是如果是对并发量有一定要求的系统,对自己所使用的工具知根知底是最低的要求。

本地缓存是什么

本地缓存的英文是 Local Cache,Cache、Buffer、Pool 是经常出现但又容易混淆的一组概念,它们都能存取数据,但是有本质上的区别:

  • Cache 的主要功能是“将东西放到更容易拿到的地方”、从而加快速度,具有随机存取的功能,一般为了不导致内存溢出会设置数据的过期回收策略,比如计算机体系结构中的 L1、L2、L3 缓存;
  • Buffer 是为了缓冲、减少对脆弱系统的冲击,具有顺序访问的特点,比如每个 TCP Socket 都有的接收发送缓存区;
  • Pool 是为了缓存资源,它和 Cache 的主要区别是 Pool 中缓存的对象往往是同构而没有特殊价值的数据,比如连接池中存储的数据库连接,所以 Pool 不需要随机存取功能、随取随用即可。

本地缓存的优点

  • 节省了了内⽹带宽。
  • 响应时延会更低。

本地缓存的缺点

⽆法保证⼀致性,解决办法是:

  1. 单节点通知其他节点,但是会导致同⼀服务的多个节点相互耦合;
  2. 利用 mq 通知其他节点,但系统会变得更复杂;
  3. 使用 timer 定时从后端拉取更新内存缓存,但在更新数据后、访问其他节点会得到脏数据,直到其他节点 timer 拉取数据。

本地缓存如何保证一致性

现在基本没有对外应用会是单机部署的,本地缓存是将数据保存到实例本身的内存中,所以一致性问题就是:我们怎么保证同一时间从每台实例上获取到的数据都是相同的?

  • 集群广播:当数据源变更时,发消息通知所有实例
    优点:实现一致性
    缺点:不适合更新特别频繁的场景,可能产生消息堆积。
  • 定时拉取:每台实例定时从数据源拉取数据更新本地缓存
    优点:实现简单,使用Guava就可以实现;
    缺点:无法控制每台实例同时去拉取,可能有的拉到了,有的还没有到定时拉取的时间。
  • zk同步:将机器注册到zk,所有机器都注册一个watcher,当有数据变更时通知所有实例。
    优点:类似消息同步的方式实现一致性。
    缺点:引入zk提升复杂度,zk并不保证高可用(CP)。

什么时候需要本地缓存

分层架构设计,有⼀条准则:站点层、服务层要做到⽆数据⽆状态,这样才能任意的加节点⽔平扩展,数据和状态尽量存储到后端的数据存储服务,例如数据库服务或者缓存服务。
可以看到,站点与服务的进程内缓存,实际上违背了分层架构设计的⽆状态准则,故一般情况下并不推荐使用
在分布式缓存存在的情况下,一般本地缓存都是不必要的,一方面本地缓存会占用大量的堆空间,容易引起频繁的 GC;另一方面,因为是在局域网内,所以访问分布式缓存的网络开销不会太大。

那么,什么时候可以使⽤进程内缓存?以下情况,可以考虑使用进程内缓存,并且应该注意对过期策略、并发安全等的定义。

  1. 只读数据,可以考虑在进程启动时加载到内存。

    当然此时也可以把数据加载到 redis / memcache 等缓存中间件,进程外缓存同样能解决这个问题。

  2. 性能敏感、极其⾼并发的、如果透传对后端压力极大的场景,可以考虑使用进程内缓存。例如,首页列表、秒杀业务,并发量极高,需要站点层挡住流量,可以使⽤内存缓存。
  3. 一定程度上允许数据不一致的业务。
    例如,有一些计数场景,运营场景,⻚面对数据⼀致性要求较低,可以考虑使⽤进程内⻚面缓存。

避免过早优化

后端开发基本都是完美主义者(粗心导致留下 Bug 可是会被产品、测试鄙视的),但是完美主义也有一个缺点——容易过早优化。
比如,项目早期使用者不多、订单只有 10W~100W 的量级,但是开发刚上来在对行业知识、产品使用场景都没有深刻理解的情况下,直接决定对 id 散列来进行分表,这个对我们来说当然是无可厚非的,但是随着业务扩大、订单量增加到 100W~1000W,发现线上数据库中近期的订单被使用得更多(即热数据)、而老订单一般不被问津,所以原来那种新老混杂的分表就不合适了,但是现在再重构成按时间分表的方式就费事了。因此,更好的方式是刚开始仅用单表存就足够了,之后时刻关注线上使用的反馈,即时地进行优化。

Java 引用

Java 中除了基本类型外所有对象都是通过引用来使用的,引用分为强引用、软引用、弱引用和虚引用。
对于一般的缓存场景来说,软引用是更好的选择,因为软引用可以避免内存用完而 GC 又回收不了内存进而导致的服务宕机,又不会像弱引用那样每次 GC 都会被回收掉、连带导致缓存被击穿。

应用 - 失败次数统计

一般调用 RPC 接口都会有重试逻辑,最简单的重试可以用一个局部变量记录失败次数:

1
2
3
4
5
6
7
8
9
10
int failedCount = 0;
while(failedCount < 3) {
try {
rpcService.hello();
break;
} catch (Exception e) {
logger.warn("调用失败 " + failedCount + " 次", e);
failedCount++;
}
}

如果不是需要实时响应的功能,可以用一个队列缓存请求,然后用一个线程轮询,因为失败后需要重新丢进队列中等待,这时就不能单纯使用局部变量来保存失败次数了,可以使用一个 Cache<string, AtomicInteger>软引用缓存失败调用记录,成功后再使其失效。这种方式能控制失败重试次数,而且当内存不足时,缓存数据可以被 GC 回收以腾出一些空间。
以 Guava 中的 LoadingCache 为例:

1
2
3
4
5
6
7
8
9
10
private LoadingCache<String, AtomicInteger> failedCache = 
CacheBuilder.newBuilder()
.softValues()
.maximumSize(10000)
.build(new CacheLoader<String, AtomicInteger>() {
@Override
public AtomicInteger load(String id) throws Exception {
return new AtomicInteger(0);
}
});
  • 当失败时,调用 failedCache.getUnchecked(id).incrementAndGet()增加失败次数。
  • 当成功时,调用 failedCache.invalidate(id)使缓存失效。

GC 友好的缓存

diff(在不等的情况下才 put 或直接修改已有对象,提高内存利用率,不然每次刷新缓存都要放到年轻代。不能用分离链接法实现,因为老的会引用年轻的(每次 put 到头部不就可以了?),导致年轻代不能转移到老年代)

如何避免 OOM(弱引用)

缓存淘汰机制
过期策略

如何实现一个本地缓存 - ConcurrentHashMap

  1. 线程安全的 Map
  2. 回收机制
    如 LRU
  3. 软引用

分布式缓存

本地缓存和分布式缓存

缓存一般分为本地缓存和分布式缓存两种。本地缓存指的是将数据存储在本机内存中,操作缓存数据的速度很快,但是缺点也很明显:第一,缓存数据的数量与大小受限于本地内存;第二,如果有多台应用服务器,可能所有应用服务器都要维护一份缓存,这样就占用了很多的内存。
分布式缓存正好解决了这两个问题。首先,数据存储在了另外的机器上,理论上由于可以不断添加缓存机器,所以缓存的数据的数量是无限的;其次,缓存集中设置在远程的缓存服务器上,应用服务器不需要耗费空间来维护缓存。但是,分布式缓存也是有缺点的,比如由于是远程操作,所以操作缓存数据的速度相较于本地缓存慢很多。
当前用得最多的本地缓存是 GoogleGuavache,用得最多的分布式缓存是 Memcached 和 Redis

缓存穿透

概念及场景:缓存穿透是指查询一个一定不存在的数据,由于缓存是不命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。在流量大时,可能 DB 就挂掉了,要是有人利用不存在的 key 频繁攻击我们的应用,这就是漏洞。
解决方案:有很多种方法可以有效地解决缓存穿透问题,最常见的则是采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层存储系统的查询压力。另外也有一个更为简单粗暴的方法,如果一个查询返回的数据为空(不管是数据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。
总而言之,当通过一个 key 去数据库查询出来的数据结果为 null,缓存系统就不会缓存该数据,每次该 key 查询都会经过数据库层,造成没有必要的 DB 开销。这种情况下,我们可以将该 key 缓存至缓存系统中,value 为一个特殊值(^^,&&…)。

缓存雪崩

概念及场景

缓存雪崩是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到 DB,DB 瞬时压力过重雪崩。
key 缓存过期失效而新缓存未到期间,该 key 的查询所有请求都会去查询数据,造成 DB 压力上升,产生不必要的 DB 开销

解决方案

缓存失效时的雪崩效应对底层系统的冲击非常可怕。大多数系统设计者考虑用加锁或者队列的方式保证缓存的单线程(进程)写,从而避免失效时大量的并发请求落到底层存储系统上。这里分享一个简单方案就是将缓存失效时间分散开,比如我们可以在原有的失效时间基础上增加一个随机值,比如 1-5 分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
解决方案总结:

  1. 加锁排队重建,使请求可以串行化,而不用全部的请求都去查询数据库
  2. 假设 key 的过期时间是 A,创建一个 key_sign,它的过期时间比 A 小,查询 key 的时候检查 key_sign 是否已经过期,如果过期则加锁后台起一个线程异步去更新 key 的值,而实际的缓存没有过期(如果实际缓存已经过期,需要加锁排队重建),但是会浪费双份缓存
  3. 在原有的 value 中存一个过期值 B,B 比 A 小,取值的时候根据 B 判断 value 是否过期,如果过期,解决方案同上
  4. 牺牲用户体验,当发现缓存中没有对应的数据直接返回失败,并且把需要的数据放入一个分布式队列,后台通过异步线程更新队列中需要更新的缓存

缓存污染

概念和场景

一些非正常操作(比如导出 excel 文件、运营偶发性访问)而导致内存中出现很多冷数据

解决方案

选取合适的缓存算法(LUR-N 算法)。

缓存首次上线

概念及场景

缓存首次上线,如果网站的访问量很大,所有的请求都经过数据库(如果访问量比较少,可以由用户访问自行缓存)

解决方案

缓存预热,在系统上线之前,所有的缓存都预先加载完毕(增加一个刷新缓存程序,上线后手动刷新或发布时自动调用刷用)

缓存击穿

概念及场景:对于一些设置了过期时间的 key,如果这些 key 可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:缓存被“击穿”的问题,这个和缓存雪崩的区别在于这里针对某一 key 缓存,前者则是很多 key。缓存在某个时间点过期的时候,恰好在这个时间点对这个 Key 有大量的并发请求过来,这些请求发现缓存过期一般都会从后端 DB 加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端 DB 压垮。

解决方案

1.使用互斥锁(mutex key)
业界比较常用的做法,是使用 mutex。简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去 load db,而是先使用缓存工具的某些带成功操作返回值的操作(比如 Redis 的 SETNX 或者 Memcache 的 ADD)去 set 一个 mutex key,当操作返回成功时,再进行 load db 的操作并回设缓存;否则,就重试整个 get 缓存的方法。
SETNX,是「SET if Not eXists」的缩写,也就是只有不存在的时候才设置,可以利用它来实现锁的效果。在 redis2.6.1 之前版本未实现 setnx 的过期时间,所以这里给出两种版本代码参考:
//2.6.1 前单机版本锁
String get(String key) {
String value = redis.get(key);
if (value == null) {
if (redis.setnx(key_mutex, “1”)) {
// 3 min timeout to avoid mutex holder crash
redis.expire(key_mutex, 3 * 60)
value = db.get(key);
redis.set(key, value);
redis.delete(key_mutex);
} else {
//其他线程休息 50 毫秒后重试
Thread.sleep(50);
get(key);
}
}
}
最新版本代码:
public String get(key) {
String value = redis.get(key);
if (value == null) { //代表缓存值过期
//设置 3min 的超时,防止 del 操作失败的时候,下次缓存过期一直不能 load db
if (redis.setnx(key_mutex, 1, 3 * 60) == 1) { //代表设置成功
value = db.get(key);
redis.set(key, value, expire_secs);
redis.del(key_mutex);
} else { //这个时候代表同时候的其他线程已经 load db 并回设到缓存了,这时候重试获取缓存值即可
sleep(50);
get(key); //重试
}
} else {
return value;
}
}
memcache 代码:
if (memcache.get(key) == null) {
// 3 min timeout to avoid mutex holder crash
if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {
value = db.get(key);
memcache.set(key, value);
memcache.delete(key_mutex);
} else {
sleep(50);
retry();
}
}

除了使用Redis加锁,zk也是常见的分布式锁实现方案:

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
try {
value = redis.get(key);
if (Objects.isNull(value)) {
long start = System.currentTimeMillis();

InterProcessLock lock = ZKSpringFactory.get().opsForLock(key.toString());
try {
// 获取zk分布式锁
acquire(lock, key);
if (Objects.isNull(value = redis.get(key))) {
// 利用客户端传入的callback回源,一般是查数据库
value = callback.call();
redis.setnx(key,value.toString(),expireTime,timeUnit);
return value;
}
} catch (Throwable throwable) {
logger.error("加载redisson key异常,key={}", key, throwable);
// TODO 报警
throw UnsafeUtil.throwException(throwable);
} finally {
try {
lock.release();
M.zk_lock_time.timer().get().update(System.currentTimeMillis() - start, TimeUnit.MILLISECONDS);
} catch (Throwable throwable) {
logger.error("释放lock异常", throwable);
// TODO 报警
try {
lock.release();
} catch (Exception e) {
logger.error("释放lock异常", throwable);
}
}
}
}
} catch (Exception ex) {
logger.error("redisson 获取值异常,key:"+key,ex);
}

private static void acquire(InterProcessLock lock, String key) throws Exception {
if (!lock.acquire(LOCK_EXPIRE_SECOND, TimeUnit.SECONDS)) {
logger.error("获取lock超时,key={}", key);
// 报警
throw new BizException("网络繁忙,请您稍后再试");
}
}

2.”提前”使用互斥锁(mutex key):
在 value 内部设置 1 个超时值(timeout1), timeout1 比实际的 memcache timeout(timeout2)小。当从 cache 读取到 timeout1 发现它已经过期时候,马上延长 timeout1 并重新设置到 cache。然后再从数据库加载数据并设置到 cache 中。伪代码如下:
v = memcache.get(key);
if (v == null) {
if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {
value = db.get(key);
memcache.set(key, value);
memcache.delete(key_mutex);
} else {
sleep(50);
retry();
}
} else {
if (v.timeout <= now()) {
if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {
// extend the timeout for other threads
v.timeout += 3 * 60 * 1000;
memcache.set(key, v, KEY_TIMEOUT * 2);

        // load the latest value from dbplainplainplainplainplainplainplainplainplainplainplainplainplainplainplainplainplain
        v = db.get(key);    
        v.timeout = KEY_TIMEOUT;    
        memcache.set(key, value, KEY_TIMEOUT * 2);    
        memcache.delete(key_mutex);    
    } else {    
        sleep(50);    
        retry();    
    }    
}    

}

3.”永远不过期”:
这里的“永远不过期”包含两层意思:
(1) 从 redis 上看,确实没有设置过期时间,这就保证了,不会出现热点 key 过期问题,也就是“物理”不过期。
(2) 从功能上看,如果不过期,那不就成静态的了吗?所以我们把过期时间存在 key 对应的 value 里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建,也就是“逻辑”过期
从实战看,这种方法对于性能非常友好,唯一不足的就是构建缓存时候,其余线程(非构建缓存的线程)可能访问的是老数据,但是对于一般的互联网功能来说这个还是可以忍受。
String get(final String key) {
V v = redis.get(key);
String value = v.getValue();
long timeout = v.getTimeout();
if (v.timeout <= System.currentTimeMillis()) {
// 异步更新后台异常执行
threadPool.execute(new Runnable() {
public void run() {
String keyMutex = “mutex:” + key;
if (redis.setnx(keyMutex, “1”)) {
// 3 min timeout to avoid mutex holder crash
redis.expire(keyMutex, 3 * 60);
String dbValue = db.get(key);
redis.set(key, dbValue);
redis.delete(keyMutex);
}
}
});
}
return value;
}

  1. 资源保护:
    采用 netflix 的 hystrix,可以做资源的隔离保护主线程池,如果把这个应用到缓存的构建也未尝不可。
解决方案 优点 缺点
简单分布式互斥锁(mutex key) 1. 思路简单;2. 保证一致性 1. 代码复杂度增大;2. 存在死锁的风险;3. 存在线程池阻塞的风险
“提前”使用互斥锁 1. 保证一致性 同上
不过期(本文) 1. 异步构建缓存,不会阻塞线程池 1. 不保证一致性;2. 代码复杂度增大(每个 value 都要维护一个 timekey);3. 占用一定的内存空间(每个 value 都要维护一个 timekey)。
资源隔离组件 hystrix(本文) 1. hystrix 技术成熟,有效保证后端;2. hystrix 监控强大。 1. 部分访问存在降级策略。

设计总结

针对业务系统,永远都是具体情况具体分析,没有最好,只有最合适。
最后,对于缓存系统常见的缓存满了和数据丢失问题,需要根据具体业务分析,通常我们采用 LRU 策略处理溢出,Redis 的 RDB 和 AOF 持久化策略来保证一定情况下的数据安全。

  1. 缓存失效策略
    添加 key 的时候要设置一个过期时间,采用惰性删除和定时删除相结合的策略删除过期键
  2. 多级缓存
    线程级->内存级->进程级->文件(静态资源)->分布式(redis)->Db 结果.
  3. 二级缓存
    二级缓存更多的解决是,缓存穿透与程序的健壮性,当集中式缓存出现问题的时候,我们的应用能够继续运行;一些热点数据做成内存缓存,这些数据是在上线之前是已知的(比如说秒杀,大促商品),通过配置定时任务定时刷新内存缓存,完成和分布式缓存的数据置换;更加自动化的方案,可以根据上游自动发现热点数据,广播消息替换现在集群中内存缓存的数据(但在整个集群中广播,成本比较高,并且二级缓存的管理的成本也很大);

实现一个简单的多级缓存

多级数据来源

  • 本地缓存
    在并发量不大的系统内,本地缓存的意义不大,反而增加维护的困难。但在高并发系统中,本地缓存可以大大节约带宽。但是要注意本地缓存不是银弹,它会引起多个副本间数据的不一致,还会占据大量的内存,所以不适合保存特别大的数据,而且需要严格考虑刷新机制。
  • 缓存 / 搜索服务器
    TODO:
  • 数据库服务器
    TODO:
  • 同机房的其他业务服务器
    TODO:
  • 不同机房的其他业务服务器
    TODO:

多级缓存组件的主要执行流程
详细描述 TODO:

缓存时机

  • 5 分钟法则
    5 分钟法则即:如果一个数据的访问周期在 5 分钟以内则存放在内存中,否则应该存放在硬盘中。
    引申到缓存中,可以表述为:如果一个数据访问吞吐率大于 1 次 / 5 分钟,就可以考虑放到缓存中。
  • 局部性原理
    局部性原理原指 CPU 访问存储器时,无论是存取指令还是存取数据,所访问的存储单元都趋于聚集在一个较小的连续区域中。
    反过来说,如果访问存在热点,就完全可以把这些热点数据放到缓存里。

算法 - FIFO

算法 - LRU

算法 - LFU

回收策略

  • 空间
  • 容量
    条⽬数
  • 时间
    存活期活太久淘汰
    空闲期太久没访问淘汰

缓存监控

命中率 = 缓存读取次数 / (缓存读取次数 + 慢速设备读取次数)

过期时间

本地缓存过期时间比分布式缓存小至少一半,以防止本地缓存太久造成多实例数据不一致。

  • 不过期缓存
    场景:长尾访问的数据、访问频率⾼、缓存空间⾜够
    使⽤Cache-Aside 模式
    不要放事务⾥,因为⽹络抖动可能导致写缓存响应时间慢,阻塞数据库事务。但是同样存在事务成功但缓存失败⽆法回滚的情况。解决办法是使用 canal 实现缓存同步。
    若对⼀致性要求不高且数据量不⼤可改成定期全量同步
  • 过期缓存
    场景:热点数据、来⾃自其他系统的数据、空间有限、访问频率低

SoR(Source-of-Resource)

数据的来源,一般称为记录系统或数据源
回源即回到源头获取数据,Cache 没有命中时,需要从 SoR 获取数据,即回源。

大 Value

如果有⼤Value 最好切换到多线程实现的缓存如 MC,或者拆成多个小 Value 由客户端聚合。

热点缓存

频繁访问的热点数据,如果每次都要从缓存服务器获取,可能导致缓存服务器负载过高、或者带宽过⾼。
解决办法是加缓存服务器,或者加本地缓存。

缓存更新

原⼦更新:

  • 版本号
  • 如果是 redis,因为单线程机制本身就是⽀持原⼦更新的
  • 使用 canal 订阅数据库 binlog 将更新请求按规则路由到多个队列,每个队列进⾏单线程的更新
  • 加分布式锁

异步写

写本地缓存后异步更新分布式缓存,尽快返回用户请求,最好不要同步写分布式缓存。

维度化与增量缓存

场景:一个商品包含多个属性,其中部分属性如上下架这种可能频繁更新的,最好做维度化并增量更新。

缓存策略 - 分区读

读缓存时划分分区异步批量读:

  • 分区可以防⽌出现慢查询;
  • 异步可以把各批 key 并⾏化。

缓存策略 - nullobj 防缓存击穿

当 db 中本身就没有该数据时,会产生每次请求都击穿的现象,解决办法是引入一个 nullobj
db 不不存在时写一个 nullobj 到缓存,下次读到 null 对象 就不去 db 读了。

缓存策略 - Cache-aside 模式

Cache-Aside 即业务代码围绕着 Cache 写,是由业务代码直接维护缓存。

什么时候使用

  • 当 Cache不提供原⽣的 Read-Through 和 Write-Through 操作的时候
  • 资源的需求是不可预测的时候。Cache-Aside 模式令应用可以根据需求来加载数据,对于应⽤需求什么数据,不需要提前做出假设。

Read 模式

先从缓存获取数据,如果没有命中,则回源到 SoR 并将源数据放入缓存供下次读取使用。

Write 模式

类似 Write-Through 策略。

  1. 先将数据写入 SoR,写入成功后立即将数据同步写入缓存;
  2. 或先将数据写入 SoR,写入成功后将缓存数据过期,下次读取时再加载缓存。

读优化 - 一致性哈希

读可以⽤⼀致性哈希减少并发。

写优化 - 使用 Canal 订阅更新

更新可以⽤Canal 订阅 binlog。

缓存数据的⽣存时间

很多 Cache 实现了过期的策略的,这些过期的策略可以实现数据的更新,将旧数据失效化,同时也令⼀定时间没有访问的数据失效。
为了让 Cache-Aside 模式能够⽣效,开发者必须确保过期策略能够正确匹配应用所访问的数据。同时,注意不能让过期时间太短,因为太短的过期时间会令应⽤频繁地从数据仓库中获取数据来添加到 Cache 之中。当然,也不要配置超时的时间太⻓,过⻓的超时时间会让缓存的数据冗余。Cache 的性能是跟其相关的数据的读取周期等信息⾼度相关的。

去除数据

绝⼤多数的缓存跟数据仓库⽐起来,容量是很有限的,所以,如果可以的话,Cache 会移除数据。
多数的 Cache 会采用 LRU 的策略来移除缓存中的数据,当然,移除的策略也是可以⾃定义的。配置全局的过期属性和缓存的其他属性,可以确保 Cache 消耗的内存资源是高效的。当然,通常不会只配置⼀个全局的过期策略。比如,某些特别昂贵、访问特别频繁而又不常更新的数据,完全可以延长其过期时间。

一致性

实现 Cache-Aside 模式并不能保证 Cache 和数据仓库之间的数据⼀致性。因为数据仓库中的数据可能在任何时候被其他程序所修改,⽽这个修改不会及时的反映到 Cache 上,直到下一次 Cache 被刷新为止。如果数据仓库中数据频繁由⾮Cahce 程序更新的话,这种一致性问题会变得更加明显。

本地(内存)缓存

Cache 也是可以做到应⽤本身里⾯的。Cache-Aside 模式在⼀些应⽤频繁访问相同的数据的时候尤其有效。然⽽,本地 Cache 都是应⽤私有 的,是属于每个应用中独有的额外的拷⻉。所以这个数据可能很快在不同的应⽤中就不一致了,所以刷新的频率最好更快些以保证⼀致性。在 有些情况下可以使⽤共享的缓存,有的时候也可以使⽤本地 Cache,具体使⽤哪⼀种就需要根据实际的场景来判断了。

缓存策略 - Cache-as-SoR 模式

由 Cache 委托给 SoR 进⾏真实的读写

缓存策略 - Read-Through

读 miss 则由 cache 回源到 SoR(需要防⽌dog-pile effect 即 miss 时只允许⼀个请求回源而不是所有请求都回源)。

缓存策略 - Write-Through

由 cache 组件负责写缓存和 SoR(一般是先 SoR 再缓存)

缓存策略 - Write-Behind

异步(队列+线程池)写 SoR

缓存策略 - Copy-Pattern

Copy-On-Read
Copy-On-Write
本地缓存的是引⽤,被擅⾃修改可能引起不可预测的问题

参考

所有参考文献的归档,集中管理,方便随时查阅。

多级缓存架构

  1. 一篇文章让你明白你多级缓存的分层架构
  2. 一个牛逼的多级缓存实现方案
  3. 日访问量百亿级的微博如何做缓存架构设计
  4. 分布式内存缓存系统设计
  5. 那些年我们一起追过的缓存写法(一)

内存池

  1. 设计模式之争:新分配内存还是内存池?(含评测)

本地缓存

不得不承认本地缓存不比分布式缓存更简单,当我们讨论分布式缓存时,更多的是在讨论如何节省带宽、如何平滑扩容,但在本地缓存的范畴内,我们更多的需要关注所使用语言的内存管理机制、甚至需要向下探索到硬件层面(其实分布式缓存的基础一般也是本地缓存)。

  1. 146. LRU Cache
    460. LFU Cache
    LeetCode 上有几道缓存相关的问题,可以拿来作热身。
  2. Java 内存模型
    Java 中的垃圾回收技术已经比较完善了,开发人员能做的除了给出合理的配置外,就是要做到对自己使用的垃圾回收技术知根知底、能写出 GC 友好的代码。
    The Java Memory Model - William Pugh
    JSR 133 (Java Memory Model) FAQ
    Java 内存模型 FAQ
    上面这篇的中文翻译,适合我这种英语渣对照阅读。
    GC(GC 友好编程)
    Doug Lea’s Home Page
    Doug Lea 并发编程文章全部译文
  3. NonBlocking HashTable
    HashTable 是实现缓存的常用数据结构,而在操作时进行普通的加锁又非常影响性能,所以一般会做一些 NonBlocking 的优化。除了 JUC 的 ConcurrentHashMap,还有其他的一些相似实现。
    stephenc/high-scale-lib
    JCTools/JCTools
  4. guava - cache
    github - google/guava - Caches
  5. ehcache
    github - ehcache3
    玩转 EhCache 之最简单的缓存框架
  6. J2Cache
    J2Cache 是一个国产的本地缓存框架,同时也提供了二级缓存、多机同步等特性。
    红薯 / J2Cache
  7. Netty 中的对象池
    netty/netty - Reference counted objects
    Netty 源码 Recycler 对象池全面解析
    netty 源码分析 4 - Recycler 对象池的设计
    Netty 为了提高性能,IO 时直接使用非堆内存来缓存收发的内容(Buf 对象),在非堆内存中 GC 效率会比 JVM 的堆内存效率低(只能通过 FullGC 回收或 CMS GC),所以 Netty 内部维护了一个对象池(Recycler),使用引用计数法来回收不用的对象到对象池中,而不是直接回收,减少了 GC 的频率。
  8. C / C++ 内存模型
    C / C++ 只保证最基本的内存管理(malloc / free),因为其贴近操作系统的特性,很多框架都会封装一套自己的内存管理库(包括 memcached、MySQL、Cocos2d-x 等),甚至是 GC。
    《C 语言接口与实现》 - 第 2、4、5、6 章
    dlmalloc - Doug Lea
    内存管理(Memory) - 许式伟
    C++ Memory Management Innovation: GC Allocator
    《STL 源码剖析》 - 第 2 章
    《深入探索 C++对象模型》
  9. bangerlee/mempool
  10. 应用层内存管理
    Memory Management Reference
    一个神奇的网站,内存管理相关的概念、综述、深入参考文献基本都能在这里找到,而且偏应用层,讲解方式友好、适合扫盲。
  11. 操作系统层内存管理
    The Unix and Internet Fundamentals HOWTO
    非常精炼地解释了 Unix 系统和网络的基本原理。
  12. 硬件层内存管理

分布式缓存

  1. 分布式缓存设计及解决方案(后端)
    大型分布式网站架构
    浅谈缓存(一)
    那些年我们一起追过的缓存写法(一)
    缓存穿透、并发和失效,来自一线架构师的解决方案
  2. 《分布式缓存——原理、架构及 Go 语言实现》
  3. 荐书:《深入分布式缓存》
  4. Redis 教程及手册
    《Redis 设计与实现》
    《Redis 深度历险:核心原理与应用实践》
    Redis 命令参考
    Redis Command Reference
    Redis Documentation
  5. Redis 及客户端源码
    github - antirez/redis
    github - redisson/redisson
    github - xetorthio/jedis
    github - lettuce-io/lettuce-core
  6. Redis - 数据结构
    Redis 为何这么快
    Redis strings vs Redis hashes to represent JSON: efficiency?
    如果业务里需要使用到对象的单个域则使用 hash 类型保存 JSON,否则使用 string。
  7. Redis - Sentinel
    Sentinel 是 Redis 提供的一种高可用集群方案,它能实现自动的故障转移。
    Redis Sentinel Documentation
    Sentinel Clients
    Jepsen: Redis
    Reply to Aphyr attack to Sentinel
    Asynchronous replication with failover
  8. Redis - Cluster
    Redis Cluster 不是使用一致性 hash、而是利用哈希槽的模式来分配数据,相对来说更简单,但是数据迁移的时候成本也会更大。
    Redis cluster tutorial
    Redis Cluster Specification
  9. 在 Spring 架构后台中使用 Redis
    spring-framework-reference - 8. Cache Abstraction
    Spring Data Redis
    Spring Boot Reference Guide
  10. Redis 最佳实践
    阿里云 Redis 开发规范
    你所不知道的 Redis 热点问题以及如何发现热点
    史上最全 50 道 Redis 面试题(含答案),以后面试再也不怕问 Redis 了
  11. Redis 应用
    一文看透 Redis 分布式锁进化史(解读 + 缺陷分析)
    How to do distributed locking
    刷新 Redis 缓存时需要加分布式锁,保证只有一个线程能够写入缓存。
    INCR key (分布式限流器的例子)
    布隆过滤器实战【防止缓存击穿】
    NOSQL 数据建模技术
  12. github - memcached
    memcached 是经常和 Redis 一并提起的一个分布式缓存中间件,了解其原理能对分布式缓存的实现模式有更好的认识。

Web 缓存

Web 缓存指的是在服务器和客户端之间的缓存,不同于我们上边提到的都是服务器(本地缓存)及服务器之后的缓存(分布式缓存)。
Web 缓存其实不仅仅指浏览器内的缓存,在剖析从客户端发送请求到服务器接收为止的一系列链路之后,可以发现 Web 缓存主要包括浏览器缓存、代理服务器缓存、网关缓存。

  1. Web 开发人员需知的 Web 缓存知识

测试

测试是发现问题的手段,最好的解决问题方式是避免问题。

  1. 《Java 并发编程实战》 - 第三部分(并发安全及性能)
  2. Redis 有多快?
  3. 《构建高性能 Web 站点》 - 第 3 章
  4. 《Java 程序性能优化》
  5. 《Web 性能权威指南》
  6. 《OptimizingLinux(R)PerformanceAHands-OnGuidetoLinux(R)PerformanceTools》
  7. 《性能之巅》

运维

  1. 《The Art of Capacity Planning》
  2. 探寻 Redis 内存诡异增长的元凶

其他

  1. How To Ask Questions The Smart Way
  2. Software Release Practice HOWTO
  3. github - hyperoslo/Cache
    这个项目同样在缓存上做文章,不过是用 swift 写的。
  4. Learn X in Y minutes - Where X=Lua
  5. 《设计模式》 - Factory, Builder, Proxy, Chain of Responsibility, Command, Iterator, template method
  6. 《Spring 源码深度解析》 - 第 5、6、7 章
  7. 使用 AI 生成图标

在一次编译 jvm 的过程中,尝试将没用版本的 gcc、g++卸载,但是没想到 apt 非常智能地把无线网卡等一系列东西都给删掉了,启动后连网都上不了了,重装系统又觉得麻烦,结果开始了苦逼的填坑之旅。

阅读全文 »

熔断是兜底杀手锏之一,在一个远程调用框架中,一个服务接口被调用时,如果出错应该优先采取重试的方案,如果多次超时——表明服务确实不可用之后——才会考虑熔断,以避免“灾害”进一步的扩大。
熔断本身没有特别难的算法,但是需要考虑比较多的细节。

阅读全文 »

幂等性描述一项操作被执行多次后,不会改变第一次执行产生的副作用。
练手项目,解决项目中的幂等性检查要求,代码地址:point_right: Github - TIdempotent

  • BlockingChecker、NonblockingChecker ;
  • 加入 MySQL 作为 KeyStore (JdbcKeyStore);
  • 加入 Redis 作为 KeyStore (RedisKeyStore);
  • 只存储正确的返回结果,返回值压缩 + 缓存;
  • 加入 Spring 便于整合进业务系统;
阅读全文 »

背景

上半年参与一个微服务架构 Web 服务的开发,期间提交了近 2W 行 Java 代码,删除近 1W 行,虽然只是 SaaS 层的代码,但是还是学到了不少东西,写代码方面学了一些 Java 基础和编程规范相关的知识,架构方面了解了一些分布式系统相关的概念,另外又抽时间自学了一些中间件、DevOps(主要是怎么使用 Docker 简化运维)相关的工具。

阅读全文 »

背景

2017 年春,和一位朋友参加华为软件精英挑战赛,负责所有编码工作,最终时运不济(启发式算法,确实有一定运气成分)、能力不足,最后成绩只有前 64,有点遗憾,不过过程中也接触到了很多平时不常见的算法,有些收获。

阅读全文 »

背景

有一段时间对 Linux 特别感兴趣(现在也一直拿来当桌面系统),因此利用实验课的机会着手开发了一个小游戏,是一个可以多人联机的贪吃蛇游戏,看起来挺 low 的,但是涉及到了 Linux 课上所学的大部分知识,答辩后也得到了不错的结果。代码不是特别规范,下面强行分享一波。
完整代码

阅读全文 »

参考

1.Tony. 企业融资渠道有哪些?[EB/OL], https://www.zhihu.com/question/23939018
/answer/29132581, 2015.5.
2.周梅. ERP 实施中存在的问题及解决方案[J]. 中国集体经济,2013,28: 35-36.
3.于洪涛. SAP 坚定云转型之路[EB/OL], http://www.cnbp.net/news/detail/13618, 2016.9.
4.James Lewis , Martin Fowler . Microservices a definition of this new architectural term
[EB/OL], https://martinfowler.com/articles/microservices.html, 2014.3.
5.IT168 企业级. 年终盘点篇:2017 年度微服务调查报告出炉[EB/OL],
http://www.sohu.com/a/216277536_374240, 2018.1.
6.Henry Robinson. What Is CAP Theorem?[EB/OL], https://www.quora.com/What-
Is-CAP-Theorem-1, 2010.9.
7. Dan Pritchett. BASE: An Acid Alternative[J]. ACM Queue, 2008, 6(3):48-55.

  1. 保证分布式系统数据一致性的 6 种方案
  2. 阮一峰. 理解 RESTful 架构[EB/OL], http://www.ruanyifeng.com/blog/2011/09
    /restful.html, 2011.9.
  3. 程立. 大规模 SOA 系统中的分布事务处理[EB/OL], http://tsingxu.github.io
    /blog/20140513/distributed-transaction-in-soa-by-chengli.pdf, 2008.12.

15.Hunt P, Konar M, Junqueira F P. ZooKeeper: Wait-free Coordination for Internet-scale
Systems[C]. Usenix Annual Technical Conference. 2010:653–710.
16.Zachary Tong, Clinton Gormley. Elasticsearch 权威指南[M]. O’Reilly Media, Inc,
2015, 27-35.
17.Baron.Scbwartz. 高性能 MySQL[M]. 北京:电子工业出版社, 2013.5, 1-33.
18.Redis Documentation[EB/OL], https://redis.io/documentation, 2018.3.

背景

项目意义

X
这个项目的业务部分的原型是司库云,但是重点不在业务,因此这里仅仅简单介绍一下。平时我们接触的最多的应该是共享单车、在线聊天、支付宝这种 To C 业务,To B 业务主要是由企业提出的,和个人不同,一个企业总是会有部门、上下级等关系,因此 To B 业务与 To C 业务的主要区别有以下几条:

  1. 层次化的权限管理
    一般的企业中员工之间都会有上下级的关系,企业又会有集团、组织这些组成部分;
  2. 无处不在的审批
    企业中大部门场景都需要审批,以前是拿着单子到处跑,现在会将单子录到一些 APP 中,方便很多;
  3. 更大的体量
    一般企业会进行更大额的交易,我们平时将几万存入支付宝中对大型企业来说都是一点零头,对这么大额的操作,安全是优先于效率的(企业内部其实也只有百来号人会去用这样的系统)。

近年,似乎所有传统企业都在试图往互联网靠拢,上次听一位朋友讲到某机关部门布了一个集群供“大数据处理”(上千条级别),再如某企业开发云服务软件,测试时专门有一项“大数据用例”(上万条级别)。
将业务上云后,正如之前很多人困惑的那样:不就是把代码放到云主机上跑吗?但问题的关键不是技术新老,而是为什么要迁移到云端,主要是当下业务变更迅速、弹性大,使用传统的机房部署服务,应用实例能使用的硬件资源是固定的,如果数据量超过了机器可处理的范围,要么换一台更高档的机器,要么将整个实例复制到另一台机器上,再在网关处做负载均衡,当然这两种方式都有些缺陷。因此,技术的变革都是被逼出来的,当下云原生应用能够提供弹性扩展、资源预警等功能,且有更人性化的操作界面,取代传统的运维方式也是必然的。微服务是当下实现应用上云的首选架构风格,有丰富的资源(当然最重要的是比较火),这个小项目也是我对微服务的初步探究。


微服务

架构、架构风格、系统架构

架构风格关注的是如何使用一些连接件来组合软件组件,在 Web 应用中,我们会使用覆盖网络来描述软件的架构,连接件可以是 HTTP 协议、数据库连接器等,在桌面应用中,连接器可以是读取用户输入的管道,等等。
系统架构关注的是软件组件是如何实例化的,比如要几台服务器、哪些组件要复制等。
平时说的架构一般指的是架构风格,但对实现细节的深究是成为架构师的必经之路。

单体式、SOA 和微服务

X
图中,小圆形、方形、三角这些小图形是服务实例,包围小图形的双层方形是 Docker 容器实例,立方体是物理服务器(一般是云提供商提供的虚拟机实例)
传统的单体式应用(monolithic)在实践中往往存在着诸多问题,比如,在达到一定的复杂度后,不仅部署效率会降低,由开发不小心引入的任何 Bug 都会弄垮整个服务,且模块之间高度耦合,添加新功能的代价变得更高,由此也对测试带来了更大的压力。
微服务架构模式有点像SOA,他们都由多个服务构成,因此对 SOA 缺陷的讨论可以参照下面对微服务的讨论。但是,从另一个角度看,微服务架构模式是一个不包含 Web 服务(WS-)和 ESB 服务的 SOA,微服务应用乐于采用简单轻量级协议,比如 REST,而不是 WS-,在微服务内部避免使用 ESB 以及 ESB 类似功能,微服务架构模式也拒绝使用 canonical schema 等 SOA 概念,因此可以认为微服务是轻量版的 SOA。简而言之,它们之间的主要区别是对服务的治理方式不同。
微服务的概念由来已久,2011 年 5 月在威尼斯附近举办的软件架构师研讨会提出了“微服务”这个概念,Adrian Cockcroft 将其描述为一种“细粒度的 SOA”。
在国内,加快互联网+步伐成为许多传统企业的必然选择。业务场景、用户习惯和行为在迅速变化,许多传统行业线上业务出现急速增长。每个月都要进行业务系统更新的企业比例超过了半数,比如金融行业的移动支付、互联网理财等,汽车制造行业的营销、电商、售后服务等线上业务比例迅速提高。IT 团队业务开发、迭代都以每月、甚至每周来计,需要 7*24 小时响应,这些给系统开发和运维带来极大挑战。在服务的复杂化、线上访问压力大、交付速度无法满足业务需求等现状的前提下,寻求架构上的转型正是大势所趋。
微服务相对以传统方式部署的应用来说,具有易扩展、访问便捷、安全、性价比高等特点,将传统的单体式应用进行拆分后成为一个个微服务后,每一个微服务都可以单独进行部署,可以根据企业用户的具体需求提供服务,另外服务之间的弱耦合性也使得扩展变得更加容易。

总而言之,微服务应用相对单体式应用的优势体现在下面这些方面:

  1. 可扩展性高
    严格界定服务边界,服务之间是弱耦合的,每个服务本身是无状态的,可以通过水平复制和负载均衡来提高服务性能。
  2. 实施效率高
    应用 Docker 实现运维自动化,且每个服务都足够小,使得部署效率不会太低。
  3. 健壮
    某个服务的不可用不会引起大规模雪崩,而且服务的复制也提高了整体的可用性。
  4. 单个服务复杂性低
    每个服务都足够简单,由一个独立团队来负责。

开发、实施和运维

开发和运维在传统情况下是完全分割开来的,开发主要负责完成功能需求,并且知道如何去优化功能,运维要明白如何部署应用服务集群、中间件集群外,同时对所使用的工具的原理要有一定程度的了解,比如某个服务器的 CPU 打满了,原因可能是代码写得太差了,也可能是数据库某张热表没有给字段加上索引。现在 DevOps 的概念盛行,开发有时也需要承担环境的维护。
实施,说简单的,就是帮用户装软件的,该人员必须对软件整体具有较好的理解,因为实施会直面用户,如果被用户问倒可不只是丢自己一个人的脸,当然,一定程度的口才也是必要的。实施在 ERP 时代是很重要的一个职位,在当下云服务盛行的情况,实施往往会被派去为用户部署私有云环境(前提是有这个必要)。

有状态和无状态服务

提到无状态我们都会想到 HTTP,HTTP 协议是一种无状态的协议,这意味着 HTTP 服务器不会保存任何用户信息,实现登录、购物车等带状态的功能时都需要额外借助 Session、分布式缓存、数据库等存储方案。以购物车为例,其中 Session 相当于将购物车的状态保存到了服务实例的内存中,而分布式缓存和数据库方案则将状态转移到了另一个服务器内(假设应用和缓存、数据库服务器是部署在不同的服务器上的)。
如果一个服务是有状态的,随着实例的运行,服务实例在内存中可能会多出一些与业务相关的数据,实例间产生差异后,我们后续就不能随意地对服务进行复制了,两次 HTTP 的可能会产生不同的结果,如下图所示。
X
因此有必要在设计的时候就保证服务是无状态的。
X

CAP & BASE

CAP是描述分布式系统特性常用的一种理论,它使用数据一致性(Consistency)服务可用性(Availability)分区容错性(Partition-tolerance)三个指标来定义一个分布式系统,这三个特性不能被同时完全满足,其中,P 是必须要满足的,因为一般的业务系统并不允许网络中的消息被随意丢弃,因此多数的讨论都集中于 C 和 A 之间的权衡。
如果需要满足强一致性,则在对数据进行读写操作时势必都需要进行加锁操作并使用事务来保证分布式一致性,但同时也会对系统的效率产生非常大的影响,起到反作用、影响用户的体验,所以在设计时往往会放宽这个要求,采取最终一致性作为实现目标。服务的高可用性要求请求必须能够完成,这可以通过复制服务实例来实现,服务实例的复制需要投入更多地成本与维护人力,需要根据具体场景进行具体分析。
eBay 的架构师 Dan Pritchett 源于对大规模分布式系统的实践总结,提出了 BASE 理论。BASE理论是对 CAP 理论的延伸,核心思想是即使无法做到 CAP 理论要求的强一致性,但应用可以采用适当的方式达到最终一致性(Eventual Consitency)。BASE 是指基本可用(Basically Available)软状态(Soft State)最终一致性(Eventual Consistency)。基本可用是指分布式系统在出现故障的时候,允许损失部分可用性,即保证核心可用。软状态是指允许系统存在中间状态,而该中间状态不会影响系统整体可用性。最终一致性是指系统中的所有数据副本经过一定时间后,最终能够达到一致的状态。
在 BASE 的提出者 Dan Pritchett 的论文《BASE: An Acid Alternative》中提出了一种实现 BASE 的经典模式,概括来讲,就是服务之间是通过消息队列连接的,消息队列会保证将消息传递给目标服务,但不保证送到的时间。


技术总结

接下来,我希望对项目中使用到的一些技术进行简短总结,尽量引入一些代码和图来辅助说明。

REST & RPC

REST 是现今一套比较成熟的 API 设计理论,主要思想是将网络上的资源抽象为 URI,并通过 HTTP 协议中的字段和动词进行描述和操作,被广泛地应用于 WEB 应用 API 的设计当中。
RPC 是常见的实现服务之间远程调用的协议(在 Java 这种面向对象的语言上实现的理应称作 RMI 协议,但是叫习惯了就无所谓了),服务实例作为不同的进程运行于多台服务器上,在发起请求时,请求的发起方称为客户端、接收者称为服务端,它们通过指定的网络层甚至传输层协议进行通信,可以自动对消息进行序列化并包装为底层消息格式进行传输,并在服务端进行反序列化得到请求。因此 RPC 的可靠性和效率与底层协议本身的效率和对对象进行的序列化和反序列化的效率息息相关。
调用 RPC 接口与调用本地方法形式上是相同的,这大大减少了代码的冗余、提升了开发效率,但是 RPC 本质上与普通方法调用却有着截然不同的执行流程,因此必须与普通方法区分开来、不能滥用。在设计微服务时,也必须考虑服务划分的粒度,如果分得过细就有可能导致一次请求需要过多的网络开销。

分布式锁、乐观锁与分布式事务

单机环境下,资源竞争者都是来自机器内部的进程或线程,那么实现锁的方案只需要借助单机资源就可以了,比如借助磁盘、内存、寄存器来实现。而在分布式环境下,资源竞争者生存环境更复杂了,原有依赖单机的方案不再发挥作用,这时候就需要一个大家都认可的协调者出来,帮助解决竞争问题,那这个协调者称之为分布式锁
通过在执行修改操作前加分布式锁,可以很好地保证临界区资源的互斥访问,一定程度上维护了数据的一致性,但是在客户端进行读写的复合操作时加锁又是不充分的,因为读和写操作之间存在一定的时间差,如果在这期间数据被其他线程所修改,那么接下来的写操作就会覆盖这个修改,导致业务层面上的不一致,解决办法是引入乐观锁,本文的乐观锁是通过为实体类添加版本号来实现的,每次进行修改操作时需要比较对象与数据库中记录的版本,只有大于的情况才能执行。
微服务系统中的多个服务往往拥有自己独立部署的数据库,在跨服务对数据进行写操作时若发生错误就有可能产生数据不一致的情况,利用单纯的加锁机制是不能保证安全性的。分布式事务是指会涉及到操作多个数据库的事务,目的是为了保证系统中各服务能保持数据一致性。分布式事务处理的关键是必须有一种方法可以知道事务的所有参与者的动作,事务最终必须统一提交或统一回滚。文中采取的解决办法是引入 TCC 分布式事务,将业务操作划分为 try、confirm、cancel 三个部分,try 负责对业务资源的锁定,confirm 负责提交事务、正式执行业务操作,cancel 负责回滚事务并释放锁定的资源,在业务流程中执行所有的 try 完毕后,由协调者根据事务的执行情况来统一调用所有事务参与者的 confirm 提交或 cancel 回滚。TCC 在本地事务的基础上进行多个实例间的协调,可以在很大程度上保证跨服务业务操作的一致性。

Spring Cloud

Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具,例如配置管理,服务发现,断路器,智能路由,微代理,控制总线等。

Spring Boot

Sping Cloud 的实现基础是Spring Boot,在构建项目前需要先引入所需基础设施的依赖。
比如若需要使用 ZooKeeper 的服务发现功能,只需要在 Maven 的 pom.xml 文件中添加名为”spring-cloud-starter-zookeeper-discovery”的依赖。

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-zookeeper-discovery</artifactId>
</dependency>

并在属性文件中指定 ZooKeeper 服务器的地址,就可以在应用中通过注入 DiscoveryClient 来使用 ZooKeeper 的服务发现功能了。

1
2
@Autowired
private DiscoveryClient discoveryClient;

其他的功能也可以依法炮制。从某种意义上来说,Spring Cloud 更像是通过 Spring Boot 自动配置机制实现的由众多独立子项目组成的大型综合项目。
Spring Boot 的主要目标是解决传统 Spring 项目中配置文件过于繁杂的问题,随着业务的复杂度增加,配置变得越来越难维护且无法定制,因此 Spring Boot 的提出者利用注解和属性文件来取代配置文件,因为注解是与代码紧紧相依的,代码中可以直接通过注解来获取到属性文件中的属性,并按用户的需求来执行 Bean 的初始化过程,从而实现配置的高度可定制化。Spring Boot 的关键特性是自动配置,实现自动配置的方式是在应用启动后从 Spring Boot 提供的包内获取配置好的 Bean,并对常用的配置设定默认值,比如其内置的 Tomcat 服务器的默认占用端口即为 8080,并且支持在属性文件中修改,这在很大程度上减少了配置项的数量,同时也降低了运维人员的压力。

Dubbo

Spring Cloud 和 Dubbo 区别?为什么不使用 Dubbo???

Docker

Docker 执行流程?为什么用它来部署环境?

Docker 是一个基于容器的应用开发、部署和运行平台,它为开发者和系统管理员们提供了一种新式的应用部署方式,具有灵活、轻量、可移植等特点。传统的部署云服务的方式是通过虚拟机完成的,虚拟机会在宿主机上运行一个完整的操作系统、通过 hypervisor 来间接使用宿主机的硬件资源,实际上这远远超出了应用运行所必须的资源要求。而容器正相反,它在操作系统中作为进程运行,与所有其他容器共享同一内核、占用相同容量的内存空间,相对来说会更加轻量。

MySQL

MySQL 是一种常用的开源数据库,MySQL 软件采用了双授权政策,分社区版和商业版,具有体积小、速度快、总体拥有成本低、开放源码等特点。

Redis

Redis 有哪些数据结构?

Redis 是一个开源的使用 ANSI C 语言编写、支持网络、支持持久化的日志型、Key-Value 数据库,并提供多种语言的 API。

Nginx

Nginx 执行流程?反向代理解释一下?为什么使用 Nginx 作为静态页面服务器?负载均衡原理是什么?

Nginx 是一款高性能的 HTTP 和反向代理服务器软件,具有高性能、高并发、低 CPU 内存消耗的特点,功能包括反向代理、负载均衡、访问控制等,且可以根据需要自定义扩展组件。

ZooKeeper

ZooKeeper 执行流程?服务发现原理是什么?分布式锁原理是什么?

ZooKeeper 是一个分布式的开放源码的分布式应用程序协调服务,可以为分布式应用提供一致性服务,提供的功能包括:配置维护、域名服务、分布式同步、组服务等。
ZooKeeper 将资源抽象为文件系统,使用节点来表示数据在该文件系统中保存的路径。为了实现服务发现机制,每个实例在加入应用服务时会在 ZooKeeper 服务的同一命名空间下创建临时节点,并在退出时由 ZooKeeper 自动删除,这样任一个实例都可以通过查询该命名空间下来获得微服务集群内服务的注册情况。ZooKeeper 本身不提供锁服务,但是可以使用节点来表示锁,如果一个客户端需要为一个资源上锁,就可以为该资源所代表的路径下创建一个顺序节点,按照节点创建的顺序进行标号,客户端监听该路径下的节点,如果自己创建的节点标号是最小的就获取到锁,当释放锁时需要删除自己创建的节点,这样基本实现了客户端之间的互斥访问。

RabbitMQ

RabbitMQ 执行流程?消息队列的数据结构是怎样的?

RabbitMQ 是一个在 AMQP 基础上完成的、可复用的企业消息系统。AMQP 协议定义了消息队列需要具有的面向消息、队列、路由(包括点对点和发布/订阅)、可靠性、安全性等特点。RabbitMQ 是一个开源的 AMQP 实现,服务器端用 Erlang 语言编写,支持包括 Java 在内的多种客户端,主要应用于在分布式系统中存储转发消息,在易用性、扩展性、高可用性等方面表现不俗。

Elasticsearch

Elasticsearch 执行流程?文档数据结构是怎样的?

Elasticsearch 是一个分布式、可扩展、实时的搜索与数据分析引擎。擅长全文搜索、结构化数据的实时统计、复杂的语言处理等。并且原生支持分布式部署,可以通过管理多节点来提高扩容性和可用性,并在硬件故障时确保数据安全。


架构设计与实现

整体架构

微服务的首要任务是对模块进行拆分,整个系统的主要模块如下图所示。
X
需要实现的主要功能包括:

  1. 使用权限模块来实现权限分配、功能隔离、租户隔离;
  2. 用户能够利用该系统进行发债放款、审批、结算等;
  3. 用户能获得预警信息;
  4. 系统内需要使用服务发现机制来实现服务的弹性伸缩。

每个模块成为一个独立的服务,它们具有明确清晰的功能边界,且相互之间只暴露必要的远程接口,形成类似下图的结构。
X

部分功能的实现涉及到一些中间件,更详细的架构图如下图所示。
X
其中:

  • 主要功能模块拥有独立的数据库,它们是独立部署的;
  • 服务发现服务用于自动实现服务的上下线,使用 ZooKeeper 服务器作为服务注册中心,服务发现服务会将注册在 ZooKeeper 服务器上的服务信息更新到 Nginx 服务器上;
  • Docker 提供容器形式的运行环境,使用 Docker Swarm 管理容器集群,服务和中间件运行在容器实例内,称为服务实例,多个服务实例组成一个服务;
  • Nginx 有两个作用:
    1. 作为静态文件服务器。前端使用 React 开发,通过运行 npm run build 编译成为静态文件,将产生的整个文件夹移动到 Nginx 的静态文件目录下;
    2. 负载均衡。Nginx 是一个反向代理服务器,调用任何服务的 REST 接口需要先经过 Nginx 进行转发,Nginx 在同一服务的各个实例间进行负载均衡。比如一次放款提交请求,请求会经过 Web 客户端、Nginx、发债管理、审批管理这几个服务。
      业务服务需要保持无状态,这样才能启动多个而不影响负载均衡的有效性,而各种中间件(如 ZooKeeper、RabbitMQ)一般有自己组织集群的协议,集群一般由 master 和 slave 组成,需要在 Nginx 中配置其中 master 节点的地址。
  • Radis 集群组成了缓存服务,因为系统中涉及到的业务大部分是读多写少的,因此缓存对提高效率是很有必要的;
  • RabbitMQ 集群组成消息队列服务,消息队列可以解耦消息管理服务和业务服务,另外,消息队列也是实现异步调用必须的,消息队列提供重试、备份等功能,有利于实现可靠消息的最终一致性效果。

部分功能实现

我将大部分业务功能忽略了,这些功能中大部分都属于简单的 CRUD 操作,下面介绍一些技术性稍微强一些的功能的实现细节。

服务发现功能

X
服务发现功能的主要运行流程大体分成三个阶段,通过定时器来定时执行:

  1. 从 zk 服务器获取服务注册信息
    在启动服务实例后,新建的服务实例会向 ZooKeeper 服务器发送该服务的注册信息,在 ZooKeeper 中这些信息作为存在于同一命名空间下的临时节点存在。
  2. 构建配置文件,并上传到 Nginx 服务器
    服务发现服务器会定时地从 ZooKeeper 服务器获取微服务集群内服务的注册情况。
  3. 远程执行命令,更新 Nginx 服务器
    在获取到服务的注册信息后,服务发现模块会将数据组织成 Nginx 配置文件的格式,并上传到 Nginx 服务器内,因为 Nginx 是运行在容器之内的,所以客户端需要通过 Docker 提供的 REST API 来操作容器来达到间接操作 Nginx 服务器的目的,在上传完毕后使用 Docker 容器运行命令的接口来执行 Nginx 重新加载配置文件的命令,至此完成服务上线的过程。

当实例退出集群时,代表其服务注册信息的临时节点会被自动清除,此时 Nginx 服务器经过同样的刷新过程将该实例移除出代理目标,由此完成服务的自动伸缩。

在实际环境中启动服务之前,需要先将项目打包成为 Docker 镜像,利用 Docker 技术只要服务器上安装有 Docker 环境即可启动应用,很大程度上减少了重复修改服务本身配置文件的麻烦,因为业务模块的框架是基于 SpringCloud 的,在启动后利用 SpringBoot 自动配置的便利,可以在初始化时调用 ZooKeeper 服务器提供的接口将服务注册到 ZooKeeper 的服务注册中心。服务发现模块的执行流程图如下图所示。
X

其中,更新 Nginx 配置文件的主要代码如下所示。

折叠/展开代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 构建nginx.conf配置文件
String content = buildConf();
String localPath = dockerDiscoveryClient.getLocalNginxConfPath(); // 本地配置文件保存位置
String fileName = dockerDiscoveryClient.getLocalNginxConfFileName(); // 配置文件名
String containerPath = dockerDiscoveryClient.getContainerNginxConfPath(); // 容器内配置文件位置
writeConf(localPath + containerPath, fileName, content);
// 遍历所有nginx服务器,重载配置文件
for (Container container : nginxContainerList) {
for (long i = sdHelper.getMaxRetries() - 1; i >= 0; i--) {
try {
// 将配置文件上传到服务器
upload(localPath + containerPath, container.id(), containerPath);
// 重新加载nginx服务器
reload(container.id());
} catch (DockerDiscoveryException e) {
// 发生错误,重新上传加载
log.info("Reload Nginx failed, retrying left " + i + " times", e);
continue;
}
break;
}
}

在将服务实例信息转换为 Nginx 配置文件后,需要循环遍历所有现存的 Nginx 服务器,对服务器地址的请求若出错需要一定次数的重试,尽可能地更新。
客户端或其他模块通过 REST 协议来访问服务的接口,访问请求由网关服务器进行拦截,再通过负载均衡技术来发布到某个服务的实例上。

放款更新功能

X
考虑多用户登录系统并同时对同一实体执行更新操作时,就有可能发生竞态条件。如果使用普通的加锁方式只能保证同一进程内的线程同步、不能保证多个服务实例进程间的互斥访问,解决办法是引入 ZooKeeper 的分布式锁和时间戳机制,在每次修改实体后更新实体的版本,并在每次对实体 ID 加锁后再与数据库中保留的最后一次修改时的版本进行校验,加锁将使得临界区代码在集群内只能被一个服务实例的线程调用,在修改完毕退出后更新实体版本,另一个线程进入后再根据版本进行校验,从而保证了服务间的并发写的一致性。
时间戳的实现比较简单,而分布式锁的实现则涉及到很多细节,已知的包括:

  • 加锁操作的原子性、只有自己可以解锁自己、死锁、线程宕掉、耗时、失效时间、阻塞、可重入。

放款提交功能

发债模块在对单据进行提交操作时实际上需要调用审批服务提供的 RPC 接口,在提交后用户即可在审批模块中查找到这些单据,并可以对其进行审批或取消审批、驳回等操作。首先需要解释的是服务为何如此划分,需要结合企业的具体业务来考虑,因为审批是十分宽泛的场景,不只是发债单据需要审批,为了提高模块的可扩展性,将其划分出来作为单独的服务是合理的。放款提交操作的时序图如下图所示。
X
和其他增删改查操作相同的是,在具体的业务处理之前需要对放款进行加锁和版本校验,成功后发债服务会将该放款信息通过调用服务接口的方式提交到审批服务,审批服务会先根据放款的单据类型从数据库查找其审批流的注册信息及审批流,并成功便新增一条审批流实例到数据库,返回成功信息给发债服务,发债服务再根据返回值来修改放款的审批状态;如果查询或新增失败,审批会返回错误信息,使得发债服务也不会进行任何处理,或者处理后回滚到最初的状态。
发债模块并不了解审批模块内部的实现方式,二者使用相互独立的两个数据库,因此在执行审批操作时是可能发生不一致的情况的,为此需要引入 TCC 补偿机制,对提交操作需要提供一个 confirm 接口和一个 cancel 接口,代表统一的提交和回滚。
放款提交的具体执行过程如下图所示。
X
在登记放款页面点击提交按钮后会将放款单的 ID、版本号等信息发送到后台,程序的初步执行流程仍然是上分布式锁、校验版本号及设置新版本号和更新时间,接下来由于涉及到跨服务的写库操作所以会显得更加复杂。
首先服务调用方的发债服务需要生成本次调用的调用 ID,其作用之后再作论述,因为发债服务的本次调用是事务内的第一次调用,所以还需要生成一个链路 ID,用于标识一次事务,这些 ID 都需要全局唯一,所以和实体 ID 一样需要使用分布式唯一 ID 算法来生成。发债服务在调用审批服务的接口时,除了传递本来需要传递的单据信息之外,还需要将调用 ID 和链路 ID 打包一同传递,以便之后审批服务将 TCC 事务的参与信息上传到缓存中间件。事务的参与者分为根参与者和枝叶参与者,根参与者为事务的发起者,在这里即为发债服务,枝叶参与者包括事务通过传播到达的所有其余服务器,参与者包含的属性包括服务器本身的地址及 TCC 补偿事务所需的 confirm 接口和 cancel,只要某个服务接口需要添加事务属性,它就需要将自己的参与者信息上传到缓存中间件中,因此这里审批服务还有一个上传参与者信息的过程。实现 TCC 补偿事务中根参与者执行流程的关键代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private Object interceptRoot(Participator participator, MethodWrapper methodWrapper)
throws TccException {
Object result;
try {
// 执行try
log.info("-> ROOT.try执行");
result = methodWrapper.invoke();
} catch (Throwable cause) {
// 执行cancel
log.info("<- ROOT.try出错.即将执行cancel", cause);
tccCoordinateHelper.submitCancel(traceIdHelper.getTraceId());
throw new TccException("Method invocating failed. cause:" + cause.getMessage(), cause);
}
// 执行confirm,结束后会发送确认信息
log.info("<- ROOT.try完毕.即将执行confirm");
tccCoordinateHelper.submitConfirm(traceIdHelper.getTraceId());
return result;
}

在前面的准备工作完成后,审批服务正式进入业务处理流程,首先需要根据登记放款的单据类型去数据库中查找审批流注册信息,因为审批存在多级审批的场景,因此每条审批流关联了自己下一阶段的审批流,这里为了创建审批流实例需要找到源头第一条审批流,并将发债传递来的单据信息填入即可得到审批流实例。
保存审批流实例时不能直接保存到实体本身代表的数据库表中,而是要暂时保存到一个 dr 表中暂存,表名为“实体名_dr”,因为此时事务并没有完成,如果直接保存到实体表中,直接去审批流页面查询是能查出的,也就是说会发生“脏读”的现象,暂时保存到这个暂存表中,还可以作为日志,供系统发生故障时手动恢复之用。审批服务执行完毕之后,发债服务需要使用其返还值来更新发债数据库中的放款状态。但是事务还未完成,因为审批服务还没有将审批实例实际保存到实体表中,发债服务需要先使用链路 ID 从缓存中间件中获取所有事物参与者信息,然后根据提交操作是否成功来决定是调用 confirm 还是 cancel,这里 confirm 的逻辑是将 dr 表中的审批流实例转移到实体表中,而 cancel 是将该审批流实例从 dr 表中移除、发债服务回滚。
为了应对偶尔的网络不稳定等情况,根参与者需要在异常发生时再重试一定的次数,另外,试想如果请求的超时也被当成故障处理了,那么多次的写操作很有可能会为系统引入脏数据了,因此 confirm 和 cancel 接口需要额外地保证接口的幂等性,为了校验接口是否被多次调用,需要客户端在发出请求时带上此次请求的调用 ID,服务端需要缓存调用 ID 并对请求的重复性进行校验。
最后发债服务释放放款 ID 上的锁,完成一次提交操作。其他审批接口、结算管理的相应接口实现思路是类似的,在此不另加论述。

总而言之,提交操作是需要跨服务写库的,除了在修改操作的加锁和版本校验之外,还需要注意TCC 的执行流程

  1. TCC 事务需要每个参与者提供 try、confirm 和 cancel 三个接口,try 执行资源的预留,比如对涉及的实体加锁,也起着服务验活的功能,confirm 执行事务的提交,也就是真正地执行业务操作、将数据持久化到数据库,cancel 需要释放预留的资源;
  2. 每个事务参与者将自己的信息提交到 Redis 缓存内,之后根参与者需要取得所有参与者信息,主要是它们的 confirm 和 cancel 接口的地址;
  3. 根据 confirm 执行情况,有:
    1. 如果业务操作都成功了,由第个参与者来调用所有其他参与者的 confirm 接口来提交事务;
    2. 如果业务操作失败了,同样由第一个参与者来调用所有其他参与者的 cancel 接口来回滚事务。

调用 ID 和链路 ID 的作用,前者可以用于保证接口调用的幂等性,后者主要用来定位事务内的参与者。

用一个常见的用户购买商品场景为例,需要先确保用户能够支付,然后锁住用户和商户的账户,这是 try 阶段;如果 try 成功,即预留资源成功了,接下来再对用户账户扣款、商户账户入账,如果因为超时等原因失败了,需要事务补偿,即重试,这是 confirm 阶段;如果 try 失败,则释放所有的锁,这是 cancel 阶段。



改进思路

当前的系统仍存在很多可改进的地方。

服务发现功能

使用 Eureka 等高可用服务发现产品替代 ZooKeeper

ZooKeeper 集群通过 Zab 协议保证高一致性,重新选举 Leader 比较耗时,且当节点数量不足以构成容错集群时,ZooKeeper 倾向于返回空;故提出改用 Eureka 等其他倾向于提供高可用性的具有服务发现功能的产品。

使用复制和 DNS 等手段工具提升 Nginx 服务器的可用性

Nginx 服务器作为网关,存在单点故障风险,但是这里的服务发现只能保证被 Nginx 代理的那些服务能弹性伸缩,对 Nginx 本身却没有什么办法,就算复制了 Nginx 服务器,客户端也只能发到一个 IP 上。这里提出使用基于操作系统的方案对 Nginx 集群实现验活及负载均衡,主要是 Keepalived 等软件或独立的 DNS 服务器,但是论文题目限于 Saas 层展开,本人水平有限没法解释。

服务间通信功能(RPC)

使用 TCP 等更底层、灵活的协议

HTTP 是应用层的,Spring Boot 封装了一个 RestTemplate 可以很方便地发 HTTP 请求。而 TCP 是传输层的,如何包装消息需要另外再讨论,常见的框架如 Netty,提供了长连接、心跳检测等功能,可以提高开发效率。

使用 Kryo 等更完善的序列化工具

Kryo 是一个优秀的 Java 序列化方案,大概用了很多优化方案,这就是很零碎的编程问题了。
TCC 的 confir 和 cance 都有可能出错,一般都需要手动恢复,异常日志指的是 redo 和 undo 日志,redo 是未执行前的数据值,undo 是执行修改后的数据值。

服务间一致性(TCC 事务)

考虑 TCC 事务发生异常的情况,做好 redo/undo 日志,供出错时手动执行恢复。

参考

  1. What is the difference between dependency injection and dependency look up?

背景情况

部门开始推一个大项目 NCC,后台几乎沿用原 NC(有近十年历史),将原来 JavaSwing 画的重量前端换成了一种“轻量前端”,其实这种轻量前端一点都不轻量;SQL 完全是自己用字符串拼接起来的;开发模式是前后端分离联调的方式,但是因为后台代码都是部署在一个服务里头的,没有司库云(微服务架构)复杂。

从增删改查流程说起

对 Controller、Service 分层的讨论放在后面。

save

保存操作有以下特点。

  1. 批量
    对普通的 CRUD 操作,最大的开销是网络传输,NCC 系统中操作的往往是多个表关联在一起的复杂结构,如果分成多次请求,一方面对用户体验不好,一方面对效率也有所影响。因此 NCC 中的做法是将同类数据放到同一页签,转换为 json 格式的数据,如下所示:
    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
    {
    "pageid":"36650BIS_CARD",
    "head":{
    "head":{
    "areaType":"form",
    "rows":[
    {
    "values":{
    "pk_bondissueregister":{
    "display":null,
    "value":null,
    "scale":"-1"
    }
    ...其他属性
    },
    "status":"2"
    }
    ],
    "areacode":"head"
    }
    },
    "bodys":{
    "repaymentplan":{
    "rows":[
    {
    "rowid":"136270.438b34a6204411a",
    "status":"2",
    "values":{
    "pk_bondrepaymentplan_b":{
    "display":null,
    "scale":null,
    "value":""
    }
    ...其他属性
    }
    }
    ],
    "areaType":"table",
    "areacode":"repaymentplan"
    }
    }
    }
  2. 差异化
    新增和更新操作皆为调用 save 接口,通过区别传递的数据中是否有 id 来进行区分。另外,更新操作会先从数据库中查询出旧的数据,并和客户端传参进行比较,若完全一致则直接跳过此次更新。

delete

执行流程和 save 几乎一样,只是系统中不会真的删除一条数据,而是采用“逻辑删除”的方案。

query

查询操作的过滤条件是由前台给出的,我没有经历过重客户端时代 NC 的开发,但是在 Web 应用中也这么做有点奇怪。

page

分页查询的思路比较奇怪,是先按前端传递的过滤条件查出所有合适记录的 pk,然后在 Controller 层对这些 pk 进行分页:

1
2
3
4
5
6
7
8
// ids指的是查出的所有合适记录的pk
// 本页之后的数据数量
int afterCurPageSize = ids.length - pageInfo.getPageIndex() * pageInfo.getPageSize() + 1;
int targetSize = pageInfo.getPageSize() < afterCurPageSize ? pageInfo.getPageSize() : afterCurPageSize;
String[] targetIds = new String[targetSize];
System.arraycopy(ids, pageInfo.getPageIndex() * pageInfo.getPageSize(),
targetIds, 0,
targetSize);

最后再调用一个批量查询的 Service 接口进行查询。

技术点

轻量前端、分层

轻量前端指的是用 React 开发的浏览器客户端,既然是轻量前端,那么做的事情肯定不会太多了,那么很多工作就必须要转移到后端了,因此在 NCC 中又引入了一个 Action 层,作为 Controller。

读写分离

后台 Service 层将读操作和写操作划分到了两个接口内,实际上应用层可以根据读写进行复制,一般来说读操作更频繁,可以多创建几个实例提供服务,并在前头使用负载均衡器进行反向代理。

SOA

SOA 是一种架构风格,与微服务的主要区别是 SOA 多了一个 ESB(企业服务总线),在 SOA 中,当一个服务需要调用另一个服务时,它需要先从 ESB 中获取那个服务的调用方法、消息格式等,而微服务可以看作一种轻量化的 SOA,它没有集中的一个 ESB,服务之间通过公开的 RPC 接口来相互调用。
实际上 NC 没有 ESB 这玩意。首先需要将 Service 接口及其对应的实现类注册到一个 xml 文件内,服务间调用通过 NCLocator.find(class)实现:如果配置文件中没有设置服务器 URL,则直接本地反射实例化 Service 的实现类;如果配置文件中设置了服务器 URL,则使用 JDK Proxy 做动态代理,将本地方法调用转换为远程调用。

元数据

元数据是描述数据的数据,实际开发业务前需要先使用相关工具”画”出元数据,NCC 的开发极大地依赖于元数据的正确性,老的功能节点的元数据若轻易修改,很有可能导致原功能出错。

模板

模板是对页面的抽象,平台的有关部门做了一个画模板的工具,由后端人员登录到工作桌面画模板,可以减轻前端开发人员的工作压力(可能是领导觉得前端比较稀缺?)。可以从元数据导出模板,并根据原型图进行适配。
随着业务的复杂化,将许多公共组件抽象出来集中管理是必须的,尤其是 NC 这么复杂的庞然大物,其业务拿出两个小时来也没办法讲完 1%,有点夸张,但是事实是这里的需求很多都是从会计等行业转来的,一次给我们讲需求时从 NC 上满屏的标签中挑了部分讲了两小时,大家都是崩溃的…因此 NC 中引入了三大模板的概念,它们分别是:查询模板、打印模板、计算模板,分别对查询 sql、打印、???进行了抽象。

依赖查找

Spring 的核心原理是 IoC,而 IoC 的主要实现方式是 DI 或依赖查找。DI 我们比较眼熟,主要有”属性注入”、”构造器注入”及”方法注入”。NCC 后台为 NC,已经有 10 来年历史,其中实现了一套依赖查找的模式,如下代码所示。

1
IXxxService xxxService = ServiceLocator.find(IXxxService.class);

当然接口和实现类之间不是自动关联在一起的,还需要定义一个 upm 文件来配置他们之间的关联关系。
这实际上和比较原始的 Spring 用法有点像,如下代码所示。

1
XxxBean bean = application.getBean(XxxBean.class);

内存锁

排他锁

就是普通的锁。

1
2
3
4
5
6
7
8
9
10
11
boolean isOK=false;
try{
isOK = PKLock.getInstance().acquireBatchLock(new String[]{ PK }, userid);
if(!isOK)
throw new BusinessException("并发操作");
// TODO: 业务逻辑
} finally {
if(isOK){
PKLock.getInstance().releaseBatchLock (new String[]{ PK }, userid);
}
}

共享锁

共享锁适用于读者写者的情景,即某类操作可以并存、而和其他类操作不能的情况。
使用方法和排他锁差不多:

1
2
PKLock.getInstance().acquireBatchLock(
new String[]{ PK + IPKLockBS.STR_SHARED_LOCK }, userid );

对同一 PK 来说,共享锁和排他锁是不能共存的。

动态锁

动态锁为一种特殊的锁,进行加锁的人员不用自己释放,其调用序列一般为:
pklock.acquireLock… pklock.addDynamicLock…
如果是远程调用,远程调用结束后系统会自动释放所有的动态锁
动态锁其实是对前面两种锁的封装,不过动态锁不需要主动解锁,中间件会在后台事务结束前进行统一解锁;并且在同一次中间件事务中,可以重复申请相同 PK 的锁

1
boolean isOK = PKLock.getInstance().addDynamicLock( PK ); 

动态锁的实现原理是事件机制,动态锁最后会发出一个解锁事件,业务操作执行结束后会触发这个事件。

时间戳校验

有了锁机制可以保证并发操作不会破坏数据一致性,但是不能保证用户的操作是对最新版本的数据进行的,因此需要引入时间戳机制。
主要通过比较当前对象的ts 值(时间戳)与数据库中该对象的 TS 值来判断用户修改的是否为最新版本的数据。在每次对数据进行修改操作时,都需要更新相应数据的时间戳标志 ts,这个一般由系统维护,不需要在业务中特殊处理。

动态代理

上面提到了动态锁机制但是没有具体说解锁的时机,假设所有含写操作的业务操作都需要加锁,难道要把加锁解锁逻辑都写一遍?NC 中为了解决这个问题也实现了一套 AOP 机制,原理是 JDK Proxy,NC 会为所有在配置文件中注册的 Service 创建动态代理,在动态代理中可以对锁等公共功能进行操作,从而大大简化业务代码。在这方面来讲,NC 也可以看作 Spring 的弱化版。

跨组件调用

NCC 后台是传统的单体式应用,所有模块几乎都放在一个服务里(用户买什么就把什么包含进来),跨模块的组件之间可能存在相互引用的关系,比如 a 需要将某些信息存入 b 中,就需要调用 b 提供的接口,而 b 在某些情况下也需要从 a 中获取某些信息,但 b 所在的 B 模块是一个更通用的组件,它不会为每个下游业务都新增代码。在 NCC 中,面对这种情况一般是由 a 来提供一个回调接口,将这个接口名保存到数据库中,然后告诉 b:”这就是 a 的接口啦”,类似于 RPC,不过将耦合的部分转移到了数据库中。

逻辑删除

数据库里的数据不会被直接删除,而是采取逻辑删除的策略,每条数据都有一个额外的dr(delete remark)字段用于标识一条数据是否被删除,查询时需要根据这个字段进行过滤。

Oracle

采用 Oracle 作为数据库,一是因为客户主要是大企业,安全、稳定有更高要求;二,数据量也确实不大,可能也用不着进行分库分表;三,作为延续了十几年的系统,当时“去 IOE 化”还没有提出,Oracle 仍是数据库领域最好的选择;最后,业务十分复杂,且底层 ORM 代码中存在大量直接使用字符串拼接出来的 SQL,想要完全移植到 MySQL 等数据库是不大现实的。

总结

以上我对最近工作中涉及到的东西进行了简单总结,嗯…产品底层仍有很多我未知的部分,一些与业务关联较大的代码也不适合拿出来分享,于是暂时告一段落吧。

0%