Posts 分布式锁/分布式事务的研究报告
Post
Cancel

分布式锁/分布式事务的研究报告

  成功换了一个后端的工作,开始的第一个任务就是熟悉下分布式锁和分布式事务的实现方案,然后写一份调研报告,这里简单记录下.

1. 分布式锁/分布式事务概念

  1. 分布式锁 只要的应用场景是在集群模式的多个相同服务,可能会部署在不同机器上,解决进程间安全问题,防止多进程同时操作一个变量或者数据库.解决的是多进程的并发问题.
  2. 分布式事务 解决一个联动操作,比如一个商品的买卖分为:
    • 添加商品到购物车
    • 修改商品库存减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: 业务代码相对复杂)
DB lock
  • 基于 Zookeeper 实现

  在 Zookeeper 中创建一个节点 locknode,当 client 请求来了之后,在locknode 下创建一个临时节点(序列递增).然后判断自己的节点在所有子节点中是不是最小编号的节点.如果是则获取锁,如果不是则等待并监听前一个节点状态.当 client 需要释放锁的时候,删除自己创建的节点.此时后一个节点发现上一个节点已经被删除了,那么就在次判断自己是否是最小编号的节点.

  但是其实基于 Zookeeper 实现的分布式锁也是存在安全问题的,因为 Zookeeper 的临时节点依靠和 Client 建立的 session 定时发送心跳信号来维持的,如果客户端没有发送心跳信号,那么 Zookeeper 会自动删除该节点,而长时间的 GC 或者网络延迟就会导致这个问题的发生,从而使得下一个 Client 获取锁,最终导致错误的发生.

基于 Zookeeper 实现
  • 基于 Redis 实现 (最常用的方法)

   在 redis 中添加一条记录作为锁,创建这个记录的 client 才能访问资源, 创建失败的就进入等待在一段时间后再去请求.解锁的话需要运行 lua 脚本解锁.

Redlock 算法解释

  • Client 获取当前时间(ms级别的性能)
  • Client 对集群内所有的 redis 进行顺序的上锁操作
  • 如果半数以上的 redis 节点上锁成功并且 上锁花费的时间小于锁的过期时间则上锁成功
  • 为了确保所有节点的过期时间一致,每个节点锁的实际有效时间是 设置的有效时间减去上锁的操作花费的时间
  • 如果上锁失败了,所有节点都解锁
基于 Redis 实现

Redlock 算法存在的问题,下面三个情况都会导致多个 client 获取到同一把锁

  • 由于系统时间服务器引发的时间跳越问题(这个是处于可控范围)
  • Client1 拿到了锁,然后 client1 由于GC挂起,在这期间锁过期,然后 client2 拿到了锁.然后 client1 GC 完毕修改了数据,最后 client2 也修改了数据.最终导致 client1 的操作被覆盖.
  • 网络延迟问题,C 这个 redis 节点等锁过期了才返回ok 给 client1,在这之前 client2已经拿到了锁.
Redlock 存在的问题

Redlock 算法问题的解决办法. Redis 返回一个 lock的版本号 token 类似于DB 版本号乐观锁

Redlock 问题解决办法

4. 方案总结

分布式锁优点缺点
数据库锁(乐观锁)不需要维护额外的第三方中间件,性能较好实现起来较为繁琐,业务代码复杂.对比缓存来说性能较低.对于高并发的场景并不是很适合
Zookeeper可以不需要关心锁超时时间,获取锁会按照加锁的顺序,所以其是公平锁.对于高可用需要利用集群来保证,需要额外维护,增加维护成本,性能相对数据库锁更差点
Redis性能最好,有 node.js 可用的 redlock 第三方库 github.com/mike-marcacci/node-redlock对于高可用需要利用集群来保证,需要额外维护,增加维护成本, 存在上文列出的问题,而且还需要考虑锁超时问题.

5. 我的理解

  基于一些中间件来实现分布式锁的方案在极端情况都是不可靠的,没有什么解决方案是在各方面都是最优的.具体还是得看我们的业务场景来选择合适的解决方案,如果我们的业务必须100%的可靠,那只能牺牲下便捷性来选择基于数据库的乐观锁的方案.相反如果我们的业务不是特别敏感,那么可以考虑使用基于 Redis 的锁,因为我最终还是选择了基于 Redis 来实现分布式锁,我的实现方案可以看这篇blog

5. Ref

  1. 技术分享:Distributed Lock Manager (陈皓)
  2. GitHub文章
  3. 个人博客文章
  4. 知乎文章

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');

This post is licensed under CC BY 4.0 by the author.

使用 Trojan 进行科学上网

Javascript 设计模式与开发实践总结