浅析项目中的并发(二)

分布式遭遇并发

在前面的章节,并发操作要么发生在单个应用内,一般使用基于JVM的lock解决并发问题,要么发生在数据库,可以考虑使用数据库层面的锁,而在分布式场景下,需要保证多个应用实例都能够执行同步代码,则需要做一些额外的工作,一个最典型分布式同步方案便是使用分布式锁。

分布式锁由很多种实现,但本质上都是类似的,即依赖于共享组件实现锁的询问和获取,如果说单体式应用中的Monitor是由JVM提供的,那么分布式下Monitor便是由共享组件提供,而典型的共享组件大家其实并不陌生,包括但不限于:Mysql,Redis,Zookeeper。同时他们也代表了三种类型的共享组件:数据库,缓存,分布式协调组件。基于Consul的分布式锁,其实和基于Zookeeper的分布式锁大同小异,都是借助于分布式协调组件实现锁,大而化之,这三种类型的分布式锁,原理也都差不多,只不过,锁的特性和实现细节有所差异。

Redis实现分布式锁

定义需求:A应用需要完成添加库存的操作,部署了A1,A2,A3多个实例,实例之间的操作要保证同步。

分析需求:显然,此时依赖于JVM的lock已经没办法解决问题了,A1添加锁,无法保证A2,A3的同步,这种场景可以考虑使用分布式锁应对。

建立一张Stock表,包含id,number两个字段,分别让A1,A2,A3并发对其操作,保证线程安全。

@Entity
public class Stock {
    @Id
    private String id;
    private Integer number;
}

定义数据库访问层:

public interface StockRepository extends JpaRepository<Stock,String> {
}

这一节的主角,redis分布式锁,使用开源的redis分布式锁实现:Redisson。

引入Redisson依赖:

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.5.4</version>
</dependency>

定义测试类:

@RestController
public class StockController {

    @Autowired
    StockRepository stockRepository;

    ExecutorService executorService = Executors.newFixedThreadPool(10);

    @Autowired
    RedissonClient redissonClient;

    final static String id = "1";

    @RequestMapping("/addStock")
    public void addStock() {
        RLock lock = redissonClient.getLock("redisson:lock:stock:" + id);
        for (int i = 0; i < 100; i++) {
            executorService.execute(() -> {
                lock.lock();
                try {
                    Stock stock = stockRepository.findOne(id);
                    stock.setNumber(stock.getNumber() + 1);
                    stockRepository.save(stock);
                } finally {
                    lock.unlock();
                }
            });
        }
    }

}

上述的代码使得并发发生在多个层面。其一,在应用内部,启用线程池完成库存的加1操作,本身便是线程不安全的,其二,在多个应用之间,这样的加1操作更加是不受约束的。若初始化id为1的Stock数量为0。分别在本地启用A1(8080),A2(8081),A3(8082)三个应用,同时并发执行一次addStock(),若线程安全,必然可以使得数据库中的Stock为300,这便是我们的检测依据。

简单解读下上述的代码,使用redisson获取一把RLock,RLock是 java.util.concurrent.locks.Lock接口的实现类,Redisson帮助我们屏蔽Redis分布式锁的实现细节,使用过 java.util.concurrent.locks.Lock的朋友都会知道下述的代码可以被称得上是同步的起手范式,毕竟这是Lock的java doc中给出的代码:

Lock l = ...;
l.lock();
try {
   // access the resource protected by this lock
} finally {
  l.unlock();
}

redissonClient.getLock("redisson:lock:stock:"+id)则是以 "redisson:lock:stock:"+id该字符串作痛同步的Monitor,保证了不同id之间是互相不阻塞的。

为了保证发生并发,实际测试中我加入了Thread.sleep(1000),使竞争得以发生。测试结果:

Redis分布式锁的确起了作用。

锁的注意点

