成功换了一个后端的工作,开始的第一个任务就是熟悉下分布式锁和分布式事务的实现方案,然后写一份调研报告,这里简单记录下.
1. 分布式锁/分布式事务概念
- 分布式锁 只要的应用场景是在集群模式的多个相同服务,可能会部署在不同机器上,解决进程间安全问题,防止多进程同时操作一个变量或者数据库.解决的是多进程的并发问题.
- 分布式事务 解决一个联动操作,比如一个商品的买卖分为:
- 添加商品到购物车
- 修改商品库存减1 此时购物车服务和商品库存服务可能部署在两台机器,这时候需要保证对两个服务的操作都全部成功或者全部回退.解决的是组合服务的数据操作的一致性问题.
By the way:node.js 分布式事务并没有很成熟的框架(java 可以使用 Seata),推荐办法是 design first. 把需要事务的微服务聚合成一个单机服务,使用数据库的本地事务.因为不论任何一种分布式事务方案都会极大的增加系统的复杂度,这样的成本实在是太高了,不要因为追求某些设计,而引入不必要的成本和复杂度.
2. 我们当前面临的问题
因为目前工作是做一个交易平台,所以老板给出了一个很具体很典型的问题就是: 处于两个节点的服务在操作同一条数据,比如多个节点机器对同一个用户的余额这条数据操作不同的流程(转账和取钱),就有可能会后者的数据覆盖前者写入的数据,造成错误.
3. 解决方案
上面我们面临的问题很明显是一个多进程间的并发问题,所以我们需要加上分布式锁.下面是常见的分布式锁的实现方案
- DB lock
- Share Lock 共享锁,正在写入数据但是允许读取该数据
- Exclusive Lock 排他锁(悲观锁),独占该数据,不允许读写(issue: 如果该select 语句添加了where 条件的话 会锁住一堆数据,如果表是自增id,where 条件是 id 大于某值 会导致插入操作失败.而且存在死锁问题)
- Versioning Optimistic Lock 版本锁(乐观锁), 任意进程可读写数据,但是只允许修改自己读取到的版本的数据,如果当前数据库中版本和读取的不一样那么我们就进行 redo (issue: 业务代码相对复杂)
- 基于 Zookeeper 实现
在 Zookeeper 中创建一个节点 locknode,当 client 请求来了之后,在locknode 下创建一个临时节点(序列递增).然后判断自己的节点在所有子节点中是不是最小编号的节点.如果是则获取锁,如果不是则等待并监听前一个节点状态.当 client 需要释放锁的时候,删除自己创建的节点.此时后一个节点发现上一个节点已经被删除了,那么就在次判断自己是否是最小编号的节点.
但是其实基于 Zookeeper 实现的分布式锁也是存在安全问题的,因为 Zookeeper 的临时节点依靠和 Client 建立的 session 定时发送心跳信号来维持的,如果客户端没有发送心跳信号,那么 Zookeeper 会自动删除该节点,而长时间的 GC 或者网络延迟就会导致这个问题的发生,从而使得下一个 Client 获取锁,最终导致错误的发生.
- 基于 Redis 实现 (最常用的方法)
在 redis 中添加一条记录作为锁,创建这个记录的 client 才能访问资源, 创建失败的就进入等待在一段时间后再去请求.解锁的话需要运行 lua 脚本解锁.
Redlock 算法解释
- Client 获取当前时间(ms级别的性能)
- Client 对集群内所有的 redis 进行顺序的上锁操作
- 如果半数以上的 redis 节点上锁成功并且 上锁花费的时间小于锁的过期时间则上锁成功
- 为了确保所有节点的过期时间一致,每个节点锁的实际有效时间是 设置的有效时间减去上锁的操作花费的时间
- 如果上锁失败了,所有节点都解锁
Redlock 算法存在的问题,下面三个情况都会导致多个 client 获取到同一把锁
- 由于系统时间服务器引发的时间跳越问题(这个是处于可控范围)
- Client1 拿到了锁,然后 client1 由于GC挂起,在这期间锁过期,然后 client2 拿到了锁.然后 client1 GC 完毕修改了数据,最后 client2 也修改了数据.最终导致 client1 的操作被覆盖.
- 网络延迟问题,C 这个 redis 节点等锁过期了才返回ok 给 client1,在这之前 client2已经拿到了锁.
Redlock 算法问题的解决办法. Redis 返回一个 lock的版本号 token 类似于DB 版本号乐观锁
4. 方案总结
分布式锁 | 优点 | 缺点 |
---|---|---|
数据库锁(乐观锁) | 不需要维护额外的第三方中间件,性能较好 | 实现起来较为繁琐,业务代码复杂.对比缓存来说性能较低.对于高并发的场景并不是很适合 |
Zookeeper | 可以不需要关心锁超时时间,获取锁会按照加锁的顺序,所以其是公平锁. | 对于高可用需要利用集群来保证,需要额外维护,增加维护成本,性能相对数据库锁更差点 |
Redis | 性能最好,有 node.js 可用的 redlock 第三方库 github.com/mike-marcacci/node-redlock | 对于高可用需要利用集群来保证,需要额外维护,增加维护成本, 存在上文列出的问题,而且还需要考虑锁超时问题. |
5. 我的理解
基于一些中间件来实现分布式锁的方案在极端情况都是不可靠的,没有什么解决方案是在各方面都是最优的.具体还是得看我们的业务场景来选择合适的解决方案,如果我们的业务必须100%的可靠,那只能牺牲下便捷性来选择基于数据库的乐观锁的方案.相反如果我们的业务不是特别敏感,那么可以考虑使用基于 Redis 的锁,因为我最终还是选择了基于 Redis 来实现分布式锁,我的实现方案可以看这篇blog
5. Ref
6. 代码附录
1.单 Redis 实例分布式锁实现
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
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
const Redis = require("ioredis");
const redis = new Redis(6379, "127.0.0.1");
const uuidv1 = require('uuid/v1');
function sleep(time) {
return new Promise((resolve) => {
setTimeout(function() {
resolve();
}, time || 1000);
});
}
class RedisLock {
/**
* 初始化 RedisLock
* @param {*} client
* @param {*} options
*/
constructor (client, options={}) {
if (!client) {
throw new Error('client 不存在');
}
if (client.status !== 'connecting') {
throw new Error('client 未正常链接');
}
this.lockLeaseTime = options.lockLeaseTime || 2; // 默认锁过期时间 2 秒
this.lockTimeout = options.lockTimeout || 5; // 默认锁超时时间 5 秒
this.expiryMode = options.expiryMode || 'EX';
this.setMode = options.setMode || 'NX';
this.client = client;
}
/**
* 上锁
* @param {*} key
* @param {*} val
* @param {*} expire
*/
async lock(key, val, expire) {
const start = Date.now();
const self = this;
return (async function intranetLock() {
try {
const result = await self.client.set(key, val, self.expiryMode, expire || self.lockLeaseTime, self.setMode);
// 上锁成功
if (result === 'OK') {
console.log(`${key} ${val} 上锁成功`);
return true;
}
// 锁超时
if (Math.floor((Date.now() - start) / 1000) > self.lockTimeout) {
console.log(`${key} ${val} 上锁重试超时结束`);
return false;
}
// 循环等待重试
console.log(`${key} ${val} 等待重试`);
await sleep(3000);
console.log(`${key} ${val} 开始重试`);
return intranetLock();
} catch(err) {
throw new Error(err);
}
})();
}
/**
* 释放锁
* @param {*} key
* @param {*} val
*/
async unLock(key, val) {
const self = this;
const script = "if redis.call('get',KEYS[1]) == ARGV[1] then" +
" return redis.call('del',KEYS[1]) " +
"else" +
" return 0 " +
"end";
try {
const result = await self.client.eval(script, 1, key, val);
if (result === 1) {
return true;
}
return false;
} catch(err) {
throw new Error(err);
}
}
}
const redisLock = new RedisLock(redis);
async function test(key) {
try {
const id = uuidv1();
await redisLock.lock(key, id, 20);
await sleep(3000);
const unLock = await redisLock.unLock(key, id);
console.log('unLock: ', key, id, unLock);
} catch (err) {
console.log('上锁失败', err);
}
}
test('name1');
test('name1');
2.Redis 集群分布式锁实现
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
const Redis = require("ioredis");
const client1 = new Redis(6379, "127.0.0.1");
const Redlock = require('redlock');
// 多个 Redis 实例
const redlock = new Redlock(
[new Redis(6379, "127.0.0.1"), new Redis(6379, "127.0.0.2"), new Redis(6379, "127.0.0.3")],
)
async function test(key, ttl, client) {
try {
const lock = await redlock.lock(key, ttl);
console.log(client, lock.value);
// do something ...
// return lock.unlock();
} catch(err) {
console.error(client, err);
}
}
test('name1', 10000, 'client1');
test('name1', 10000, 'client2');