程序员老猫

程序员老猫 查看完整档案

上海编辑南京工程学院  |  电子信息工程 编辑中智上海股份有限公司  |  资深Java工程师 编辑 blog.ktdaddy.com/ 编辑
编辑

老猫,一个坚持原创输出的男人。 在技术的路上期待与你的共同前行。 个人公众号“程序员老猫”。 个人博客地址:https://blog.ktdaddy.com/

个人动态

程序员老猫 发布了文章 · 1月3日

【分布式锁的演化】分布式锁居然还能用MySQL?

前言

之前的文章中通过电商场景中秒杀的例子和大家分享了单体架构中锁的使用方式,但是现在很多应用系统都是相当庞大的,很多应用系统都是微服务的架构体系,那么在这种跨jvm的场景下,我们又该如何去解决并发。

单体应用锁的局限性

在进入实战之前简单和大家粗略聊一下互联网系统中的架构演进。

架构简单演化

在互联网系统发展之初,消耗资源比较小,用户量也比较小,我们只部署一个tomcat应用就可以满足需求。一个tomcat我们可以看做是一个jvm的进程,当大量的请求并发到达系统时,所有的请求都落在这唯一的一个tomcat上,如果某些请求方法是需要加锁的,比如上篇文章中提及的秒杀扣减库存的场景,是可以满足需求的。但是随着访问量的增加,一个tomcat难以支撑,这时候我们就需要集群部署tomcat,使用多个tomcat支撑起系统。

在上图中简单演化之后,我们部署两个Tomcat共同支撑系统。当一个请求到达系统的时候,首先会经过nginx,由nginx作为负载均衡,它会根据自己的负载均衡配置策略将请求转发到其中的一个tomcat上。当大量的请求并发访问的时候,两个tomcat共同承担所有的访问量。这之后我们同样进行秒杀扣减库存的时候,使用单体应用锁,还能满足需求么?

之前我们所加的锁是JDK提供的锁,这种锁在单个jvm下起作用,当存在两个或者多个的时候,大量并发请求分散到不同tomcat,在每个tomcat中都可以防止并发的产生,但是多个tomcat之间,每个Tomcat中获得锁这个请求,又产生了并发。从而扣减库存的问题依旧存在。这就是单体应用锁的局限性。那我们如果解决这个问题呢?接下来就要和大家分享分布式锁了。

分布式锁

什么是分布式锁?

那么什么是分布式锁呢,在说分布式锁之前我们看到单体应用锁的特点就是在一个jvm进行有效,但是无法跨越jvm以及进程。所以我们就可以下一个不那么官方的定义,分布式锁就是可以跨越多个jvm,跨越多个进程的锁,像这样的锁就是分布式锁。

设计思路

分布式锁思路

由于tomcat是java启动的,所以每个tomcat可以看成一个jvm,jvm内部的锁无法跨越多个进程。所以我们实现分布式锁,只能在这些jvm外去寻找,通过其他的组件来实现分布式锁。

上图两个tomcat通过第三方的组件实现跨jvm,跨进程的分布式锁。这就是分布式锁的解决思路。

实现方式

那么目前有哪些第三方组件来实现呢?目前比较流行的有以下几种:

  • 数据库,通过数据库可以实现分布式锁,但是高并发的情况下对数据库的压力比较大,所以很少使用。
  • Redis,借助redis可以实现分布式锁,而且redis的java客户端种类很多,所以使用方法也不尽相同。
  • Zookeeper,也可以实现分布式锁,同样zk也有很多java客户端,使用方法也不同。

针对上述实现方式,老猫还是通过具体的代码例子来一一演示。

基于数据库的分布式锁

思路:基于数据库悲观锁去实现分布式锁,用的主要是select ... for update。select ... for update是为了在查询的时候就对查询到的数据进行了加锁处理。当用户进行这种行为操作的时候,其他线程是禁止对这些数据进行修改或者删除操作,必须等待上个线程操作完毕释放之后才能进行操作,从而达到了锁的效果。

实现:我们还是基于电商中超卖的例子和大家分享代码。

咱们还是利用上次单体架构中的超卖的例子和大家分享,针对上次的代码进行改造,我们新键一张表,叫做distribute_lock,这张表的目的主要是为了提供数据库锁,我们来看一下这张表的情况。
初始化订单数据
由于我们这边模拟的是订单超卖的场景,所以在上图中我们有一条订单的锁数据。

我们将上一篇中的代码改造一下抽取出一个controller然后通过postman去请求调用,当然后台是启动两个jvm进行操作,分别是8080端口以及8081端口。完成之后的代码如下:

/**
 * @author kdaddy@163.com
 * @date 2021/1/3 10:48
 * @desc 公众号“程序员老猫”
 */
@Service
@Slf4j
public class MySQLOrderService {
    @Resource
    private KdOrderMapper orderMapper;
    @Resource
    private KdOrderItemMapper orderItemMapper;
    @Resource
    private KdProductMapper productMapper;
    @Resource
    private DistributeLockMapper distributeLockMapper;
    //购买商品id
    private int purchaseProductId = 100100;
    //购买商品数量
    private int purchaseProductNum = 1;
    
    @Transactional(propagation = Propagation.REQUIRED)
    public  Integer createOrder() throws Exception{
        log.info("进入了方法");
        DistributeLock lock = distributeLockMapper.selectDistributeLock("order");
        if(lock == null) throw new Exception("该业务分布式锁未配置");
        log.info("拿到了锁");
        //此处为了手动演示并发,所以我们暂时在这里休眠1分钟
        Thread.sleep(60000);

        KdProduct product = productMapper.selectByPrimaryKey(purchaseProductId);
        if (product==null){
            throw new Exception("购买商品:"+purchaseProductId+"不存在");
        }
        //商品当前库存
        Integer currentCount = product.getCount();
        log.info(Thread.currentThread().getName()+"库存数"+currentCount);
        //校验库存
        if (purchaseProductNum > currentCount){
            throw new Exception("商品"+purchaseProductId+"仅剩"+currentCount+"件,无法购买");
        }

        //在数据库中完成减量操作
        productMapper.updateProductCount(purchaseProductNum,"kd",new Date(),product.getId());
        //生成订单
        ...次数省略,源代码可以到老猫的github下载:https://github.com/maoba/kd-distribute
        return order.getId();
    }
}

SQL的写法如下:

select
   *
    from distribute_lock
    where business_code = #{business_code,jdbcType=VARCHAR}
    for update

以上为主要实现逻辑,关于代码中的注意点:

  • createOrder方法必须要有事务,因为只有在事务存在的情况下才能触发select for update的锁。
  • 代码中必须要对当前锁的存在性进行判断,如果为空的情况下,会报异常

我们来看一下最终运行的效果,先看一下console日志,

8080的console日志情况:

11:49:41  INFO 16360 --- [nio-8080-exec-2] c.k.d.service.MySQLOrderService          : 进入了方法
11:49:41  INFO 16360 --- [nio-8080-exec-2] c.k.d.service.MySQLOrderService          : 拿到了锁

8081的console日志情况:

11:49:48  INFO 17640 --- [nio-8081-exec-2] c.k.d.service.MySQLOrderService          : 进入了方法

通过日志情况,两个不同的jvm,由于第一个到8080的请求优先拿到了锁,所以8081的请求就处于等待锁释放才会去执行,这说明我们的分布式锁生效了。

再看一下完整执行之后的日志情况:

8080的请求:

11:58:01  INFO 15380 --- [nio-8080-exec-1] c.k.d.service.MySQLOrderService          : 进入了方法
11:58:01  INFO 15380 --- [nio-8080-exec-1] c.k.d.service.MySQLOrderService          : 拿到了锁
11:58:07  INFO 15380 --- [nio-8080-exec-1] c.k.d.service.MySQLOrderService          : http-nio-8080-exec-1库存数1