如果仅仅是实现一个能够用于demo的Redis分布式锁并不难,但为何大家更偏向于使用开源的实现呢?主要还是可用性和稳定性,we make things work是我在写博客,写代码时牢记在脑海中的,如果真的要细究如何自己实现一个分布式锁,或者平时使用锁保证并发,需要有哪些注意点呢?列举几点:阻塞,超时时间,可重入,可用性,其他特性。

阻塞

意味着各个操作之间的等待,A1正在执行增加库存时,A1其他的线程被阻塞,A2,A3中所有的线程被阻塞,在Redis中可以使用轮询策略以及redis底层提供的CAS原语(如setnx)来实现。(初学者可以理解为:在redis中设置一个key,想要执行lock代码时先询问是否有该key,如果有则代表其他线程在执行过程中,若没有,则设置该key,并且执行代码,执行完毕,释放key,而setnx保证操作的原子性)

超时时间

在特殊情况,可能会导致锁无法被释放,如死锁,死循环等等意料之外的情况,锁超时时间的设置是有必要的,一个很直观的想法是给key设置过期时间即可。

如在Redisson中,lock提供了一个重载方法 lock(longt,TimeUnittimeUnit);可以自定义过期时间。

可重入

这个特性很容易被忽视,可重入其实并不难理解,顾名思义,一个方法在调用过程中是否可以被再次调用。实现可重入需要满足三个特性:

  1. 可以在执行的过程中可以被打断;
  2. 被打断之后,在该函数一次调用执行完之前,可以再次被调用(或进入,reentered)。
  3. 再次调用执行完之后,被打断的上次调用可以继续恢复执行,并正确执行。

比如下述的代码引用了全局变量,便是不可重入的:

int t;

void swap(int x, int y) {
    t = x;
    x = y;
    y = t;
    System.out.println("x is" + x + " y is " + y);
}

一个更加直观的例子便是,同一个线程中,某个方法的递归调用不应该被阻塞,所以如果要实现这个特性,简单的使用某个key作为Monitor是欠妥的,可以加入线程编号,来保证可重入。

使用可重入分布式锁的来测试计算斐波那契数列(只是为了验证可重入性):

@RequestMapping("testReentrant")
    public void ReentrantLock() {
        RLock lock = redissonClient.getLock("fibonacci");
        lock.lock();
        try {
            int result = fibonacci(10);
            System.out.println(result);
        } finally {
            lock.unlock();
        }
    }

    int fibonacci(int n) {
        RLock lock = redissonClient.getLock("fibonacci");
        try {
            if (n <= 1) return n;
            else
                return fibonacci(n - 1) + fibonacci(n - 2);
        } finally {
            lock.unlock();
        }
    }

最终输出:55,可以发现,只要是在同一线程之内,无论是递归调用还是外部加锁(同一把锁),都不会造成死锁。

可用性

借助于第三方中间件实现的分布式锁,都有这个问题,中间件挂了,会导致锁不可用,所以需要保证锁的高可用,这就需要保证中间件的可用性,如redis可以使用哨兵+集群,保证了中间件的可用性,便保证了锁的可用性、

其他特性

除了可重入锁,锁的分类还有很多,在分布式下也同样可以实现,包括但不限于:公平锁,联锁,信号量,读写锁。Redisson也都提供了相关的实现类,其他的特性如并发容器等可以参考官方文档。

新手遭遇并发

基本算是把项目中遇到的并发过了一遍了,案例其实很多,再简单罗列下一些新手可能会遇到的问题。

使用了线程安全的容器就是线程安全了吗?很多新手误以为使用了并发容器如:concurrentHashMap就万事大吉了,却不知道,一知半解的隐患可能比全然不懂更大。来看下面的代码:

public class ConcurrentHashMapTest {

    static Map<String, Integer> counter = new ConcurrentHashMap();

    public static void main(String[] args) throws InterruptedException {
        counter.put("stock1", 0);
        ExecutorService executorService = Executors.newFixedThreadPool(10);
        CountDownLatch countDownLatch = new CountDownLatch(100);
        for (int i = 0; i < 100; i++) {
            executorService.execute(new Runnable() {
                @Override
                public void run() {
                    counter.put("stock1", counter.get("stock1") + 1);
                    countDownLatch.countDown();
                }
            });
        }
        countDownLatch.await();
        System.out.println("result is " + counter.get("stock1"));
    }
}

