{"id":88021,"date":"2020-09-01T17:41:03","date_gmt":"2020-09-01T17:41:03","guid":{"rendered":"https:\/\/www.red-gate.com\/simple-talk\/?p=88021"},"modified":"2026-03-09T14:36:28","modified_gmt":"2026-03-09T14:36:28","slug":"hands-on-with-columnstore-indexes-part-4-query-patterns","status":"publish","type":"post","link":"https:\/\/www.red-gate.com\/simple-talk\/databases\/sql-server\/t-sql-programming-sql-server\/hands-on-with-columnstore-indexes-part-4-query-patterns\/","title":{"rendered":"Query Patterns for Columnstore Indexes: Joins &#038; Performance (Part 4)"},"content":{"rendered":"<p><strong>The series so far:<\/strong><\/p>\n<ol>\n<li><a href=\"https:\/\/www.red-gate.com\/simple-talk\/sql\/sql-development\/hands-on-with-columnstore-indexes-part-1-architecture\/\">Hands-On with Columnstore Indexes: Part 1 Architecture<\/a><\/li>\n<li><a href=\"https:\/\/www.red-gate.com\/simple-talk\/sql\/sql-development\/hands-on-with-columnstore-indexes-part-2-best-practices-and-guidelines\/\">Hands-On with Columnstore Indexes: Part 2 Best Practices and Guidelines<\/a><\/li>\n<li><a href=\"https:\/\/www.red-gate.com\/simple-talk\/sql\/performance\/hands-on-with-columnstore-indexes-part-3-maintenance-and-additional-options\/\">Hands-On with Columnstore Indexes: Part 3 Maintenance and Additional Options<\/a><\/li>\n<li><a href=\"https:\/\/www.red-gate.com\/simple-talk\/sql\/sql-development\/hands-on-with-columnstore-indexes-part-4-query-patterns\/\">Hands-On with Columnstore Indexes: Part 4 Query Patterns<\/a><\/li>\n<\/ol>\n\n\n\n\n<p>Query performance against a columnstore index depends on controlling the number of segments read. Two mechanisms achieve this: rowgroup elimination (filtering on the ordered column to skip irrelevant rowgroups) and segment elimination (querying fewer columns to reduce the segments accessed per rowgroup).<\/p>\n\n\n\n<p>For aggregate queries, SQL Server can use segment metadata (min\/max values) to return results without decompressing the data, making queries across hundreds of millions of rows return in under a second. When joining dimension tables, be cautious: unnecessary JOINs force additional segment reads even when no columns from those tables are used in the output. Pre-selecting dimension keys into temporary tables before joining to the columnstore often outperforms direct multi-table joins.<\/p>\n\n\n\n<p><strong>Read also:<\/strong> <a href=\"https:\/\/www.red-gate.com\/simple-talk\/databases\/sql-server\/t-sql-programming-sql-server\/hands-on-with-columnstore-indexes-part-2-best-practices-and-guidelines\/\" target=\"_blank\" rel=\"noreferrer noopener\">Part 2: Best Practices and Data Ordering<\/a><\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"h-introduction\">Introduction<\/h2>\n\n\n\n<p>Following a solid dive into the architecture, maintenance, and performance of columnstore indexes, the next logical step is to analyze the data within a columnstore index.<\/p>\n\n\n\n<p>Reading data from a highly compressed analytical structure is quite different from the query patterns used on transactional data. By leveraging metadata, data order, segment elimination, and compression, large tables can be quickly read and results returned in seconds (or less!).<\/p>\n\n\n\n<p>Taking this further, read queries against columnstore indexes can be further optimized by simplifying queries and providing the query optimizer with the easiest path to the smallest columnstore scans needed to return results.<\/p>\n\n\n\n<p>This article explores the most efficient ways to read a columnstore index and produce guidelines and best practices for analytics against large columnstore data structures.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"h-test-data\">Test Data<\/h2>\n\n\n\n<p>The test data for this article comes from the table <em>fact_order_BIG_CCI<\/em>, which was introduced in the first article in this series and is a <a href=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2020\/07\/ColumnStoreScript.zip\">blown-up version of <em>fact_order<\/em><\/a> from the <em>WorldWideImportersDW<\/em> Microsoft demo database. To quickly recap, the table contains 23,141,200 rows of data spanning a date range of 2013-01-01 through 2016-05-31.<\/p>\n\n\n\n<p>The table is ordered and partitioned by [Order Date Key], the time dimension for this fact table. The following is a sample of data from the table:<\/p>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"751\" height=\"304\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2020\/09\/word-image.jpeg\" alt=\"\" class=\"wp-image-88022\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>This table represents a classic data warehouse-style table with narrow columns that reference dimension tables as lookups. The result is data that compresses exceptionally well and is optimal for a columnstore index in SQL Server.<\/p>\n\n\n\n<p>This change is a good start, but to illustrate the impact of effective query patterns, the data set can be expanded further:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">INSERT INTO dbo.fact_order_BIG_CCI\nSELECT\n     [Order Key] + (250000 * ([Day Number] + \n         ([Calendar Month Number] * 31))) AS [Order Key]\n    ,[City Key]\n    ,[Customer Key]\n    ,[Stock Item Key]\n    ,[Order Date Key]\n    ,[Picked Date Key]\n    ,[Salesperson Key]\n    ,[Picker Key]\n    ,[WWI Order ID]\n    ,[WWI Backorder ID]\n    ,[Description]\n    ,[Package]\n    ,[Quantity]\n    ,[Unit Price]\n    ,[Tax Rate]\n    ,[Total Excluding Tax]\n    ,[Tax Amount]\n    ,[Total Including Tax]\n    ,[Lineage Key]\nFROM Fact.[Order]\nCROSS JOIN\nDimension.Date\nWHERE Date.Date &gt; '2013-04-10'\nAND Date.Date &lt;='2014-03-31';\nGO\nDROP INDEX CCI_fact_order_BIG_CCI ON dbo.fact_order_BIG_CCI;\nCREATE CLUSTERED INDEX CCI_fact_order_BIG_CCI \nON dbo.fact_order_BIG_CCI ([Order Date Key]);\nCREATE CLUSTERED COLUMNSTORE INDEX CCI_fact_order_BIG_CCI \nON dbo.fact_order_BIG_CCI WITH (MAXDOP = 1, DROP_EXISTING = ON);<\/pre>\n\n\n\n<p>This script inserts more rows into the table and then drops the columnstore index, creates a clustered index, and lastly drops the clustered index in favor of a clustered columnstore index. The added indexing steps ensure a fully ordered (and heftier) data source for us to test on throughout the remainder of this article.<\/p>\n\n\n\n<p>Before moving on, a quick query against this data can confirm its size and date range:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">SELECT\n\tCOUNT(*) AS row_count,\n\tMIN([Order Date Key]) AS min_order_date_key,\n\tMAX([Order Date Key]) AS max_order_date_key\nFROM dbo.fact_order_BIG_CCI;<\/pre>\n\n\n\n<p>The results show that we have multiplied our data by a factor of five while retaining the same date range as before:<\/p>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"336\" height=\"68\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2020\/09\/word-image-1.jpeg\" alt=\"\" class=\"wp-image-88023\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>Each date contains between 40k and 250k rows. While this is in no way big data, it is large enough to be interesting and forces us to use caution when querying it as a scan of too much data would be quite slow and painful.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"h-controlling-columnstore-index-performance\">Controlling Columnstore Index Performance<\/h2>\n\n\n\n<p>The key to columnstore index performance is to control the number of segments read. Rowgroup elimination and segment elimination in conjunction with partition elimination allow a large columnstore index to be subdivided very quickly, like cutting a piece of graph paper to remove a small section of squares and discarding the rest.<\/p>\n\n\n\n<p>Segment elimination is achieved by reducing the number of columns accessed in a query. Rowgroup elimination is the result of filtering on a column that is well-ordered within the columnstore index. The combination allows for two efficient query mechanisms that are facilitated by a third process:<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"h-many-columns-targeted-date-range\">Many Columns, Targeted Date Range<\/h3>\n\n\n\n<p>Due to their superb compression, columnstore indexes can service queries across many columns, and does so efficiently so long as the count of rowgroups to be read remains relatively low. A rowgroup represents 2<sup>20<\/sup> (about 1 million) rows, and therefore each rowgroup read indicates a million rows that need to be read within each referenced segment.<\/p>\n\n\n\n<p>Consider the following query:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">SELECT\n\t*\nFROM dbo.fact_order_BIG_CCI\nWHERE [Order Date Key] = '2015-03-19'<\/pre>\n\n\n\n<p>This query returns 135,135 rows with all 19 columns from the table but does so for only a single date. Since our data is ordered by date, all partitions that do not contain this date can be eliminated. Similarly, all rowgroups that do not contain this date can be eliminated.<\/p>\n\n\n\n<p>The query takes about a second to process, most of which was returning the data to the client. We can confirm the most effort needed to read a single rowgroup by checking the metadata for all rowgroups in this columnstore index:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">SELECT\n\tMAX(column_store_row_groups.size_in_bytes) \n             AS max_size_in_bytes,\n\tCOUNT(*) AS rowgroup_count\nFROM sys.column_store_row_groups\nINNER JOIN sys.indexes\nON indexes.index_id = column_store_row_groups.index_id\nAND indexes.object_id = column_store_row_groups.object_id\nINNER JOIN sys.tables\nON tables.object_id = indexes.object_id\nINNER JOIN sys.partitions\nON partitions.partition_number = \n      column_store_row_groups.partition_number\nAND partitions.index_id = indexes.index_id\nAND partitions.object_id = tables.object_id\nWHERE tables.name = 'fact_order_BIG_CCI'<\/pre>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"234\" height=\"67\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2020\/09\/word-image-2.jpeg\" alt=\"\" class=\"wp-image-88024\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>The results tell us that there is a total of 102 rowgroups in this index, of which the largest is about 4MB. This data remains compressed until needed for a query, at which time it is decompressed for use by the SQL Server execution engine. This is not a lot of data, and therefore can be returned quite quickly! Note that the amount of effort needed by SQL Server to process this query is the same whether we read one row or all rows from the rowgroup as a segment read is a segment read, regardless of how many rows are read.<\/p>\n\n\n\n<p>In general, wide queries over few rowgroups perform well due to rowgroup elimination and compression saving the day. Therefore, we can target a columnstore index with OLTP-style read queries and not pay a significant penalty for doing so.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"h-few-columns-larger-date-range\">Few Columns, Larger Date Range<\/h3>\n\n\n\n<p>The use-case most supported and encouraged by columnstore indexes are aggregate queries that span many rowgroups but read few segments. These are queries that scan lots of rows, but only a few columns. Aggregate queries can often take advantage of segment metadata to return needed data, significantly speeding up execution.<\/p>\n\n\n\n<p>The following query pulls some quantity metrics for a full year\u2019s worth of data:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">SELECT\n\tMIN(Quantity) AS min_quantity,\n\tMAX(Quantity) AS max_quantity,\n\tCOUNT(*) AS orders_sampled\nFROM dbo.fact_order_BIG_CCI\nWHERE [Order Date Key] &gt;= '2015-01-01'\nAND [Order Date Key] &lt; '2016-01-01';<\/pre>\n\n\n\n<p>This query only requires segments for two columns and can rely on metadata for the MIN, MAX, and COUNT functions. The results are returned sub-second:<\/p>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"280\" height=\"67\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2020\/09\/word-image-3.jpeg\" alt=\"\" class=\"wp-image-88025\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>33.2 million rows were \u201cread\u201d, but in actuality, the segment reads were accomplished via metadata, rather than having to actually decompress and read a million rows of segment data per segment:<\/p>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"654\" height=\"97\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2020\/09\/word-image-4.jpeg\" alt=\"\" class=\"wp-image-88026\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>The image above is cut in the middle of a much wider series of text so that it fits nicely here on a page. Note that 32 segments are read (the entire partition for the year), which sounds like a lot, but the reads are low \u2013 only 395 LOB reads. This gives the illusion that all rowgroups (and possibly all segments) in the columnstore index were read, but remember that this table is partitioned and the rowgroups for all other years were skipped prior to runtime.<\/p>\n\n\n\n<p>Note that all columnstore metadata and data lookups manifest as LOB reads in STATISTICS IO. This is normal and is indicative of how SQL Server stores columnstore index segments within native LOB storage.<\/p>\n\n\n\n<p>So long as metadata is mostly being relied on for results, more columns can be queried with minimal additional effort:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">SELECT\n\tMIN(Quantity) AS min_quantity,\n\tMAX([Unit Price]) AS max_unit_price,\n\tAVG([Tax Rate]) AS avg_tax_rate,\n\tSUM([Tax Amount]) AS total_tax_amount,\n\tSUM([Total Including Tax]) AS total_including_tax,\n\tMIN([Order Date Key]) AS min_order_date_key,\n\tMIN([Picked Date Key]) AS min_picked_date_key,\n\tCOUNT(*) AS orders_sampled\nFROM dbo.fact_order_BIG_CCI\nWHERE [Order Date Key] &gt;= '2015-01-01'\nAND [Order Date Key] &lt; '2016-01-01';<\/pre>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"799\" height=\"59\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2020\/09\/word-image-5.jpeg\" alt=\"\" class=\"wp-image-88027\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>Results are returned FAST, and the IO stats show more IO, but nothing significant:<\/p>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"917\" height=\"114\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2020\/09\/word-image-6.jpeg\" alt=\"\" class=\"wp-image-88028\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>The IO is about four times what it was previously, which makes perfect sense as eight columns are being queried rather than two. Like before, metadata was used instead of data, allowing relatively low IO and a fast execution time given that 33.2 million rows were read.<\/p>\n\n\n\n<p>A common analytic need is to group data by time range. For example, the following query returns grouped quantity data for the second half of 2015:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">SELECT\n\tYEAR([Order Date Key]) AS order_year,\n\tMONTH([Order Date Key]) AS order_month,\n\tMIN(Quantity) AS min_quantity,\n\tMAX(Quantity) AS max_quantity,\n\tCOUNT(*) AS orders_sampled\nFROM dbo.fact_order_BIG_CCI\nWHERE [Order Date Key] &gt;= '2015-07-01'\nAND [Order Date Key] &lt; '2016-01-01'\nGROUP BY YEAR([Order Date Key]), MONTH([Order Date Key])\nORDER BY YEAR([Order Date Key]), MONTH([Order Date Key]);<\/pre>\n\n\n\n<p>The results take about a second to be returned, and are as follows:<\/p>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"419\" height=\"177\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2020\/09\/word-image-7.jpeg\" alt=\"\" class=\"wp-image-88029\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>Once again, IO is quite low, and since we sampled only half of the year, half of the rowgroups (and hence half of the segments) were skipped:<\/p>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"888\" height=\"72\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2020\/09\/word-image-8.jpeg\" alt=\"\" class=\"wp-image-88030\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"h-no-date-range-use-metadata\">No Date Range? Use Metadata!<\/h3>\n\n\n\n<p>It is possible with a columnstore index to read absurd numbers of rows relatively quickly, and the key to doing so lies in metadata. If a large number of segments need to be read, and the query can be limited to metrics stored within metadata, then the results can be returned quickly. To test this, the previously executed queries for a single year can be expanded to the entire data set:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">SELECT\n\tMIN(Quantity) AS min_quantity,\n\tMAX(Quantity) AS max_quantity,\n\tCOUNT(*) AS orders_sampled\nFROM dbo.fact_order_BIG_CCI;\nSELECT\n\tMIN(Quantity) AS min_quantity,\n\tMAX([Unit Price]) AS max_unit_price,\n\tAVG([Tax Rate]) AS avg_tax_rate,\n\tSUM([Tax Amount]) AS total_tax_amount,\n\tSUM([Total Including Tax]) AS total_including_tax,\n\tMIN([Order Date Key]) AS min_order_date_key,\n\tMIN([Picked Date Key]) AS min_picked_date_key,\n\tCOUNT(*) AS orders_sampled\nFROM dbo.fact_order_BIG_CCI;<\/pre>\n\n\n\n<p>The queries each sample 105.2 million rows and the total time was under a second. Even with eight columns returned, the query performed well.<\/p>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"1161\" height=\"173\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2020\/09\/word-image-26.png\" alt=\"\" class=\"wp-image-88031\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>The IO for each query is as follows:<\/p>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"765\" height=\"64\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2020\/09\/word-image-9.jpeg\" alt=\"\" class=\"wp-image-88032\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"926\" height=\"69\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2020\/09\/word-image-10.jpeg\" alt=\"\" class=\"wp-image-88033\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>The reads required to return this data were not trivial but were significantly lower than would typically be expected for scanning 105 million rows.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"h-joins-and-filters-on-dimension-tables\">Joins and Filters on Dimension Tables<\/h3>\n\n\n\n<p>Most real-world queries are not as simple as a single table and a handful of metrics. Joining and filtering when consuming a large columnstore index may require some consideration to ensure that even as more dimensions are added, performance on the columnstore does not become significantly worse than what we have seen thus far.<\/p>\n\n\n\n<p>In general, data retrieval from a columnstore index is relatively fast so long as metadata can be used and\/or the segment data can remain compressed. For each segment that needs to be read, the time it takes to complete a query will increase. The more segments that are read, the slower queries get.<\/p>\n\n\n\n<p>Joins naturally require segments to be read in order to link columns within a columnstore index into separate dimension tables. This query joins a handful of dimensions, but only returns one, along with a SUM:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">SELECT\n\tCustomer.[Customer Key],\n\tSUM([Total Including Tax]) AS [Total Including Tax]\nFROM dbo.fact_order_BIG_CCI\nINNER JOIN Dimension.City\nON fact_order_BIG_CCI.[City Key] = City.[City Key]\nINNER JOIN Dimension.Customer\nON fact_order_BIG_CCI.[Customer Key] = Customer.[Customer Key]\nINNER JOIN Dimension.Employee\nON fact_order_BIG_CCI.[Salesperson Key] = Employee.[Employee Key]\nWHERE [Order Date Key] &gt;= '2015-01-01'\nAND [Order Date Key] &lt; '2016-01-01'\nGROUP BY Customer.[Customer Key]\nORDER BY Customer.[Customer Key];<\/pre>\n\n\n\n<p>Despite having to scan a year of data and churn through all customers in that time frame, the results are returned sub-second:<\/p>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"230\" height=\"252\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2020\/09\/word-image-11.jpeg\" alt=\"\" class=\"wp-image-88034\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>The following is an adjustment on the query where the SUM on the [Total Including Tax] column is removed:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">SELECT\n\tCustomer.[Customer Key]\nFROM dbo.fact_order_BIG_CCI\nINNER JOIN Dimension.City\nON fact_order_BIG_CCI.[City Key] = City.[City Key]\nINNER JOIN Dimension.Customer\nON fact_order_BIG_CCI.[Customer Key] = Customer.[Customer Key]\nINNER JOIN Dimension.Employee\nON fact_order_BIG_CCI.[Salesperson Key] = Employee.[Employee Key]\nWHERE [Order Date Key] &gt;= '2015-01-01'\nAND [Order Date Key] &lt; '2016-01-01'\nGROUP BY Customer.[Customer Key]\nORDER BY Customer.[Customer Key];<\/pre>\n\n\n\n<p>At first glance, it would seem that this query should be even faster. Without the need to pull the SUM, all that is left to retrieve is the [Customer Key]. The same results are returned, but a 40-second wait is incurred:<\/p>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"878\" height=\"136\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2020\/09\/word-image-12.jpeg\" alt=\"\" class=\"wp-image-88035\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>What happened to cause such terrible performance? In this scenario, SQL Server chose to resolve the joins and aggregate operations prior to the filter being applied. As a result, the entire columnstore index was scanned, resulting in a hefty amount of IO and a long wait.<\/p>\n\n\n\n<p>At first glance, it would appear that this performance is unnecessary. SQL Server should be able to determine the minimum effort needed to retrieve data from a large columnstore index first, and properly weigh that against the comparatively tiny dimension tables. This is a common problem and one that can be programmatically solved by avoiding unnecessary joins or leaning MORE on the columnstore index and less on dimension tables. This particular query can be simplified easily to remove this problem:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">SELECT\n\tfact_order_BIG_CCI.[Customer Key]\nFROM dbo.fact_order_BIG_CCI\nWHERE [Order Date Key] &gt;= '2015-01-01'\nAND [Order Date Key] &lt; '2016-01-01'\nGROUP BY fact_order_BIG_CCI.[Customer Key]\nORDER BY fact_order_BIG_CCI.[Customer Key];<\/pre>\n\n\n\n<p>This brings us back to 32 segment reads while returning the same results. Interestingly enough, the poorly performing query earlier can be \u201coptimized\u201d by adding an aggregate:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">SELECT\n\tCustomer.[Customer Key],\n\tCOUNT(*)\nFROM dbo.fact_order_BIG_CCI\nINNER JOIN Dimension.City\nON fact_order_BIG_CCI.[City Key] = City.[City Key]\nINNER JOIN Dimension.Customer\nON fact_order_BIG_CCI.[Customer Key] = Customer.[Customer Key]\nINNER JOIN Dimension.Employee\nON fact_order_BIG_CCI.[Salesperson Key] = Employee.[Employee Key]\nWHERE [Order Date Key] &gt;= '2015-01-01'\nAND [Order Date Key] &lt; '2016-01-01'\nGROUP BY Customer.[Customer Key]\nORDER BY Customer.[Customer Key];<\/pre>\n\n\n\n<p>This method is cringe-worthy and is not a real optimization \ud83d\ude0a It highlights ways in which the optimizer can hit odd breakpoints that tilt it between good decisions and bad decisions.<\/p>\n\n\n\n<p>Joined tables can incur unnecessary costs even when not used. Consider this example:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">SELECT\n\tCustomer.[Customer Key],\n\tCOUNT(*)\nFROM dbo.fact_order_BIG_CCI\nINNER JOIN Dimension.City\nON fact_order_BIG_CCI.[City Key] = City.[City Key]\nINNER JOIN Dimension.Customer\nON fact_order_BIG_CCI.[Customer Key] = Customer.[Customer Key]\nINNER JOIN Dimension.Employee\nON fact_order_BIG_CCI.[Salesperson Key] = Employee.[Employee Key]\nWHERE Customer.[Customer Key] = 0\nGROUP BY Customer.[Customer Key]\nORDER BY Customer.[Customer Key];<\/pre>\n\n\n\n<p>The following is the IO for the query:<\/p>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"795\" height=\"110\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2020\/09\/word-image-13.jpeg\" alt=\"\" class=\"wp-image-88036\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>No columns are returned from the City table, but it is scanned anyway. This is not as big of a surprise as the last performance bomb that was unearthed above. INNER JOINs to a table force the joined columns to be read, even if not needed elsewhere in the query. This is no different than similar queries against OLTP tables. This can exacerbate performance problems related to columnstore indexes as seen previously.<\/p>\n\n\n\n<p>If the City and Employee tables are removed from the 40-second query from earlier, then runtime is reduced to 10 seconds:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">SELECT\n\tCustomer.[Customer Key]\nFROM dbo.fact_order_BIG_CCI\nINNER JOIN Dimension.Customer\nON fact_order_BIG_CCI.[Customer Key] = Customer.[Customer Key]\nWHERE [Order Date Key] &gt;= '2015-01-01'\nAND [Order Date Key] &lt; '2016-01-01'\nGROUP BY Customer.[Customer Key]\nORDER BY Customer.[Customer Key];<\/pre>\n\n\n\n<p>This is still painful, but indicative of how joins (necessary or not) can impact the performance of a columnstore index.<\/p>\n\n\n\n<p><strong>Read also:<\/strong><br><a href=\"https:\/\/www.red-gate.com\/simple-talk\/databases\/sql-server\/t-sql-programming-sql-server\/temporary-tables-in-sql-server\/\" target=\"_blank\" rel=\"noreferrer noopener\">Temporary tables for dimension key pre-selection<\/a><br><a href=\"https:\/\/www.red-gate.com\/simple-talk\/databases\/sql-server\/t-sql-programming-sql-server\/using-a-subquery-in-a-select-statement\/\" target=\"_blank\" rel=\"noreferrer noopener\">Subqueries for columnstore filtering<\/a><\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"h-joins-and-lookups-forcing-good-performance\">Joins and Lookups: Forcing Good Performance<\/h3>\n\n\n\n<p>The easiest workaround to these challenges is to retrieve the data needed from the columnstore index first with as few filters and joins as possible. This methodology is not very ORM or app-friendly but can help make important analysis run exceptionally fast.<\/p>\n\n\n\n<p>For example, if there was a need to report on a specific set of customers and the columnstore index query was performing poorly, an easy fix would be to preselect the customer list, similar to this:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">CREATE TABLE #customer_list\n\t([Customer Key] INT NOT NULL PRIMARY KEY CLUSTERED);\nINSERT INTO #customer_list\n\t([Customer Key])\nSELECT\n\tCustomer.[Customer Key]\nFROM Dimension.Customer\nWHERE [Buying Group] = 'Tailspin Toys'\nAND [Postal Code] BETWEEN '90000' AND '90200'\nSELECT\n\tSUM(Quantity) AS Quantity\nFROM dbo.fact_order_BIG_CCI\nWHERE [Order Date Key] &gt;= '1\/1\/2015'\nAND [Order Date Key] &lt; '1\/1\/2016'\nAND fact_order_BIG_CCI.[Customer Key] IN\n\t(SELECT customer_list.[Customer Key] \n         FROM #customer_list customer_list);\nDROP TABLE #customer_list;<\/pre>\n\n\n\n<p>In this example, a very specific filter is applied to the <em>Customer<\/em> dimension. Once applied, the resulting customer keys are stored in a temporary table for use against the columnstore index.<\/p>\n\n\n\n<p>Note that this is typically a reactionary approach. If a report or analytic process is exceptionally slow, then moving metadata searches to a separate query and feeding the results into the columnstore index will often improve performance. I would not recommend proactively implementing changes like this as it does add complexity to the script. Instead, save it as a tool to be used when needed.<\/p>\n\n\n\n<p>This technique is not unique to the OLAP world and can be useful in complex OLTP queries to reduce the size and complexity of a query and encourage better performance from the query optimizer.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"h-columnstore-indexes-and-memory-usage\">Columnstore Indexes and Memory Usage<\/h3>\n\n\n\n<p>Like OLTP indexes, all data to satisfy a query needs to be read into the buffer cache prior to being used by the execution engine. Because columnstore indexes are not updated as frequently as OLTP tables, the data that is read into memory can remain there compressed and untouched for an extended period of time. Compounding this, the high compression ratios achieved in columnstore indexes allow more data to reside in the buffer cache for longer, assuming no memory pressure exists.<\/p>\n\n\n\n<p>The combination of these columnstore indexes and OLAP attributes is that memory becomes a key component of columnstore index performance. The following are the storage details for the columnstore index used thus far in this article:<\/p>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"696\" height=\"659\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2020\/09\/word-image-14.jpeg\" alt=\"\" class=\"wp-image-88037\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>The entire index contains 105 million rows that are squeezed into only 430MB. This is small enough that an entire partition (or even the entire index) could reside in memory for reporting purposes at all times.<\/p>\n\n\n\n<p>Real-world columnstore indexes will typically be much larger than this, to the point where the entire index remaining in memory is not feasible, but we can plan appropriately for memory sizing so that an adequate amount is available to service analytic queries.<\/p>\n\n\n\n<p>Consider a hypothetical production analytic data store with the following details:<\/p>\n\n\n<div class=\"block-core-list\">\n<ul class=\"wp-block-list\">\n<li>10 billion rows of data spread over 5 years (2016-2020)<\/li>\n\n\n\n<li>20 columns in the table, including an ordered date dimension<\/li>\n\n\n\n<li>Data is partitioned by quarter based on the date dimension.<\/li>\n\n\n\n<li>A single rowgroup of 2<sup>20<\/sup> rows consumes 10MB of space.<\/li>\n<\/ul>\n<\/div>\n\n\n<p>Based on these details, we can calculate the necessary metrics to assess memory consumption by this index and plan accordingly:<\/p>\n\n\n\n<p>This table contains 20 partitions, each of which has 500 million rows. Each partition contains 477 rowgroups that consume 5GB of space apiece.<\/p>\n\n\n\n<p>Let\u2019s say the most common reporting needs are:<\/p>\n\n\n<div class=\"block-core-list\">\n<ol class=\"wp-block-list\">\n<li>Report on current year revenue, which requires aggregating two decimal(20,4) columns and one integer ID columns and filtering by date.<\/li>\n\n\n\n<li>Report year-over-year revenue for the current quarter, which requires two decimal columns and one integer ID column for the current quarter and the same quarter in the previous year.<\/li>\n\n\n\n<li>Report on all metrics aggregated for the current quarter.<\/li>\n<\/ol>\n<\/div>\n\n\n<p>These represent very common reporting needs that read current data most frequently and older data less and less. These requests can each be evaluated, and a memory estimate created that allows us to understand how to keep our most commonly queried data in cache indefinitely:<\/p>\n\n\n<div class=\"block-core-list\">\n<ol class=\"wp-block-list\">\n<li>Four columns are needed: This represents 1\/5 of the columns in the table and therefore approximately 1\/5 of the data. Therefore, one year of data for these four columns will require about 4GB of data. As long as 4GB of memory are free for use by this columnstore index, then the data can remain in memory for an extended period of time and service this query effectively.<\/li>\n\n\n\n<li>The same four columns are needed from query #1 above. This again represents 1\/5 of the columns in the table and therefore approximately 1\/5 of the data. This time, only two quarters are needed, rather than a full year, one of which is already accounted for in the first query, and the older one which is querying older data. To maintain the data needed by this query, 2GB of data are required, of which 1GB is already included in the first query above.<\/li>\n\n\n\n<li>This query requires the entire current partition, all 20 columns, which requires 5GB, of which 1GB is already accounted for in the previous queries, and 4GB represents new data not already in cache.<\/li>\n<\/ol>\n<\/div>\n\n\n<p>The end result is that approximately 6GB of memory are needed to maintain all data required by these queries in cache indefinitely.<\/p>\n\n\n\n<p>Determining the overlap from the above math is a bit convoluted, and most administrators would make an educated estimate, add additional headroom, and move on, rather than attempt the precise calculation. If this were my table and if allocating memory were my responsibility, I\u2019d start with 10GB and budget to add another 2GB per year in the future.<\/p>\n\n\n\n<p>Assuming this memory expenditure is acceptable, then reporting on these metrics will be exceptionally fast as all segments required will remain in memory and rarely need to be read from storage. Reading columnstore index data from memory is exceptionally fast when compared to scenarios where data needs to be read from storage into the cache first.<\/p>\n\n\n\n<p>The key takeaway from this discussion is that query patterns can affect memory consumption, therefore influencing how much computing resources should be allocated for an analytic data store. Understanding query patterns can allow smart decisions to be made with regards to capacity planning and ensuring that important analytics perform as optimally as possible.<\/p>\n\n\n\n<p><strong>Read also:<\/strong> <a href=\"https:\/\/www.red-gate.com\/simple-talk\/databases\/sql-server\/t-sql-programming-sql-server\/what-are-columnstore-indexes\/\" target=\"_blank\" rel=\"noreferrer noopener\">What Are Columnstore Indexes?<\/a><\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"h-conclusion\">Conclusion<\/h2>\n\n\n\n<p>How data is queried has a profound impact on performance, indexing needs, and capacity planning.<\/p>\n\n\n\n<p>While columnstore indexes typically perform impressively well, they may choke on more complex queries. Being able to troubleshoot these problems and rewrite queries to be more modular can significantly improve performance by optimizing data retrieval from the large columnstore index and managing smaller dimension lookups separately.<\/p>\n\n\n\n<p>Memory allocation can also be determined based on common query patterns, allowing a server to maintain more than enough cache to service common\/critical queries. Since columnstore index data changes far less frequently than OLTP tables, most segments can remain in memory for a long time, with only the newest data being added on a rolling basis.<\/p>\n\n\n\n<p>Combined, these concepts can further improve columnstore index performance and allow analysts and administrators to query large data warehouses or OLAP data stores exceptionally fast.<\/p>\n\n\n\n<p><strong>Read also:<\/strong> <a href=\"https:\/\/www.red-gate.com\/simple-talk\/databases\/sql-server\/performance\/hands-on-with-columnstore-indexes-part-3-maintenance-and-additional-options\/\" target=\"_blank\" rel=\"noreferrer noopener\">Part 3: Maintenance and Additional Options<\/a><\/p>\n\n\n\n<section id=\"my-first-block-block_f1b255f37d3809428cbf6ebae42e3ba9\" class=\"my-first-block alignwide\">\n    <div class=\"bg-brand-600 text-base-white py-5xl px-4xl rounded-sm bg-gradient-to-r from-brand-600 to-brand-500 red\">\n        <div class=\"gap-4xl items-start md:items-center flex flex-col md:flex-row justify-between\">\n            <div class=\"flex-1 col-span-10 lg:col-span-7\">\n                <h3 class=\"mt-0 font-display mb-2 text-display-sm\">Fast, reliable and consistent SQL Server development&#8230;<\/h3>\n                <div class=\"child:last-of-type:mb-0\">\n                                            &#8230;with SQL Toolbelt Essentials. 10 ingeniously simple tools for accelerating development, reducing risk, and standardizing workflows.                                    <\/div>\n            <\/div>\n                            <a href=\"https:\/\/www.red-gate.com\/products\/sql-toolbelt-essentials\/\" class=\"btn btn--secondary btn--lg\">Learn more &amp; try for free<\/a>\n                    <\/div>\n    <\/div>\n<\/section>\n\n\n<section id=\"faq\" class=\"faq-block my-5xl\">\n    <h2>FAQs: Columnstore indexes in SQL Server part 4 &#8211; query patterns<\/h2>\n\n                        <h3 class=\"mt-4xl\">1. How do you optimize queries against a columnstore index in SQL Server?<\/h3>\n            <div class=\"faq-answer\">\n                <p>Three strategies: (1) Filter on the ordered column (typically a date) to enable rowgroup elimination &#8211; SQL Server skips entire rowgroups whose date range doesn\u2019t match. (2) Query only the columns you need to minimize segment reads. (3) Use aggregate functions (SUM, MIN, MAX, COUNT) which can leverage segment metadata instead of reading actual data. For wide-column queries, filter to a narrow date range. For many-column aggregates, let metadata do the work.<\/p>\n            <\/div>\n                    <h3 class=\"mt-4xl\">2. Why do JOINs slow down columnstore index queries in SQL Server?<\/h3>\n            <div class=\"faq-answer\">\n                <p>INNER JOINs force SQL Server to read the join columns from the columnstore even when those columns aren\u2019t in the SELECT or WHERE clause. Worse, the optimizer sometimes processes joins and aggregations before applying date filters, causing a full columnstore scan. The fix: remove unnecessary joins, or pre-select dimension keys into a temporary table and use an IN subquery against the columnstore. Adding an aggregate column (even COUNT(*)) can sometimes redirect the optimizer toward a better plan.<\/p>\n            <\/div>\n                    <h3 class=\"mt-4xl\">3. How much memory does a columnstore index need in SQL Server?<\/h3>\n            <div class=\"faq-answer\">\n                <p>Estimate based on your query patterns: calculate how many partitions, rowgroups, and columns your most common queries access. Each segment is typically a few MB compressed. For a 10-billion-row table with 20 columns partitioned by quarter, common queries accessing 4 columns for one year of data might need roughly 4\u20136 GB of buffer cache. Budget additional headroom and plan for growth. Columnstore data stays compressed in cache, so more data fits in memory than with rowstore tables.<\/p>\n            <\/div>\n            <\/section>\n","protected":false},"excerpt":{"rendered":"<p>Optimize query patterns against columnstore indexes in SQL Server. Learn segment and rowgroup elimination, dimension table joins, performance pitfalls, and memory sizing for analytical workloads.&hellip;<\/p>\n","protected":false},"author":329827,"featured_media":0,"comment_status":"open","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"footnotes":""},"categories":[143529,143531],"tags":[5842],"coauthors":[101655],"class_list":["post-88021","post","type-post","status-publish","format-standard","hentry","category-performance-sql-server","category-t-sql-programming-sql-server","tag-sql-monitor"],"acf":[],"_links":{"self":[{"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/posts\/88021","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/users\/329827"}],"replies":[{"embeddable":true,"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/comments?post=88021"}],"version-history":[{"count":10,"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/posts\/88021\/revisions"}],"predecessor-version":[{"id":109077,"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/posts\/88021\/revisions\/109077"}],"wp:attachment":[{"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/media?parent=88021"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/categories?post=88021"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/tags?post=88021"},{"taxonomy":"author","embeddable":true,"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/coauthors?post=88021"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}