8081的请求:

11:58:03  INFO 16276 --- [nio-8081-exec-1] c.k.d.service.MySQLOrderService          : 进入了方法
11:58:08  INFO 16276 --- [nio-8081-exec-1] c.k.d.service.MySQLOrderService          : 拿到了锁
11:58:14  INFO 16276 --- [nio-8081-exec-1] c.k.d.service.MySQLOrderService          : http-nio-8081-exec-1库存数0
11:58:14 ERROR 16276 --- [nio-8081-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.Exception: 商品100100仅剩0件,无法购买] with root cause

java.lang.Exception: 商品100100仅剩0件,无法购买
    at com.kd.distribute.service.MySQLOrderService.createOrder(MySQLOrderService.java:61) ~[classes/:na]

很明显第二个请求由于没有库存,导致最终购买失败的情况,当然这个场景也是符合我们正常的业务场景的。最终我们数据库的情况是这样的:
订单记录

产品库存记录

很明显,我们到此数据库的库存和订单数量也都正确了。到此我们基于数据库的分布式锁实战演示完成,下面我们来归纳一下如果使用这种锁,有哪些优点以及缺点。

  • 优点:简单方便、易于理解、易于操作。
  • 缺点:并发量大的时候对数据库的压力会比较大。
  • 建议:作为锁的数据库和业务数据库分开。

写在最后

对于上述数据库分布式锁,其实在我们的日常开发中用的也是比较少的。基于redis以及zk的锁倒是用的比较多一些,本来老猫想把redis锁以及zk锁放在这一篇中一起分享掉,但是再写在同一篇上面的话,篇幅就显得过长了,因此本篇就和大家分享这一种分布式锁。源码大家可以在老猫的github中下载到。地址是:https://github.com/maoba/kd-d...,后面老猫会把redis锁以及zk锁都分享给大家,敬请期待,当然更多的干货分享,也欢迎大家关注公众号“程序员老猫”。

查看原文

赞 0 收藏 0 评论 0

程序员老猫 发布了文章 · 1月3日

【分布式锁的演化】电商“超卖”场景实战

前言

从本篇开始,老猫会通过电商中的业务场景和大家分享锁在实际应用场景下的演化过程。从Java单体锁到分布式环境下锁的实践。

超卖的第一种现象案例

其实在电商业务场景中,会有一个这样让人忌讳的现象,那就是“超卖”,那么什么是超卖呢?举个例子,某商品的库存数量只有10件,最终却卖出了15件,简而言之就是商品卖出的数量超过了商品本身的库存数目。“超卖”会导致商家没有商品发货,发货的时间延长,从引起交易双方的纠纷。

我们来一起分析一下该现象产生的原因:假如商品只有最后一件,A用户和B用户同时看到了商品,并且同时加入了购物车提交了订单,此时两个用户同时读取库存中的商品数量为一件,各自进行内存扣减之后,进行更新数据库。因此产生超卖,我们具体看一下流程示意图:
超卖示意图

解决方案

遇到上述问题,在单台服务器的时候我们如何解决呢?我们来看一下具体的方案。之前描述中提到,我们在扣减库存的时候是在内存中进行。接下来我们将其进行下沉到数据库中进行库存的更新操作,我们可以向数据库传递库存增量,扣减一个库存,增量为-1,在数据库进行update语句计算库存的时候,我们通过update行锁解决并发问题。(数据库行锁:在数据库进行更新的时候,当前行被锁定,即为行锁,此处老猫描述比较简单,有兴趣的小伙伴可以自发研究一下数据库的锁)。我们来看一下具体的代码例子。

业务逻辑代码如下:

@Service
@Slf4j
public class OrderService {
    @Resource
    private KdOrderMapper orderMapper;
    @Resource
    private KdOrderItemMapper orderItemMapper;
    @Resource
    private KdProductMapper productMapper;
    //购买商品id
    private int purchaseProductId = 100100;
    //购买商品数量
    private int purchaseProductNum = 1;

    @Transactional(rollbackFor = Exception.class)
    public Integer createOrder() throws Exception{
        KdProduct product = productMapper.selectByPrimaryKey(purchaseProductId);
        if (product==null){
            throw new Exception("购买商品:"+purchaseProductId+"不存在");
        }

        //商品当前库存
        Integer currentCount = product.getCount();
        //校验库存
        if (purchaseProductNum > currentCount){
            throw new Exception("商品"+purchaseProductId+"仅剩"+currentCount+"件,无法购买");
        }
        //计算剩余库存
        Integer leftCount = currentCount -purchaseProductNum;
        product.setCount(leftCount);
        product.setTimeModified(new Date());
        product.setUpdateUser("kdaddy");
        productMapper.updateByPrimaryKeySelective(product);
        //生成订单
        KdOrder order = new KdOrder();
        order.setOrderAmount(product.getPrice().multiply(new BigDecimal(purchaseProductNum)));
        order.setOrderStatus(1);//待处理
        order.setReceiverName("kdaddy");
        order.setReceiverMobile("13311112222");
        order.setTimeCreated(new Date());
        order.setTimeModified(new Date());
        order.setCreateUser("kdaddy");
        order.setUpdateUser("kdaddy");
        orderMapper.insertSelective(order);

        KdOrderItem orderItem = new KdOrderItem();
        orderItem.setOrderId(order.getId());
        orderItem.setProductId(product.getId());
        orderItem.setPurchasePrice(product.getPrice());
        orderItem.setPurchaseNum(purchaseProductNum);
        orderItem.setCreateUser("kdaddy");
        orderItem.setTimeCreated(new Date());
        orderItem.setTimeModified(new Date());
        orderItem.setUpdateUser("kdaddy");
        orderItemMapper.insertSelective(orderItem);
        return order.getId();
    }
}

通过以上代码我们可以看到的是库存的扣减在内存中完成。那么我们再看一下具体的单元测试代码:

@SpringBootTest
class DistributeApplicationTests {
    @Autowired
    private OrderService orderService;

    @Test
    public void concurrentOrder() throws InterruptedException {
        //简单来说表示计数器
        CountDownLatch cdl = new CountDownLatch(5);
        //用来进行等待五个线程同时并发的场景
        CyclicBarrier cyclicBarrier = new CyclicBarrier(5);

        ExecutorService es = Executors.newFixedThreadPool(5);
        for (int i =0;i<5;i++){
            es.execute(()->{
                try {
                    //等待五个线程同时并发的场景
                    cyclicBarrier.await();
                    Integer orderId = orderService.createOrder();
                    System.out.println("订单id:"+orderId);
                } catch (Exception e) {
                    e.printStackTrace();
                }finally {
                    cdl.countDown();
                }
            });
        }
        //避免提前关闭数据库连接池
        cdl.await();
        es.shutdown();
    }
}

代码执完毕之后我们看一下结果:

订单id:1
订单id:2
订单id:3
订单id:4
订单id:5

很显然,数据库中虽然只有一个库存,但是产生了五个下单记录,如下图:
订单记录
产品库存记录
这也就产生了超卖的现象,那么如何才能解决这个问题呢?

单体架构中,利用数据库行锁解决电商超卖问题。

那么如果是这种解决方案的话,我们就要将我们扣减库存的动作下沉到我们的数据库中,利用数据库的行锁解决并发情况下同时操作的问题,我们来看一下代码的改造点。