counter.put("stock1",counter.get("stock1")+1)并不是原子操作,并发容器保证的是单步操作的线程安全特性,这一点往往初级程序员特别容易忽视。

总结

项目中的并发场景是非常多的,而根据场景不同,同一个场景下的业务需求不同,以及数据量,访问量的不同,都会影响到锁的使用,架构中经常被提到的一句话是:业务决定架构,放到并发中也同样适用:业务决定控制并发的手段,如本文未涉及的队列的使用,本质上是化并发为串行,也解决了并发问题,都是控制的手段。了解锁的使用很简单,但如果使用,在什么场景下使用什么样的锁,这才是价值所在。

同一个线程之间的递归调用不应该被阻塞,所以如果要实现这个特性,简单的使用某个key作为Monitor是欠妥的,可以加入线程编号,来保证可重入。

原文发布于微信公众号 - Kirito的技术分享(cnkirito)

原文发表时间:2017-10-15

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏FreeBuf

利用Winrm.vbs绕过白名单限制执行任意代码

winrm.vbs(一个位于system32目录下的具有Windows签名的脚本文件)可以被用来调用用户定义的XSL文件,从而导致任意的、没有签名的代码执行。当...

2124
来自专栏数据库

替代SE16N的超强悍SE16H

文 | 大话SAP 又到了天气转冷,懒得出门,窝在家里学习新知识的季节。 也许你早就对SE11/SE16/SE16N/SQVI等T-code熟得不能再熟,不过,...

33010
来自专栏Kirito的技术分享

从Spring Session源码看Session机制的实现细节

去年我曾经写过几篇和 Spring Session 相关的文章,从一个未接触过 Spring Session 的初学者视角介绍了 Spring Session ...

63112
来自专栏智能大石头

ObjectDataSource选择业务对象列表为空的探讨

前天晚上,在一个页面上拖了一个ObjectDataSource,配置数据源时发现选择业务对象的列表没有列出当前项目的实体类,甚至连NewLife.CommonE...

1877
来自专栏java达人

众里寻她千百度,蓦然回首,那bug却在灯火阑珊处

今天发现consul上的A服务处于failed状态,幸运的是服务部署了两份,以预防单点故障,做负载均衡,连忙查看http://ip:port/health输出,...

2319
来自专栏安富莱嵌入式技术分享

【RL-TCPnet网络教程】第30章 RL-TCPnet之SNTP网络时间获取

本章节为大家讲解RL-TCPnet的SNTP应用,学习本章节前,务必要优先学习第29章的NTP基础知识。有了这些基础知识之后,再搞本章节会有事半功倍的效果。

1122
来自专栏程序员宝库

从零开始写 PHP 扩展

PHP 是用 C 语言写的。对于每个 PHPer 来说,都有着内心的一种希望写扩展的冲动了吧。然而,缺乏一个很好的切入点。Google 上搜 PHP 扩展开发,...

3427
来自专栏大内老A

WCF 4.0一个鲜为人知的改变

本篇文章介绍可以算是WCF 4.0基于限流(Throttling)的新特性,是在修订《WCF技术剖析(卷1)》的时候编写演示实例的时候发现的。这个特性没有出现在...

2779
来自专栏有趣的django

Django REST framework+Vue 打造生鲜超市(十二) 十三、首页、商品数量、缓存和限速功能开发

十三、首页、商品数量、缓存和限速功能开发  13.1.轮播图接口实现 首先把pycharm环境改成本地的,vue中local_host也改成本地  (1)goo...

6957
来自专栏圣杰的专栏

ABP入门系列(15)——创建微信公众号模块

源码路径:Github-LearningMpaAbp 1. 引言 现在的互联网已不在仅仅局限于网页应用,IOS、Android、平板、智能家居等平台正如火如...

3317

扫码关注云+社区

领取腾讯云代金券