THE SQL Server Blog Spot on the Web

Welcome to SQLblog.com - The SQL Server blog spot on the web Sign in | |
in Search

Alexander Kuznetsov

  • Inline UDFs do not prevent parallel execution plans

    Lots of things can prevent parallel execution plans, but inline UDFs are not one of them. Inline UDFs are essentially macros - they are at least as fast as CTEs. As such, the optimizer can produce exactly the same parallel execution plans whether if our queries are wrapped in inline UDFs or copied and pasted into the main query.

    As usual, I will provide a repro script.

    Prerequisites

    Let us set up a table with 2 million rows - that should be enough to qualify for a parallel plan:


    CREATE TABLE dbo.Numbers(n INT NOT NULL PRIMARY KEY);
    GO

    INSERT INTO dbo.Numbers
        
    ( n )
            
    VALUES  ( 1 );
    GO
    DECLARE @i INT;
        
    SET @i=0;
    WHILE @i<21  
        
    BEGIN
        INSERT INTO
    dbo.Numbers
            
    ( n )
            
    SELECT n + POWER(2, @i)
            
    FROM dbo.Numbers;
        
    SET @i = @i + 1;
        
    END;  
    GO

    SELECT  n ,
            
    'asdfasdfas' AS Filler
    INTO    dbo.ParallelTest
    FROM    dbo.Numbers ;
    GO

    ALTER TABLE dbo.ParallelTest ADD CONSTRAINT PK_ParallelTest PRIMARY KEY(n) ;
    GO
     

    Running a query with parallel execution plan

    The following query runs with parallel execution plan (number of executions is 12):

    SET STATISTICS IO ON;
    SET STATISTICS TIME ON;
    GO
    SELECT COUNT(*) FROM dbo.ParallelTest
    WHERE Filler LIKE '%nosuchstring%'

    Let us wrap it in an inline UDF:

    CREATE FUNCTION dbo.MatchCountInParallelTestByPattern ( @Pattern VARCHAR(10) )
    RETURNS TABLE
    AS RETURN
      
    ( SELECT  COUNT(*) AS MatchCount
        
    FROM    dbo.ParallelTest
        
    WHERE   Filler LIKE @Pattern
      
    ) ;

    The following query invokes the inline UDF, yet still runs with exactly the same parallel execution plan (number of executions is the same: 12):


    SELECT  MatchCount
    FROM    dbo.MatchCountInParallelTestByPattern('%nosuchstring%') ;
     

    Conclusion

    At the time of this writing, reusing code wrapped in inline UDFs does not prevent parallel execution plans, at least on the following version:

    Microsoft SQL Server 2008 R2 (RTM) - 10.50.1600.1 (X64)   Apr  2 2010 15:48:46   Copyright (c) Microsoft Corporation  Developer Edition (64-bit) on Windows NT 6.1 <X64> (Build 7600: ) 

     

  • Using constraints to enforce uniqueness of ordered sets of rows

    Even the simplest data integrity rules are not easy to implement in the database if instead of individual rows we need to deal with groups or subsets.

    For example, making sure that a column value is unique in a table is as trivial as creating a unique constraint or index. However, what are we supposed to do if we do not want to store a group of values twice? What if we store cooking recipes as sequences of steps (sets of values), and we do not want to store one and the same recipe (one set of values) more than once?

    In the following sections we shall solve this problem using only constraints and indexed views, just for fun, as a SQL puzzle. (Of course, we could use a trigger or some other thing with subqueries - but that would be a different puzzle).

    At the time of this writing I have not yet used in production the technique I am going to describe. I think it is quite complex and looks tricky, and the reason is simple: in my opinion RDBMS have not been designed to deal with this kind of problems. As such, we have to come up with complex workarounds.

    Enforcing Uniqueness of Ordered Sets, of Limited Size Only

    In this solution we shall deal with sets that have at most five elements.

    In some cases, the order of elements in a subset matters. For example, the following two sequences of instructions yield different results.

    Cooking a hard-boiled egg:

     

    • Put eggs into pan, add water
    • Bring water to boiling, boil 4 minutes;
    • Remove eggs and cool them down in cold water

     

    Keeping the egg uncooked:

     

    • Put eggs into pan, add water
    • Remove eggs and cool them down in cold water
    • Bring water to boiling, boil 4 minutes

     

    These two recipes are clearly different, and they yield different results. Of course, the second recipe makes no sense, but we want the database to store them both.

    Suppose, however, that the database should not be able to store one and the same recipe more than once.

    Setting up tables and test data

    Before implementing this requirement, let us create the tables and add test data. Both recipes discussed above involve the same three steps:


    CREATE TABLE dbo.RecipeSteps
        
    (
          
    RecipeStepId INT NOT NULL ,
          
    Intsructions VARCHAR(50) NOT NULL ,
          
    CONSTRAINT PK_RecipeSteps PRIMARY KEY ( RecipeStepId ) 
        ) ;
    GO
    INSERT  INTO dbo.RecipeSteps
            
    ( RecipeStepId, Intsructions )
    VALUES  ( 1, 'Put eggs into pan, add water' ),
            ( 
    2, 'Bring water to boiling, boil 4 minutes' ),
            ( 
    3, 'Remove eggs and cool them down in cold water' ) ; 

     

    Let us upload upload the first recipe, as follows:

     

    CREATE TABLE dbo.RecipesStepByStep
        
    (
          
    RecipeId INT NOT NULL ,
          
    StepNumber SMALLINT NOT NULL ,
          
    RecipeStepId INT NOT NULL ,
          
    CONSTRAINT PK_RecipesStepByStep PRIMARY KEY ( RecipeId, StepNumber ) ,
          
    CONSTRAINT FK_RecipesStepByStep_RecipeSteps FOREIGN KEY ( RecipeStepId ) REFERENCES dbo.RecipeSteps ( RecipeStepId )
        ) ;
    GO

    INSERT  INTO dbo.RecipesStepByStep
            
    ( RecipeId, StepNumber, RecipeStepId )
    VALUES  ( 1, 1, 1 ),
            ( 
    1, 2, 2 ),
            ( 
    1, 3, 3 ) ;

    SELECT  StepNumber ,
            
    Intsructions
    FROM    dbo.RecipeSteps AS rs
            
    JOIN dbo.RecipesStepByStep AS sbs ON rs.RecipeStepId = sbs.RecipeStepId
    WHERE   sbs.RecipeId = 1 ;

    StepNumber Intsructions
    ---------- --------------------------------------------------
    1          Put eggs INTO pan, ADD water
    2          Bring water 
    TO boiling, boil 4 minutes
    3          Remove eggs 
    AND cool them down IN cold water

     

    Let us also add the second recipe:



    INSERT  INTO dbo.RecipesStepByStep
            
    ( RecipeId, StepNumber, RecipeStepId )
    VALUES  ( 2, 1, 1 ),
            ( 
    2, 2, 3 ),
            ( 
    2, 3, 2 ) ;

    SELECT  StepNumber ,
            
    Intsructions
    FROM    dbo.RecipeSteps AS rs
            
    JOIN dbo.RecipesStepByStep AS sbs ON rs.RecipeStepId = sbs.RecipeStepId
    WHERE   sbs.RecipeId = 2 ;

    StepNumber Intsructions
    ---------- --------------------------------------------------
    1          Put eggs INTO pan, ADD water
    2          Remove eggs 
    AND cool them down IN cold water
    3          Bring water 
    TO boiling, boil 4 minutes 

     

    Right now nothing prevents us from adding a second copy of the first recipe. You can run the following script and see for yourself that it succeeds:

     


    BEGIN TRANSACTION ;

    INSERT  INTO dbo.RecipesStepByStep
            
    ( RecipeId, StepNumber, RecipeStepId )
    VALUES  ( 3, 1, 1 ),
            ( 
    3, 2, 2 ),
            ( 
    3, 3, 3 ) ;

    SELECT  StepNumber ,
            
    Intsructions
    FROM    dbo.RecipeSteps AS rs
            
    JOIN dbo.RecipesStepByStep AS sbs ON rs.RecipeStepId = sbs.RecipeStepId
    WHERE   sbs.RecipeId = 3 ;

    ROLLBACK ;

    To enforce uniqueness of recipes, we shall use an indexed view and a unique index on top of it.

    To keeps the scripts simple, let us assume that recipies cannot consist of more than five steps:

    ALTER TABLE dbo.RecipesStepByStep 
    ADD CONSTRAINT RecipesStepByStepMax5 CHECK (StepNumber BETWEEN 1 AND 5) ;

    Let us also create an indexed view that stores all the steps of a recipe in one row.

    Once we have all the steps in one row, we can use a unique index to make sure that a sequence of steps is unique. We want the contents of the indexed view to look as follows, with the first line representing the first recipe:

     


    RecipeId    Step1       Step2       Step3       Step4       Step5
    ----------- ----------- ----------- ----------- ----------- -----------
    1           1           2           3           0           0
    2           1           3           2           0           0

     

    Note: To get around the limitations of indexed views, we need to make sure that all the pivoted columns are not nullable. This is why columns Step4 and Step5 are zeroes instead of nulls – otherwise we would not be able to create the unique index RecipesStepByStepsPivoted_UniqueRecipes in the following script:

     


    CREATE VIEW dbo.RecipesStepByStepsPivoted
    WITH SCHEMABINDING
    AS
    SELECT 
    RecipeId, 
    count_big(*) AS Cnt,
    SUM(CASE WHEN StepNumber = 1 THEN RecipeStepId ELSE 0 END) AS Step1,
    SUM(CASE WHEN StepNumber = 2 THEN RecipeStepId ELSE 0 END) AS Step2,
    SUM(CASE WHEN StepNumber = 3 THEN RecipeStepId ELSE 0 END) AS Step3,
    SUM(CASE WHEN StepNumber = 4 THEN RecipeStepId ELSE 0 END) AS Step4,
    SUM(CASE WHEN StepNumber = 5 THEN RecipeStepId ELSE 0 END) AS Step5
    FROM dbo.RecipesStepByStep
    GROUP BY RecipeId ;
    GO

    CREATE UNIQUE CLUSTERED INDEX RecipesStepByStepsPivoted_CI
    ON dbo.RecipesStepByStepsPivoted(RecipeId) ;
    GO

    CREATE UNIQUE INDEX RecipesStepByStepsPivoted_UniqueRecipes
    ON dbo.RecipesStepByStepsPivoted(Step1, Step2, Step3, Step4, Step5) ;
    GO
      
    SELECT  RecipeId ,
            
    Step1 ,
            
    Step2 ,
            
    Step3 ,
            
    Step4 ,
            
    Step5 
    FROM    dbo.RecipesStepByStepsPivoted ;


    RecipeId    Step1       Step2       Step3       Step4       Step5
    ----------- ----------- ----------- ----------- ----------- -----------
    1           1           2           3           0           0
    2           1           3           2           0           0

    It is easy to verify that the unique index built on top of the indexed view does enforce the uniqueness of recipes:


    BEGIN TRANSACTION ;

    INSERT  INTO dbo.RecipesStepByStep
            
    ( RecipeId, StepNumber, RecipeStepId )
    VALUES  ( 3, 1, 1 ),
            ( 
    3, 2, 2 ),
            ( 
    3, 3, 3 ) ;

    SELECT  StepNumber ,
            
    Intsructions
    FROM    dbo.RecipeSteps AS rs
            
    JOIN dbo.RecipesStepByStep AS sbs ON rs.RecipeStepId = sbs.RecipeStepId
    WHERE   sbs.RecipeId = 3 ;

    ROLLBACK ;

    Cannot INSERT duplicate KEY row IN object 'dbo.RecipesStepByStepsPivoted' WITH UNIQUE INDEX 'RecipesStepByStepsPivoted_UniqueRecipes'.
    The statement has been terminated.


    RecipeId    Step1       Step2       Step3       Step4       Step5
    ----------- ----------- ----------- ----------- ----------- -----------
    1           1           2           3           0           0
    2           1           3           2           0           0

     

    Note: because we are using an undex to enforce uniqueness, we must set a limit on the number of elements. It does not have to be as low as five – we set it that low just to keep the examples short.

    Let us close a couple of loopholes in this limited solution and move on to another problem.

    First, we used zeroes in the indexed view to indicate that there is no such step in the recipe. In our example both recipies had three steps, so the indexed view had zeroes in Step4 and Step5 columns in both rows. To distinguish between missing steps and valid step IDs, we need to make sure that zero is not a valid ID:

     

    ALTER TABLE dbo.RecipeSteps
    ADD CONSTRAINT CHK_RecipeSteps_PositiveId CHECK ( RecipeStepId > 0 );

    Also we need to make sure that step numbers in the recipe have no gaps, otrherwise we still can store the same recipe twice. The following script demonstrates the loophole:

     

    BEGIN TRANSACTION ;

    INSERT  INTO dbo.RecipesStepByStep
            
    ( RecipeId, StepNumber, RecipeStepId )
    VALUES  ( 3, 1, 1 ),
            ( 
    3, 3, 2 ),
            ( 
    3, 5, 3 ) ;

    SELECT  StepNumber ,
            
    Intsructions
    FROM    dbo.RecipeSteps AS rs
            
    JOIN dbo.RecipesStepByStep AS sbs ON rs.RecipeStepId = sbs.RecipeStepId
    WHERE   sbs.RecipeId = 3 ;

    ROLLBACK ;

    StepNumber Intsructions
    ---------- --------------------------------------------------
    1          Put eggs INTO pan, ADD water
    3          Bring water 
    TO boiling, boil 4 minutes
    5          Remove eggs 
    AND cool them down IN cold water
     

     

    To close the loophole, we need to make sure that step numbers have no gaps. The following script enforces the rule:

     

    ALTER TABLE dbo.RecipesStepByStep
    ADD PreviousStepNumber AS CAST(CASE WHEN StepNumber > 1 THEN StepNumber-1 END AS SMALLINT) PERSISTED ;
    GO

    ALTER TABLE dbo.RecipesStepByStep
    ADD CONSTRAINT FK_RecipesStepByStep_PreviousStep
    FOREIGN KEY(RecipeId, PreviousStepNumber) 
    REFERENCES dbo.RecipesStepByStep(RecipeId, StepNumber);

     

    Conclusion

    As we have seen, enforcing the uniqueness of ordered groups of rows with constraints is doable, although somewhat complex. I am not sure if I would use this approach with a large production table. I think that RDBMS is not designed to natively solve these kinds of problems - this is why solving this apparently simple problem is so complicated.

    If you are using some platform other than RDBMS to solve such problems, please comment this post and share your experiences.

     

     

     

  • The client code that handles timeouts

    After timeouts we need to make sure that active transactions, if any, are rolled back. All timeout handling must be done on the client. This post provides the implementation and unit tests.

    Implementation

    The following class extends SqlCommand and rolls back active transactions after timeouts:


        
    public static class SqlCommandExtentions
        {
            
    public static void ExecuteNonQueryWithErrorHandling(this SqlCommand command)
            
    {
                
    try
                
    {
                    command.ExecuteNonQuery
    ();
                
    }
                
    catch (SqlException e)
                
    {
                    HandleSqlException
    (command, e);
                    
    throw;
                
    }
            }

            
    public static SqlDataReader ExecuteReaderWithErrorHandling(this SqlCommand command)
            
    {
                
    try
                
    {
                    
    return command.ExecuteReader();
                
    }
                
    catch (SqlException e)
                
    {
                    HandleSqlException
    (command, e);
                    
    throw;
                
    }
            }

            
    private static void HandleSqlException(SqlCommand command, SqlException e)
            
    {
                
    const int timeoutCode = -2;
                
    if (e.Number != timeoutCode) return;
                
    var rollbackCommand = command.Connection.CreateCommand();
                
    rollbackCommand.CommandText = "IF @@TRANCOUNT > 0 BEGIN ; ROLLBACK ;  END ;";
                
    rollbackCommand.CommandType = CommandType.Text;
                
    rollbackCommand.CommandTimeout = 0;
                
    rollbackCommand.ExecuteNonQueryWithErrorHandling();
            
    }
        }
      

    Testing

    We need to unit test the two methods. For each one, we need to test the following four cases:

    • Command succeeds 
    • Command blows up with some other error, which must not be caught by our handling
    • Command times out, active transaction
    • Command times out, no active transaction

    The following tests cover these four cases for ExecuteReader. The other four tests are very similar, so there is no need to post them.

     
            [Test]
            
    public void ExecuteReaderWithErrorHandling_WorksOnSuccess()
            
    {
                
    using (var dr = ExecuteReaderWithErrorHandling(sqlConn, "SELECT 1 AS n"))
                
    {
                    Assert.IsTrue
    (dr.Read());
                    
    Assert.AreEqual(1, dr.GetInt32(0));
                
    }
            }

            [Test]
            
    public void ExecuteReaderWithErrorHandling_HandlesTimeout_ActiveTransaction()
            
    {
                
    try
                
    {
                    ExecuteReaderWithErrorHandling
    (sqlConn, "EXEC dbo.IWillTimeOut", timeoutInSeconds: 1);
                    
    Assert.Fail("Must throw exception");
                
    }
                
    catch(SqlException e)
                
    {
                    Assert.AreEqual
    (-2, e.Number);
                    
    VerifyNoOpenTransaction(sqlConn);
                
    }
            }

            [Test]
            
    public void ExecuteReaderWithErrorHandling_HandlesTimeout
    _NoActiveTransaction()
            
    {
                
    try
                
    {
                    ExecuteReaderWithErrorHandling
    (sqlConn, "EXEC dbo.IWillTimeOutWithoutTransaction", timeoutInSeconds: 1);
                    
    Assert.Fail("Must throw exception");
                
    }
                
    catch (SqlException e)
                
    {
                    Assert.AreEqual
    (-2, e.Number);
                    
    VerifyNoOpenTransaction(sqlConn);
                
    }
            }

            [Test]
            
    public void ExecuteReaderWithErrorHandling_ThrowsOtherExceptions()
            
    {
                
    try
                
    {
                    ExecuteReaderWithErrorHandling
    (sqlConn, "EXEC dbo.IThrowError8134");
                    
    Assert.Fail("Must throw exception");
                
    }
                
    catch (SqlException e)
                
    {
                    Assert.AreEqual
    (8134, e.Number);
                    
    VerifyNoOpenTransaction(sqlConn);
                
    }
            }

    These unit tests utilize a few database objects:

     
    CREATE FUNCTION dbo.IWillCompleteInNSeconds ( @NumSeconds INT )
    RETURNS INT
    AS
        BEGIN
    ;
          
    DECLARE @StartTime DATETIME ,
            
    @Toggle INT ;
          
    SELECT  @StartTime = GETDATE() ,
                  
    @Toggle = 1 ;
          
    WHILE DATEDIFF(SECOND, @StartTime, GETDATE()) < @NumSeconds
            
    BEGIN ;
              
    SET @Toggle = 1 - @Toggle ;
            
    END ;
          
    RETURN 0 ;
        
    END ;
    GO

    CREATE PROCEDURE dbo.IWillTimeOut
    AS
      BEGIN
    ;
        
    BEGIN TRANSACTION ;
        
    SELECT  dbo.IWillCompleteInNSeconds( 5 ) AS SomeNumber ;
        
    COMMIT ;
      
    END ;
    GO

    CREATE PROCEDURE dbo.IWillTimeOutWithoutTransaction
    AS
      BEGIN
    ;
        
    WAITFOR DELAY '00:00:05' ;
      
    END ;
    GO

    CREATE PROCEDURE dbo.IThrowError8134
    AS
    BEGIN
    ;
        
    SET XACT_ABORT ON ;
        
    DECLARE @i INT ;
      
    SELECT @i = 1/0 ;
      
    SELECT 1 AS n;
    END ;
     

  • After the timeout

    After the timeout, we may sometimes, but not always, end up with an outstanding transaction. If this is the case, we need to rollback the transaction ourselves. Otherwise the consequent commands which use the same connection might not work as expected.

    Let us debug through some client code and see what is happening in more detail.

    Prerequisites

    We shall need a few database objects, as follows:

     CREATE TABLE dbo.Log1(InTran VARCHAR(20));
    GO
    CREATE PROCEDURE dbo.TestTran
    AS
    BEGIN
       IF
    @@TRANCOUNT > 0 BEGIN
                           INSERT
    dbo.Log1(InTran) VALUES('In Tran');
                          
    END
                          ELSE BEGIN
                           INSERT
    dbo.Log1(InTran) VALUES('Not In Tran');
                          
    END ;
    END ;
    GO

    CREATE FUNCTION dbo.IWillCompleteInNSeconds ( @NumSeconds INT )
    RETURNS INT
    AS
        BEGIN
    ;
          
    DECLARE @StartTime DATETIME ,
            
    @Toggle INT ;
          
    SELECT  @StartTime = GETDATE() ,
                  
    @Toggle = 1 ;
          
    WHILE DATEDIFF(SECOND, @StartTime, GETDATE()) < @NumSeconds
            
    BEGIN ;
              
    SET @Toggle = 1 - @Toggle ;
            
    END ;
          
    RETURN 0 ;
        
    END ;
    GO

    CREATE PROCEDURE dbo.IWillTimeOut
    AS
      BEGIN
    ;
        
    BEGIN TRANSACTION ;
        
    SELECT  dbo.IWillCompleteInNSeconds( 5 ) AS SomeNumber ;
        
    COMMIT ;
      
    END ;
    GO
     

    Running the example

    In the following test, add three breakpoints at the placeholder statements:

           [Test]
            
    public void TimeoutTest()
            
    {
                
    for (var i = 0; i < 1000; i++)
                
    {
                    
    using (var conn = new SqlConnection(localConnString))
                    
    {
                        conn.Open
    ();
                        
    try
                        
    {
                            
    const string iwilltimeout = "EXEC dbo.IWillTimeOut";
                            
    ExecuteSql(conn, iwilltimeout, 1);
                        
    }
                        
    catch (SqlException e)
                        
    {
                            System.Console.WriteLine
    (e.Message + " " + e.Number);
                        
    }
                        
    const string saveTrancount = "EXEC dbo.TestTran";
                        
    ExecuteSql(conn, saveTrancount);
                        
    System.Console.WriteLine("Breakpoint placeholder 1");
                    
    }
                    
    using (var conn = new SqlConnection(localConnString))
                    
    {
                        conn.Open
    ();
                        
    const string saveTrancount = "EXEC dbo.TestTran";
                        
    System.Console.WriteLine("Breakpoint placeholder 2");
                        
    ExecuteSql(conn, saveTrancount);
                        
    System.Console.WriteLine("Breakpoint placeholder 3");
                    
    }
                }
            }
      

    Let us start a profiling session that captures individual SP:StmtStarting and SP:StmtCompleted events. Also let us debug through this code.

    First breakpoint - outstanding transaction

    When we stop at the first breakpoint, let us run the following query:

    SELECT * FROM dbo.Log1 WITH(NOLOCK)

    It will return "In Tran", which means that after the timeout dbo.TestTran was called in the middle of an outstanding transaction.

    Note: not all timeouts result in outstanding transactions. For example, later you can rerun this example using a different stored procedure:


    ALTER PROCEDURE dbo.IWillTimeOut
    AS
      BEGIN
    ;
        
    BEGIN TRANSACTION ;
        
    WAITFOR DELAY '00:00:05' ;
        
    COMMIT ;
      
    END ;
    GO

    You can see for yourself that when this procedure times out, there will be no outstanding transaction.

    Second breakpoint - still outstanding transaction

    Let us debug to the second breakpoint. Technically the first connection went out of scope and has been reused by the second using block, but we can still see the same uncommitted data:

    SELECT * FROM dbo.Log1 WITH(NOLOCK)

    The connection has not been reset yet - we do not see sp_reset_connection in the Profiler yet.

    Third breakpoint - connection reset

    Only when we actually get to run a command against the reused connection, the connection is actually reset. We can observe sp_reset_connection call in the Profiler, and the uncommitted data inserted by the first invocation of dbo.TestTran is gone.

    Note: to my best knowledge, there is no guarantee that the same connection will be reused. It consistently happens on my box, but it could work differently on yours. As usual with closed source systems, one can never know for sure.

    Consequences of continuing to use the same connection after the timeout

    As we have seen, if we catch a timeout and later modify the database using the same connection, all the modifications done after the timeout may be eventually rolled back and lost, without us getting any error messages.

    Conclusion

    We need to rollback the outstanding transaction ourselves. We must also be aware that not all timeouts result in outstanding transactions. I have written two extension methods that implement it, and I will post it soon.

    You may want to read more about connection pools here

  • Book Review: Pro SQL Server 2008 Relational Database Design and Implementation

    Investing in proper database design is a very efficient way to cut maintenance costs. If we expect a system to last, we need to make sure it has a good solid foundation - high quality database design.

    Surely we can and sometimes do cut corners and save on database design to get things done faster. Unfortunately, such cutting corners frequently comes back and bites us: we may end up spending a lot of time solving issues caused by poor design. 

    So, solid understanding of relational database design is absolutely essential for database developers, including Agile folks like myself. Let me put it stronger: we Agile developers especially need solid understanding of relational database design, because we are quite likely to cut corners and spend just enough effort to pass unit tests.

    This is why I think the book by "Pro SQL Server 2008 Relational Database Design and Implementation" by Louis Davidson, Kevin Kline, and others is a must read.

    The following is a chapter by chapter synopsis

    Introduction to Database Concepts

    This chapter introduces several crucial theoretical concepts, and each concept is demonstrated with a short crisp example. The chapter must be read before proceeding any further.

    The Language of Data Modeling

    This chapter describes various aspects of traditional waterfall methodology. As such, it can safely be skipped by Agile developers.

    Conceptual Data Modeling

    This chapter deals with business rules and requirements, and provides very good practical examples. As the previous chapter, all the examples are described in the context of waterfall methodology. However, this should not turn us off. Agile developers need to identify business rules and requirements, and implement them via database design, as mush as anyone else.

    This chapter is a must read for everyone, even if some of us do not use Erwin or Visio.

    The Normalization Process

    This chapter builds up on the concepts introduced in the previous one. As such, we shall get better results if we read about Conceptual Data Modeling first.

    As before, all theoretical concepts are illustrated with succinct practical examples. 

    In my opinion, this chapter is most important in this book - it gives a great description of a complex technique that is not always described well. If it was the last chapter in this book, the book would still be a highly useful one.

    Of course, there are other books describing normalization. This one really stands out because it provides clear in-depth explanations with good concrete practical examples.

    The rest of the book

    The next chapters of the book are describing various steps needed to implement the complete RDBMS solution, including developing T-SQL, securing access, indexing and so on. All these chapters are useful and practical as well.

    Although these chapters are very useful and a pleasure to read, I am not going into more detail here. The reason: many other books describe similar things as well. In my opinion, it is the first chapters that really make this book unique.

    Conclusion

    We may be using very different methodologies to develop systems build on top of RDBMS. However, eventually professionally developed systems that are meant to last should have proper database design.

    The book provides a practical description of what  is proper database design. As such, it will be really useful for all developers who want to develop robust systems using RDBMS.

    Enjoy!

     

  • Awesome videos on Agile

    The following videos are a must-see for anyone interested in Agile methodology:

     

    Decisions, Decisions

     

    Embracing Uncertainty

     

    Patterns of Effective Delivery

    Enjoy!

     

  • Yet another use of OUTER APPLY in defensive programming

    When a SELECT is used to populate variables from a subquery, it fails to change them if the subquery returns nothing - and that can lead to subtle bugs. We shall use OUTER APPLY to eliminate this problem.

    Prerequisites

    All we need is the following mock function that imitates a subquery:

    CREATE FUNCTION dbo.BoxById ( @BoxId INT )
    RETURNS TABLE
    AS RETURN
      
    ( SELECT  CAST(1 AS INT) AS [Length] ,
                
    CAST(2 AS INT) AS [Width] ,
                
    CAST(3 AS INT) AS [Height]
        
    WHERE   @BoxId = 1
      
    ) ;

    Let us assume that this subquery is slow and we do not want to run it more than absolutely necessary.

    Short recap on SET vs. SELECT

    The following approach uses SET to populate variables:

    DECLARE @Length INT ,
      
    @Width INT ,
      
    @BoxId INT ;

    SELECT  @Length = 0 ,
            
    @Width = 0  ;

    SET @BoxId = 1 ;

    SET @Length = ( SELECT  [Length]
                    
    FROM    dbo.BoxById(@BoxId)
                  ) ;
    SET @Width = ( SELECT [Width]
                  
    FROM   dbo.BoxById(@BoxId)
                 ) ;

    This script will always change the values of variables, even when the subquery returns nothing, which is good. However, it runs a possibly slow/expensive subquery twice. Also it duplicates code. We want to avoid both these disadvantages.

    The following script uses SELECT, it has no duplication, and it runs the subquery only once:

    DECLARE @Length INT ,
      
    @Width INT ,
      
    @BoxId INT ;
      
    SELECT  @Length = 0 ,
            
    @Width = 0  ;

    SET @BoxId = 1 ;

    SELECT  @Length = [Length] ,
            
    @Width = [Width]
    FROM    dbo.BoxById(@BoxId) ;

    SELECT  @Length AS [Length] ,
            
    @Width AS [Width] ;
     

    This code looks cleaner and performs better, but it has the following problem: if the subquery returns nothing, the variables are not changed. You can change @BoxId from 1 to any other number, rerun the script, and see for yourself.

    When the variables are not changed by an assignment, that can lead to subtle bugs, 

    like this one.

    To avoid this vulnerability, we can use OUTER APPLY, as follows:

    DECLARE @Length INT ,
      
    @Width INT ,
      
    @BoxId INT ;
      
    SELECT  @Length = 0 ,
            
    @Width = 0  ;

    SET @BoxId = 2 ;

    SELECT  @Length = [Length] ,
            
    @Width = [Width]
    FROM    (SELECT 1 AS PlaceHolder) AS AlwaysOneRow
    OUTER APPLY dbo.BoxById(@BoxId) ;

    SELECT  @Length AS [Length] ,
            
    @Width AS [Width] ;

    As a result, we still have no duplication, run an expensive subquery only once, and also always change the variables, making the code less vulnerable to bugs.
  • Catching multiple exceptions on the client is robust and easy

    Maria Zakourdaev has just demonstrated that if our T-SQL throws multiple exceptions,
    ERROR_MESSAGE() in TRY..CATCH block will only expose one.

    When we handle errors in C#, we have a very easy access to all errors.

    The following procedure throws two exceptions:

    CREATE PROCEDURE dbo.ThrowsTwoExceptions
    AS
      BEGIN
    ;
        
    RAISERROR('Error 1', 16, 1) ;
        
    RAISERROR('Error 2', 16, 1) ;
      
    END ;
    GO

    EXEC dbo.ThrowsTwoExceptions ;

    Both exceptions are shown by SSMS:

    Msg 50000, LEVEL 16, State 1, PROCEDURE ThrowsTwoExceptions, Line 4
    Error 1
    Msg 50000
    , LEVEL 16, State 1, PROCEDURE ThrowsTwoExceptions, Line 5
    Error 2

    The following C# code shows how easily we can access both exceptions:

    try
    {
        var command
    = connection.CreateCommand();
        
    command.CommandText = "dbo.ThrowsTwoExceptions";
        
    command.CommandType = CommandType.StoredProcedure;
        
    command.ExecuteNonQuery();
    }
    catch(SqlException e)
    {
        Console.WriteLine
    (e.ToString());
        
    foreach (var error in e.Errors)
        
    {
            var exception
    = error as SqlError;
            
    if(exception != null)
            
    Console.WriteLine(string.Format("Number: {0} Message: {1}", exception.Number, exception.Message));
        
    }
    }

    Its output is as follows:

    Number: 50000 Message: Error 1
    Number: 50000 Message: Error 2

    As we have seen, catching more than one exception is easy if we use C#.

  • Avoiding nested transactions might not improve performance.

    Beginning a transaction only when @@TRANCOUNT=0 might not improve performance at all. At least, I did not notice any difference whatsoever. No matter if I use this pattern:

    BEGIN TRAN ;
    -- (snip)
    COMMIT ;

    or a more complex one:

    DECLARE @trancount INT ;
    SET @trancount = @@TRANCOUNT ;
    IF 
    @trancount = 0 BEGIN ;
      
    BEGIN TRAN ;
    END ;
    --(snip)
    IF 
    @trancount = 0 BEGIN ;
      
    COMMIT ;
    END ;
     

    the performance stays the same: I was not able to notice any difference. Here are my benchmarks.

    Prerequisites

    All we need is the following two tables:

    CREATE TABLE dbo.Toggle1
        
    (
          
    id INT NOT NULL
                
    PRIMARY KEY ,
          
    i INT NOT NULL
        ) ;
    GO
    INSERT  INTO dbo.Toggle1
            
    ( id, i )
    VALUES  ( 1, 0 ) ;
    GO
    CREATE TABLE dbo.Toggle2
        
    (
          
    id INT NOT NULL
                
    PRIMARY KEY ,
          
    i INT NOT NULL
        ) ;
    GO
    INSERT  INTO dbo.Toggle2
            
    ( id, i )
    VALUES  ( 1, 0 ) ;
     

    Simple benchmarking

    First, let us run a very simple case, without nested stored procedure calls:

    CREATE PROCEDURE dbo.ToggleWithNestedTrans
      
    @NumIterations INT
    AS
        BEGIN
    ;
            
    SET NOCOUNT ON ;
            
    SET XACT_ABORT ON ;
            
    DECLARE @i INT ;
            
    SET @i = 0 ;
            
    WHILE @i < @NumIterations
                
    BEGIN ;
                    
    BEGIN TRAN ;
                    
    UPDATE  dbo.Toggle1
                    
    SET     i = 1 - i
                    
    WHERE   id = 1 ;
                    
    BEGIN TRAN ;
                    
    UPDATE  dbo.Toggle2
                    
    SET     i = 1 - i
                    
    WHERE   id = 1 ;
                    
    COMMIT ;
                    
    COMMIT ;
                    
    SET @i = @i + 1 ;
                
    END ;
        
    END ;
    GO

    CREATE PROCEDURE dbo.ToggleAvoidingNestedTrans
      
    @NumIterations INT
    AS
        BEGIN
    ;
            
    SET NOCOUNT ON ;
            
    SET XACT_ABORT ON ;
            
    DECLARE @i INT ;
            
    SET @i = 0 ;
            
    WHILE @i < @NumIterations
                
    BEGIN ;
                    
    BEGIN TRAN ;
                    
    UPDATE  dbo.Toggle1
                    
    SET     i = 1 - i
                    
    WHERE   id = 1 ;
                    
    DECLARE @trancount INT ;
                    
    SET @trancount = @@TRANCOUNT ;
                    
    IF 
    @trancount = 0 BEGIN ;
                      
    BEGIN TRAN ;
                  
    END ;
                    
    UPDATE  dbo.Toggle2
                    
    SET     i = 1 - i
                    
    WHERE   id = 1 ;
                    
    IF
    @trancount = 0 BEGIN ;
                      
    COMMIT ;
                  
    END ;
                    
    COMMIT ;
                    
    SET @i = @i + 1 ;
                
    END ;
        
    END ;
    GO

    CREATE PROCEDURE dbo.ToggleNoNestedTrans
      
    @NumIterations INT
    AS
        BEGIN
    ;
            
    SET NOCOUNT ON ;
            
    SET XACT_ABORT ON ;
            
    DECLARE @i INT ;
            
    SET @i = 0 ;
            
    WHILE @i < @NumIterations
                
    BEGIN ;
                    
    BEGIN TRAN ;
                    
    UPDATE  dbo.Toggle1
                      
    SET     i = 1 - i
                      
    WHERE   id = 1 ;
                    
    UPDATE  dbo.Toggle2
                      
    SET     i = 1 - i
                      
    WHERE   id = 1 ;
                    
    COMMIT ;
                    
    SET @i = @i + 1 ;
                
    END ;
        
    END ;
    GO

    EXEC dbo.ToggleWithNestedTrans @NumIterations = 100000;
    GO
    EXEC dbo.ToggleAvoidingNestedTrans @NumIterations = 100000;
    GO

    EXEC dbo.ToggleWithNestedTrans @NumIterations = 100000;
    GO
    EXEC dbo.ToggleAvoidingNestedTrans @NumIterations = 100000;
    GO

    EXEC dbo.ToggleWithNestedTrans @NumIterations = 100000;
    GO
    EXEC dbo.ToggleAvoidingNestedTrans @NumIterations = 100000;

    GO

    EXEC dbo.ToggleNoNestedTrans @NumIterations = 100000;
    GO
    EXEC dbo.ToggleNoNestedTrans @NumIterations = 100000;
    GO
    EXEC dbo.ToggleNoNestedTrans @NumIterations = 100000;
    GO
     
     

    In fact, real execution costs are the same for both approaches, and not different from then run without nested transactions at all.

    Benchmarking with nested stored procedure calls

    Let us benchmark another, possibly more realistic scenario, when the avoiding nested transaction logic is inside another, nested stored procedure. Here is the benchmarking script:

    CREATE PROCEDURE dbo.Toggle2WithNestedTran
    AS
        BEGIN
    ;
            
    SET NOCOUNT ON ;
            
    SET XACT_ABORT ON ;
            
    BEGIN TRAN ;
            
    UPDATE  dbo.Toggle2
            
    SET     i = 1 - i
            
    WHERE   id = 1 ;
            
    COMMIT ;
        
    END ;
    GO

    ALTER PROCEDURE dbo.ToggleWithNestedTrans
      
    @NumIterations INT
    AS
        BEGIN
    ;
            
    SET NOCOUNT ON ;
            
    SET XACT_ABORT ON ;
            
    DECLARE @i INT ;
            
    SET @i = 0 ;
            
    WHILE @i < @NumIterations
                
    BEGIN ;
                    
    BEGIN TRAN ;
                    
    UPDATE  dbo.Toggle1
                    
    SET     i = 1 - i
                    
    WHERE   id = 1 ;
                    
    EXEC dbo.Toggle2WithNestedTran ;
                    
    COMMIT ;
                    
    SET @i = @i + 1 ;
                
    END ;
        
    END ;
    GO

    CREATE PROCEDURE dbo.Toggle2AvoidingNestedTrans
    AS
        BEGIN
    ;
            
    SET NOCOUNT ON ;
            
    SET XACT_ABORT ON ;
            
    DECLARE @trancount INT ;
            
    SET @trancount = @@TRANCOUNT ;
            
    IF
    @trancount = 0 BEGIN ;
              
    BEGIN TRAN ;
          
    END ;
            
    UPDATE  dbo.Toggle2
            
    SET     i = 1 - i
            
    WHERE   id = 1 ;
            
    IF 
    @trancount = 0 BEGIN ;
              
    COMMIT ;
          
    END ;
        
    END ;
    GO

    ALTER PROCEDURE dbo.ToggleAvoidingNestedTrans
      
    @NumIterations INT
    AS
        BEGIN
    ;
            
    SET NOCOUNT ON ;
            
    SET XACT_ABORT ON ;
            
    DECLARE @i INT ;
            
    SET @i = 0 ;
            
    WHILE @i < @NumIterations
                
    BEGIN ;
                    
    BEGIN TRAN ;
                    
    UPDATE  dbo.Toggle1
                    
    SET     i = 1 - i
                    
    WHERE   id = 1 ;
                    
    EXEC dbo.Toggle2AvoidingNestedTrans ;
                    
    COMMIT ;
                    
    SET @i = @i + 1 ;
                
    END ;
        
    END ;
    GO

    Again, I was not able to notice any difference.

    Conclusion

    Apparently the following logic does not affect performance at all:

    DECLARE @trancount INT ;
    SET @trancount = @@TRANCOUNT ;
    IF @TRANCOUNT = 0 BEGIN ;
      
    BEGIN TRAN ;
    END ;
    --(snip)
    IF @TRANCOUNT = 0 BEGIN ;
      
    COMMIT ;
    END ;

    As such, this pattern seems to be just an unnecessary complication.

    What do you think - are you getting the same results when you benchmark on your system?

    Are you using this pattern for other than performance? What are those?

  • Using XACT_ABORT ON may be faster than using TRY...CATCH

    To ensure atomicity of transactions, we can use XACT_ABORT ON or wrap the transaction in TRY block and rollback in CATCH block. In some cases, the XACT_ABORT ON approach uses noticeably less CPU. I am posting repro scripts. Please run them, tweak them, and post your findings.

    Environment

    I've run my scripts on 2008 R2 Dev Edition. Snapshot isolation is enabled, READ_COMMITTED_SNAPSHOT is not enabled.

    Test data

     We are using the same test data as in my previous post:

     CREATE TABLE dbo.Toggle1
        
    (
          
    id INT NOT NULL
                
    PRIMARY KEY ,
          
    i INT NOT NULL
        ) ;
    GO
    INSERT  INTO dbo.Toggle1
            
    ( id, i )
    VALUES  ( 1, 0 ) ;
    GO
    CREATE TABLE dbo.Toggle2
        
    (
          
    id INT NOT NULL
                
    PRIMARY KEY ,
          
    i INT NOT NULL
        ) ;
    GO
    INSERT  INTO dbo.Toggle2
            
    ( id, i )
    VALUES  ( 1, 0 ) ;

    Benchmarking

    The following two stored procedures run the same modifications 10K times. The first one uses XACT_ABORT ON to ensure atomicity of the transaction:

    CREATE PROCEDURE dbo.Toggle10kTimesWithXactAbortOn
    AS
        BEGIN
    ;
            
    SET NOCOUNT ON ;
            
    SET XACT_ABORT ON ;
            
    DECLARE @i INT ;
            
    SET @i = 0 ;
            
    WHILE @i < 10000
                
    BEGIN ;
                    
    BEGIN TRAN ;
                    
    UPDATE  dbo.Toggle1
                    
    SET     i = 1 - i
                    
    WHERE   id = 1 ;
                    
    UPDATE  dbo.Toggle2
                    
    SET     i = 1 - i
                    
    WHERE   id = 1 ;
                    
    COMMIT ;
                    
    SET @i = @i + 1 ;
                
    END ;
        
    END ;
    The second procedure uses a ROLLBACK in a CATCH block:

     CREATE PROCEDURE dbo.Toggle10kTimesWithTryCatch
    AS
        BEGIN
    ;
            
    SET NOCOUNT ON ;
            
    DECLARE @i INT ;
            
    SET @i = 0 ;
            
    WHILE @i < 10000
                
    BEGIN ;
                    
    BEGIN TRY ;
                        
    BEGIN TRAN ;
                        
    UPDATE  dbo.Toggle1
                        
    SET     i = 1 - i
                        
    WHERE   id = 1 ;
                        
    UPDATE  dbo.Toggle2
                        
    SET     i = 1 - i
                        
    WHERE   id = 1 ;
                        
    COMMIT ;
                    
    END TRY
                    
    BEGIN CATCH ;
                        
    ROLLBACK ;
                    
    END CATCH ;
                    
    SET @i = @i + 1 ;
                
    END ;
        
    END ;

    Benchmarking

    Let us run these two procedures several times:

    EXEC dbo.Toggle10kTimesWithXactAbortOn ;
    GO
    EXEC dbo.Toggle10kTimesWithTryCatch ;

    Typical results are as follows:

    dbo.Toggle10kTimesWithXactAbortOn

    CPU: 265-280

    dbo.Toggle10kTimesWithTryCatch

    CPU: 406-468

     

    Conclusion

    As we have seen, sometimes using XACT_ABORT ON may use less CPU as opposed to using TRY...CATCH.

    I am and not making any blanket statements here - you are encouraged to run your own benchmarks and see if you are observing this effect in your situation. If you do so, can you do us all a favor and post your findings.


  • Wrapping related changes in a transaction may use less CPU.

    Wrapping related changes in a transaction is a good way to ensure data integrity. Besides, in some cases it just runs noticeably faster, using less CPU. As usual, I am posting repro scripts, which you can run, tweak, and see for yourself.

    Environment

    I've run my scripts on 2008 R2 Dev Edition. Snapshot isolation is enabled, READ_COMMITTED_SNAPSHOT is not enabled.

    Test data

     After applying Occum's razor, all we need is two tables with one row in each:

     CREATE TABLE dbo.Toggle1
        
    (
          
    id INT NOT NULL
                
    PRIMARY KEY ,
          
    i INT NOT NULL
        ) ;
    GO
    INSERT  INTO dbo.Toggle1
            
    ( id, i )
    VALUES  ( 1, 0 ) ;
    GO
    CREATE TABLE dbo.Toggle2
        
    (
          
    id INT NOT NULL
                
    PRIMARY KEY ,
          
    i INT NOT NULL
        ) ;
    GO
    INSERT  INTO dbo.Toggle2
            
    ( id, i )
    VALUES  ( 1, 0 ) ;
     

    Benchmarking

    The following two stored procedures run the same modifications 10K times. The first one does not use an explicit transaction:

     CREATE PROCEDURE dbo.Toggle50kTimesX3WithoutTransaction
    AS
        BEGIN
    ;
            
    SET NOCOUNT ON ;
            
    SET XACT_ABORT OFF ;
            
    DECLARE @i INT ;
            
    SET @i = 0 ;
            
    WHILE @i < 50000
                
    BEGIN ;
                    
    UPDATE  dbo.Toggle1
                    
    SET     i = 1 - i
                    
    WHERE   id = 1 ;
                    
    UPDATE  dbo.Toggle2
                    
    SET     i = 1 - i
                    
    WHERE   id = 1 ;
                    SET @i = @i + 1 ;
                
    END ;
        
    END ;

    The second procedure does use an explicit transaction:

    CREATE PROCEDURE dbo.Toggle50kTimesX3WithXactAbortOff
    AS
        BEGIN
    ;
            
    SET NOCOUNT ON ;
            
    SET XACT_ABORT OFF ;
            
    DECLARE @i INT ;
            
    SET @i = 0 ;
            
    WHILE @i < 50000
                
    BEGIN ;
                    
    BEGIN TRAN ;
                    
    UPDATE  dbo.Toggle1
                    
    SET     i = 1 - i
                    
    WHERE   id = 1 ;
                    UPDATE  dbo.Toggle2
                    
    SET     i = 1 - i
                    
    WHERE   id = 1 ;
                    COMMIT ;
                    
    SET @i = @i + 1 ;
                
    END ;
        
    END ;

    Let's run the following script a few times:

    EXEC dbo.Toggle50kTimesX3WithoutTransaction ;
    GO
    EXEC dbo.Toggle50kTimesX3WithXactAbortOff ;
     

    Typical CPU and duration is as follows:

     dbo.Toggle50kTimesX3WithoutTransaction

    CPU: 1900-2340 Duration:5900

    dbo.Toggle50kTimesX3WithXactAbortOff

    CPU: 1500-1700 Duration: 3200-3300

    Conclusion

    As we have seen, wrapping related changes in as transaction may improve performance.By related, I mean changes that should all succeed or all fail if we are ensuring atomicity.

    However, we typically need to keep transactions short, so we should not bundle unrelated changes in transactions - that may hurt concurrency.

    Also I am being very careful and not making any blanket statements here. I did not research this effect with due diligence. I noticed the effect in one case and came up with a repro that works in one particular case on one server, this is all.

    I would really appreciate if you run the repro, tweak it as you see needed, and post the results here. TIA!

    In general, we should not assume that larger transactions always mean better performance - the opposite is true in many cases. We should always benchmark the specific case at hand and decide on case by case basis.

    Edit: I have simplified the scripts a little bit, and that changed the numbers a little bit.

  • Yet another gotcha: variables' scopes do not end where they should.

    Be careful: unlike most other languages, T-SQL does not limit variables' scope to the block where the variable has been defined. For example, the following snippet compiles and runs:

     -- @to is not in scope yet
    -- the line below would not compile
    --SET @to = @to + 3 ;

    IF DATEPART(weekday, GETDATE()) = 3
        
    BEGIN ;
        
    -- the scope of this DECLARE does not end when the block ends
            
    DECLARE @from INT ,
                
    @to INT ;
            
    SELECT  @from = 5 ,
                    
    @to = 7 ;
            
    SELECT  Number
            
    FROM    data.Numbers
            
    WHERE   Number BETWEEN @from AND @to ;
        
    -- both @from and @to are defined beyond this point
        
    END
    ELSE
        BEGIN
    ;
        
    -- Big surprise:
        -- @from and @to are still in scope, this compiles and runs
            
    SET @to = @to + 3 ;
            
    SELECT  Number
            
    FROM    data.Numbers
            
    WHERE   Number BETWEEN @from AND @to ;
        
    END ;  
     

    This is counter-intuitive for most developers that worked with other languages. IMO this is one of those features we would probably be better off without. What do you think?

  • Running OLTPish system without deadlocks, and loving it.

    Our OLTPish mixed load system has not had a single deadlock since last April, and we just love it.

    I would not make any blanket statements, but I think in our case being deadlock-free just makes a lot of practical sense. Of course, in other cases in might not be worth it.

    To accomplish deadlock-free state, we have taken the following steps:

    • Snapshot isolation for all readers
    • If concurrent updates are not frequent, we just acquire some exclusive lock before updating. As long as collisions are not likely, this does not slow us down much. We stress test how it works.
    • If concurrent updates become frequent, we solve the problem outside the database. Our application servers accumulate incoming changes and save them in bulk. Of course, these bulk saves still have to acquire some exclusive lock before updating. We stress test how it works.

    I still have to see how it works out long term, but short term we save considerable effort on not troubleshooting deadlocks, and not having to implement retry logic. So far I think that being deadlock-free is worth it, but only if we can avoid high concurrency, when lots of modifications touch a single row each. If we cannot avoid high concurrency, then it might be easier to just let it deadlock from time to time.

    What do you think? Are you doing anything similar?

     

  • Refactoring large live OLTP tables without downtime

    Refactoring tables does not have to be such a big deal. We do not have to shut modifications down, migrate all data into new structure, deploy modified modules, and do it all at once, while the system is down.

    Doing all migration at once may be risky, and if we make a mistake, there is no easy rollback to the old version. This is why whenever we need to refactor a large table, we are using an alternative, low-risk, no-downtime, incremental approach. The method I am going to describe has been used in practice several times, and we did not have any problems with it.

    All our transitions from old table structure to the new one were smooth and incremental. More to the point, we were able to set aside the migration at any time and switch to some other more important task, or leave for the day, or enjoy the weekend, all while the database was fully functional.

    Typical refactoring scenario

    I am totally making this up, I have never worked on bug tracking systems.

    Suppose that we are storing tickets in a table dbo.Tickets, which, among other columns, has a column named AssignedTo. As such, each ticket can be assigned to only one person at any time.

    However, the next version of our system will allow to assign a ticket to more than one person. Our plan is to create another table, dbo.TicketAssignments(TicketID, AssignedTo), migrate existing data to it, and change appr. 50 stored procedures affected by this change. To minimize risks, we are going to finish all database changes at least a few days before the new version of our system is released.

    This means that the current version of our system is going to run against our refactored database exactly as it did against the old one. BTW, the table dbo.Tickets is quite large, and is heavily used all the time.

    Before the migration. Prerequisites.

    We shall need some extra disk space, approximately as much as the old table uses up. Besides, we need a server that is not struggling with its current workload, so that it can withstand some additional work for the duration of our migration.

    Also we shall need a good test coverage on all modules using the table, including unit tests, stress tests, and performance baselines. Typically in our system we already have solid test coverage.

    One more thing: because table structure is going to change, inserts directly into the table are not going to work any more. As a result, all test data should be populated via stored procedures that continue to work against the new table structure.

    For example, we might use a stored procedure dbo.SaveTicket that has a parameter @AssingedTo to populate test data for our unit tests.

    Creating new tables, changing modifications.

    As our first step, we create two new empty tables:

    • dbo.TicketsV2, which has all the same columns as dbo.Tickets, except it does not have AssignedTo column;
    • dbo.TicketAssignments(TicketID, AssignedTo)

    We also change all the procedures which modify dbo.Tickets, so that they write to both old and new tables.

    This biggest risk in this step is introducing concurrency-related problems. We need to stress test our modifications well. If we have any problems, however, we can just run a rollback script, changing all the modifications back to their original version. Such rollback takes just a split second.

    In fact, we never actually had any problems at this step, because our stress testing harness is quite solid.

    Of course, our modifications get slower, but we have made sure that our hardware can handle it. We had not had actual problems with slow modifications either.

    Migrating existing data

    At this stage all the reports are running off the old table.

    As all new changes get saved into both old and new tables, we are also moving over all existing data to the new structure. We want this migration to be non-intrusive, so we typically just run one WHILE loop, moving over like 1K-10K rows at a time, so that our migration does not hinder OLTP activity and reports.

    Modifying the reports.

    While the data is migrating, we can take our time changing our stored procedures to read from new tables. Because we have good unit testing coverage, we can refactor procedures with confidence - if we break something, we shall know it right away.

    Because not all data has been migrated yet, we do not deploy the modified procedures.

    Verifying that migration completed.

    To verify that all data migrated correctly, we need to write a SQL query. To my best knowledge, there is no GUI tool that can efficiently compare large tables. Writing a query, however, is not that difficult.

    This query is going to use a lot of resources. We need to be careful not to bring the server to its knees while the query is running. There are several ways to accomplish that.

    Deploying modified reports.

    Once we have complete and correct data in the new tables, we can start deploying new procedures. We do not have to deploy them all at once - we can deploy them five or ten modules at a time, even if some other procedures still access the old table.

    There is one more problem our team need to be very careful with - our test server is not exactly identical to our production one. As such, we encounter a risk that our procedure runs fast in test environment, but is slow in production.

    This is why we first deploy our changed procedures into a different schema, which (schema) is not exposed to our user. For example, instead of altering procedure dbo.GetTicketsForDay, we create a new procedure Internal.GetTicketsForDay. Only developers have privileges on Internal schema, so users cannot execute it yet. Once we have executed Internal.GetTicketsForDay in production environment and are happy with performance, we can deploy it as dbo.GetTicketsForDay.

    Our only risk at this stage is that we can deploy poorly performing procedures. Our rollback strategy is simple - we just roll back to the original stored procedures that read from the old table.

    Finalizing the migration.

    Once all the reports access the new table, we can change our modifications, so that they no longer write to the old table. The old table can be archived out and dropped, reclaiming the additional storage which we needed for the migration.

    Conclusion

    As we have seen, we can refactor an OLTP table without downtime and with low risks, even if it is big and data migration takes a lot of time.

    One more thing: a common reaction to such Agile war stories is a recommendation to "do it right the first time". It is so common that I would like to address it right now.

    Of course, at the time when the previous version of bug tracking system, it was quite obvious that eventually we might need to add the ability to assign a ticket to more than one person. Of course, if we had the table dbo.TicketAssignments from the very beginning, we would not have to go through this complex refactoring. In other words, we should "do it right the first time", should we not?

    In general, "doing it right the first time", developing a flexible database structure that we should not have to change later, makes a lot of practical sense, but not under all circumstances.

    More specifically, when the previous version of bug tracking system was being designed and developed, there were hundreds of brilliant ideas, lots of great features that might be useful later on. Implementing all these hundreds of brilliant ideas would take years, and a much bigger team. It was just not possible. More to the point, it was not what our customers wanted - they wanted us to take care of their most important problems first, and quickly. They did not want to wait until we could provide more features.

    Instead of trying to add as many features as possible and as a result delivering a half baked low quality product, the team concentrated on developing only must-have features, on performance, and on very high quality. As a result, the product had a very limited set of features, but it delivered everything it promised, and it was simple, very fast, and rock solid. As such, it was a success.

    It would be quite easy to provide examples of over-engineered projects that tried to "do it right the first time" and miserably failed, but this is beyond the scope of this post...

  • "Trusted" Foreign Keys Allow Orphans, Reject Valid Child Rows

    In SQL 2008 R2, MERGE does not implement foreign keys properly. I will show both false negatives (valid rows are rejected) and false positives - orphan rows that are allowed to save.

     

    False Negatives

     

    The following tables implement a very common type/subtype pattern:

    CREATE TABLE dbo.Vehicles(
        
    ID INT NOT NULL,
        
    [Type] VARCHAR(5) NOT NULL,
        
    CONSTRAINT Vehicles_PK PRIMARY KEY(ID),
        
    CONSTRAINT Vehicles_UNQ_ID_Type UNIQUE(ID, [Type]),
        
    CONSTRAINT Vehicles_CHK_ValidTypes CHECK([Type] IN ('Car', 'Truck'))
    );
    GO

    CREATE TABLE dbo.Cars(ID INT NOT NULL,
        
    [Type] AS CAST('Car' AS VARCHAR(5)) PERSISTED,
        
    OtherData VARCHAR(10) NULL,
        
    CONSTRAINT Cars_PK PRIMARY KEY(ID),
        
    CONSTRAINT Cars_FK_Vehicles FOREIGN KEY(ID, [Type])
            
    REFERENCES dbo.Vehicles(ID, [Type])
    );
    GO
    -- adding parent rows
    INSERT INTO dbo.Vehicles(ID, [Type])
    VALUES(1, 'Car'),
    (
    2, 'Truck');

    The following INSERT succeeds:

    INSERT INTO dbo.Cars(ID, OtherData)
    VALUES(1, 'Some Data');

    DELETE FROM dbo.Cars;

    MERGE should be able to add the same row, but it fails:

      MERGE dbo.Cars AS TargetTable
        USING
            
    ( SELECT    1 AS ID ,
                        
    'Some Data' AS OtherData
            
    ) AS SourceData
        
    ON  SourceData.ID = TargetTable.ID
        
    WHEN NOT MATCHED
            
    THEN INSERT (ID, OtherData)
            
    VALUES(SourceData.ID, SourceData.OtherData);

    Msg 547, LEVEL 16, State 0, Line 1
    The MERGE statement conflicted
    WITH the FOREIGN KEY CONSTRAINT "Cars_FK_Vehicles" The conflict occurred IN DATABASE "Test" TABLE "dbo.Vehicles"
    The statement has been terminated.

     

    False Positives

     

    Martin Smith has posted this repro on stackoverflow, in response to my scenario described above. This script successfully adds an orphan row into dbo.Cars table:

    ALTER TABLE dbo.Vehicles
      
    DROP CONSTRAINT Vehicles_CHK_ValidTypes ;
    GO

    INSERT INTO dbo.Vehicles(ID, [Type]) VALUES (3, '');
    GO

    MERGE dbo.Cars AS TargetTable
        USING
            
    ( SELECT    3 AS ID ,
                        
    'Some Data' AS OtherData
            
    ) AS SourceData
        
    ON  SourceData.ID = TargetTable.ID
        
    WHEN NOT MATCHED
            
    THEN INSERT (ID, OtherData)
            
    VALUES(SourceData.ID, SourceData.OtherData);
    GO

    -- the FK constraint is enabled and trusted
    SELECT is_disabled,is_not_trusted
      
    FROM sys.foreign_keys
      
    WHERE name = 'Cars_FK_Vehicles';
    -- yet we have an orphan row
    SELECT * FROM dbo.Cars
    SELECT * FROM dbo.Vehicles
    GO

    is_disabled is_not_trusted
    ----------- --------------
    0           0

    (1 row(s) affected)

    ID          TYPE  OtherData
    ----------- ----- ----------
    3           Car   SOME Data

    (1 row(s) affected)

    ID          TYPE
    ----------- -----
    1           Car
    2           Truck
    3          


    Conclusion 

    In Ch 7 of my book "Defensive Database Programming", I wrote the following statement.

    "As long as a constraint is trusted, then we know for sure that all our data complies with the rules that it dictates."

    At the time I was writing that, I believed that was true. Unfortunately, as we have just seen, I was wrong - we can bypass trusted foreign key constraints in SQL 2008 R2.

    P.S.

    I have just learned that there is another scenario to bypass a foreign key. Google up this: "Parameterized DELETE and MERGE Allow Foreign Key Constraint Violations".

    Another very important thing that I have just learned that this Connect item did not get urgent priority. Quoting directly from Connect: "Since this is not a common scenario or use case i.e., doing DDL changes and DML in the same transaction we don't see this as a big issue".


More Posts Next page »

This Blog

Syndication

Powered by Community Server (Commercial Edition), by Telligent Systems
  Privacy Statement