@Service
@Slf4j
public class OrderServiceOptimizeOne {
    .....篇幅限制,此处省略,具体可参考github源码
    @Transactional(rollbackFor = Exception.class)
    public Integer createOrder() throws Exception{
        KdProduct product = productMapper.selectByPrimaryKey(purchaseProductId);
        if (product==null){
            throw new Exception("购买商品:"+purchaseProductId+"不存在");
        }

        //商品当前库存
        Integer currentCount = product.getCount();
        //校验库存
        if (purchaseProductNum > currentCount){
            throw new Exception("商品"+purchaseProductId+"仅剩"+currentCount+"件,无法购买");
        }

        //在数据库中完成减量操作
        productMapper.updateProductCount(purchaseProductNum,"kd",new Date(),product.getId());
        //生成订单
        .....篇幅限制,此处省略,具体可参考github源码
        return order.getId();
    }
}

我们再来看一下执行的结果
订单记录
产品库存记录

从上述结果中,我们发现我们的订单数量依旧是5个订单,但是库存数量此时不再是0,而是由1变成了-4,这样的结果显然依旧不是我们想要的,那么此时其实又是超卖的另外一种现象。我们来看一下超卖现象二所产生的原因。

超卖的第二种现象案例

上述其实是第二种现象,那么产生的原因是什么呢?其实是在校验库存的时候出现了问题,在校验库存的时候是并发进行对库存的校验,五个线程同时拿到了库存,并且发现库存数量都为1,造成了库存充足的假象。此时由于写操作的时候具有update的行锁,所以会依次扣减执行,扣减操作的时候并无校验逻辑。因此就产生了这种超卖显现。简单的如下图所示:
超卖现象

解决方案一:

单体架构中,利用数据库行锁解决电商超卖问题。就针对当前该案例,其实我们的解决方式也比较简单,就是更新完毕之后,我们立即查询一下库存的数量是否大于等于0即可。如果为负数的时候,我们直接抛出异常即可。(当然由于此种操作并未涉及到锁的知识,所以此方案仅做提出,不做实际代码实践)

解决方案二:

校验库存和扣减库存的时候统一加锁,让其成为原子性的操作,并发的时候只有获取锁的时候才会去读库库存并且扣减库存操作。当扣减结束之后,释放锁,确保库存不会扣成负数。那此时我们就需要用到前面博文提到的java中的两个锁的关键字synchronized关键字 和 ReentrantLock

关于synchronized关键字的用法在之前的博文中也提到过,有方法锁和代码块锁两种方式,我们一次来通过实践看一下代码,首先是通过方法锁的方式,具体的代码如下:

//`synchronized`方法块锁
@Service
@Slf4j
public class OrderServiceSync01 {
    .....篇幅限制,此处省略,具体可参考github源码
    @Transactional(rollbackFor = Exception.class)
    public synchronized Integer createOrder() throws Exception{
        KdProduct product = productMapper.selectByPrimaryKey(purchaseProductId);
        if (product==null){
            throw new Exception("购买商品:"+purchaseProductId+"不存在");
        }

        //商品当前库存
        Integer currentCount = product.getCount();
        //校验库存
        if (purchaseProductNum > currentCount){
            throw new Exception("商品"+purchaseProductId+"仅剩"+currentCount+"件,无法购买");
        }

        //在数据库中完成减量操作
        productMapper.updateProductCount(purchaseProductNum,"kd",new Date(),product.getId());
        //生成订单
        .....篇幅限制,此处省略,具体可参考github源码
        return order.getId();
    }
}

此时我们看一下运行的结果。

[pool-1-thread-2] c.k.d.service.OrderServiceSync01         : pool-1-thread-2库存数1
[pool-1-thread-1] c.k.d.service.OrderServiceSync01         : pool-1-thread-1库存数1
订单id:12
[pool-1-thread-5] c.k.d.service.OrderServiceSync01         : pool-1-thread-5库存数-1
订单id:13
[pool-1-thread-3] c.k.d.service.OrderServiceSync01         : pool-1-thread-3库存数-1

订单记录
产品库存记录

此时我们很明显地发现数据还是存在问题,那么这个是什么原因呢?

其实聪明的小伙伴其实已经发现了,我们第二个线程读取到的数据依旧是1,那么为什么呢?其实很简单,第二个线程在读取商品库存的时候是1的原因是因为上一个线程的事务并没有提交,我们也能比较清晰地看到目前我们方法上的事务是在锁的外面的。所以就产生了该问题,那么针对这个问题,我们其实可以将事务的提交进行手动提交,然后放到锁的代码块中。具体改造如下。

 public synchronized Integer createOrder() throws Exception{
     //手动获取当前事务   
     TransactionStatus transaction = platformTransactionManager.getTransaction(transactionDefinition);
        KdProduct product = productMapper.selectByPrimaryKey(purchaseProductId);
        if (product==null){
            platformTransactionManager.rollback(transaction);
            throw new Exception("购买商品:"+purchaseProductId+"不存在");
        }

        //商品当前库存
        Integer currentCount = product.getCount();
        log.info(Thread.currentThread().getName()+"库存数"+currentCount);
        //校验库存
        if (purchaseProductNum > currentCount){
            platformTransactionManager.rollback(transaction);
            throw new Exception("商品"+purchaseProductId+"仅剩"+currentCount+"件,无法购买");
        }

        //在数据库中完成减量操作
        productMapper.updateProductCount(purchaseProductNum,"kd",new Date(),product.getId());
        //生成订单并完成订单的保存操作
         .....篇幅限制,此处省略,具体可参考github源码
        platformTransactionManager.commit(transaction);
        return order.getId();
    }

此时我们再看一下运行的结果:

 [pool-1-thread-3] c.k.d.service.OrderServiceSync01         : pool-1-thread-3库存数1
 [pool-1-thread-5] c.k.d.service.OrderServiceSync01         : pool-1-thread-5库存数0
订单id:16
 [pool-1-thread-4] c.k.d.service.OrderServiceSync01         : pool-1-thread-4库存数0
 [pool-1-thread-1] c.k.d.service.OrderServiceSync01         : pool-1-thread-1库存数0

根据上面的结果我们可以很清楚的看到只有第一个线程读取到了库存是1,后面所有的线程获取到的都是0库存。我们再来看一下具体的数据库。
订单记录
产品库存记录

很明显,我们到此数据库的库存和订单数量也都正确了。

后面synchronized代码块锁以及ReentrantLock交给小伙伴们自己去尝试着完成,当然老猫也已经把相关的代码写好了。具体的源码地址为:https://github.com/maoba/kd-d...

写在最后

本文通过电商中两种超卖现象和小伙伴们分享了一下单体锁解决问题过程。当然这种锁的使用是无法跨越jvm的,当遇到多个jvm的时候就失效了,所以后面的文章中会和大家分享分布式锁的实现。当然也是通过电商中超卖的例子和大家分享。敬请期待。

当然更多干货也欢迎大家搜索关注公众号“程序员老猫”。老猫,一个专注原创干货的男人

查看原文

赞 0 收藏 0 评论 0

程序员老猫 发布了文章 · 1月3日

【分布式锁的演化】常用锁的种类以及解决方案

前言

上一篇分布式锁的文章中,通过超市存放物品的例子和大家简单分享了一下Java锁。本篇文章我们就来深入探讨一下Java锁的种类,以及不同的锁使用的场景,当然本篇只介绍我们常用的锁。我们分为两大类,分别是乐观锁和悲观锁,公平锁和非公平锁。

乐观锁和悲观锁

乐观锁

老猫相信,很多的技术人员首先接触到的就是乐观锁和悲观锁。老猫记得那时候是在大学的时候接触到,当时是上数据库课程的时候。当时的应用场景主要是在更新数据的时候,当然多年工作之后,其实我们也知道了更新数据也是使用锁非常主要的场景之一。我们来回顾一下一般更新的步骤:

  1. 检索出需要更新的数据,提供给操作人查看。
  2. 操作人员更改需要修改的数值。
  3. 点击保存,更新数据。

这个流程看似简单,但是如果一旦多个线程同时操作的时候,就会发现其中隐藏的问题。我们具体看一下:

  1. A检索到数据;
  2. B检索到数据;
  3. B修改了数据;
  4. A修改了数据,是否能够修改成功呢?

上述第四点A是否能够修改成功当然要看我们的程序如何去实现。就从业务上来讲,当A保存数据的时候,最好的方式应该系统给出提示说“当前您操作的数据已被其他人修改,请重新查询确认”。这种其实是最合理的。

那么这种方式我们该如何实现呢?我们看一下步骤:

  1. 在检索数据的时候,我们将相关的数据的版本号(version)或者最后的更新时间一起检索出来。
  2. 当操作人员更改数据之后,点击保存的时候在数据库执行update操作。
  3. 当执行update操作的时候,用步骤1检索出的版本号或者最后的更新时间和数据库中的记录做比较;
  4. 如果版本号或者最后更新时间一致,那么就可以更新。
  5. 如果不一致,我们就抛出上述提示。

其实上述流程就是乐观锁的实现思路。在Java中乐观锁并没有确定的方法,或者关键字,它只是一个处理的流程、策略或者说是一种业务方案。看完这个之后我们再看一下Java中的乐观锁。

乐观锁,它是假设一个线程在取数据的时候不会被其他线程更改数据。就像上述描述类似,但是只有在更新的时候才会去校验数据是否被修改过。其实这种就是我们经常听到的CAS机制,英文全称(Compare And Swap),这是一种比较交换机制,一旦检测到有冲突。它就会进行重试。直到最后没有冲突为止。

乐观锁机制图示如下:
乐观锁
下面我们来举个例子,相信很多同学都是C语言入门的编程,老猫也是,大家应该都接触过i++,那么以下我们就用i++做例子,看看i++是否是线程安全的,多个线程并发执行的时候会存在什么问题。我们看一下下面的代码:

/**
 * @author kdaddy@163.com
 * @date 2020/12/15 22:42
 */
public class NumCountTest {
    private int i=0;
    public static void main(String[] args) {
        NumCountTest test = new NumCountTest();
        //线程池:50个线程
        ExecutorService es = Executors.newFixedThreadPool(50);
        //闭锁
        CountDownLatch cdl = new CountDownLatch(5000);
        for (int i = 0;i < 5000; i++){
            es.execute(()->{
                test.i++;
                cdl.countDown();
            });
        }
        es.shutdown();
        try {
            //等待5000个任务执行完成后,打印出执行结果
            cdl.await();
            System.out.println("执行完成后,i="+test.i);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

上面的程序中,我们用50个线程同时执行i++程序,总共执行5000次,按照常规的理解,得到的应该是5000,但是我们连续运行三次,得到的结果如下:

执行完成后,i=4975
执行完成后,i=4955
执行完成后,i=4968

(注:可能有小伙伴不清楚CountDownLatch,简单说明一下,该类其实就是一个计数器,初始化的时候构造器传了5000表示会执行5000次, 这个类使一个线程等待其他线程各自执行完毕后再执行,cdl.countDown()这个方法指的就是将构造器参数减一。具体的可以自行问度娘,在此老猫也是展开 )

从上面的结果我们可以看到,每次结果都不同,反正也不是5000,那么这个是为什么呢?其实这就说明i++程序并不是一个原子性的,多线程的情况下存在线程安全性的问题。我们可以将详细执行步骤进行一下拆分。

  1. 从内存中取出i的值
  2. 将i的值+1
  3. 将计算完毕的i重新放入到内存中

其实这个流程和我们之前说到的数据的流程是一样的。只不过是介质不同,一个是内存,另一个是数据库。在多个线程的情况下,我们想象一下,假如A线程和B线程同时同内存中取出i的值,假如i的值都是50,然后两个线程都同时进行了+1的操作,然后在放入到内存中,这时候内存的值是51,但是我们期待的是52。这其实就是上述为什么一直无法达到5000的原因。那么我们如何解决这个问题?其实在Java1.5之后,JDK的官网提供了大量的原子类,这些类的内部都是基于CAS机制的,也就是说使用了乐观锁。我们更改一下代码,如下:

/**
 * @author kdaddy@163.com
 * @date 2020/12/15 22:42
 */
public class NumCountTest {
    private AtomicInteger i= new AtomicInteger(0);
    public static void main(String[] args) {
        NumCountTest test = new NumCountTest();
        //线程池:50个线程
        ExecutorService es = Executors.newFixedThreadPool(50);
        //闭锁
        CountDownLatch cdl = new CountDownLatch(5000);
        for (int i = 0;i < 5000; i++){
            es.execute(()->{
                test.i.incrementAndGet();
                cdl.countDown();
            });
        }
        es.shutdown();
        try {
            //等待5000个任务执行完成后,打印出执行结果
            cdl.await();
            System.out.println("执行完成后,i="+test.i);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

此时我们得到的结果如下,执行三次:

执行完成后,i=5000
执行完成后,i=5000
执行完成后,i=5000

结果看来是我们所期待的,以上的改造我们可以看到,我们将原来int类型的变量更改成了 AtomicInteger,该类是一个原子类属于concurrent包(有兴趣的小伙伴可以研究一下这个包下面的一些类)我们将原来的i++的地方改成了test.i.incrementAndGet(),incrementAndGet这个方法采用得了CAS机制。也就是说采用了乐观锁,所以我们以上的结果是正确的。

我们对乐观锁进行一下总结,其实乐观锁就是在读取数据的时候不加任何限制条件,但是在更新数据的时候,进行数据的比较,保证数据版本的一致之后采取更新相关的数据信息。由于这个特点,所以我们很容易可以看出乐观锁比较试用于读操作大于写操作的场景中。

悲观锁

我们再一起看一下悲观锁,也是通过这个例子来说明一下。悲观锁其实和乐观锁不同,悲观锁从读取数据的时候就显示地去加锁,直到数据最后更新完成之后,锁才会被释放。这个期间只能由一个线程去操作。其他线程只能等待。其实上一篇文章中我们就用到了 synchronized关键字 ,其实这个关键字就是悲观锁。与其相同的其实还有ReentrantLock类也可以实现悲观锁。那么以下我们再使用synchronized关键字 和 ReentrantLock进行悲观锁的改造。具体代码如下:

/**
 * @author kdaddy@163.com
 * @date 2020/12/15 22:42
 */
public class NumCountTest {
    private int i= 0;
    public static void main(String[] args) {
        NumCountTest test = new NumCountTest();
        //线程池:50个线程
        ExecutorService es = Executors.newFixedThreadPool(50);
        //闭锁
        CountDownLatch cdl = new CountDownLatch(5000);
        for (int i = 0;i < 5000; i++){
            es.execute(()->{
                synchronized (test){
                     test.i++;
                }
                cdl.countDown();
            });
        }
        es.shutdown();
        try {
            //等待5000个任务执行完成后,打印出执行结果
            cdl.await();
            System.out.println("执行完成后,i="+test.i);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

以上我们的改动就是新增了synchronized代码块,它锁住了test的对象,在所有的线程中,谁获取到了test的对象,谁就能执行i++操作(此处锁test是因为test只有一个)。这样我们采用了悲观锁的方式我们的结果当然也是OK的执行完毕之后三次输出如下:

执行完成后,i=5000
执行完成后,i=5000
执行完成后,i=5000

再看一下ReentrantLock类实现悲观锁,代码如下:

/**
 * @author kdaddy@163.com
 * @date 2020/12/15 22:42
 */
public class NumCountTest {
    private int i= 0;
    Lock lock = new ReentrantLock();
    public static void main(String[] args) {
        NumCountTest test = new NumCountTest();
        //线程池:50个线程
        ExecutorService es = Executors.newFixedThreadPool(50);
        //闭锁
        CountDownLatch cdl = new CountDownLatch(5000);
        for (int i = 0;i < 5000; i++){
            es.execute(()->{
                test.lock.lock();
                test.i++;
                test.lock.unlock();
                cdl.countDown();
            });
        }
        es.shutdown();
        try {
            //等待5000个任务执行完成后,打印出执行结果
            cdl.await();
            System.out.println("执行完成后,i="+test.i);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

用法如上,其实也不用太多介绍,小伙伴们看代码即可,上述通过lock加锁,通过unlock释放锁。当然我们三次执行完毕之后结果也是OK的。

执行完成后,i=5000
执行完成后,i=5000
执行完成后,i=5000

三次执行下来都是5000,完全没有问题。

我们再来总结一下悲观锁,悲观锁其实就是从读取数据的那一刻就加了锁,而且在更新数据的时候,保证只有一个线程在执行更新操作,并没有如乐观锁那种进行数据版本的比较。所以可想而知,悲观锁适用于读取相对少,写相对多的操作中。

公平锁和非公平锁

前面和小伙伴们分享了乐观锁和悲观锁,下面我们就来从另外一个维度去认识一下锁。公平锁和非公平锁。顾名思义,公平锁在多线程的情况下,对待每个线程都是公平的,然而非公平锁确是恰恰相反的。就光这么和小伙伴们同步,估计大家还会有点迷糊。我们还是以之前的储物柜来说明,去超市买东西,储物柜只有一个,正好有A、B、C三个人想要用柜子,这时候A来的比较早,所以B和C自觉进行排队,A用完之后,后面排着队的B才会去使用,这就是公平锁。在公平锁中,所有的线程都会自觉排队,一个线程执行完毕之后,后续的线程在依次进行执行。

然而非公平锁则不然,当A使用完毕之后,A将钥匙往后面的一群人中一丢,谁先抢到,谁就可以使用。我们大概可以用以下两个示意图来体现,如下:
公平锁
对应的多线程中,线程A先抢到了锁,A就可以执行方法,其他的线程则在队列中进行排队,A执行完毕之后,会从队列中获取下一个B进行执行,依次类推,对于每个线程来说都是公平的,不存在后加入的线程先执行的情况。
非公平锁
多线程同时执行方法的时候,线程A抢到了锁,线程A先执行方法,其他线程并没有排队。当A执行完毕之后,其他的线程谁抢到了锁,谁就能执行方法。这样就可能存在后加入的线程,反而先拿到锁。

关于公平锁和非公平锁,其实在我们的ReentrantLock类中就已经给出了实现,我们来看一下源码:

 /**
     * Creates an instance of {@code ReentrantLock}.
     * This is equivalent to using {@code ReentrantLock(false)}.
     */
    public ReentrantLock() {
        sync = new NonfairSync();
    }

    /**
     * Creates an instance of {@code ReentrantLock} with the
     * given fairness policy.
     *
     * @param fair {@code true} if this lock should use a fair ordering policy
     */
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

该类中有两个构造方法,从字面上来看默认的构造方法中 sync = new NonfairSync()是一个非公平锁。再看看第二个构造方法,需要传入一个参数,true是的时候是公平锁,false的时候是非公平锁。以上我们可以看到sync有两个实现类,分别是FairSync以及NonfairSync,我们再来看一下获取锁的核心方法。

获取公平锁:

@ReservedStackAccess
protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (!hasQueuedPredecessors() &&
            compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

非公平锁:

@ReservedStackAccess
final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

以上两个方法,我们很容易就能发现唯一的不同点就是 !hasQueuedPredecessors() 这个方法,从名字上来看就知道这个是一个队列,因此我们也就可以推断,公平锁是将所有的线程放到一个队列中,一个线程执行完成之后,从队列中区所下一个线程。而非公平锁则没有这样的队列。这些就是公平锁和非公平锁的实现原理。这里也不去再深入去看源码了,我们重点是了解公平锁和非公平锁的含义。我们在使用的时候传入true或者false即可。

总结

其实在Java中锁的种类非常的多,在此老猫只介绍了常用的几种,有兴趣的小伙伴其实还可以去钻研一下独享锁、共享锁、互斥锁、读写锁、可重入锁、分段锁等等。

乐观锁和非乐观锁是最基础的,我们在工作中肯定接触的也比较多。

从公平非公平锁的角度,大家如果用到ReetrantLock其实默认的就是用到了非公平锁。那什么时候用到公平锁呢?其实业务场景也是比较常见的,就是在电商秒杀的时候,公平锁的模型就被套用上了。

再往下写估计大家就不想看了,所以此篇幅到此结束了,后续陆陆续续会和大家分享分布式锁的演化过程,以及分布式锁的实现,敬请期待。

查看原文

赞 1 收藏 1 评论 0

程序员老猫 发布了文章 · 1月3日

【分布式锁的演化】什么是锁?

从本篇开始,我们来好好梳理一下Java开发中的锁,通过一些具体简单的例子来描述清楚从Java单体锁到分布式锁的演化流程。本篇我们先来看看什么是锁,以下老猫会通过一些日常生活中的例子也说清楚锁的概念。

描述

锁在Java中是一个非常重要的概念,在当今的互联网时代,尤其在各种高并发的情况下,我们更加离不开锁。那么到底什么是锁呢?在计算机中,锁(lock)或者互斥(mutex)是一种同步机制,用于在有许多执行线程的环境中强制对资源的访问限制。锁可以强制实施排他互斥、并发控制策略。举一个生活中的例子,大家都去超市买东西,如果我们带了包的话,要放到储物柜。我们再把这个例子极端一下,假如柜子只有一个,那么此时同时来了三个人A、B、C都要往这个柜子里放东西。那么这个场景就是一个多线程,多线程自然也就离不开锁。简单示意图如下

存储柜子模型

A、B、C都要往柜子里面放东西,可是柜子只能存放一个东西,那么怎么处理?这个时候我们就引出了锁的概念,三个人中谁先抢到了柜子的锁,谁就可以使用这个柜子,其他的人只能等待。比如C抢到了锁,C就可以使用这个柜子,A和B只能等待,等到C使用完毕之后,释放了锁,AB再进行抢锁,谁先抢到了,谁就有使用柜子的权利。

抽象成代码

我们其实可以将以上场景抽象程相关的代码模型,我们来看一下以下代码的例子。

/**
 * @author kdaddy@163.com
 * @date 2020/11/2 23:13
 */
public class Cabinet {
    //表示柜子中存放的数字
    private int storeNumber;

    public int getStoreNumber() {
        return storeNumber;
    }
    public void setStoreNumber(int storeNumber) {
        this.storeNumber = storeNumber;
    }
}

柜子中存储的是数字。

然后我们把3个用户抽象成一个类,如下代码

/**
 * @author kdaddy@163.com
 * @date 2020/11/7 22:03
 */
public class User {
    // 柜子
    private Cabinet cabinet;
    // 存储的数字
    private int storeNumber;

    public User(Cabinet cabinet, int storeNumber) {
        this.cabinet = cabinet;
        this.storeNumber = storeNumber;
    }
    // 表示使用柜子
    public void useCabinet(){
        cabinet.setStoreNumber(storeNumber);
    }
}

在用户的构造方法中,需要传入两个参数,一个是要使用的柜子,另一个是要存储的数字。以上我们把柜子和用户都已经抽象完毕,接下来我们再来写一个启动类,模拟一下3个用户使用柜子的场景。

/**
 * @author kdaddy@163.com
 * @date 2020/11/7 22:05
 */
public class Starter {
    public static void main(String[] args) {
        final Cabinet cabinet = new Cabinet();
        ExecutorService es = Executors.newFixedThreadPool(3);

        for(int i= 1; i < 4; i++){
            final int storeNumber = i;
            es.execute(()->{
                User user = new User(cabinet,storeNumber);
                user.useCabinet();
                System.out.println("我是用户"+storeNumber+",我存储的数字是:"+cabinet.getStoreNumber());
            });
        }
        es.shutdown();
    }
}

我们仔细的看一下这个main函数的过程

  • 首先创建一个柜子的实例,由于场景中只有一个柜子,所以我们只创建了一个柜子实例。
  • 然后我们新建了一个线程池,线程池中一共有三个线程,每个线程执行一个用户的操作。
  • 再来看看每个线程具体的执行过程,新建用户实例,传入的是用户使用的柜子,我们这里只有一个柜子,所以传入这个柜子的实例,然后传入这个用户所需要存储的数字,分别是1,2,3,也分别对应了用户1,2,3。
  • 再调用使用柜子的操作,也就是想柜子中放入要存储的数字,然后立刻从柜子中取出数字,并打印出来。

我们运行一下main函数,看看得到的打印结果是什么?

我是用户1,我存储的数字是:3
我是用户3,我存储的数字是:3
我是用户2,我存储的数字是:2

从结果中,我们可以看出三个用户在存储数字的时候两个都是3,一个是2。这是为什么呢?我们期待的应该是每个人都能获取不同的数字才对。其实问题就是出在"user.useCabinet();"这个方法上,这是因为柜子这个实例没有加锁的原因,三个用户并行执行,向柜子中存储他们的数字,虽然3个用户并行同时操作,但是在具体赋值的时候,也是有顺序的,因为变量storeNumber只有一块内存,storeNumber只存储一个值,存储最后的线程所设置的值。至于哪个线程排在最后,则完全不确定,赋值语句执行完成之后,进入打印语句,打印语句取storeNumber的值并打印,这时storeNumber存储的是最后一个线程锁所设置的值,3个线程取到的值有两个是相同的,就像上面打印的结果一样。

那么如何才能解决这个问题?这就需要我们用到锁。我们再赋值语句上加锁,这样当多个线程(此处表示用户)同时赋值的时候,谁能优先抢到这把锁,谁才能够赋值,这样保证同一个时刻只能有一个线程进行赋值操作,避免了之前的混乱的情况。

那么在程序中,我们如何加锁呢?

下面我们介绍一下Java中的一个关键字synchronized。关于这个关键字,其实有两种用法。

  • synchronized方法,顾名思义就是把synchronize的关键字写在方法上,它表示这个方法是加了锁的,当多个线程同时调用这个方法的时候,只有获得锁的线程才能够执行,具体如下:

    public synchronized String getTicket(){
            return "xxx";
        }

    以上我们可以看到getTicket()方法加了锁,当多个线程并发执行的时候,只有获得锁的线程才可以执行,其他的线程只能够等待。

  • synchronized代码块。如下:

    synchronized (对象锁){
        ……
    }

    我们将需要加锁的语句都写在代码块中,而在对象锁的位置,需要填写加锁的对象,它的含义是,当多个线程并发执行的时候,只有获得你写的这个对象的锁,才能够执行后面的语句,其他的线程只能等待。synchronized块通常的写法是synchronized(this),这个this是当前类的实例,也就是说获得当前这个类的对象的锁,才能够执行这个方法,此写法等同于synchronized方法。

回到刚才的例子中,我们又是如何解决storeNumber混乱的问题呢?咱们试着在方法上加上锁,这样保证同时只有一个线程能调用这个方法,具体如下。

/**
 * @author kdaddy@163.com
 * @date 2020/12/2 23:13
 */
public class Cabinet {
    //表示柜子中存放的数字
    private int storeNumber;

    public int getStoreNumber() {
        return storeNumber;
    }

    public synchronized void setStoreNumber(int storeNumber) {
        this.storeNumber = storeNumber;
    }
}

我们运行一下代码,结果如下

我是用户2,我存储的数字是:2
我是用户3,我存储的数字是:2
我是用户1,我存储的数字是:1

我们发现结果还是混乱的,并没有解决问题。我们检查一下代码

 es.execute(()->{
                User user = new User(cabinet,storeNumber);
                user.useCabinet();
                System.out.println("我是用户"+storeNumber+",我存储的数是:"+cabinet.getStoreNumber());
            });

我们可以看到在useCabinet和打印的方法是两个语句,并没有保持原子性,虽然在set方法上加了锁,但是在打印的时候又存在了并发,打印语句是有锁的,但是不能确定哪个线程去执行。所以这里,我们要保证useCabinet和打印的方法的原子性,我们使用synchronized块,但是synchronized块里的对象我们使用谁的?这又是一个问题,user还是cabinet?回答当然是cabinet,因为每个线程都初始化了user,总共有3个User对象,而cabinet对象只有一个,所以synchronized要用cabine对象,具体代码如下

/**
 * @author kdaddy@163.com
 * @date 2020/12/7 22:05
 */
public class Starter {
    public static void main(String[] args) {
        final Cabinet cabinet = new Cabinet();
        ExecutorService es = Executors.newFixedThreadPool(3);

        for(int i= 1; i < 4; i++){
            final int storeNumber = i;
            es.execute(()->{
                User user = new User(cabinet,storeNumber);
                synchronized (cabinet){
                    user.useCabinet();
                    System.out.println("我是用户"+storeNumber+",我存储的数字是:"+cabinet.getStoreNumber());
                }
            });
        }
        es.shutdown();
    }
}

此时我们再去运行一下:

我是用户3,我存储的数字是:3
我是用户2,我存储的数字是:2
我是用户1,我存储的数字是:1

由于我们加了synchronized块,保证了存储和取出的原子性,这样用户存储的数字和取出的数字就对应上了,不会造成混乱,最后我们用图来表示一下上面例子的整体情况。
最终模型

如上图所示,线程A,线程B,线程C同时调用Cabinet类的setStoreNumber方法,线程B获得了锁,所以线程B可以执行setStore的方法,线程A和线程C只能等待。

总结

通过上面的场景以及例子,我们可以了解多线程情况下,造成的变量值前后不一致的问题,以及锁的作用,在使用了锁以后,可以避免这种混乱的现象,后续,老猫会和大家介绍一个Java中都有哪些关于锁的解决方案,以及项目中所用到的实战。

查看原文

赞 0 收藏 0 评论 0

程序员老猫 发布了文章 · 1月3日

分享一个普通程序员的“沪漂”六年的历程以及感想

开篇

没错,标题中沪漂六年的程序员就是我,老猫。2020年即将结束,这也是老猫在上海的第六个年头,回想一路磕磕碰碰,到而今总算在上海有个落脚的地儿,老猫想和大家分享一下这段历程。

初来乍到

14年,老猫刚从南京的一所高校毕业,同学们大都选择留在了南京,老猫贯彻一向的杀马特作风,就是追求标新立异,来到了魔都上海。其实当时也想得比较简单,大上海机会多一些,另外的上海距离自己的老家会稍微近一点(老家南通),更重要的是老猫单身,来去自由。

  • 住所:住的是川杨新苑的群租房,房租比较便宜1250一个月,房间虽然小了些,但是冰箱洗衣机等等该有的都有。
  • 通勤:永远忘不了金科路附近的浦东12路公交有多么拥挤,一般不厚点脸皮,不多使点力气,等个三四班车都上不去。当然后来开了窍自己花了600元在闲鱼上买了工作之后的第一辆二手小电驴,感觉美滋滋。
  • 社交:虽然一个人来的上海,但是老猫的社交能力还算可以,一个月之内结交了几个不错的基友,包括到现在都打得火热的那种。那时候,单身,双休日一般就是宅家里和远在南京的老铁一起开黑LOL,要么就是约上基友去附近玩桌球,或者打篮球,日子也是过得自在。
  • 辛酸:每次回到住处就很孤独,住的地方也就一个人,所以也就对下班回去没有太大的期待,因为毕竟在公司还有个能聊天的活人。记得最最辛酸的一次是下班回去的时候发现住的地方被凿了一个大洞,当时就蒙圈了,后来才知道其实是为了整改群租房,被社区的人给砸了,无奈,连夜找房子,找房到搬家完毕折腾到下半夜,还好第二天是双休日。南京的朋友劝我回南京,因为毕竟还能和认识四年多的基友一起租房,南京的压力可能也没有那么大。当时有点动摇,后来碍于面子,想着回去的话不就是魔都混不下去回到二线了么。于是咬牙坚持了下来。

跳槽脱单

15年,跳槽去了一家互联网公司,薪水涨幅不错,就是加班多了些。当然也是个人技术能力成长比较快的一年。

  • 住所:依旧还是川杨新苑,不过这次不再是群租房,是一个朝南的主卧,从2014年到2015年之间,上海的房价基本翻了一翻,房租也往上直冒,一个简单的朝南主卧标价是1950。当时其实也不会想到去买房,因为根本就是屌丝光棍一个,也未曾想过在上海可以有个房子。那时候同事们大多都在议论买房的事情,而老猫却毫无波澜,因为毕竟也确实买不起,另外连个女朋友都没有,买个蛋。后来才发现,当时应该东拼西凑借钱也得买,那时候还没有限购,另外的2016年上海的房价又是猛涨了一波。
  • 通勤:由于互联网弹性工作时间,也比较自由,小电驴通勤时间15分钟到公司,一般到公司也是10点半左右了,中饭自费,晚餐包吃,下班比较晚,一般晚上10点多下班,晚一点的话12点左右,那时候觉得这种生活不错,反正回去也没事,所以也接受这样的节奏。
  • 社交:15年年底一个偶然的机遇,遇到老猫现在的老婆(咳咳,具体认识经过也不赘述,当然劝现在单身的程序员小伙伴也不要着急,因为缘分该来的时候就会来)。
  • 辛酸:基本没什么辛酸,整年都沉浸在忙忙碌碌的工作中,硬要说辛酸,就是整年加班到很晚。当然15年年底的时候比较幸运地认识了现在的媳妇,有个伴了。

合伙人创业

16年,比较重要的事情,就是放弃了互联网公司的期权,被第一家公司的经理拉过去一起和另外的几个销售从0创业做一款基于SAAS平台的CRM系统。

  • 住所:前半年依旧还是川杨新苑,后来下半年从浦东搬家到了浦西的五号线附近。
  • 通勤:五号线换一号线到南京西路地铁站,五号线也是比较恶心的一条线路,挤地铁的程度和日本通勤上地铁差不多,基本就是靠着地铁管理员把人当成货物塞进去的,痛苦溢于言表,但是没办法,媳妇在浦西。工作地点是个南京西路的创业孵化中心,在路上的时间其实也还能接受,40分钟左右的路程。
  • 成长:技术上的成长比较快,前前后后的技术接触了遍,从系统的设计到代码的开发,到前端到运维,最终成功在钉钉开放平台上线了基于SAAS的CRM。
  • 辛酸:有了媳妇,再多的辛酸都不是辛酸,嘿嘿嘿。

连续两家单位暴雷

17年-18年,这两年比较奇葩的一年,16年我们经历了一年左右的创业,但是最终,因为产品的销售问题,成效不是很好,于是创业这件事情也就黄了。后来又入职了P2P公司,记得15年-18年左右的时候,这种金融公司特别火爆,并且里面的薪水开的也很不错。然而在17年12月份左右,第一家p2p公司暴雷。老板跑去公安局自首了,老猫失业了,但是失业后的第一周就又另外找了家p2p的单位成功入职,但是万万没想到的是到次年的3月份,老板也跑去公安局自首了。记得当时的场景是这样的,一群金侦局的人冲进公司让我们双手离开电脑键盘,手机上缴。我们的经理干脆吓的双手举起,靠到了墙上。老猫记得在基友群里发送的最后一段文字是“完了,警察来了”,之后老猫被关在公司整整一天,到下午5点左右才从公司放出来。基友们炸了,差点没去警局保我,后来拿到手机之后回复了他们,他们才知道情况。这意味着又失业了,而且还有两个月的薪水没有拿到。

  • 住所:还是在五号线附近。
  • 通勤:由于两个p2p的公司都在浦东,所以浦西到浦东去上班,整整单次通勤时间为两个小时,可想而知的辛酸,但是为了媳妇,坚持下来了。
  • 辛酸:连续两次暴雷,对老猫内心造成的伤害超级大,一度认为不适合工作,后来基友也嘲讽说我是“企业杀手”,去哪家哪家倒。
  • 喜事儿:后来18年的5月份和认识三年的媳妇结婚了,女朋友成为了老婆。

还算不错

18年-20年,这期间又是将近三年的时间,老猫从那次暴雷之后,入职了一家不错的单位,兢兢业业的工作赚钱养家。目前也在这家单位也将近三年时间,此期间负责带领公司的结算技术团队,今年也开始兼顾起公司KA客户的技术支持,日子也还算顺利。当然今年也开通了老猫的个人微信公众号“程序员老猫”,也希望和大家聊聊技术,聊聊除了工作之外的副业。有兴趣的小伙伴可以加个关注。

  • 住所:从工作到现在一共经历了7次搬家,终于在上海付了首付,买了房。
  • 喜事儿:今年是特殊的一年,当然一个是疫情,另外一个是在今年的大年初一,老猫成功晋级奶爸,目前父母也在上海在帮助我们照顾小孩儿。
  • 辛酸:除了房贷,其他也还好,现在每次下班回家都会有个盼头,家里的老婆孩子都在还等着。

感想

一路走来,磕磕碰碰,困难的时候也咬牙坚持了下来。有了属于自己的一个地儿,有了家庭孩子。至于未来的打算么,就是好好工作,另外的话希望谋求除了本职工作以外的另一个副业,可能也是因为大家都听说“程序员35岁”的梗吧。当然也是在慢慢摸索中,如果再看本篇文章的你也有谋求副业这个打算的话,不妨加老猫的微信:ktdaddy或者公众号“程序员老猫”给老猫留言,咱们可以一起探讨探讨。

老猫相信,很多时候咱们遇到困难,再坚持一下,往往就能有一个好的结果。明年的目标是积累更多的技术知识,夯实底子,写更多优质的文章,和大家一起探讨一起成长,加油,未来还有很远,老猫一直在路上。

查看原文

赞 0 收藏 0 评论 0

程序员老猫 发布了文章 · 2020-12-17

海量数据切分,这么搞就完事儿了

背景

当今社会是一个信息大爆炸的社会,大家都在用各种应用软件,也因此产生了大量的数据,企业把这些数据当做宝贝,然而这些被视为宝贝的数据往往是我们技术人员的烦恼,这些海量的数据存储和访问成为了系统设计与使用的瓶颈,而这些数据往往存储在数据库中,然后传统的数据库又是存在不足的。单个数据库是存在性能瓶颈的,并且扩展起来十分困难,在当今这个大数据的时代,我们就必须要解决这样的问题。如果单机数据库易于扩展,数据可切分,就可以避免这些问题,但是当前的这些数据库厂商,包括开源的数据库MySQL在内,提供这些服务都是要收费的。所以我们一般转向第三方的软件,使用这些软件来给我们的数据做数据切分,将原本一台数据库上的数据,分散到多台数据库中,降低每一个单体数据库的负载。那么我们如何做数据切分呢?接下来,跟着老猫来看一下切分的方案。

数据切分

所谓的数据的切分其实就是按照某种规则将一台机器上的数据分配到多台数据库中,从而达到降低单台数据库压力的效果。我们所说的数据库切分,大致其实分为两类,一类是垂直切分,另外一类是水平切分。下面我们一一来看一下这两种的方案。

垂直切分

所谓的垂直切分就是按照不同的表或者schema切分到不同的数据库中。我们就简单举例电商产品中的订单表(order)以及商品信息表(product)以及会员(member),初期的时候我们可能放在同一个数据库中,现在我们就要对其进行拆分,拆分的规则就是讲不同的业务线表分别落到不同的物理机的不同的数据中心,使他们完全隔离,从而达到降低数据库负载的效果。如下示例图
垂直切分
垂直切分的特点其实就是规则比较简单,比较容易实施,可以根据不同的业务类型进行模块的划分,这样的话各个业务耦合性降低,相互影响也小。
老猫觉得一个架构设计比较好的应用系统,总体功能应该是有不同的业务模块组成的。每个不同的业务模块对应着数据里面的一系列的表。就刚才我们举例的三个业务模块,如果我们稍微扩展一下的话。应该会是这样的:

  • 订单模块:订单、订单明细、订单收货地址、订单日志等等表
  • 商品模块:类目、属性、属性值、商品、sku等等表
  • 会员模块:会员基本信息表、会员信息操作日志表等等

这样我们进行一下扩展应该就会变成如下图示
业务垂直拆分(细)
在我们对一个系统进行架构设计的时候,各个模块之间的交互越统一越好,越少越好。这样,系统模块之间的耦合度会很低,各个系统模块的可扩展性以及可维护性会大大提高,这样的系统如果后续数量级大的时候,我们实现数据的垂直切分就相当容易。
但是,在我们实际的系统架构设计中,往往很难做到完全的独立,表和表之间存在夸库join的查询还是会有的。比如我们要查询一个类目下面产生了多少个订单,如果是单个数据库的话,我们直接连表查询即可,但是现在垂直切分成两块数据的话,这时候就要通过调用接口的方式去查询,这样系统的复杂程度就会提高。所以此时就要去平衡,是数据库让步于业务,将这些表放在一个数据库中,还是拆分成多个数据库,然后通过接口的方式来调用。如何去切分,切分到什么程度其实对于架构师来说是一个考验。
关于垂直气氛最终优缺点的整理:
优点:

  • 拆分之后,我们的业务更加地清晰了,拆分规则也比较明确。
  • 数据维护变得简单。
  • 系统之间更加容易扩展和整合。

缺点:

  • 业务中表和表无法做join查询,只能通过接口调用,提升了系统的复杂度。
  • 如果涉及到事务,跨库事务比较难处理。
  • 虽然进行了垂直切分,但是有些业务数据还是会过于庞大,例如订单,其实依旧存在着单体性能的瓶颈。

以上我们讲述了垂直切分的缺点,然而最后一点我们如何才能解决呢?这个时候其实我们就需要用到水平切分。

水平切分

水平切分其实会比垂直切分更加复杂,它需要根据特定的规则将一张表中的数据拆分到不同的数据库中。例如,举个比较简单的例子,我们之前说的订单垂直切分之后业务数据依旧很大,那么我们可以根据某种规则进行水平切分,比方说根据订单编号的奇数或者是偶数,将编号为奇数的订单存放到数据库A中,将编号为偶数的订单存放到数据库B中。但是给我们带来的麻烦就是查询的时候需要根据奇偶性去不同的库中查询数据。我们来看一下水平切分的架构图,如下:
水平拆分
在进行水平拆分的时候,我们需要定义数据具体按照什么维度去拆分,前面的订单中,我们提及说按照尾号的奇偶去拆分,我们想一下会有什么问题呢?我是一个用户,我下了两单,一单订单编号为奇数,另外一单是偶数,这时候我们查看自己下单记录的时候,就需要根据用户的ID去两个不同的库中分别查询两单数据,可想而知这种是相当麻烦的。
所以我们在进行水平拆分的时候需要结合具体的业务场景。如果我们按照用户的ID去拆是不是就OK了呢?其实也不一定,我们换个角度,如果我们站在不是用户的立场而是站在商户的立场。在商户后台也会有很多订单,商户需要管理自己的订单,订单拆分的时候我们根据用户的ID,这就意味着很多商户在获取订单的时候还是要去不同的订单表中查询,然后聚合成一张订单表给商户,此时我们用用户ID去拆分显然是不合理的。
我们看看场景的几种水平拆分的方法:

  • 用户ID求模法,上述已经提及。
  • 按照日期去拆分数据。
  • 按照其他字段进行求模拆分数据。

上述用户ID求模法示意图如下:
用户ID求模法示意图
综上我们再看一下水平切分的优点和缺点,
优点:

  • 解决了单库大数据、高并发的性能瓶颈。
  • 拆分规则封装完毕之后对应用层透明,开发人员无需关心拆分细节。
  • 提高了系统的稳定性以及负载能力。

缺点:

  • 拆分规则很难定义。
  • 事务一致性问题难解决。
  • 二次扩展的时候(例如根据模3扩展成模5的时候,历史数据的处理,此时由三台数据库变成5台数据库),数据迁移,维护的难度比较大。

写在最后

其实世界上就没有完美的事情,有利也有弊。大数据切分也是一样,无论是垂直切分还是水平切分。这两种虽然都解决了海量数据的存储以及访问的性能问题,但是同时又会产生很多新的问题。共同的问题:

  • 分布式的事务问题。
  • 跨库连接查询问题。
  • 多个数据源的管理问题。

针对最后一种情况,多源数据的管理问题,其实有两种思路:

  1. 客户端模式——在每个应用模块内,配置自己需要的数据源,然后进行数据库的访问。
  2. 中间代理模式——中间代理统一管理所有的数据源,数据库层对开发人员透明,开发人员无需关注拆分细节。

根据上述两种模式,其实市面就有成熟的第三方的软件。MyCat(中间代理模式)和sharding-jdbc(客户端模式)。
由于篇幅的限制,后续老猫会详细针对这两种软件的实际落地做举例。

查看原文

赞 0 收藏 0 评论 0

程序员老猫 关注了专栏 · 2020-12-15

争做认真学习冠军

?争做认真学习冠军

关注 3655

程序员老猫 关注了专栏 · 2020-12-15

SegmentFault 行业快讯

第一时间为开发者提供行业相关的实时热点资讯

关注 63891

程序员老猫 关注了用户 · 2020-12-15

曾是然 @sranpro

Hello World~

关注 865

程序员老猫 关注了专栏 · 2020-12-15

Java中文社群

专注Java干货和面试题分享

关注 3844

认证与成就

  • 获得 1 次点赞
  • 获得 1 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 1 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

  • 猫爸博客

    个人的博客项目,里面有自己沉淀下来的文章

注册于 2020-12-15
个人主页被 511 人浏览